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>
This commit is contained in:
Cyd
2026-08-14 18:07:05 -05:00
co-authored by Claude Fable 5
parent 9fae4406b5
commit 29ad567ffb
3 changed files with 303 additions and 0 deletions
+1
View File
@@ -127,6 +127,7 @@ mission. Full map with pad and keyboard diagrams:
| [docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) | Multiplayer test procedure, Steam Input notes, the abort key |
| [docs/RP412-LINUX.md](docs/RP412-LINUX.md) | Linux compatibility assessment — Proton path vs native port; parked until late playtesting |
| [docs/NET-TEST.md](docs/NET-TEST.md) | Network test protocol — WAN emulation profiles, the NetLog/NetStats telemetry, the stall-recovery test, `-spoolstats` |
| [docs/RP412-UNIFIED-BUILD.md](docs/RP412-UNIFIED-BUILD.md) | Proposal (not scheduled) — one source tree building native on Windows and Linux; the portability survey behind it |
| [BUILD.md](BUILD.md) | Toolchain and build steps |
Dev tooling: `tools/two-pod-test.ps1` races two pods on loopback,
+6
View File
@@ -121,6 +121,12 @@ is genuinely required; Steam installs it as a depot prerequisite.)
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 native port** is scoped separately in
[RP412-UNIFIED-BUILD.md](RP412-UNIFIED-BUILD.md) (proposal, not scheduled).
Its survey supersedes several figures in §1 and §3 below — notably that
`/Zp1` affects exactly one struct, that the `<windows.h>` leak into engine
core is `NETWORK.h` rather than `MATRIX.h`, and that ISO conformance rather
than include hygiene is what actually blocks a GCC compile.
- **The durable fix for the blocker**: replace `D3DXConcatenateMeshes` with a
hand-written mesh merge, ~6080 lines using only helpers Wine implements
(`D3DXCreateMesh`, `D3DXGetDeclVertexSize`, `D3DXVec3TransformCoordArray`,
+296
View File
@@ -0,0 +1,296 @@
# 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](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:
```c
// 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:
```cpp
// 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_t`
`unsigned 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 `Scalar`s.
`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`, `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-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 base**
`GetModuleFileName` 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 namespace** — `bindings.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 exactly** —
`ConsolidateSingleObject` 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 `.gitignore`d.
- **Shims**: `FILESTUB` (6 syscalls; `_lseek`'s `long` caps 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: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.