pack-dist copies OpenAL32.dll out of the build machine's SysWOW64. That is the same shape as the d3dx9_43 bug found this week - a runtime dependency the dev box quietly satisfies - except here it does not fail outright, it just means the audio implementation a customer gets is decided by whatever was installed on the machine that happened to pack the release. And what that machine has is Creative's 2009 stack. OpenAL32.dll v6.14 is only a router; the real implementation is wrap_oal.dll v2.2.0.5 beside it, with oalinst.exe bundled as a 791KB fallback installer. The router exists so several vendors' OpenAL implementations could coexist on one machine, which stopped being a thing about fifteen years ago. Bundling OpenAL Soft instead is the fix: vendored like steam_api.dll is, not lifted from the system. wrap_oal.dll and oalinst.exe go, dist gets 1.2MB smaller, and the same implementation runs on Windows and Linux - which matters more than the size, because it means an audio bug reproduces on both instead of being someone's Windows. It also removes the only __declspec in the tree, which lives in Creative's al.h. Only al.h, alc.h and efx.h are ever included, eleven sites across ten files. efx-creative.h, EFX-Util.h and xram.h are vendored and referenced by nothing, so they simply go. Filed as a near-term item rather than a port phase, and written up in both docs, because it stands on its own: OpenAL is not what makes the game hard to move. The API is cross-platform and all ninety-one call sites compile unchanged against libopenal - it is the reason audio barely appears in the port plan at all. Dropping OpenAL itself would mean reimplementing EFX reverb and rewriting ninety-one working call sites to remove a dependency that is not in the way. The note says so, so nobody talks themselves into it later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 KiB
RP 4.12 — unified Linux/Windows native build (proposal)
Status: proposal, not scheduled. Written 2026-08-14 against HEAD 9fae440.
Nothing here is committed to. It is filed so the measurements survive — the
survey behind it took three full sweeps of the tree, and the numbers are the
durable part whether or not the plan is ever run.
The shipping Linux path today is Proton, and it works: see
RP412-LINUX.md §0 for the field test and the d3dx9_43.dll
fix that unblocked it. This document describes the other thing — one source
tree producing a native binary on both platforms — and exists because the
question "what would that actually take?" deserved a real answer rather than a
guess.
Do this one now, independent of the plan
Swap Creative's OpenAL for OpenAL Soft. This is not port work. It is the
same shape of problem as the d3dx9_43.dll bug found on 2026-08-14 — a runtime
dependency the build machine quietly satisfies — and it is worth fixing on
Windows whether or not any of the phases below ever run.
What ships today is Creative's 2009-era stack: OpenAL32.dll v6.14 is only a
router that dispatches to wrap_oal.dll v2.2.0.5 (the real implementation),
with oalinst.exe (791 KB) bundled as a fallback installer. Worse,
pack-dist.ps1 copies OpenAL32.dll out of the build machine's SysWOW64,
so the audio stack that ships is whatever happened to be installed on the
machine that packed it. The router architecture exists so multiple vendors'
implementations could coexist, which stopped mattering fifteen years ago.
The work:
- Bundle OpenAL Soft — ship its
soft_oal.dllrenamed toOpenAL32.dll, vendored in the repo likesteam_api.dllis, not copied from the system. - Delete
wrap_oal.dllandoalinst.exefromdist\and frompack-dist.ps1; drop the "copy fromSysWOW64if present" branch. Net ~1.2 MB smaller. - Replace the vendored Creative headers in
MUNGA_L4\openal\with OpenAL Soft's. Onlyal.h,alc.handefx.hare ever included (11 sites across 10 files) —efx-creative.h,EFX-Util.handxram.hare referenced by nothing and can simply go. - Replace
lib\OpenAL32.libwith OpenAL Soft's import library.
Why it is worth doing on its own merits:
- The same implementation then runs on both platforms, since Linux distros
ship OpenAL Soft as
libopenal.so. Audio bugs reproduce across platforms instead of being "works on my Windows." - It removes the only
__declspecin the entire tree, which lives in Creative'sal.h. - It ends the silent dependency on the packing machine's system state.
Licensing is unremarkable: OpenAL Soft is LGPL, and shipping it as a separate, dynamically-linked DLL is the standard compliant arrangement for a closed-source game.
Verify: audio plays in a mission; EFX reverb still engages (EFX_Available()
in L4AUDEFX.cpp — it probes ALC_EXT_EFX and degrades silently, so confirm
the log does not say "filters and reverb inert"); the two-pod harness
passes; dist\ contains no wrap_oal.dll or oalinst.exe.
What this does not do: remove OpenAL. The API is cross-platform and is
precisely why audio barely features in the phases below — all ~91 call sites
compile unchanged against libopenal.so. Dropping OpenAL entirely is possible
in principle (the game disables OpenAL's own attenuation and Doppler, does its
own 3D math, and never streams — it is using OpenAL as little more than a
mixer) but would mean reimplementing EFX reverb and rewriting ~91 working call
sites to remove a dependency that causes no portability problem. Not
recommended.
The four decisions this plan assumes
Taken 2026-08-14. Changing any of them changes the plan's shape.
| Question | Decision | Consequence |
|---|---|---|
| Cross-play Windows↔Linux? | Yes, same race | Serialized platform-sized types must be pinned to fixed width |
| Renderer | GL on Linux now, converge Windows later | The GL path must be Windows-capable from day one — SDL context creation, portable GL loader, no GLX-specific code |
| Build system | One CMake for both | The working MSBuild build gets migrated; highest-regression-risk step |
| What ships meanwhile | Proton stays the shipping path | No deadline pressure; Deck work proceeds separately and benefits both |
What was measured
The survey is the reason to keep this document. Three findings materially changed the risk picture from the earlier 2026-08-12 assessment.
1. /Zp1 is nearly a non-issue
The prior audit flagged project-wide 1-byte struct packing as a serious
entanglement with "no manifest of which structs matter." There is now a
manifest. Of 31 distinct whole-object stream types, 29 are enums, scalar
typedefs, or all-float aggregates. Modelled at both packings and compared by
sizeof, exactly one struct changes layout:
// MUNGA_L4/L4D3D.cpp:264-273 — read whole from every .met file at :282
struct { unsigned char minify, magnify, alpha, wrap_u, wrap_v;
bool doScroll; float scrollUDelta, scrollVDelta; } fileTexOp;
// /Zp1 = 14 bytes; default packing = 16 (padding after the bool)
Dropping /Zp1 costs one #pragma pack(1). It also retires
WINDOWS_IGNORE_PACKING_MISMATCH, the Steamworks pack(push,8) guards, and
the latent RPL4TOOL-vs-rpl4opt packing divergence that /FORCE:MULTIPLE
currently hides.
2. ISO conformance is the real blocker, not includes
Under GCC 15 with -fsyntax-only, 172 of 172 MUNGA+RP translation units
fail — and 171 still fail with every 64-bit-related error removed. The
keystone is one line:
// MUNGA/SCHAIN.h — friend-declared name used as a type
class SChainLink : public Link {
friend class SChain; // :10
SChainLink(SChain *chain, Plug *plug, …); // :18 ← not a type under ISO
};
schain.h sits in MUNGA.h's precompiled block, so that single line breaks
the entire core. The same pattern appears at 155 friend class sites over
117 names. Other classes: iterator typedefs of friend-declared names (66
TUs), Now()/HiResNow() declared friend-only inside SystemClock but called
at 50+ sites (25 TUs), new (Subsystem (*[n])) array syntax GCC parses as a
lambda (4 sites), __try/__except, std::ios::_Openprot, MSVC <float.h>
SW_* constants, goto crossing initialization.
Roughly 30 distinct sites across ~20 headers — small in absolute terms, but they gate all 241 TUs.
3. LP64 has two systemic breaks
MEMSTRM.H:20void Rewind() { SetPointer(0U); }becomes ambiguous betweenSetPointer(void*)andSetPointer(size_t)the momentsize_t≠unsigned int. Fails in 171 of 172 TUs on 64-bit, zero on 32-bit.- On LP64,
size_tmatches none of the eightMemoryStream_Readoverloads. On ILP32 it silently binds to theunsigned intone.
The good news, quantified
RP\is 100% clean — 0 of 20 TUs touch a platform API. Of 241 compiled TUs, 190 touch no Win32/D3D/Winsock symbol at all.- The renderer's distinct surface is tiny: 19 render states, 7
texture-stage states (stages 1–2 only ever
D3DTOP_DISABLE, so effectively one texture unit), 5 sampler states hardcoded to linear, 5 FVFs, one mesh draw call, no shaders, no indexed draws, no render targets, no stencil, no scissor, one directional light. - The assets are a small closed subset: all 224
.Xfiles are text format using 8 templates, no skinning, no vertex colours; 56,521 verts total, largest single mesh 2,195. All 104 PNGs are 8-bit RGBA. - The gauge/MFD production path is already portable C++ —
GRAPH2D,GAUGREND,GAUGE,GAUGMAP,L4VB8,L4GAUGE,L4GREND,L4GAUIMA,RPL4GAUGall measure zero Win32/D3D hits, plus ~4,700 ofL4VB16.cpp's 7,230 lines (the software rasterizer, theL4GraphicsPortchannel packing that emulates the pod's five-MFDs-in-two-video-outputs trick, the palette fades). Only its destination is platform-bound. RIOBase— the whole input seam — is 2 pure virtuals (GetNextEvent,SetLamp), 13 virtuals with working empty bodies, and 5 publicScalars.PadRIOimplements it in 6 methods.- The GDI vocabulary is minimal: no
Rectangle,Ellipse,LineToorCreatePenanywhere. The entire visual language isFillRect,FrameRect,DrawTextA, onePolygon(a combo arrow),BitBlt,StretchDIBits.
Counts at HEAD
1856 quoted includes · 351 with backslashes · 1764 case-wrong (only 30
fully correct) across 468 files · 226 #pragma hdrstop · 129 stricmp in 28
files · 12 stdext::hash_map in 5 files · 54 PostQuitMessage in 17 files
· 111 getenv in 38 files · 421 user32/gdi32 calls in 27 files · 54 backslash
path literals in 17 files, of which 8 are directory-case and 3 file-case
mismatches against disk.
Confirmed dead — delete rather than port
DivLoader (zero references tree-wide; DivLoader.lib is 2.6 MB and linked by
nothing) · RPL4Lobby_Room/_Join plus RunRoom/PaintRoom/RoomWndProc,
~410 lines with no callers — the front end absorbed the room · L4KEYBD (only
call site commented out) · L4MOUSE (fully stubbed) · MUNGA_L4/sos/ and
libDPL/ · LOGGER.cpp (on disk, in no project) · the 44 .tcp test includes.
The strategic shape
Almost all the work happens on Windows, verified by the existing build and the two-pod harness, before a single Linux compile is attempted. Phases 0–3 make the Windows build ISO-conformant, platform-decoupled, fixed-width and CMake-built, each step green against a live race. Only Phase 4 adds Linux.
That ordering is the point. It is what stops a year-long project from having a six-month stretch where nothing runs and nothing can be tested.
Phases
Phase 0 — ISO conformance and hygiene (Windows only)
Fix the friend class-as-type family (keystone SCHAIN.h:18; same shape in
CAMINST.h, ENTITY.h, INTORGN.h, PLUG.h, TRACE.h, L4AUDHDW.h, and
the iterator typedefs in HOSTMGR.h, HOST.h, COLLORGN.h, COLLASST.h,
RNDORGN.h, MISSION.h). Declare Now()/HiResNow()/
Get_Frame_Percent_Used() at namespace scope. Clear the ~30 remaining
conformance sites. Scripted include normalization across 468 files. A CRT
compat header (stricmp, Annex K _s, ostrstream→ostringstream, itoa).
Drop #pragma hdrstop. Delete the dead list.
Gate: MSBuild Release clean · two-pod passes · g++ -fsyntax-only reaches
zero errors over MUNGA+RP on a 32-bit target.
Phase 1 — Break platform coupling (Windows only)
MUNGA/NETWORK.h:8-9is the real<windows.h>leak —<Winsock2.h>reachesAPP.hand 157 TUs, and is whyAPPMGR.h:5's bareextern HWND ghWnd;compiles at all. (MATRIX.h's<D3DX9.h>is the other route and is much narrower: 10 files, 2 members.) ReplaceSOCKADDR_INin engine headers with a portable address struct.VIDREND.h:60std::vector<MONITORINFO>→ portable rect struct.APPMGR.h/SPOOLER.hHWND/HINSTANCE→ opaque handle.- Split
RIOBaseout ofL4RIO.h(lines 16–220 have no Win32 dependency; onlyRIObelow needsl4pcspak.h→<windows.h>). Retype ~8RIO::→RIOBase::inL4CTRL.h. PostQuitMessage→ the portable abort hook whose prototype is still commented out atMUNGA/STYLE.H:31(54 sites).- Remove
<dos.h>fromMUNGAL4.H:6(reaches 62 TUs). Fix the 3 OpenAL calls leaking intoMUNGA/AUDIO.cpp. - One
ResolveAssetPath()for separator, case, and an exe-relative base —GetModuleFileNameis called nowhere today, so everything resolves against the working directory, which breaks read-only installs and XDG conventions. Careful:RPL4VID.cpp:1099doesTempFileName[8] = skeleton_type;, hardcoding the"video\\"prefix length.
Gate: MSBuild clean · two-pod passes · a race against an unmodified build still works, since nothing on the wire has changed yet.
Phase 2 — Fixed-width formats and LP64 readiness (Windows only)
Retype, don't redesign — the files already contain 32-bit values.
Pin to uint32_t: RESOURCE.h:208-210 and the .RES offset table
(RESOURCE.cpp:658/807/867 — note :867 already writes sizeof(long) against
:658's sizeof(size_t), a latent inconsistency) · RECEIVER.h:172
messageLength, which is in every wire packet and every spool byte ·
SIMULATE.h:57, DAMAGE.h:137, BOXSOLID.h:171, CAMINST.h:12 ·
TIME.h:53 Time::ticks · the spool headers in SPOOLER.cpp and
L4CTRL.cpp:2662 · L4VIDEO.cpp:920/970 fread(sizeof(size_t)).
L4NET.H:404/431 unsigned long streamPointer carries a SOCKET and those
classes are on the wire — must become a fixed-width handle.
Fix the MemoryStream overload set for LP64. Handle VDATA.h:9/12
(RegisteredClassMemoryAddress is a truncating pointer cast used as a
container key) and VERIFY.cpp:132/213 *(int*)(0xFFFFFFFF), a hard 64-bit
compile error.
Drop /Zp1; add #pragma pack(1) to fileTexOp.
Egg text rasterization: AppendNameBitmap (RPL4FE.cpp:1809) uses GDI to
rasterize the callsign into 1bpp hex rows inside the egg. With cross-play
on, both platforms must emit identical bytes — so this moves to a bundled
bitmap font used on both, not a native text API on each.
Gate: .RES loads unchanged (validated through
tools/resbuild/build-res.ps1, the only regeneration path) · .met textures
still read · a cross-version race proving the wire is byte-identical.
Phase 3 — CMake migration (Windows first)
Reproduce the settings exactly: Unicode charset, the four defines,
/FORCE:MULTIPLE, steam_api.dll delay-load + delayimp, /DYNAMICBASE:NO,
LargeAddressAware, LTCG/COMDAT in Release, $(DXSDK_DIR) paths appended
after the Windows SDK, and the stamp-version pre-build step (~30 lines of
CMake; the logic has nothing PowerShell-specific).
Use -iquote, never -I for the source directories: MUNGA/TIME.h,
SET.h, RANDOM.h, ITERATOR.h collide with standard headers — verified to
break <pthread.h> → <time.h> → MUNGA/TIME.h.
Gate: the CMake-built Windows exe passes the two-pod harness and a smoke mission before MSBuild is retired.
Phase 4 — The Linux platform layer
- SDL3 for window, events, timers, input. Map SDL scancodes into the
Windows VK namespace —
bindings.txtstores VK codes and is a shipped, user-editable file; changing the namespace breaks every existing profile. - GL 2.1 compatibility profile implementing
VideoRenderer— a near-1:1 mapping for fixed-function.D3DZB_USEWhas no GL equivalent: use a normal depth buffer andglPolygonOffsetfor the decal pass, which is the correct GL tool and replaces the_33 -= 5e-7projection hack. - Asset loaders: a text
.Xparser for the 8 templates in use;stb_imagefor PNG; a hand-written mesh merge replacingD3DXConcatenateMeshes. The merge must reproduce D3DX's subset-numbering order exactly —ConsolidateSingleObjectremaps attribute IDs against it and both lookup-failure branches are empty//Freak outblocks. It must also preserve one-texture-object-per-image, becauseHashDrawOphashes the raw texture pointer (which is why the 1×1 per-plate marker textures exist). - BSD sockets behind the existing
NetTransportseam (3 errno branches). Steam viadlopen+steam_api_flat.h— ELF has no delay-load equivalent and C++ vtable calls can't be resolved bydlsym. Thelinux32/linux64.sofiles are already on disk but.gitignored. - Shims:
FILESTUB(6 syscalls;_lseek'slongcaps files at 2 GB) ·L4TIME→clock_gettime· the console thread →std::thread· the crash handler →signal+backtrace. - Fix the particle VB lock length (
L4PARTICLES.cpp:513requests 1/6 of what it writes) — benign underD3DLOCK_DISCARD, not under a mapped GL range.
Gate: a Linux binary reaching LocalConsole: mission running with no crash,
then a Linux↔Windows two-machine race.
Phase 5 — The UI
Reimplement the front end's drawing against a portable primitive set: rect
fill, rect frame, single-line text with ellipsis, a monospace font at a
requested pixel height, a text-extent query, plus dropdown and single-line-entry
widgets. RPL4FE.cpp is ~32% drawing/windowing and ~68% already portable, and
LayoutMenu is 137 lines of pure rect arithmetic before it touches Win32.
Candidate substrate: the engine's own GraphicsDisplay interface
(MUNGA/GRAPH2D.h:213), which already provides DrawFilledRectangle,
DrawText and DrawBitMap.
Phase 6 — Convergence
Switch Windows to the GL renderer, retiring the D3D9 path, the June-2010 SDK
dependency and d3dx9_43.dll shipping — but only after the GL path has field
time on Linux.
Risks
.gitattributesforceseol=crlfon all source in the working tree, so a Linux checkout gets CRLF. Decide early: normalize to LF, or keep and ensure tooling tolerates it.- Resource-embedded filenames (
GRAPH2D.cpp:188/614/801,RPL4VID.cpp:1093) concatenate a prefix with names read out ofRPL4.RES; their case cannot be determined from source and needs a resource-table dump. - Phase 3 is the highest-regression-risk step — it replaces a working build system for a shipping product. It is deliberately placed after the code is already portable, so a rollback to MSBuild stays possible.
bindings.txtand the egg name bitmaps are user- and wire-visible formats. Both are handled above, but a mistake in either is a silent compatibility break.
When this would not be worth doing
Stated plainly, because the honest answer matters more than the plan:
Proton already delivers the Linux player experience, and Phases 0–2 are the only parts with standalone value (ISO conformance, decoupled seams, fixed-width formats and an exe-relative path layer would each improve the Windows build on their own). If the goal is "Linux players can buy and play it," that goal is already met by the shipping path. This project is worth starting when the goal is different: retiring a 2010-era closed SDK dependency, owning the render path outright, or removing Wine as a variable in support. If none of those becomes pressing, Phases 0–2 can be cherry-picked as ordinary maintenance and the rest left on the shelf.