Platform assumptions
June 15, 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_NUMunion —LIB386/H/OBJECT/AFF_OBJ.H:21-25T_OBJ_3D(3×T_PTR_NUM, 2×void *, 2×PTR_U32) —LIB386/H/OBJECT/AFF_OBJ.H:28-86T_EXTRA::PtrBody—SOURCES/COMMON.H:704S_PART_FLOW::PtrListDot—SOURCES/FLOW.H:65S_CRED_OBJ_2(credits HQR records) —SOURCES/CREDITS.H(paired*_DISKtype 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. long double is 80-bit on Linux x86_64, 64-bit on macOS arm64 and Windows MSYS2. The result is small but real per-platform divergence in screen coordinates and Z values. Mitigated where it matters (projection, rotation, polygon slopes); tested in tests/fpu_precision/.
LongProjectPoint3D—LIB386/3D/LPROJ3DF.CPP:25-28LongRotatePointF—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 volatilebarrier rationale (forces FPU stack → memory round-trip) —tests/fpu_precision/test_fpu_precision.cpp:109,125
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. Quantify the per-platform delta in a regression test.
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_ASMoption —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 and mkdir signature. 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.
_WIN32mkdir signature + path separator —LIB386/H/SYSTEM/ADELINE.H:38-44ADELINE_PATH_SEPusage —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_UpdateTexturebecauseSDL_LockTextureis 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) andMVIDEO_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 byscripts/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_STORAGEprompt (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
charsignedness 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_GetTickspolled per frame; nopthread,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.CPPandLIB386/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_GPUor 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_platformseam, 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 asys/subtree would surface the boundaries the way Doom 3'ssys/win32//sys/linux/does. No code change; pure rename +#includeupdates. 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_ASMoption could be removed and the*.ASMfiles 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.