Files
RP412/docs/RP412-UNIFIED-BUILD.md
T
CydandClaude Fable 5 29ad567ffb What a native Linux build would actually cost, measured
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>
2026-08-14 18:07:05 -05:00

15 KiB
Raw Blame History

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:20 void Rewind() { SetPointer(0U); } becomes ambiguous between SetPointer(void*) and SetPointer(size_t) the moment size_tunsigned int. Fails in 171 of 172 TUs on 64-bit, zero on 32-bit.
  • On LP64, size_t matches none of the eight MemoryStream_Read overloads. On ILP32 it silently binds to the unsigned int one.

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 12 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 .X files 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, RPL4GAUG all measure zero Win32/D3D hits, plus ~4,700 of L4VB16.cpp's 7,230 lines (the software rasterizer, the L4GraphicsPort channel 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 public Scalars. PadRIO implements it in 6 methods.
  • The GDI vocabulary is minimal: no Rectangle, Ellipse, LineTo or CreatePen anywhere. The entire visual language is FillRect, FrameRect, DrawTextA, one Polygon (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 03 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, ostrstreamostringstream, 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-9 is the real <windows.h> leak<Winsock2.h> reaches APP.h and 157 TUs, and is why APPMGR.h:5's bare extern HWND ghWnd; compiles at all. (MATRIX.h's <D3DX9.h> is the other route and is much narrower: 10 files, 2 members.) Replace SOCKADDR_IN in engine headers with a portable address struct.
  • VIDREND.h:60 std::vector<MONITORINFO> → portable rect struct. APPMGR.h/SPOOLER.h HWND/HINSTANCE → opaque handle.
  • Split RIOBase out of L4RIO.h (lines 16220 have no Win32 dependency; only RIO below needs l4pcspak.h<windows.h>). Retype ~8 RIO::RIOBase:: in L4CTRL.h.
  • PostQuitMessage → the portable abort hook whose prototype is still commented out at MUNGA/STYLE.H:31 (54 sites).
  • Remove <dos.h> from MUNGAL4.H:6 (reaches 62 TUs). Fix the 3 OpenAL calls leaking into MUNGA/AUDIO.cpp.
  • One ResolveAssetPath() for separator, case, and an exe-relative baseGetModuleFileName is called nowhere today, so everything resolves against the working directory, which breaks read-only installs and XDG conventions. Careful: RPL4VID.cpp:1099 does TempFileName[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 namespacebindings.txt stores 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_USEW has no GL equivalent: use a normal depth buffer and glPolygonOffset for the decal pass, which is the correct GL tool and replaces the _33 -= 5e-7 projection hack.
  • Asset loaders: a text .X parser for the 8 templates in use; stb_image for PNG; a hand-written mesh merge replacing D3DXConcatenateMeshes. The merge must reproduce D3DX's subset-numbering order exactlyConsolidateSingleObject remaps attribute IDs against it and both lookup-failure branches are empty //Freak out blocks. It must also preserve one-texture-object-per-image, because HashDrawOp hashes the raw texture pointer (which is why the 1×1 per-plate marker textures exist).
  • BSD sockets behind the existing NetTransport seam (3 errno branches). Steam via dlopen + steam_api_flat.h — ELF has no delay-load equivalent and C++ vtable calls can't be resolved by dlsym. The linux32/linux64 .so files are already on disk but .gitignored.
  • Shims: FILESTUB (6 syscalls; _lseek's long caps files at 2 GB) · L4TIMEclock_gettime · the console thread → std::thread · the crash handler → signal + backtrace.
  • Fix the particle VB lock length (L4PARTICLES.cpp:513 requests 1/6 of what it writes) — benign under D3DLOCK_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

  • .gitattributes forces eol=crlf on 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 of RPL4.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.txt and 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 02 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 02 can be cherry-picked as ordinary maintenance and the rest left on the shelf.