diff --git a/BUILD.md b/BUILD.md
index 5777104..e2eba2b 100644
--- a/BUILD.md
+++ b/BUILD.md
@@ -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:
diff --git a/MUNGA_L4/L4APP.cpp b/MUNGA_L4/L4APP.cpp
index fabaffd..167b612 100644
--- a/MUNGA_L4/L4APP.cpp
+++ b/MUNGA_L4/L4APP.cpp
@@ -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;
diff --git a/MUNGA_L4/L4STEAMTRANSPORT.cpp b/MUNGA_L4/L4STEAMTRANSPORT.cpp
index 0d19d3b..ff10c5e 100644
--- a/MUNGA_L4/L4STEAMTRANSPORT.cpp
+++ b/MUNGA_L4/L4STEAMTRANSPORT.cpp
@@ -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 "
diff --git a/MUNGA_L4/L4STEAMTRANSPORT.h b/MUNGA_L4/L4STEAMTRANSPORT.h
index 0a333bc..48b79f4 100644
--- a/MUNGA_L4/L4STEAMTRANSPORT.h
+++ b/MUNGA_L4/L4STEAMTRANSPORT.h
@@ -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
diff --git a/MUNGA_L4/L4VIDEO.cpp b/MUNGA_L4/L4VIDEO.cpp
index a1aa9a0..f0df866 100644
--- a/MUNGA_L4/L4VIDEO.cpp
+++ b/MUNGA_L4/L4VIDEO.cpp
@@ -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*)(¤tFogNear)));
mDevice->SetRenderState(D3DRS_FOGEND, *((DWORD*)(¤tFogFar)));
@@ -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;
}
//
//#############################################################################
diff --git a/RP_L4/RPL4ENVIRON.cpp b/RP_L4/RPL4ENVIRON.cpp
index 6a2fe08..3974cdc 100644
--- a/RP_L4/RPL4ENVIRON.cpp
+++ b/RP_L4/RPL4ENVIRON.cpp
@@ -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"
diff --git a/RP_L4/RPL4LOBBY.cpp b/RP_L4/RPL4LOBBY.cpp
index 94826ae..01118f2 100644
--- a/RP_L4/RPL4LOBBY.cpp
+++ b/RP_L4/RPL4LOBBY.cpp
@@ -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);
diff --git a/RP_L4/RP_L4.vcxproj b/RP_L4/RP_L4.vcxproj
index fb06fe5..94bd219 100644
--- a/RP_L4/RP_L4.vcxproj
+++ b/RP_L4/RP_L4.vcxproj
@@ -66,7 +66,16 @@
/FORCE:MULTIPLE %(AdditionalOptions)
..\lib;%(AdditionalLibraryDirectories)
- ws2_32.lib;dinput8.lib;dxguid.lib;OpenAL32.lib;libsndfile-1.lib;d3d9.lib;legacy_stdio_definitions.lib;steam_api.lib;%(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)
+
+ steam_api.dll;%(DelayLoadDLLs)
false
true
diff --git a/docs/RP412-FRONTEND-DESIGN.md b/docs/RP412-FRONTEND-DESIGN.md
index a826bbc..c4d3ea5 100644
--- a/docs/RP412-FRONTEND-DESIGN.md
+++ b/docs/RP412-FRONTEND-DESIGN.md
@@ -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
diff --git a/pack-dist.ps1 b/pack-dist.ps1
index c076990..2abed29 100644
--- a/pack-dist.ps1
+++ b/pack-dist.ps1
@@ -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