Files
RP412/docs/RP412-LINUX.md
T
CydandClaude Fable 5 9fae4406b5 The DirectX runtime we never shipped
A tester ran 4.12.233 under Proton and it died on first mission load. The
cause turned out to name itself, and to be a Windows bug we have been
shipping the whole time.

Wine implements d3dx9_43 itself, and 63 of its 329 exports are stubs. The
game imports twenty of them and exactly one is stubbed:
D3DXConcatenateMeshes, called once per session from
ConsolidateStaticObjects on the first-mission-load latch - so every player
hits it on their first race. Wine raised EXCEPTION_WINE_STUB naming the
function in plain text, and our own crash filter caught it and wrote a
minidump, which is how a self-describing failure arrived as a mystery.

The fix is one file, and it was overdue on Windows. d3dx9_43.dll is a HARD
import of the exe - only steam_api.dll is delay-loaded - and it is not part
of Windows. It ships with the June 2010 DirectX End-User Runtime and
nothing else. Any customer on a clean Windows 10 or 11 who never installed
that redistributable cannot start the game at all: the loader fails at
0xC0000135 before WinMain, no window, no log line. Every machine this has
been built or tested on had the SDK or some older game installed, which is
precisely why nobody has ever seen it. pack-dist now extracts the DLL from
the SDK's redist cab into dist. Wine prefers an application-directory DLL
over its builtin, so the same file closes the Windows failure and the Linux
blocker together, and it is verified working on a stock prefix with nothing
installed. The SDK terms nominate DXSETUP as the delivery method for this
redistributable; the loose DLL is what is verified and is near-universal
practice, but that wants confirming before a public release.

/LARGEADDRESSAWARE, because the same session reported VmSize 3193MB and an
older Wine that died before video init with the address space exhausted.
The game is not using that memory: measured here, a standalone mission
peaks at 166MB committed against 795MB of address space. The gap is
reservations, mapped files, and mostly the graphics driver mapping VRAM
apertures into our process - under wined3d's OpenGL path that grows several
times over. For a 32-bit process the 2GB address ceiling is a hard limit
with nothing to do with RAM, so it is entirely possible to run out of
addresses while using 170MB. The flag raises it to 4GB, frees nothing, and
changes no behaviour. Worth having on Windows too: 795MB of 2GB is already
40% before 1080p, eight pods, and the 100MB buffer RP412RECORDSIZE takes
when recording is armed.

And two diagnostics so the next one costs a log line instead of an
investigation. The crash filter now decodes 0x80000100 and writes the
offending module.function before the minidump; startup probes
wine_get_version in ntdll and logs the platform beside the version banner.
Neither can appear on Windows - verified: the smoke run's log has the
version line and no platform line.

Not fixed here, deliberately: the durable version of this is to write the
mesh merge by hand, since D3DXSimplifyMesh and D3DXSplitMesh are stubs too
and any future mesh work hits the same wall. And the field test ran
wined3d, not DXVK, so the child-window Present with GDI panes clipped over
it - the risk the assessment led with - is still untested. The doc records
both, along with the prediction it got wrong: the windowsapp.lib import was
called a load-time failure risk under Wine, and it simply is not one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:39:48 -05:00

21 KiB
Raw Blame History

RP 4.12 on Linux — compatibility assessment

Requirements exploration for making RP412 playable on Linux (desktop and Steam Deck). A formatted copy of the original assessment lives at https://claude.ai/code/artifact/47e4cf5f-a9a3-416e-bd5e-25c810983551.

Status: it runs. The assessment below was written 2026-08-12 as prediction; on 2026-08-14 a field test on real hardware found and cleared the one blocker, and the game played a full mission on Linux. §0 is what the test found and what was done about it — read it first, it corrects several predictions in §2. The remaining work is the Deck-specific and DXVK items in §0's open list.

Verdict: target Proton as the official Linux path; treat a native port as a separately-scoped future project. The existing 32-bit Win32/D3D9 build is squarely on Proton's best-supported path, every Proton-blocking issue found is fixable inside the Windows build, and Valve's Deck Verified program accepts Windows-only titles. A native port is a genuine second platform layer (~77k lines of MUNGA_L4 plus ~6,300 lines of raw Win32 UI in RP_L4) and it forces the 32/64-bit question, which reaches into the wire protocol and the .RES format. Nothing on the Proton path is throwaway: each fix is an improvement to the shipping Windows build.

Provenance: code findings below are measured against this repo (four exploration sweeps over MUNGA, MUNGA_L4, RP, RP_L4, DivLoader and the build files, 2026-08-12, main @ 1e0c412). External claims — Proton/DXVK behavior, Steam Runtime direction, Deck Verified criteria — are from Valve partner docs, the DXVK source, the steam-runtime repository, and Wine release notes as of August 2026, with unconfirmed items called out as needing live tests.

0. Field test, 2026-08-14 — one stubbed function, and a Windows bug behind it

Test environment: Linux Mint 22.3, RTX 5070 Ti (driver 595.84), Wine 10.0 Staging from GE-Proton10-21, 32-bit prefix, wined3d renderer, windowed 1920×1080, symbols from the shipped rpl4opt.pdb. Build 4.12.233.

The blocker was D3DXConcatenateMeshes. Wine has never implemented it. The game calls it exactly once per session, from ConsolidateStaticObjects (MUNGA_L4/L4VIDEO.cpp) via MUNGA/APP.cpp's first-mission-load latch, so every player hits it on their first race. Wine raised EXCEPTION_WINE_STUB (0x80000100) naming the function in plain text; our own crash filter caught it and wrote rpl4crash.dmp, so it presented as a mystery crash. Of Wine's 329 d3dx9_43 exports, 63 are stubs; the game imports 20 of them; exactly one is stubbed. There is no second landmine on the current code path.

The fix was already a Windows bug. d3dx9_43.dll is a hard import of rpl4opt.exe — confirmed in the import table, and only steam_api.dll is delay-loaded — and it is not part of Windows. It ships solely with the June 2010 DirectX End-User Runtime. A customer on a clean Windows 10/11 who has never installed that redistributable cannot start the game at all: the loader fails at 0xC0000135 before WinMain, with no window and no log line. Every machine this was built or tested on had the SDK or some older game installed, which is why it went unnoticed. pack-dist.ps1 now ships the DLL beside the exe, extracted from the SDK's redistributable cab. Wine prefers an application-directory DLL over its own builtin, so the same one file closes the Windows failure and the Linux blocker together — verified working on a stock prefix with no overrides and nothing installed.

Licensing: the DirectX SDK terms nominate DXSETUP as the delivery method for this redistributable. Shipping the loose DLL is near-universal practice and is what is verified working on both platforms, but confirm the terms before a public release.

Address space, not memory. The 32-bit process mapped VmSize 3193 MB under Wine, and on an older Wine that exhausted the address space outright — the game died before video init with nothing useful logged. It is not using that much memory: measured on Windows, a standalone mission peaks at 166 MB committed / 179 MB working set against 795 MB of address space, already a ~4.8× ratio. Address space is inflated by reservations, file mappings, and above all the graphics driver mapping VRAM apertures and its own bookkeeping into the process; under wined3d's OpenGL path with an NVIDIA driver that grows several-fold again. For a 32-bit process this is a hard 2 GB ceiling entirely separate from RAM, so it is possible to run out of addresses while using 170 MB. /LARGEADDRESSAWARE is now set: it raises the ceiling to 4 GB on any 64-bit host, frees no memory and changes no behaviour. Worth having on Windows too — 795 MB of 2 GB is already 40% before 1080p, eight pods, and the 100 MB buffer RP412RECORDSIZE takes when recording is armed.

Diagnostics added, so the next gap names itself. The crash filter now decodes 0x80000100 and writes the offending module.function to rpl4.log before the minidump, and startup probes wine_get_version in ntdll and logs the platform next to the version banner. Neither line can ever appear on Windows. Together they would have reduced this entire investigation to reading one line of a tester's log.

Do NOT install the VC++ redistributable into a Wine prefix. It causes a crash — a null dereference inside std::mutex, reached from KeyLight_SetLogger via PadRIO::PadRIO. Wine's builtin C++ runtime works correctly. This is a support-desk trap worth knowing, because installing the redistributable is a common first instinct. (On real Windows the VC++ runtime is genuinely required; Steam installs it as a depot prerequisite.)

Predictions in §2 that the test corrected

  • The windowsapp.lib static-import worry was wrong. §2 called it a load-time failure risk under Wine. The game boots, and the VC++ trace above runs through KeyLight_SetLogger — that code executes. Wine satisfies the WinRT API-set imports.
  • d3dx9 was the right area, the wrong function. §2 named D3DXLoadMeshFromXA and D3DXCreateTextureFromFile as the heaviest leans on Wine's reimplementation. Both are fully implemented. The lesson is method: rank by what Wine stubs, not by what the game uses most — intersecting the import table against Wine's stub list yields the answer directly.
  • Address-space exhaustion was not predicted at all, and static analysis would not have found it.

Still open after this test

  • DXVK is untested. The test ran wined3d; Proton defaults to DXVK for D3D9. The §2 headline risk — Present into a child STATIC via hDestWindowOverride with GDI panes clipped over it — is specifically a DXVK question, and whether the run exercised cockpit split mode (L4MFDSPLIT=1) at all is not recorded. This is the next test, and the rig now exists to do it cheaply.
  • Steam networking under Proton — SDR/FakeIP through the lsteamclient bridge. The test's networking was almost certainly plain TCP.
  • Everything Deck-specific: gamescope with the plasma window and the exploded MFD view, the gamepad-complete front end, the 9 px font floor.
  • Optional load-time polish, both platforms: 303 d3dx_load_pixels_from_pixels calls (texture format conversion at load — shipping textures in device format skips it) and 150 OptimizeInplace calls (the .X loader; baking meshes to an in-house format at build time would retire the .X dependency).
  • The durable fix for the blocker: replace D3DXConcatenateMeshes with a hand-written mesh merge, ~6080 lines using only helpers Wine implements (D3DXCreateMesh, D3DXGetDeclVertexSize, D3DXVec3TransformCoordArray, D3DXMatrixInverse, …). Worth doing eventually because D3DXSimplifyMesh and D3DXSplitMesh are stubs too, so any future mesh work hits the same wall. Verify by comparing vertex/face counts and attribute ranges against the D3DX output on Windows.

1. How the Windows dependency actually distributes

The architecture is far more portable than the mechanics. The engine is single-threaded by construction, has exactly one timing seam (SystemClock, implemented in MUNGA_L4/L4TIME.cpp), one file-I/O seam (MUNGA/FILESTUB.cpp, six syscalls), a formal network transport interface with all real socket calls in one ~350-line class (MUNGA_L4/L4NETTRANSPORT.cpp), and audio already speaks OpenAL. But the mechanical surface is total: 1,800 of 1,844 quoted #include lines cite a filename in a case that doesn't exist on disk, and 334 use backslash separators — on a case-sensitive filesystem, nothing compiles until a repo-wide normalization pass is done.

Tree Lines Portability
RP\ 16,441 Clean. Zero Win32 API, zero Win32 types, zero threads. Only 16 stricmp and 4 getenv.
MUNGA\ 116,695 ~90% clean (267 of 297 files carry no Win32/MSVC token). The leaks are few and named: PostQuitMessage as the abort path (32 sites in 9 files; the portable prototype still sits commented out at MUNGA/STYLE.H:31), MUNGA/MATRIX.h line 4 including <D3DX9.h> (which transitively feeds windows.h to most of the engine), HWND/SOCKADDR_IN carried as opaque values in a handful of headers, and SPOOLER's CreateDirectoryA/CopyFileA.
RP_L4\ 22,588 Mixed. 20 of 34 files clean. The rest is WinMain/WndProc (RP_L4/RPL4.CPP) plus ~6,300 lines of hand-written Win32+GDI UI (front end, lobby, console board) behind no platform interface.
MUNGA_L4\ 76,575 The platform layer. 41 of 100 files touch D3D9 / DirectInput / XInput / Winsock / serial / WinRT / Steam. This is what a native port rewrites.
DivLoader\ 1,714 No callers. Entirely non-portable (D3D9 in its public API, an implementation-defined bitfield read off disk) — and nothing in this tree calls it. Delete, don't port.

Inventories that came back smaller than expected:

  • Input: the only XInput entry point used anywhere is XInputGetState (3 call sites). The live DirectInput reader (MUNGA_L4/L4JOY.cpp) models 8 axes / 32 buttons / 4 hats — a direct match for SDL's joystick model.
  • Audio: OpenAL (~91 calls) + libsndfile (3 calls), zero winmm/mmsystem anywhere. EFX is already optional and self-degrading. One engine-core leak: MUNGA/AUDIO.cpp calls three al* functions directly.
  • Networking: TCP only, no UDP, no select()/poll() — a pure per-frame nonblocking poll loop branching on exactly three error codes (WSAECONNREFUSED/WSAECONNRESET/WSAEWOULDBLOCK).
  • Steam: four interfaces total (NetworkingSockets, NetworkingUtils, Matchmaking, Friends+User), all gated behind the SteamNetTransport_ClientLibraryPresent() probe. The vendored SDK already ships linux32/linux64 libsteam_api.so. Steam Input is not used.
  • Renderer: 100% fixed-function D3D9 — no shaders, no D3D9Ex. All ~193 device calls sit in five MUNGA_L4 files. D3DX usage is mesh loading (D3DXLoadMeshFromXA), texture loading (PNG), and matrix helpers.

The leaky seam is windowing: of ~430 user32/gdi32 call sites, about two-thirds live outside the platform layer, and the per-frame message pump sits in engine core (MUNGA/APPMGR.cpp:126). The cockpit composition is genuinely exotic Win32: up to 11 top-level windows, D3D Present into a child STATIC control via hDestWindowOverride, GDI-painted panes clipped over the 3D viewscreen, and hit-testing that relies on the stock STATIC proc returning HTTRANSPARENT.

External findings (August 2026): 32-bit D3D9 through DXVK is Proton's most-traveled path and is deliberately preserved — Steam Runtime 4 dropped most i386 libraries except the ones Proton needs. Wine 10 (in current Proton) merged Vulkan child-window rendering, removing the one historical blocker that would have hit the viewscreen pane directly. Valve's stated position is neutral between native and Proton, Deck Verified is achievable with a Windows-only build, and the prevailing small-studio practice for legacy Win32 titles is to ship Proton officially. Wine resolves filenames case-insensitively, so the repo's asset-case inconsistencies cost nothing on this path.

One dated fact that matters: a 32-bit-specific Proton bug corrupted ISteamNetworkingSockets::ReceiveMessagesOnConnection data — exactly RP412's receive path — and was fixed only in Proton 10.0-4b / 11.0-1b (July 2026). The multiplayer test matrix should pin that as the minimum version.

Fix in the Windows build (confirmed in code)

Written 2026-08-12 as prediction. The first row is disproven by the field test and is kept only so the reasoning is on the record; rows 25 stand and remain Deck work. See §0.

Item Evidence Candidate fix
WinRT lamp mirror is a static import. WRONG — see §0. The reasoning was that windowsapp.lib is not delay-loaded and Wine has no windows.devices.lights, so the image would fail to load before the in-code guards could run. In practice Wine satisfies the WinRT API-set imports and the game boots; KeyLight code demonstrably executes. MUNGA_L4/L4KEYLIGHT.cpp line 33 None needed.
Second top-level window fights gamescope. The "Plasma Display" window plus the exploded L4MFDSPLIT=2 mode (6 top-level panes) hit gamescope's known multi-window weakness on Deck. MUNGA_L4/L4PLASMASCREEN.cpp line 271, MUNGA_L4/L4MFDVIEW.cpp line 297 On Deck/gamescope, default the plasma glass into the cockpit window (the SetParent path already exists at L4PLASMASCREEN.cpp:91) and treat exploded view as desktop-only.
Implicit hit-test transparency. Clicks fall through the viewscreen only because the stock STATIC proc returns HTTRANSPARENT; no explicit WM_NCHITTEST handler exists. If Wine's STATIC differs, all mouse input over the 3D view dies silently. MUNGA_L4/L4VB16.cpp line 4659 Handle WM_NCHITTEST explicitly in the viewscreen subclass. Removes the dependency on an undocumented control behavior everywhere.
Deck legibility floor. The 1920×1080 canvas lands on Deck at 1280×720 — a 1.5× shrink. Valve's gate is 9 px minimum font height, so anything under ~14 px on the canvas fails. Fixed canvas: MUNGA_L4/L4APP.cpp line 269 Audit the smallest cockpit and front-end type; bump or provide a Deck-scale preset.
Gamepad-complete front end. The menu/lobby is mouse-driven GDI; callsign entry is a Win32 EDIT control. Deck Verified requires the full flow on pad alone, with the Steam on-screen keyboard for text. RP_L4/RPL4FE.cpp line 2467 Add pad navigation to the front end and invoke ShowFloatingGamepadTextInput for the callsign field. The largest Path-A work item.

Verify under Proton (status as of the 2026-08-14 field test)

  • Child-window Present: Present(…, hDestWindowOverride) into a child STATIC under DXVK — supported per the DXVK source (per-HWND presenter map) and Wine 10's child-window Vulkan work, but this exact composition (GDI siblings clipped over the presented pane) is the least-exercised path in any D3D9 stack. STILL OPEN — the field test ran wined3d, not DXVK. Next test.
  • Steam networking end-to-end: repeat the three-machine SDR/FakeIP race (STEAM-3-MACHINE-TEST.md) with one or more peers on Proton ≥ 10.0-4b. No Proton-specific FakeIP defects are on record, but absence of reports is not confirmation. STILL OPEN.
  • Wine's d3dx9: D3DXLoadMeshFromXA and D3DXCreateTextureFromFileDONE, and both are fine. The stubbed import was D3DXConcatenateMeshes; see §0.
  • Input odds and ends: the IG_ RawInput device-path heuristic for XInput de-duplication (L4JOY.cpp:72-146) assumes Windows-shaped HID paths; GetAsyncKeyState + foreground-window focus gating under gamescope's focus model; the legacy DirectInput paths' DISCL_EXCLUSIVE claims.
  • The joyconfig wizard (AllocConsole + conio, L4JOY.cpp:811-1392) very likely misbehaves under Proton — it is optional, and pad/keyboard defaults don't need it; document rather than block on it.
  • Multi-adapter gauge windows (up to 4 D3D devices, one per adapter, L4VB16.cpp:207) are thin ice under DXVK — but that's the arcade multi-monitor config, not the consumer default. Confirm the default path never creates them.

3. Path B — the native port, honestly scoped

Feasible — the clean core makes it a real option, not a rewrite of the game — but it is a phased engineering project, not a compatibility fix:

  1. Mechanical compile pass — everything blocks on this. Normalize include case and separators repo-wide (267 of 289 distinct include names don't match disk case; renaming files inside a case-insensitive checkout takes care), shim the MSVC CRT tail (100 stricmp, the Annex K _s family, the _time64 family, itoa/strlwr, 9 std::ostrstream sites), fix Fail_To_Debugger's non-const char* signature (GCC/Clang reject it outright, so the DEBUG_LEVEL>0 build doesn't compile at all), and address the SKIPPY_CAST strict-aliasing punning (MUNGA/STYLE.H:108).
  2. Drain the core leaks. Replace PostQuitMessage-as-abort with the portable hook whose prototype still sits commented out in STYLE.H:31 (32 sites); strip <D3DX9.h> out of MATRIX.h and give it a neutral interop type; typedef-shim the HWND/HINSTANCE/SOCKADDR_IN values carried in core headers; split RIOBase out of MUNGA_L4/L4RIO.h so the controls stack stops dragging <windows.h> through the serial-packet header; port FILESTUB (six syscalls) and L4TIME (one file); add a path-separator/case layer to FILEUTIL and the 19 hardcoded "dir\\" literals.
  3. The 32/64-bit fork — the strategic decision. The wire protocol and .RES format are /Zp1-packed host-order struct dumps carrying size_t and unsigned long fields (MUNGA/RECEIVER.h:172, MUNGA/RESOURCE.h:208-210). A 32-bit native build keeps every format byte-identical but targets Steam's legacy scout runtime — swimming against the current. A 64-bit build (what Steam Runtime 4 wants) means retyping the serialized fields to fixed widths, discovering and pack-annotating the serialized struct set (46 raw stream overloads, no manifest exists), and versioning the wire so Linux and Windows builds can still race each other. Dovetails with the un-/Zp1 cleanup already on the roadmap; if the native port ever happens, 64-bit with fixed-width formats is the right side of the fork.
  4. The new platform layer. SDL for window/events/input (XInput's one function → SDL_GameController; L4JOY's 8-axis/32-button model maps directly to SDL_Joystick, and SDL makes the RawInput de-dup unnecessary); OpenGL for the fixed-function renderer (~193 device calls in five files, no shaders to translate — the real work is replacing D3DX mesh/texture loading); the CPU-side gauge canvases become streaming textures instead of GDI blits; OpenAL-soft is a near drop-in; sockets go BSD (mechanically small: 3 errno branches, ~45 SOCKADDR_IN occurrences); Steam loads via dlopen through the flat API (~30 symbols) since ELF has no /DELAYLOAD.
  5. Rebuild the desktop UI. The front end, lobby, and console board — ~6,300 lines of GDI drawing and Win32 controls behind no interface — get redrawn through the engine or SDL, and the ~580-line console-mode joyconfig wizard gets a portable rewrite. This is also where the four separate message pumps collapse into one event loop.
  6. Delete, don't port. The legacy DirectInput paths (L4DINPUT, ThrustMaster), DOS-era L4KEYBD/L4MOUSE stubs, the AWE32 audio layer and sos/, JOYSTICK.asm, DivLoader (no call sites), and Windows Dynamic Lighting (OS-exclusive by nature). The serial RIO path is optional hardware support — stub it, or port it to termios only if pod preservation on Linux ever matters.

4. Sequencing

The Path-A fixes (lamp-mirror delay-load, explicit hit-test, Deck window policy, font audit, pad-driven front end) all improve the Windows build on their own, and none are wasted if Path B ever runs. The reverse is not true. When this resumes: an afternoon smoke test of the current build under Proton on a Linux box — child-window Present first — before committing to anything.