The wireframe key works and Steam is optional

Alt+W had been a no-op since the 2007 DPL->Direct3D port, which stubbed
DPLToggleWireframe along with every other dpl_ call. D3D9 has no global
wireframe property, so the toggle now records the intent in gWireframe
and ExecuteImplementation applies D3DRS_FILLMODE once per frame - which
also means it re-asserts itself after a device Reset. The sky dome and
the 2D pass are held solid: a wireframed dome buries the geometry you
turned the key on to look at, and the gunsight would otherwise come out
as bare diagonals.

Proving it needed a working copy, and that turned up a separate problem.
steam_api.dll was a hard import, so a machine without it died at load
time with 0xC0000135 - before a window, before a log line. It is now
delay-loaded, and because delay loading only moves that failure to the
first call, every Steam path is gated on
SteamNetTransport_ClientLibraryPresent(): Install and the two lobby
entries, with everything else downstream of one of them. Absent DLL
boots and races; absent DLL with RP412STEAM=1 logs the reason and stays
on TCP; DLL present brings the transport up exactly as before.

The documentation now says which debug keys are real. Five of the seven
are still 2007 stubs and always have been, so they are named as inert in
environ.ini's template and at the dispatch site - that beats letting the
next person debug a dead key, which is how this started. BUILD.md gains
a debug-key table, the delay-load contract for anyone adding a Steam
call site, and the environ.ini BOM trap that silently reverts
L4CONTROLS to KEYBOARD and then fail-fasts for want of a pod mapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-10 11:15:11 -05:00
co-authored by Claude Opus 5
parent 34abf40e7f
commit 0f4592c39a
10 changed files with 265 additions and 27 deletions
+62
View File
@@ -144,10 +144,72 @@ textures now log `L4D3D.cpp couldn't load texture …` and render untextured;
the game boots to a running window with `-windowed -res 640 480 -egg TEST.EGG`
from a working copy like `assets/RP411/`.
**`steam_api.dll` is optional.** It is **delay-loaded**
(`DelayLoadDLLs` in [RP_L4/RP_L4.vcxproj](RP_L4/RP_L4.vcxproj), with
`delayimp.lib` supplying the helper), so a working copy that has never seen
Steam — like `assets/RP411/`, which predates the Steam work — boots and races
normally. Only Steam itself is switched off, logged as
`Steam: steam_api.dll not found beside the exe - Steam features off, staying
on TCP`.
Delay loading alone would only move the failure: the first call into a
delay-loaded DLL that cannot be found raises the helper's fatal exception
rather than returning an error. So every path that reaches a Steam symbol
first asks `SteamNetTransport_ClientLibraryPresent()`
([MUNGA_L4/L4STEAMTRANSPORT.cpp](MUNGA_L4/L4STEAMTRANSPORT.cpp)) — a cached
`LoadLibrary` probe using the same plain-name lookup the helper does. The
gates are `SteamNetTransport_Install` and the two lobby entries
`RPL4Lobby_Host`/`_Join`; everything else in the transport and lobby is
downstream of one of those. **Adding a new Steam call site means checking
which gate covers it.**
For Steam features you need the DLL from
[extern/steamworks_sdk_164/sdk/redistributable_bin/steam_api.dll](extern/steamworks_sdk_164/sdk/redistributable_bin/steam_api.dll)
(the 32-bit one, not `win64\`) beside the exe, plus `steam_appid.txt` — with
the DLL but no appid file `SteamAPI_Init` fails and the game falls back to TCP.
`environ.ini` must be written **without a BOM**. The parser matches key names
from the start of the line, so a leading UTF-8 BOM silently invalidates the
first key in the file — put `L4CONTROLS` there and it reverts to the built-in
`KEYBOARD` default, which then fail-fasts on `0xC0000409` with
`*****VTV has no controls mapping!*****` in the log (there is no keyboard-only
pod mapper). PowerShell's `Set-Content -Encoding utf8` writes a BOM in 5.1;
use `[System.IO.File]::WriteAllText` with an `ASCIIEncoding`. The log line
`Environ: environ.ini does not mention N option(s)` naming a key that is
plainly in the file is the tell.
For runtime debugging the v143 build produces full PDBs — run
`cdb -g -G -lines -y Release rpl4opt.exe ...` from the working directory
(cdb ships in this machine's Windows Kits).
### Debug keys
`RP412DEVKEYS=1` arms them, and they arrive through the engine keyboard
handler, so `L4CONTROLS` must include `KEYBOARD`. That handler takes one key
per frame off the front of the message queue and genuinely drops presses, so
press again before concluding a key is broken.
| Key | State |
|-----|-------|
| **Alt+W** wireframe | **Live.** Reimplemented on D3D9 as a per-frame `D3DRS_FILLMODE` (`gWireframe`, [MUNGA_L4/L4VIDEO.cpp](MUNGA_L4/L4VIDEO.cpp)). The sky dome and the 2D pass (gunsight, cam-ship HUD) are held solid; particles are wireframed with everything else. |
| **Alt+E** event-queue dump | **Live.** Reaches `GeneralEventQueue::DumpEventQueue`. |
| Alt+V predator vision | Inert. |
| Alt+F frame dump | Inert. |
| Alt+/ perf stats | Inert. |
| Alt+K free memory | Inert, silent. |
| Alt+R dither, Alt+P eyepoint | Inert; they log "Function net yet enabled." |
| Alt+Q abort | Always live, no `RP412DEVKEYS` needed. |
The inert ones call `DPLRenderer` methods whose bodies were commented out
with the rest of the DPL calls in the 2007 port (`STUBBED: DPL RB 1/14/07`).
Reviving one means writing it against D3D9 rather than un-commenting
anything: the `dpl_*` types those bodies used are empty placeholder classes
now ([DPLSTUB.h](DPLSTUB.h)), and `libDPL/` is reference headers that are not
compiled. Alt+V is the worst of them — the DPL renderer implemented predator
vision internally, reached by passing an out-of-band explosion effect type
(`-1` on, `-2` off) with a NULL DCS, and nothing in this tree records what it
actually looked like.
### Running without the cockpit (Workstream A prototype)
Two new environment options remove the hardware dependency entirely:
+21 -6
View File
@@ -867,6 +867,13 @@ void
// The debug keys are for developers: RP412DEVKEYS=1 arms them.
// Players get exactly one chord - Alt+Q, the deliberate abort.
//
// Only Alt+W and Alt+E actually do anything. The rest call DPLRenderer
// methods whose bodies were commented out with the rest of the DPL
// calls in the 2007 Direct3D port and have been empty ever since; each
// is marked INERT below. Reviving one means writing it against D3D9,
// not un-commenting anything - the dpl_* types it used are empty
// placeholder classes now (DPLSTUB.h).
//
static int dev_keys = -1;
if (dev_keys < 0)
{
@@ -910,6 +917,7 @@ void
//--------------------------------------------
// FrameDump from Division card to Targa file
// INERT: DPLFrameDump and dump_frame_buffer are both stubs.
//--------------------------------------------
case PCK_ALT_F:
{
@@ -925,6 +933,7 @@ void
//------------------------------------
// Report current free memory in card
// INERT: the body below is commented out, so this key is silent.
//------------------------------------
case PCK_ALT_K:
{
@@ -952,6 +961,7 @@ void
//---------------------------------------
// Report performance statistics (Alt-?)
// INERT: DPLReportPerfStats is a stub - it read DPL's own counters.
//---------------------------------------
case PCK_ALT_SLASH:
{
@@ -964,9 +974,10 @@ void
}
break;
}
//--------------------------
// Toggle Wireframe display
//--------------------------
//--------------------------------------------------------------
// Toggle Wireframe display. Live: reimplemented on D3D9 as a
// per-frame D3DRS_FILLMODE (see gWireframe in L4VIDEO.cpp).
//--------------------------------------------------------------
case PCK_ALT_W:
{
if (!dev_keys) break;
@@ -978,9 +989,13 @@ void
}
break;
}
//--------------------------
// Toggle "Predator-vision"
//--------------------------
//--------------------------------------------------------------
// Toggle "Predator-vision" - a global false-colour/thermal mode
// the DPL renderer implemented internally, reached by passing an
// out-of-band explosion effect type (-1 on, -2 off) with a NULL
// DCS. INERT: DPLTogglePVision is a stub, and what it looked
// like is not recorded anywhere in this tree.
//--------------------------------------------------------------
case PCK_ALT_V:
{
if (!dev_keys) break;
+45
View File
@@ -3,6 +3,41 @@
#include "l4steamtransport.h"
//########################################################################
// Is steam_api.dll actually here?
//
// Defined outside the RP412_STEAM guard on purpose: callers ask without
// caring how the build was configured, and a build without the SDK
// truthfully has no client library. See the header for why every Steam
// path has to come through here first.
//
// The answer is cached because it is asked on menu paint, and because a
// DLL that appeared halfway through a session is not a case worth
// supporting - the delay-load helper would have bound the first miss
// anyway.
//########################################################################
Logical
SteamNetTransport_ClientLibraryPresent()
{
#ifdef RP412_STEAM
static int present = -1;
if (present < 0)
{
present = (LoadLibraryA("steam_api.dll") != NULL) ? 1 : 0;
if (!present)
{
DEBUG_STREAM << "Steam: steam_api.dll not found beside the exe - "
<< "Steam features off, staying on TCP\n" << std::flush;
}
}
return present ? True : False;
#else
return False;
#endif
}
#ifdef RP412_STEAM
#include "l4nettransport.h"
@@ -686,6 +721,16 @@ Logical
return True;
}
//
// Before ANY Steam symbol: steam_api.dll is delay-loaded, so calling
// into it when it is missing raises the helper's fatal exception
// rather than failing. This is the gate that makes the DLL optional.
//
if (!SteamNetTransport_ClientLibraryPresent())
{
return False;
}
if (!SteamAPI_Init())
{
DEBUG_STREAM << "SteamNetTransport: SteamAPI_Init failed "
+21 -1
View File
@@ -14,7 +14,8 @@
//########################################################################
// SteamNetTransport - the retail wire (l4steamtransport.cpp). Built
// only under RP412_STEAM (Steamworks SDK vendored at
// extern\steamworks_sdk_164; steam_api.dll ships beside the exe).
// extern\steamworks_sdk_164; steam_api.dll ships beside the exe, but is
// delay-loaded and optional - see the note further down).
//
// Method mapping onto ISteamNetworkingSockets:
//
@@ -55,6 +56,25 @@
// egg is distributed.
//########################################################################
//########################################################################
// steam_api.dll is DELAY-LOADED (see the DelayLoadDLLs setting in
// RP_L4.vcxproj), so the game runs on a machine that has never seen
// Steam - a plain import of a missing DLL kills the process at load time
// with 0xC0000135, before a window or a single log line.
//
// The catch is that delay loading only MOVES the failure: the first call
// to a delay-loaded function whose DLL cannot be found raises a fatal
// exception instead. So every path that would reach a Steam symbol has
// to ask this first. It is declared outside the RP412_STEAM guard, and
// answers False in a build without the SDK, so callers need no #ifdef.
//
// Cheap and idempotent: one LoadLibrary on first call, cached after.
// Deliberately the same plain-name load the delay-load helper itself
// does, so a yes here means the helper will succeed too.
//########################################################################
Logical
SteamNetTransport_ClientLibraryPresent();
#ifdef RP412_STEAM
// Bring Steam up and make this the process transport. False (with the
+56 -12
View File
@@ -83,6 +83,19 @@ DWORD
// (NULL = present to the device window as always).
HWND gMainPresentWindow = NULL;
//
// Alt+W wireframe (RP412DEVKEYS). File scope rather than a DPLRenderer
// member because a fresh renderer is built per mission - a member would
// drop the toggle every time the race restarted, which is exactly when
// you are looking at geometry.
//
// DPLToggleWireframe only flips this; the fill mode is applied once per
// frame in ExecuteImplementation. Setting it there rather than in the key
// handler means it re-asserts itself after a device Reset, which reverts
// the fill mode to D3DFILL_SOLID underneath us.
//
static Logical gWireframe = 0;
//STUBBED: DPL RB 1/14/07
// when this is resolved it can be removed
#include "..\DPLSTUB.h"
@@ -6402,6 +6415,17 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
hr = mDevice->BeginScene();
//
// Alt+W wireframe. Set every frame from the flag rather than once at
// toggle time - see gWireframe. It is turned back off for the sky pass
// and again for the 2D pass below; everything between here and there
// draws as edges.
//
mDevice->SetRenderState(
D3DRS_FILLMODE,
gWireframe ? D3DFILL_WIREFRAME : D3DFILL_SOLID
);
mDevice->SetFVF(L4VERTEX_FVF);
D3DXMATRIX viewTransform;
@@ -6478,6 +6502,17 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
mDevice->SetTransform(D3DTS_PROJECTION, &mProjectionMatrix);
//
// The sky stays solid under Alt+W. Wireframing the dome fills the top
// of the screen with its own tessellation and buries the geometry you
// turned wireframe on to look at; a solid sky gives the edges something
// to read against.
//
if (gWireframe)
{
mDevice->SetRenderState(D3DRS_FILLMODE, D3DFILL_SOLID);
}
if (!l4_application->IsDead())
{
@@ -6492,6 +6527,11 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
for (d3d_OBJECT *obj = mRenderLists[PASS_SKY]; obj != NULL; obj = obj->GetNext(PASS_SKY))
obj->Draw(PASS_SKY, &viewTransform, mTargetRenderTime);
if (gWireframe)
{
mDevice->SetRenderState(D3DRS_FILLMODE, D3DFILL_WIREFRAME);
}
//Reactivate fog
mDevice->SetRenderState(D3DRS_FOGSTART, *((DWORD*)(&currentFogNear)));
mDevice->SetRenderState(D3DRS_FOGEND, *((DWORD*)(&currentFogFar)));
@@ -6535,6 +6575,12 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
// Wrap it up by doing the 2D pass
//
mDevice->SetFVF(L4VERTEX_2D_FVF);
//
// Always solid from here on: the gunsight and the cam-ship HUD are
// textured quads on this device, and in wireframe they come out as bare
// diagonals.
//
mDevice->SetRenderState(D3DRS_FILLMODE, D3DFILL_SOLID);
mDevice->SetRenderState(D3DRS_ZWRITEENABLE, true);
mDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
@@ -6818,19 +6864,17 @@ void
void
DPLRenderer::DPLToggleWireframe()
{
//STUBBBED: DPL RB 1/14/07
//static Logical wireframe_on = 0;
//
// The DPL original set a renderer property here
// (dpl_render_prop_wireframe) and was stubbed out with the rest of the
// DPL calls in the 2007 D3D port. D3D9 has no equivalent global: the
// fill mode is device state, so all this does is record the intent and
// let the frame apply it. See gWireframe at the top of this file.
//
gWireframe = !gWireframe;
//if ((wireframe_on ^= 1) != 0)
//{
// DEBUG_STREAM << "wireframe ON" << std::endl << std::flush;
// dpl_SetRenderProperty(dpl_render_prop_wireframe, dpl_render_value_on, NULL );
//}
//else
//{
// DEBUG_STREAM << "wireframe OFF" << std::endl << std::flush;
// dpl_SetRenderProperty(dpl_render_prop_wireframe, dpl_render_value_off, NULL );
//}
DEBUG_STREAM << "wireframe " << (gWireframe ? "ON" : "OFF")
<< std::endl << std::flush;
}
//
//#############################################################################
+20 -5
View File
@@ -257,9 +257,11 @@ namespace
"# when real RIO hardware is selected - the pod keeps its own cadence.\n"
"#RP412LAMPSWEEP=0\n"
"\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n"
"# running and steam_appid.txt beside the exe; without them the game\n"
"# logs the reason and falls back to plain TCP. 0 = TCP only.\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs steam_api.dll, the\n"
"# Steam client running, and steam_appid.txt beside the exe; missing any\n"
"# of them logs the reason and falls back to plain TCP - nothing here can\n"
"# stop the game starting, and steam_api.dll being absent altogether is\n"
"# fine. 0 = TCP only.\n"
"RP412STEAM=1\n"
"\n"
"# Line up each remote player's clock with ours, so their vehicle is\n"
@@ -408,8 +410,21 @@ namespace
"\n"
"# ---- Developer / testing ----------------------------------------------------\n"
"\n"
"# Nonzero arms the debug keys: Alt+W wireframe, Alt+V predator vision,\n"
"# Alt+F frame dump, Alt+/ perf stats, Alt+E event-queue dump.\n"
"# Nonzero arms the debug keys. Two of them do something in this build:\n"
"# Alt+W wireframe. The sky dome stays solid so the edges have\n"
"# something to read against, and the gunsight and cam-ship\n"
"# HUD stay solid too.\n"
"# Alt+E write the event queue to the log.\n"
"# The others are stubs the 2007 DPL->Direct3D port left behind and do\n"
"# nothing at all. They are named here so a dead key is not mistaken for\n"
"# a broken one: Alt+V predator vision, Alt+F frame dump, Alt+/ perf\n"
"# stats and Alt+K free memory are silent; Alt+R dither pattern and\n"
"# Alt+P eyepoint position at least say so in the log.\n"
"#\n"
"# All of them arrive through the engine keyboard handler, so L4CONTROLS\n"
"# has to include KEYBOARD - the shipped stack does. That handler reads\n"
"# one key per frame off the front of the message queue and so drops\n"
"# presses; if a key seems dead, press it again before believing it.\n"
"# 0 or unset = off. (Alt+Q, the mission abort, is always live.)\n"
"#RP412DEVKEYS=1\n"
"\n"
+17
View File
@@ -987,9 +987,21 @@ Logical
return gInLobby;
}
//
// Host and Join are the two places that reach Steam without the
// transport having got there first, so they carry their own guard.
// steam_api.dll is delay-loaded and calling into it when it is absent
// raises the helper's fatal exception - the menu already greys these on
// Available(), but a dead DLL must not depend on the UI for safety.
//
int
RPL4Lobby_Host(HINSTANCE instance, HWND main_window)
{
if (!SteamNetTransport_ClientLibraryPresent())
{
return LobbyRoomLeft;
}
gCallDone = False;
SteamAPICall_t call =
SteamMatchmaking()->CreateLobby(k_ELobbyTypePublic, kMaxLobbyMembers);
@@ -1011,6 +1023,11 @@ int
int
RPL4Lobby_Join(HINSTANCE instance, HWND main_window)
{
if (!SteamNetTransport_ClientLibraryPresent())
{
return LobbyRoomLeft;
}
gCallDone = False;
SteamMatchmaking()->AddRequestLobbyListStringFilter(
kLobbyTagKey, "1", k_ELobbyComparisonEqual);
+10 -1
View File
@@ -66,7 +66,16 @@
<AdditionalOptions>/FORCE:MULTIPLE %(AdditionalOptions)</AdditionalOptions>
<AdditionalLibraryDirectories>..\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
<!-- legacy_stdio_definitions satisfies the June-2010 dxerr.lib on modern MSVC. -->
<AdditionalDependencies>ws2_32.lib;dinput8.lib;dxguid.lib;OpenAL32.lib;libsndfile-1.lib;d3d9.lib;legacy_stdio_definitions.lib;steam_api.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalDependencies>ws2_32.lib;dinput8.lib;dxguid.lib;OpenAL32.lib;libsndfile-1.lib;d3d9.lib;legacy_stdio_definitions.lib;steam_api.lib;delayimp.lib;%(AdditionalDependencies)</AdditionalDependencies>
<!-- steam_api.dll is DELAY-LOADED so the game runs without it: a
statically imported DLL that is merely absent kills the process
at load time, before any window or log line (0xC0000135). Now
nothing touches it until Steam is actually asked for, and
SteamNetTransport_ClientLibraryPresent() gates the call sites so
a missing DLL falls back to TCP instead of raising the
delay-load helper's fatal exception. delayimp.lib supplies that
helper. -->
<DelayLoadDLLs>steam_api.dll;%(DelayLoadDLLs)</DelayLoadDLLs>
<RandomizedBaseAddress>false</RandomizedBaseAddress>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
+9
View File
@@ -128,6 +128,15 @@ by `SteamNetTransport_RegisterPeer`). Runtime-verified on this box:
32257 game)` under AppID 480, graceful TCP fallback without Steam,
default boot untouched. `steam_api.dll` ships in the dist.
**The DLL is optional (2026-08-10):** it is delay-loaded, and every Steam
call site is gated on `SteamNetTransport_ClientLibraryPresent()`, so a
machine with no `steam_api.dll` boots and races instead of dying at load
time with `0xC0000135`. Verified all three ways on this box: absent DLL
boots, absent DLL with `RP412STEAM=1` logs `Steam: steam_api.dll not
found beside the exe` and stays on TCP, and DLL present still brings the
transport up as before. See [BUILD.md](../BUILD.md) §4 for which gate
covers what — a new Steam call site has to sit behind one of them.
Remaining for Steam multiplayer: the lobby (front-end UI +
`ISteamMatchmaking`): owner collects each member's FakeIP + fake game
port + loadout via lobby member data, feeds `RegisterPeer` on every
+4 -2
View File
@@ -89,8 +89,10 @@ Copy-Item $exe $dist
$pdb = Join-Path $root 'Release\rpl4opt.pdb'
if (Test-Path $pdb) { Copy-Item $pdb $dist }
# steam_api.dll: the exe imports it (RP412_STEAM build). The Steam wire
# only activates with RP412STEAM=1; plain desktop runs never touch it.
# steam_api.dll: DELAY-loaded by the exe (RP412_STEAM build), not a hard
# import - the game runs without it, with Steam features off. It ships
# anyway because the retail build wants them. The Steam wire only
# activates with RP412STEAM=1; plain desktop runs never touch it.
Copy-Item (Join-Path $root 'extern\steamworks_sdk_164\sdk\redistributable_bin\steam_api.dll') $dist
# steam_appid.txt: until RP412 has its own AppID, Steam testing runs