Platform assumptions

August 20, 2026 · View on GitHub

This is a high-level map of the host assumptions the engine makes — pointer widths, endianness, floating-point precision, OS boundaries, and similar. It exists so contributors stepping into platform work can orient quickly and find the deep-dive doc for whatever they're touching.

This doc does not duplicate the existing deep dives. For pointer-ABI patterns and the fat-type catalogue, see ABI.md. For the savegame load path specifically, see SAVEGAME.md. For calling conventions, see COMPILER_NOTES.md. What lives here is the cross-cutting map and the assumptions that don't yet have their own doc.

It is a baseline. It covers the hot zones with representative file:line citations and grows as platform work surfaces more cases.

Status legend:

  • ✓ contained — assumption is documented and behind a clear boundary (SDL, wire32, build-time backend select, etc.).
  • ⚠ partial — boundary exists but leaks; or a workaround is in place while the root cause is unfixed.
  • ✗ exposed — no boundary; engine code knows the assumption directly.

Truth hierarchy: code > this document > external sources. If a citation drifts, fix the doc.


1. Pointer width and ABI      ⚠ partial

Several writeable structs contain native pointer fields and are serialized as raw bytes. On 32-bit retail those pointers are 4 bytes; on 64-bit hosts they are 8 + alignment padding. Today this is handled by per-struct mirror types and (for legacy saves where the writer's ABI is unknown) auto-retry stride at load time.

Canonical sites:

  • T_PTR_NUM union — LIB386/H/OBJECT/AFF_OBJ.H:21-25
  • T_OBJ_3D (3× T_PTR_NUM, 2× void *, 2× PTR_U32) — LIB386/H/OBJECT/AFF_OBJ.H:28-86
  • T_EXTRA::PtrBody — SOURCES/COMMON.H:704
  • S_PART_FLOW::PtrListDot — SOURCES/FLOW.H:65
  • S_CRED_OBJ_2 (credits HQR records) — SOURCES/CREDITS.H (paired *_DISK type lives there)
  • Wire32 mirror structs for legacy save format — SOURCES/SAVEGAME.CPP:201-342

Deep dive: ABI.md documents the three patterns (paired on-disk type, field-by-field serialization, tolerant read with stride retry), the full fat-type catalogue, the compile-time guards, and the review checklist for new file-load sites.

Next: A canonical NUM_VERSION 37+ save format that does not memcpy pointer-bearing structs would remove this category for new saves entirely; legacy loaders stay in place. Tracked at issue #64.

Renderer-side wraparound

A second, independent face of the 32→64-bit pointer transition, distinct from the disk-layout side above: pointer arithmetic with a U32 offset that may legitimately be negative. On 32-bit, pointer + (U32)-24 wraps in the address space and resolves to pointer - 24. On 64-bit it zero-extends to a +4 GiB byte offset, so the pointer lands in unmapped memory — or, worse, a mapped page belonging to something else, which is silent corruption.

Coupled defect that hides the first one: clip tests written in U32 (if (xMin < ClipXMin)) silently fail for negative xMin, so the existing margin/clip branch never fires and the corrupted offset reaches the inner loop unnoticed.

Worked example — LIB386/SVGA/COPYMASK.CPP, called from DrawOverBrick3 (interior brick recover pass) with x = -24 for column 0. The school scene with the grand wizard and foreground candles triggers it on every entry. Fixed in PR #84 by switching the geometry locals to S32. Pinned by tests/copymask_negx/. Investigation runbook in CRASH_INVESTIGATION.md.

The smell: a renderer-adjacent function takes signed coordinates but stores them in U32 locals before doing pointer math against Log / Screen. Crashes are intermittent because whether pointer + 4 GiB lands in a mapped page is allocator/ASLR luck — easy to mistake for "flaky" rather than a real bug.

Audit log: per-file sweep results live in PLATFORM_AUDITS.md § "U32 wrap". SVGA and pol_work groups swept (#84, #86, #87); 3D + 3DEXT and GRILLE + INTEXT remain.


2. Endianness      ✗ exposed

The engine assumes little-endian throughout. HQR readers cast raw bytes through *(U32 *)ptr. Save reads and writes use memcpy with no byteswap. There is no byteswap helper layer in the tree. Every supported target today is little-endian, so this has not bitten anyone, but the assumption is wide and the call sites do not name it.

  • HQR offset reads as raw casts — LIB386/SYSTEM/HQRMEM.CPP:17,22
  • Save read/write macros (no byteswap) — SOURCES/SAVEGAME.CPP:60-90 (LbaRead / LbaWrite)
  • No byteswap helpers anywhere in the tree (notable absence).

Next: Document as immutable for now. Introduce a byteswap layer only if a big-endian or mixed-endian target enters scope.


3. Floating-point precision and FPU semantics      ⚠ partial

Projection and rotation paths use long double + lrintl() to match the original x87 FPU's round-to-nearest fistp behavior.

The split is between architectures, not between operating systems. Both x86-64 hosts carry a 64-bit mantissa (LDBL_MANT_DIG), Linux and Windows MSYS2 alike, because MinGW-w64 GCC keeps x87 extended precision; MSVC is the compiler that would not, and this tree does not build with it. ARM has no x87, so on macOS arm64 and Android long double is a plain double with a 53-bit mantissa. Measured on each toolchain rather than inferred: lrintl, sqrtl and the projection factor-and-round shape are bit-identical across the two x86-64 hosts, including at ties and near-ties.

  • LongProjectPoint3D — LIB386/3D/LPROJ3DF.CPP:25-28
  • LongRotatePointF — LIB386/3D/LROT3DF.CPP:13-15
  • POLY.CPP slope volatile long double — LIB386/pol_work/POLY.CPP:263, 668, 686, 859-863, 921-936
  • FPU control word reference (test) — tests/fpu_precision/fpu_ops.asm:15-16
  • volatile barrier rationale (forces FPU stack → memory round-trip) — tests/fpu_precision/test_fpu_precision.cpp:109,125

tests/numeric_contract is what pins the values, and it runs on every platform: 161 results across Distance2D/Distance3D, the two rotations, LongProjectPoint3D and LongRotate, asserted where the mantissa is 64 bits and reported where it is not, so the macOS job log says how far ARM sits from the x86-64 numbers. Read that file's own header before drawing a conclusion from the figure: most of its cases cannot discriminate, and it says which ones can. tests/fpu_precision answers a different question, ASM against C, and needs the 32-bit toolchain, so it runs only in the Docker leg; tests/precision_paths drives these paths under the sanitizers without asserting values.

Why it matters beyond the picture: a recorded session declares the mantissa width it was made under (numeric.long_double_bits, see RECORDING.md), because a replay cannot reproduce a simulation whose arithmetic rounds differently. Distance is the one entry point here that the simulation reads rather than the renderer.

Next: Consolidate the lrintl(long double) callers behind a single helper TU (lba_round_to_int) so the platform divergence has one place to live.


4. x86 ASM fastpath      ✓ contained

x86 assembly is opt-in via the ENABLE_ASM CMake option (default OFF). Every ASM function has a CPP equivalent, and ASM↔CPP equivalence is tested per function. Watcom-style register-passing in the legacy ASM is bridged by naked-function shims in the test harness. Non-ASM builds run on any architecture supported by the rest of the toolchain; ASM builds require x86 plus UASM.

  • ENABLE_ASM option — CMakeLists.txt:6-20
  • ASM sources — LIB386/3D/*.ASM (9 files), LIB386/pol_work/*.ASM (12+ files)
  • Watcom calling convention example — LIB386/3D/LROT3DF.ASM:5,40-42
  • ASM↔CPP equivalence tests — tests/3D/, tests/pol_work/, tests/fpu_precision/
  • Naked-function shims (Watcom→cdecl bridge) — tests/ANIM/test_intanim.cpp:29-49

Deep dive: COMPILER_NOTES.md for calling-convention background; ASM_TO_CPP_REFERENCE.md for which modules have CPP equivalents.

Next: None required. Could be retired if profiling ever shows the CPP path is good enough on all targets, but no urgency.


5. OS boundary (filesystem, conditionals, SDL3)      ✓ contained

Operating-system contact is concentrated in a thin layer. SDL3 wraps window, events, audio, video, and timer. Path manipulation is gated by _WIN32 for the separator character; directory creation is SDL's. Asset and save discovery uses SDL_GetBasePath plus LBA2_GAME_DIR plus a candidate walk. Case-insensitivity on macOS APFS bit us once (<version> vs the build's VERSION text file) and is now sidestepped by writing VERSION.txt instead.

  • _WIN32 path separator — LIB386/H/SYSTEM/ADELINE.H:31-37
  • Directory creation goes through SDL_CreateDirectory, which creates parents and understands a drive spec — SOURCES/DIRECTORIES.CPP (AddDirIfNot), covered by tests/user_dirs
  • ADELINE_PATH_SEP usage — SOURCES/RES_DISCOVERY.CPP
  • macOS case-insensitivity hazard (with explanation) — cmake/git_version.cmake:15-20
  • SDL3 window — LIB386/SYSTEM/WINDOW.CPP:5,89
  • SDL3 events — LIB386/SYSTEM/EVENTS.CPP:10,35,64,67
  • SDL3 timer — LIB386/SYSTEM/TIMER.CPP:5-6,29,73
  • SDL3 present path — desktop locks the renderer texture for a zero-copy write; Android stages into a persistent buffer + SDL_UpdateTexture because SDL_LockTexture is MTE-unsafe on Android 16+ (texture-internal memory freed/realloced between frames → stale tag → SEGV_ACCERR). The fork is confined to a present-target acquire/commit seam — LIB386/SVGA/SDL.CPP
  • Asset discovery — SOURCES/RES_DISCOVERY.CPP:238-315

Deep dive: GAME_DATA.md for the discovery rules and override knobs.

Next: Audit #ifdef _WIN32 / __linux__ / __APPLE__ for vestigial branches. Add the case-insensitivity hazard to a per-PR checklist for files that get added to the build dir on a header search path.


6. Backend abstractions (audio, video)      ✓ contained

Both audio and video are pluggable at build time through CMake options. Engine code sees only the common interface; the chosen backend's TU is compiled in. Good prior art for any future "swap an implementation per platform" work.

  • SOUND_BACKEND (null / miles / sdl) and MVIDEO_BACKEND (null / smacker) — CMakeLists.txt:25-39
  • AIL backend implementations — LIB386/AIL/{NULL,MILES,SDL}/
  • Backend selection in CMake — LIB386/AIL/CMakeLists.txt:3-9
  • Common interface — LIB386/H/AIL/VIDEO_AUDIO.H:17-29

Deep dive: AUDIO.md.

Next: None structural.


7. Build matrix      ✓ contained

CMake presets cover the supported targets and toolchains. Cross-compile to Windows from Linux is supported via mingw-w64.

  • Presets (linux, linux_clang, linux_test, macos_arm64, macos_x86_64, windows_ucrt64, windows_mingw64, cross_linux2win) — CMakePresets.json
  • Cross-toolchain — cmake/mingw-w64-i686.cmake
  • LBA2_NATIVE_ARCH (per-arch tuning flags) — CMakeLists.txt
  • Android arm64 native libs are 16 KB-page aligned (NDK r28 + max-page-size; stored uncompressed + zipalign -P 16; extractNativeLibs="false"), verified in CI by scripts/dev/check-16k-align.sh — see ANDROID.md. 16 KB pages are 64-bit-only, so armeabi-v7a stays 4 KB.

Deep dive: WINDOWS.md for the MSYS2 path; root README for the broader build instructions.

Next: None immediate. Add a one-line comment to each preset explaining its role if not obvious from the name.


8. Android platform layer      ✓ contained

Android-specific behavior — JNI, TV (leanback) detection, storage-permission prompting, and the app-specific external-files dir — is concentrated behind one translation unit. Engine code calls each platform function unconditionally; off-Android each is a no-op stub, so call sites carry no #ifdef. <jni.h> is included in exactly one file. The engine→game layering inversion (WINDOW.CPP reaching up into SOURCES/TOUCH_INPUT.CPP) is gone.

  • Android platform unit (extern "C", stub-on-desktop) — LIB386/SYSTEM/ANDROID.{CPP,H}
  • TV/leanback detection (IsAndroidTVDevice) — LIB386/SYSTEM/ANDROID.CPP
  • MANAGE_EXTERNAL_STORAGE prompt (Android_EnsureExternalStoragePermission) — LIB386/SYSTEM/ANDROID.CPP
  • App-specific external-files dir (Android_GetExternalFilesDir) — LIB386/SYSTEM/ANDROID.CPP
  • Touch overlay (mobile-only input) — SOURCES/TOUCH_INPUT.CPP
  • 16 KB-page packaging — see §7 and ANDROID.md

Deep dive: ANDROID.md for build, data placement, and the touch layout.

Next: None structural. The software-present path carries its own MTE-driven Android fork in the SDL video TU (LIB386/SVGA/SDL.CPP), separate from this system/JNI layer.


Open questions / deeper-dive candidates

These are not assumptions the doc has fully audited — they are areas worth checking when the work surfaces.

  • Char signedness. Default char signedness differs across compilers and platforms. No known site has bitten us, but the codebase is C++98 with heavy x86 heritage, so it is worth a sweep when someone has time.
  • Threading model. Single-threaded today. SDL_GetTicks polled per frame; no pthread, std::thread, or async primitives in tree. If multithreaded work ever lands, the contract for shared state needs to be written down before the first race lands.
  • HQR file format. The on-disk format is implicit in LIB386/SYSTEM/HQRMEM.CPP and LIB386/SYSTEM/HQR.CPP. A written spec would help future format-touching work the same way SAVEGAME.md helps savegame work.
  • Save format canonicalization. A NUM_VERSION 37+ format that does not memcpy pointer-bearing structs would remove the entire pointer-ABI workaround chain for new saves. Tracked at issue #64.

Future directions

Architectural changes that would shift the readiness picture above. Listed here to keep the surface visible; each becomes its own design doc or issue when it matures. Entries are holding-pen items, not commitments.

  • GPU-backed rendering. The polygon path today is software (LIB386/pol_work/, CPU-side rasterization, 8-bit indexed framebuffer). A GPU renderer — SDL_GPU or a thin Vulkan/Metal/D3D backend with cross-compiled shaders — would change the FP-precision picture (matches GPU float semantics, not x87), open the door to higher resolutions and HDR, and deprecate the ASM fillers. Big enough that the software path likely stays as a parallel implementation, with the polyrec replay harness as the equivalence test.
  • Platform abstraction layer (PAL). Routing the engine's direct SDL3 calls (window, events, present, input, timing) through a single g_platform seam, with a headless backend for deterministic CI and a path to console/libretro targets. Grounded in RFC #120 and audited against this map in PLATFORM_PAL_PLAN.md, a PR-sequenced, behavior-preserving extraction plan currently awaiting go/no-go. Complements the directory restructure below (the PAL is the call-routing seam; the restructure is the physical file layout).
  • Directory restructure. LIB386/ ("32-bit Intel") encodes a platform assumption in its name. A renaming sweep (engine/, lib/, or similar) plus splitting platform-specific files into a sys/ subtree would surface the boundaries the way Doom 3's sys/win32/ / sys/linux/ does. No code change; pure rename + #include updates. Worth doing once the team agrees on preferred layout.
  • Canonical save format (NUM_VERSION 37+). Tracked at issue #64. Removes the wire32 / auto-retry chain for new saves; legacy loaders stay.
  • ASM retirement. Once profiling confirms the CPP path is fast enough on all targets, the ENABLE_ASM option could be removed and the *.ASM files retired. The CPP equivalents are already canonical; equivalence is already tested.

Iterating on this doc

When you touch a file referenced here, check that the line numbers still match. When you find a new platform assumption that is not listed, add a row in the relevant section with a citation. When a status changes (a workaround lands or a root-cause fix removes one), update the badge.