Filed, not started. Proton already carries Linux players and the Windows build reaches a running mission under it; this is the answer to the separate question of what one source tree building natively on both would take, so that the number exists before anyone needs it. Three sweeps of the tree, and the measurements are the part worth keeping. Two of them make the job smaller than the earlier assessment feared, and one makes it different. /Zp1 was called an entanglement with no manifest of which structs mattered. There is a manifest now: 31 distinct whole-object stream types, of which 29 are enums, scalar typedefs or all-float aggregates. Modelled at both packings and compared by sizeof, exactly one struct changes - the anonymous .met reader in L4D3D.cpp, 14 bytes packed against 16 default. Dropping /Zp1 costs one pragma. That also retires the packing divergence between RPL4TOOL and the game that FORCE:MULTIPLE has been hiding. The include mess is not the blocker. 172 of 172 core translation units fail under GCC, and 171 still fail with every 64-bit error removed, because MUNGA/SCHAIN.h uses a friend-declared name as a type and sits in the precompiled block. One line breaks the entire core, and 155 friend sites over 117 names share the shape. The whole conformance list is about thirty sites across twenty headers - small, and it gates everything. And the windows.h leak into engine core is NETWORK.h, not MATRIX.h. Winsock2.h reaches APP.h and 157 translation units, which is how APPMGR.h's bare 'extern HWND ghWnd' has been compiling all along. The D3DX9 include in MATRIX.h is real but narrow: ten files, two members. docs/RP412-LINUX.md said otherwise and now says so. What the sweeps found in our favour: RP is clean outright, 190 of 241 units touch no platform symbol, the renderer's distinct surface is nineteen render states and one mesh draw call with no shaders, all 224 .X files are text using eight templates, and the entire gauge production path - including four thousand seven hundred lines of L4VB16 - is already portable C++. RIOBase, the whole input seam, is two pure virtuals and five scalars. DivLoader is referenced by nothing and linked by nothing; the lobby room screen has no callers at all. The plan is phased so that almost everything happens on Windows first, verified by the existing build and the two-pod harness, with Linux arriving only at phase four. A port that spends six months unable to run is a port that gets abandoned. The document ends by saying when this would not be worth doing, which is most of the time: Proton already delivers the player experience, and only the first three phases have standalone value. It becomes worth starting when the goal changes to retiring the 2010 SDK dependency or removing Wine as a support variable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 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.
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.