diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d88cc03..5122ba47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,10 @@ on: branches: [ main, master ] tags: [ 'v*' ] # No paths-ignore here: tag pushes must always build so a release can be - # cut even when the tagged commit only touched docs. PRs keep the filter. + # cut even when the tagged commit only touched docs, and a workflow that + # never triggers would leave a required status check pending forever. + # Docs-only branch pushes are instead skipped by the `changes` job below, + # which reports the build jobs as "skipped" (green for branch protection). pull_request: paths-ignore: [ '**.md', 'docs/**', 'LICENSE', 'NOTICE' ] workflow_dispatch: @@ -20,7 +23,11 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true -# Three jobs: +# Jobs: +# +# linux-build — ubuntu, system GCC. Actually compiles + links (unlike +# validate). Surfaces GCC-on-Linux strictness gaps MinGW +# misses. Not a runtime claim; not a required check yet. # # validate — ubuntu, configure-only. Byte-compiles the tooling, # regenerates the US ROM-asset symbol file, and CMake- @@ -42,7 +49,42 @@ concurrency: # only job granted contents: write. jobs: + # Did this push touch anything outside docs / licenses? Branch pushes to main + # get no workflow-level paths-ignore (see the `on:` comment), so this is where + # a docs-only merge is filtered out. Tags and pull_request events always build. + # Mirrors the pull_request paths-ignore globs: **.md, docs/**, LICENSE, NOTICE. + changes: + runs-on: ubuntu-22.04 + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 + + - id: filter + run: | + if [ "${{ github.event_name }}" != "push" ] || [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "code=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + base='${{ github.event.before }}' + if ! git cat-file -e "${base}^{commit}" 2>/dev/null; then + echo "no usable base ${base} — building to be safe" + echo "code=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + changed="$(git diff --name-only "$base" '${{ github.sha }}')" + echo "Changed files:"; echo "$changed" + if echo "$changed" | grep -qvE '(\.md$|^docs/|^LICENSE$|^NOTICE$)'; then + echo "code=true" >> "$GITHUB_OUTPUT" + else + echo "docs-only push — skipping build" + echo "code=false" >> "$GITHUB_OUTPUT" + fi + validate: + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v7 @@ -70,6 +112,47 @@ jobs: - name: CMake configure (ntsc-final) run: cmake -S . -B build-ntsc -DROMID=ntsc-final + # Linux (Ubuntu, system GCC) compile job. Unlike `validate` this actually + # runs `cmake --build`, so it surfaces the GCC-on-Linux strictness gaps that + # MinGW does not flag. The port has never been run on Linux — a green build + # here is a "does it compile + link" signal only, not a runtime claim. + # Not a required check yet; flip it to required once it is reliably green. + # + # Toolchain: ubuntu-24.04 + gcc-14. The port is developed on MinGW GCC 16; + # pinning a recent GCC here keeps the first bring-up focused on real + # portability gaps rather than a decade of accumulated old-GCC pedantry. + # (A "does it build on the GCC a typical distro ships" pass can come later.) + linux-build: + runs-on: ubuntu-24.04 + env: + CC: gcc-14 + CXX: g++-14 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential gcc-14 g++-14 cmake python3 \ + libsdl2-dev zlib1g-dev libgl1-mesa-dev + + - name: Regenerate ROM-asset symbol file (US) + run: python3 scripts/gen_romassets.py u + + - name: Configure (ntsc-final) + run: cmake -S . -B build-linux -DROMID=ntsc-final + -DCMAKE_C_COMPILER=gcc-14 -DCMAKE_CXX_COMPILER=g++-14 + + - name: Build (ntsc-final) + run: cmake --build build-linux -j"$(nproc)" + + - name: Show binary + run: | + ls -la build-linux/ge007* || true + file build-linux/ge007* || true + # Real Windows (MSYS2 MINGW64) build — the only platform the port is # developed and tested on. This compiles and LINKS the full executable. # @@ -78,6 +161,8 @@ jobs: # address-constant romassets_.s. The ROM (.z64) and the ROM-derived # blobs are runtime-only dependencies — CI cannot run the game, only build it. windows-build: + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: windows-latest defaults: run: @@ -139,7 +224,7 @@ jobs: # gets contents: write. release: if: github.ref_type == 'tag' - needs: [ validate, windows-build ] + needs: [ changes, validate, windows-build ] runs-on: ubuntu-22.04 permissions: contents: write diff --git a/CLAUDE.md b/CLAUDE.md index 6888f20a..b2816cee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,3 +66,28 @@ Full template: `docs/dev-process.md` -> "Investigation-brief template". - **Per batch / integration checkpoint:** one 60s `-level_09` crash-free run. - Subagents on small disjoint subsystems (input, audio, saves, converters) don't run the game — the overseer verifies. + +## Local Qwen dispatch (cost-free, data-local worker) + +A local Qwen3.8-27B is wired in via the `delegate-local` MCP server (see +`C:\Users\james\Source\Repos\20260902-qwen38claudepair`). Use it as a +*subagent whose compute runs on-machine* for scoped, checkable work — the lead +still owns judgment, planning, and the verification gate. + +- **Tools:** `local_backend_status()` (run once before first dispatch), + `list_local_agents()`, `delegate_to_local_agent(agent_name, task, workdir, max_turns?, max_tokens?)`. +- **Workers** (`~/.claude/agents/`): `repo-mapper` (read-only ingest/map), + `mechanical-coder`, `test-writer`, `triage` (read-only root-cause + patch), + `refactor-bot`. +- **Dispatch when ALL hold:** narrow blast radius, clear spec, output checkable + by a build/test/diff. Keep on Claude: ABI/handedness/format reasoning, novel + debugging, anything you can't cheaply verify. +- **Backend reality:** ~1 inference slot, slow per token — dispatch + **sequentially**, `max_tokens` ~4–8K, treat as batch work. Needs the LiteLLM + proxy (`litellm/run.ps1`) + Unsloth Studio both up. +- **Windows quirk:** the worker's shell is `cmd.exe`, not bash — its agent + prompt tells it to use `read_file`/`findstr`, not `cat`/`grep`. Always + spot-check the returned artifact against the real files before integrating; + the 27B will confidently fabricate if a read fails. +- Fits the **dispatch preflight** rules above (FILES / BUDGET / ON EXPIRY / + CONSTRAINTS / REPORT) — write the `task` string to that shape. diff --git a/CMakeLists.txt b/CMakeLists.txt index 50dd3e56..afb4d1d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,11 +52,25 @@ include(TargetArch) include(FindPkgConfig) set(CMAKE_C_STANDARD 11) -set(CMAKE_C_EXTENSIONS OFF) +# GNU extensions ON: strict -std=c11/-std=c++20 define __STRICT_ANSI__, which +# disables glibc's _DEFAULT_SOURCE and hides POSIX symbols the port needs +# (clock_gettime/CLOCK_REALTIME in port/src/libultra.c, mmap flags, etc.). +# -std=gnu11/gnu++20 is GCC's own default and what MinGW was already using. +set(CMAKE_C_EXTENSIONS ON) set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_CXX_EXTENSIONS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# Opt-in ccache launcher. The game sources are frozen (1:1 with the decomp), +# so ccache makes branch-switch / worktree rebuilds nearly free. No-op when +# ccache is not installed. +find_program(CCACHE_PROGRAM ccache) +if(CCACHE_PROGRAM) + set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + message(STATUS "ccache enabled: ${CCACHE_PROGRAM}") +endif() + # Default build type if(NOT CMAKE_BUILD_TYPE AND NOT EXISTS ${CMAKE_BINARY_DIR}/CMakeCache.txt) set(CMAKE_BUILD_TYPE "RelWithDebInfo" CACHE STRING "" FORCE) @@ -148,6 +162,15 @@ configure_file( "${CMAKE_SOURCE_DIR}/port/include/hoststring.h.in" "${CMAKE_BINARY_DIR}/port/include/hoststring.h" @ONLY) +# Host path for port/shim/sched.h. The decomp's src/sched.h is on +# the include path and shadows the system header; libstdc++'s /gthr +# headers in the fast3d C++ TUs need the real (cpu_set_t, +# sched_yield). Both MinGW and Linux keep at bin/../include. +get_filename_component(PORT_HOST_SCHED_H "${_PORT_GCC_DIR}/../include/sched.h" ABSOLUTE) +configure_file( + "${CMAKE_SOURCE_DIR}/port/include/hostsched.h.in" + "${CMAKE_BINARY_DIR}/port/include/hostsched.h" @ONLY) + # Host path for port/shim/stdlib.h. The N64 include/stdlib.h # typedefs lldiv_t/ldiv_t, which conflict with the host's when both land in # one C++ TU (fast3d): MinGW's own sec_api/stdlib_s.h does `#include @@ -160,7 +183,17 @@ configure_file( # Host path for port/shim/stddef.h (same reason as stdlib: the N64 # stub shadows the host header in C++ TUs, hiding max_align_t from cstddef). -get_filename_component(PORT_HOST_STDDDEF_H "${_PORT_GCC_DIR}/../include/stddef.h" ABSOLUTE) +# stddef.h is compiler-internal, not in bin/../include: on a native Linux GCC +# it lives under lib/gcc///include. Ask the compiler where it is; +# fall back to the MinGW bin/../include layout. +execute_process( + COMMAND "${CMAKE_C_COMPILER}" -print-file-name=include/stddef.h + OUTPUT_VARIABLE _PORT_GCC_STDDEF OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) +if(_PORT_GCC_STDDEF AND EXISTS "${_PORT_GCC_STDDEF}") + get_filename_component(PORT_HOST_STDDDEF_H "${_PORT_GCC_STDDEF}" ABSOLUTE) +else() + get_filename_component(PORT_HOST_STDDDEF_H "${_PORT_GCC_DIR}/../include/stddef.h" ABSOLUTE) +endif() configure_file( "${CMAKE_SOURCE_DIR}/port/include/hoststddef.h.in" "${CMAKE_BINARY_DIR}/port/include/hoststddef.h" @ONLY) @@ -192,7 +225,11 @@ add_compile_options( # get a real backtrace of a hung/crashed game thread. add_compile_options(-fno-omit-frame-pointer) -if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug") +# Optimisation: the software RSP + software rasteriser in port/fast3d are the +# hot path and are very sensitive to -O level (the difference between ~1 fps +# and playable). Non-Debug builds keep the CMAKE_BUILD_TYPE optimisation +# (RelWithDebInfo => -O2); only an explicit Debug build drops to -Og. +if(CMAKE_BUILD_TYPE STREQUAL "Debug") add_compile_options(-Og) endif() @@ -223,6 +260,25 @@ add_compile_options( add_compile_options($<$:-Wno-pointer-sign>) add_compile_options($<$:-Wno-unused-function>) +# The decomp's `#define inherits struct` idiom (src/bondtypes.h) makes the base +# struct an unnamed tagged member and accesses its fields directly +# (obj->type where type lives in the embedded PropRecord). GCC targeting +# *-w64-mingw32 enables this via -fms-extensions implicitly; a native Linux/ +# macOS GCC does not, so `'ObjectRecord' has no member named 'type'`. Enable +# the Plan 9 extensions explicitly there (superset of -fms-extensions: also the +# automatic base-pointer conversion the idiom relies on). C only — the decomp +# is C; fast3d C++ does not use `inherits`. +if(NOT MINGW) + add_compile_options($<$:-fplan9-extensions>) + # GCC 14 promoted several long-standing C leniencies to hard errors + # (int<->pointer for a known prototype, incompatible-pointer arg types, + # implicit int/function). The WIP decomp still trips these (e.g. + # append_text_picked_up's vestigial u8* params in propobj.c). -fpermissive + # downgrades them to warnings — the C counterpart of the -Wno-error lines + # above, and the documented path for building older C against GCC 14+. + add_compile_options($<$:-fpermissive>) +endif() + # ASLR must be OFF: dram_syms.s defines absolute symbols (cfb_16 @ 0x70000000, # _bssSegmentEnd @ 0x70050000) that the game references through pointer-typed # externs (src/bondgame.h: `extern u32 *_bssSegmentEnd`). On x86-64/PE, taking @@ -233,6 +289,27 @@ add_compile_options($<$:-Wno-unused-function>) # base at startup. if(MINGW) set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,--disable-dynamicbase") +elseif(UNIX AND NOT APPLE) + # Linux equivalent of the ASLR-off requirement above: dram_syms.s defines + # absolute symbols at 0x70000000 referenced through pointer-typed externs. A + # PIE build (GCC's default on modern distros) emits GOT/relative relocations + # for them — R_X86_64_32S link errors, or a runtime rebase to garbage. Build + # and link no-PIE so the image keeps its preferred load address. + add_compile_options(-fno-pie) + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -no-pie") + # Move the image base to 0x20000000. The default no-PIE base 0x400000 puts + # .data/.bss symbols around 0x00500000-0x00600000, which COLLIDES with the + # N64 address ranges fast3d's seg_addr() decodes: a game-built display list + # that embeds `&symbol` (via the osVirtualToPhysical identity shim) hands + # seg_addr a value < 0x800000, which it treats as a K0-physical DRAM offset + # and remaps to 0x80xxxxxx (zeroed DRAM mirror) -> "unknown GBI opcode 0x00" + # -> black screen. 0x20000000 sits in the gap between the segmented-address + # space (< 0x10000000), the cart map (0x10000000), the D131 module range + # ([0x40000000,0x70000000)) and DRAM (>= 0x70000000); seg_addr passes such + # addresses through untouched. Still < 2GiB so -mcmodel=small / R_X86_64_32 + # relocations are fine (unlike a 0x140000000 base, which needs mcmodel=large). + # This is the ELF analogue of the Windows build's fixed 0x140000000 base. + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-Ttext-segment=0x20000000") endif() # port/shim first: PC-only shadow headers (bondtypes.h, bondconstants.h, diff --git a/README.md b/README.md index 3ba471a1..19b15482 100644 --- a/README.md +++ b/README.md @@ -187,13 +187,13 @@ the front end into the early game. It is not finished and it is not stable. ## Download -Pre-built **Windows x86-64** bundles are published under -[Releases](../../releases) as marked pre-releases — the engine executable plus -its runtime DLLs, no ROM and no game assets. You still supply your own ROM (see -[Requirements](#requirements)) and drop it in `data/` as described in the -bundled README. Every download is a research alpha with the caveats in -[Status](#status) above; there is no stable build yet. To build from source -instead, see [Building](#building). +There are no pre-built binaries yet. The project is Phase 2 WIP with the +caveats in [Status](#status) above — nothing runs end to end — so the only way +to try it is to build it yourself; see [Building](#building). You supply your +own ROM (see [Requirements](#requirements)) and drop it in `data/` before +building. Pre-release **Windows x86-64** bundles (engine executable plus +runtime DLLs, no ROM or game assets) may be published under +[Releases](../../releases) once the port is playable. ## Requirements @@ -388,19 +388,19 @@ GitHub. It follows the same conventions they do: - **No ROM and no game assets are distributed** — not in this repository and not in any release. Textures, audio, models, level data and in-game text are - extracted from a ROM *you already own*, on *your* machine (at build time for - a source build; at first run for a pre-built binary). + extracted from a ROM *you already own*, on *your* machine, at build time. - The repository is a fork of the public [GoldenEye 007 decompilation](https://github.com/n64decomp/007) and inherits its contents unmodified (see [`NOTICE`](NOTICE) for what that includes). - No official logos, box art, or marketing assets are used. "GoldenEye 007", "007", "James Bond" and related marks belong to their respective owners (Nintendo, Microsoft/Rare, MGM, Danjaq, EON Productions). -- **Pre-built binaries** offered under [Releases](../../releases) contain only - the engine — the `port/` layer plus the compiled decompilation, with no game - data of any kind — bundled with permissively-licensed runtime libraries - (SDL2, zlib, the MinGW runtime; their licenses travel in the download). Any - build, yours or ours, is useless without a ROM you supply. +- **If** pre-built binaries are published under [Releases](../../releases) in + the future, they will contain only the engine — the `port/` layer plus the + compiled decompilation, with no game data of any kind — bundled with + permissively-licensed runtime libraries (SDL2, zlib, the MinGW runtime; their + licenses travel in the download). No such release exists yet, and any build — + yours or ours — is useless without a ROM you supply. This project is **not affiliated with, endorsed by, or sponsored by** Nintendo, Rare, Microsoft, MGM, Danjaq, EON Productions, or any rights holder in diff --git a/build-pc.sh b/build-pc.sh index 8f71030f..34a6388c 100644 --- a/build-pc.sh +++ b/build-pc.sh @@ -22,7 +22,7 @@ set -euo pipefail ROMID="${1:-ntsc-final}" -BUILD_DIR="build-pc" +BUILD_DIR="${BUILD_DIR:-build-pc}" echo "==> Configuring PC port (ROMID=${ROMID})" cmake -S . -B "${BUILD_DIR}" -DROMID="${ROMID}" diff --git a/docs/_includes/head-custom.html b/docs/_includes/head-custom.html index c8965e02..253dcf8d 100644 --- a/docs/_includes/head-custom.html +++ b/docs/_includes/head-custom.html @@ -250,4 +250,39 @@ .page-header { padding: 3rem 1rem 2.75rem; } .main-content { padding-top: 2rem; } } + + /* ---- Mermaid diagrams ------------------------------------------------ */ + /* Kramdown renders ```mermaid fences as
.
+     The script below hands their text to mermaid and swaps in the SVG. Keep
+     the pre unstyled (no dark code background) while it waits to render. */
+  .main-content pre.mermaid,
+  .main-content pre > code.language-mermaid {
+    background: transparent;
+    border: 0;
+    color: var(--ink);
+    padding: 0;
+  }
+  .main-content pre.mermaid { text-align: center; overflow-x: auto; }
+  .main-content pre.mermaid:not([data-processed]) { visibility: hidden; }
 
+
+
diff --git a/docs/dev/LINUX-PORT.md b/docs/dev/LINUX-PORT.md
new file mode 100644
index 00000000..9586411f
--- /dev/null
+++ b/docs/dev/LINUX-PORT.md
@@ -0,0 +1,91 @@
+# Linux port — status & gap analysis
+
+Session M-38 (2026-09-02). Goal-2 on the roadmap (after the Windows alpha /
+release artifact). This is a scoping + first-patch pass; **nothing here has run
+on a Linux machine yet.**
+
+## Method
+
+Read `build-pc.sh`, `CMakeLists.txt`, `docs/building.md`, and grepped
+`port/src`, `port/fast3d`, `port/include` for Windows-only headers / Win32 APIs
+/ unguarded `_WIN32` branches. A local Qwen `triage` pass seeded the hunt; every
+claim below was re-checked against the real files (the 27B was wrong on two of
+three headline items — see "Ruled out").
+
+## What is already Linux-ready (verified by reading)
+
+- **`port/include/platform.h`** — sets `PLATFORM_LINUX` / `PLATFORM_MACOS` /
+  `PLATFORM_WINDOWS` cleanly.
+- **`CMakeLists.txt`** — `find_package(SDL2 REQUIRED)`, `find_package(ZLIB
+  REQUIRED)`, and `else()` branches already present for the non-Windows case:
+  `GL_LIBRARY = GL` (l.291), `EXTRA_LIBRARIES = stdc++ m dl pthread` (l.304),
+  `elseif(UNIX)` platform block (l.86). `WIN32_EXECUTABLE` and the MinGW zlib
+  fallback are correctly `if(WIN32)` / `if(MINGW)` guarded.
+- **`build-pc.sh`** — already documents the Linux apt deps
+  (`cmake libsdl2-dev zlib1g-dev libgl1-mesa-dev`) and is a plain bash script,
+  no MSYS-isms in the executable body.
+- **`port/src/system.c`** — `sysSleep()` has a `nanosleep()` `#else` branch
+  (l.76-82); `` is `PLATFORM_WINDOWS`-guarded.
+- **`port/src/libultra.c`** — the OS-thread kernel has a real pthreads branch
+  next to the Win32 `_beginthreadex` one.
+- **`port/src/crash.c`** — `MessageBoxW` / `SetThreadPriority` / `__debugbreak`
+  all `_WIN32`-guarded, with `PLATFORM_LINUX` counterparts.
+- **`port/src/video.c`** — `GE_MKDIR` has both `_mkdir` and `mkdir(p,0777)`.
+- No `__declspec`, `#pragma comment`, or hardcoded `\`-path / drive-letter
+  usage anywhere in `port/`.
+
+## Ruled out (Qwen triage false positives)
+
+- ~~`system.c:75` `Sleep()` has no POSIX branch~~ — it does (`nanosleep`).
+- ~~CMake needs `else()` branches for zlib/pthread on Linux~~ — already there.
+
+## The one real code gap — FIXED this session (verification owed)
+
+**`port/src/romdata.c`** — the fixed-address cart map (`0x10000000`, so absolute
+ROM-asset symbols are live host pointers) had a **Windows-only** implementation
+(`VirtualAlloc`); the `#else` branch was a stub that logged "not implemented on
+this platform" and fell straight to the heap-copy fallback. In heap-copy mode
+anything that dereferences a cart address *directly* (not via
+`romdataGetRom()` / the PI shims) reads wrong memory — so Linux would run, if at
+all, in the same degraded mode Windows only hits when `VirtualAlloc` loses the
+address race.
+
+**Change (M-38):**
+- Factored the post-map work (memcpy + `pcmodelsLoadSidecars` +
+  `pccgLoadSidecars` + D55 RLE-header fixup) into `romdataFinishCartMap()`,
+  shared by both paths so they cannot drift.
+- POSIX `#else` now does an anonymous `mmap((void*)CART_BASE, len,
+  PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED_NOREPLACE, -1, 0)`.
+  `MAP_FIXED_NOREPLACE` (Linux 4.17+) fails rather than clobbering an existing
+  mapping; where the macro is absent the plain hint is advisory and the
+  `at == CART_BASE` check catches a relocated result. On any failure it
+  `munmap`s and falls through to the identical heap-copy path as before.
+- `romdataDestroy()` `munmap`s on POSIX (tracked length in `mappedLen`).
+
+**Owed verification (needs a Linux box):**
+1. Compiles under GCC on Linux (`` resolves; no decomp-include-path
+   shadowing surprise).
+2. `mmap` actually lands at `0x10000000` on a stock ASLR Linux — `0x10000000`
+   (256 MB) is low but usually free; if the kernel routinely refuses it,
+   consider linking the binary no-PIE or reserving the range via a linker
+   script so the loader keeps it clear.
+3. `-level_09` boots crash-free with the mapped path taken (check the log says
+   "mapped at 0x10000000 (cart base)", not "using heap copy").
+4. Whether the heap-copy fallback is actually survivable on Linux if step 2
+   fails — audit for direct `0x1xxxxxxx` derefs outside the PI shims.
+
+## Remaining Linux work after the romdata patch lands
+
+| # | Item | Size | Note |
+|---|------|------|------|
+| 1 | Run the Ubuntu **`validate`** CI job → upgrade it to a real **compile** job | S | `.github/workflows/ci.yml` already configures on Ubuntu; flipping it to `cmake --build` surfaces the real GCC-strictness gaps cheaply |
+| 2 | First actual Linux runtime bring-up (`-level_09`) | M | expect a short tail of unguarded calls / struct-layout asserts GCC-on-Linux vs MinGW |
+| 3 | `docs/building.md` — promote Linux from "untested" to "supported" once 1+2 pass | S | |
+| 4 | SDL2 / GL context creation on X11 + Wayland | M | fast3d is GL 3.3 core; PD port runs on Linux so the path is known-good |
+| 5 | Package: an AppImage or tarball equivalent of `bundle-win.sh` | M | reuse `prepare-assets.py` verbatim — it is already OS-agnostic |
+
+## Recommended next step
+
+Land the romdata patch behind CI (item 1) — turn the Ubuntu `validate` job into
+a compile job in the same PR so the "does it build on Linux" question is
+answered by the bots, then do the runtime bring-up on a real machine.
diff --git a/docs/dev/agentic-development.md b/docs/dev/agentic-development.md
index 34ecbe19..67bc71a5 100644
--- a/docs/dev/agentic-development.md
+++ b/docs/dev/agentic-development.md
@@ -3,7 +3,7 @@
 *A case study in AI-agent collaboration on a large, low-level codebase. A
 GoldenEye 007 Nintendo 64 → desktop port, taken from "builds a ROM" to
 "playable front end" in about two weeks — driven mostly by two coding agents (a
-local open-weight model, Qwen 3, on a single RTX 5090, and a hosted frontier
+local open-weight model, Qwen 3.8, on a single RTX 5090, and a hosted frontier
 model, Claude / Claude Code), handing work back and forth through shared
 written notes under one person's part-time direction. This is the goal, the
 method, the timeline, and an honest read on what did and didn't work.*
diff --git a/docs/dev/findings.md b/docs/dev/findings.md
index 572d3c83..bd8c0b65 100644
--- a/docs/dev/findings.md
+++ b/docs/dev/findings.md
@@ -392,7 +392,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
 | D169 | **Front-end mouse pointer can't reach the outer cells of the mission/level-select grid — only an inner ~3×3 selectable (M-33, developer bug report).** The D165 pointer P-controller in `port/src/input.c` is hard-coded to a **320×240** virtual field (`MENU_CURSOR_HI_H`/`_HI_V` = 300/220, `MENU_CURSOR_MID_H`/`_MID_V` = 160/120, `input.c:145-149`), but GE's front end runs at **440×330** (`front.c:8570` `viSetViewSize(440,330)`; default cursor home 220/165 `front.c:285`). `frontUpdateControlStickPosition` clamps the real cursor to `[20,420]×[20,310]` (`front.c:1195-1217`); the mission-select hit-test grid spans x 73…352 / y 62…270 with outer split points 317 / 235.5 (`front.c:516,519,3180,3194`). The port clamps its pointer *target* and *estimate* to 300/220 (`input.c:553-564`) → the stick zeroes out once both saturate → the game's `cursor_h_pos`/`cursor_v_pos` park at ≈300/220, short of the two outer columns and the bottom row. File-select / mode-select / main-menu are unaffected because their hit targets (centred folder boxes, the `x=126` mode list, the `x=106` difficulty list) all sit inside the 320×240 sub-box. Not the D118d `joyGetStickY`-threshold class — this is a virtual-resolution constant mismatch, closer to D164 (a front-end layout constant wrong on the PC path). Also note: the "re-syncs whenever the target is held at a screen edge" comment at `input.c:141-143` describes behaviour the code doesn't implement — the only estimator reset is the activation re-home (`input.c:545-549`), so once pinned at the clamp the estimate never recovers. Confidence: **medium-high** (constants + clamp math unambiguous in source; static-only, no build/run this session; exact reachable block "~4×3" vs the reported "3×3" within tolerance). | **FIXED (M-33, `port/src/input.c`, port-only, no `#ifdef PORT`).** The `menuMode` pointer branch now derives its clamp bounds from the live virtual screen — `[screenleft+20, screenleft+screenwidth-20] × [screentop+20, screentop+screenheight-20]` via `getPlayer_c_screenwidth/height/left/top()` (`src/game/bondview.c:880-895`) — falling back to the old 320×240 constants when the front-end screen isn't set (`sw` outside 200…2000). The estimator seed on activation is now the real `cursor_h_pos`/`cursor_v_pos` (`front.c:285`, externed in `front.h`) instead of the 160/120 centre guess. `MENU_CURSOR_HI_H/_V` / `MID_H/_V` kept only as the fallback. **Verified:** builds + links clean (`getPlayer_c_screen*` and `cursor_[hv]_pos` all resolve — non-static engine symbols, as expected); `GE_STARTMENU=7` mission-select boots crash-free 600+ frames; `-level_09` unregressed (framediff 3/3 within threshold, 91.6% nonclear). The in-level path is untouched (menu-only branch). **Interactive feel-check still owed** — headless input can't drive the mouse pointer, so "every grid tile is now reachable" is inferred from the corrected clamp math, not observed. `Input.MenuPointerMode=0` (legacy velocity) and the constant fallback remain as escape hatches. |
 | D178 | **Pre-mission briefing screen: objectives blank / missing, briefing pages blank (also the D143 "briefing text blank" side effect) — FIXED (M-36).** Root cause: the briefing segment (`Ubrief*Z`, source `assets/obseg/brief/*.c`) is a raw ROM image of `struct BriefStruct` = `{ u16 brief[4]; struct { u16 textid; u16 enabled_difficulty; } objective[10]; }` (48 bytes), loaded by `front.c load_briefing_text_for_stage()` via `_fileNameLoadToAddr()` — **no converter and no BE→LE fixup anywhere in the chain**. On the LE host all 24 `u16` read byte-swapped. Measured on Dam (`GE_D178=1`): raw `brief=002c,012c,022c,032c obj0=042c/0100 obj3=072c/0000` vs correct `2c00,2c01,2c02,2c03 / 2c04/0001 / 2c07/0000`. Two independent symptoms follow: (a) `textid` `0x2C04` (= `getStringID(LDAM,4)`, bank 11 slot 4) reads as `0x042C` → bank 1 slot 44, never loaded → `langGet()` NULL → **blank text** (this is exactly the D143 residual, `front.c:6732` and `brief[0..3]` at `front.c:6855-6867`); (b) `enabled_difficulty` `0x0001` (Secret Agent) reads as `0x0100 = 256`, so `selected_difficulty >= enabled_difficulty` (`front.c:6729`) is false for every difficulty-gated objective — on Agent only the one `DIFFICULTY_AGENT`(0) objective survived the filter, which is why a single bare "a." bullet printed. **Fix:** `romdataFixupBriefing()` in `port/src/romdata.c` (+ `port/include/romdata.h`), called from a `#ifdef PORT` block in `load_briefing_text_for_stage()` right after the load — the same shape as `langFixupLoadedBank()` in `language.c` (BE-serialized-struct reconciliation, semantics-preserving, no game logic touched). `GE_D178=1` prints the raw and fixed words. **Verified:** Dam briefing on Agent now shows "a. Bungee jump from platform"; on `GE_STARTMENU_DIFF=3` (00 Agent) all four (Neutralize all alarms / Install covert modem / Intercept data backup / Bungee jump from platform), correct difficulty gating. `-level_09` framediff 3/3; `GE_STARTMENU=7`/`=13` crash-free. | **FIXED (M-36)** — high confidence |
 | D175 | **In-game stutter / brief hang during normal play** (user QA report): opening a door on Runway (`-level_35`, mission 3) and also observed on Surface. Self-recovered; no backtrace captured. Likely one of the known transient-hang classes (D155 catch-up spiral / D156 anim NaN loop / D134 task-done event / D147-D152 audio-lock steal) or a benign new-room texture-import frame spike on door open. See `GRAPHICS-BACKLOG.md` D175 for the gdb pattern-match sheet. | Observed, not investigated |
-| D176 | **Surface exterior renders wrong (`-level_36`), two independent defects.** **(a)** sky solid black — env data is correct (`Clouds=1`, warm `CloudRGB`), the cloud-sky path runs, but `skyRenderTri`/`skyRenderFull` emit nothing → see "D176(a)" below, partial, not root-caused. **(b)** cliff/rock walls = grey diagonal static — NOT a texture decode bug (D183 disproved the shear hypothesis, 0/166 loads strided); re-scope from ROM ground truth of the wall texnum. See `GRAPHICS-BACKLOG.md` D176. | (a) partial / (b) open — both M-36 |
+| D176 | **Surface exterior renders wrong (`-level_36`), two independent defects.** **(a)** sky solid black — env data is correct (`Clouds=1`, warm `CloudRGB`), the cloud-sky path runs, but `skyRenderTri`/`skyRenderFull` emit only `G_RDPHALF_*` immediates which `gfx_pc.cpp:2901` deliberately no-ops → **root-caused M-37** (see "D176(a) — M-37 UPDATE"); fix = decode the RDPHALF sky-tri stream in fast3d. **(b)** cliff/rock walls = grey diagonal static — NOT a texture decode bug (D183 disproved the shear hypothesis, 0/166 loads strided); re-scope from ROM ground truth of the wall texnum (M-37: inconclusive, leaning tiling-density). See `GRAPHICS-BACKLOG.md` D176. | (a) root-caused, fix owed / (b) open |
 | D177 | **Ladders non-functional — progression blocker (user QA report) — FIXED (M-36).** Not the input path and not the ladder state machine: `MoveBond` gates the ladder-collision path on `stanGetLocusCount(&curLocus)`, which was pinned at 0 on PC. `stanCheckLinkedSpecialTile` writes the LADDER signal via raw `outFlags[1] = 1` into a `struct StandTileLocusCallbackRecord` whose first member `s32 *rooms` is pointer-widened on PC → `[1]` is the high half of `rooms`, and `count` (moved +4→+8) is never written. Compounded by `curLocus` being declared as the 8-byte placeholder `move_bond_temp_struct` (too small for the widened record) and a `(s32)coords` pointer-truncation AV waiting in `stanGetMoveBondCollisionTiles`. **Fix:** `#ifdef PORT` — write record fields by name, declare `curLocus` as the real struct, `PORT_PTRADD` for the truncating cast (`src/game/stan.c`, `src/game/bondview2.c`; no game logic). Full detail: §F "D177". | **FIXED (M-36)** — high confidence; interactive climb test owed |
 | D174 | **"No blood effect" (user QA report).** Likely the unverified D120 decal fix: `d43_emit.py` now emits the opcode-0x18 `PointUsage[]` chain, but it was never interactively verified and requires a full sidecar regen to take effect (`debug.ps1` does not regen). Spray path is D172 (draws, wrong colour) — total absence would be new. See `GRAPHICS-BACKLOG.md` D174. | Observed; first step = BUNKER1 firefight with regenerated sidecars |
 | D183 | **M-36 Family A ("texture line/pitch shear") is DISPROVEN for the Surface repro (`-level_36`).** The importers' pitch assumption is real but never fires there; the Surface cliff "grey static" is not a shear. Full evidence + what the walls actually are: §F "D183" below. A defensive, provably-no-op de-stride landed in `import_texture()` anyway (covers the `gfx_dp_load_tile` case the `SUPPORT_CHECK`s assert against), plus `GE_DTEX` STRIDED marker + `GE_TEXRAW` raw-source dump. | Hypothesis DISPROVEN + diagnostics shipped; D176(b)/D182(2) still OPEN |
@@ -4557,6 +4557,24 @@ Remaining candidates, in order:
    still be a real `gfx_dp_load_tile` strided case (front-end `texLoadFromGdl`
    paths do use windowed loads). The defensive fix in §5 would cover it if so.
 
+**M-37 UPDATE (inconclusive, ~8-min timebox):** the §7 offline decoder + logs
+live *outside* this checkout — a full offline ROM decode was not achievable.
+Static findings: Surface's bg is `bg/bg_sevx_all_p.seg` (`bg.c:201`); the wall
+texture is a `u16` image ID *embedded in the bg GDL stream*, not a static
+per-level texnum list, so it cannot be grepped — needs a runtime `GE_DTEX`/
+`GE_TEXRAW` correlation. `assets/images.def` has no rock/cliff names (only
+`STATIC_NOISE 0x389`); Surface-named entries are mission-select thumbnails.
+Leaning **"real texture / tiling-density" (candidate 2), low-med confidence** —
+D183's own measurement (constant full alpha 0x0F + full-range noise intensity,
+zero residual structure at any pitch) is the signature of an intentional grey
+rock-detail tile, not a mis-decoded structured image. Decisive next steps:
+(i) grep the *converted* `bg_sevx_all_p` GDL for opcode `0xC0` (`G_SETTEX`) —
+settles the one remaining "wrong bind" scenario (stale tile state, TEXTURE-
+GLITCH-ANALYSIS §2 RC1) offline; (ii) headless `-level_36` `GE_DTEX/TEXRAW=1`
+logging the image ID bound to the cliff tris, look it up in `g_Textures[]`,
+read + decode that ROM range per §4. Scratch note: M-37 session scratchpad
+`D176b-rom-groundtruth.md`.
+
 ### 5. What shipped (`port/fast3d/gfx_pc.cpp` only)
 
 - **De-stride in `import_texture()`**, before the format dispatch: when
@@ -4637,6 +4655,51 @@ sunset cloud gradient. Headless `-level_36` + `GE_D176=1` probe
 `src/game/bgfog.c` + `src/game/sky.c`. Separate from D176(b) (the tree/rock
 "grey static", Family A / a different investigation).
 
+### D176(a) — M-37 UPDATE: ROOT-CAUSED (static analysis, high confidence)
+
+The emit path is not "wrong" — **the PC software RSP deliberately discards it.**
+`port/fast3d/gfx_pc.cpp:2901`:
+
+```cpp
+case (uint8_t)G_RDPHALF_1:
+case (uint8_t)G_RDPHALF_2:
+case (uint8_t)G_RDPHALF_CONT:
+    // on N64 skyRender uses these to render some types of skies and skybox water
+    // by issuing low-level ucode commands G_TRI_FILL and G_TRI_SHADE_TXTR
+    // the port renders the sky in a different manner
+    break;
+```
+
+`skyRenderTri` / `skyRenderFull` build their geometry **exclusively** as
+`gImmp1(gdl++, G_RDPHALF_1 / G_RDPHALF_CONT / G_RDPHALF_2, …)` pairs — a bespoke
+packed triangle-raster stream (command byte in bits 24–31 = `G_TRI_SHADE_TXTR`/
+`G_TRI_FILL`, `0x00800000` backface flag, subpixel Y coords, then S15.16 edge X +
+dX/dY slopes via `sub_GAME_7F094298`, then per-vertex RGBA shade + S/T/W tex-coord
+gradients, `G_RDPHALF_2` terminator) that GE's modified RSP ucode (`gmain.s`)
+interprets. There is **no** `gSPVertex`/`gSP*Triangle` fallback and **no**
+fill-rect on the textured case-12 branch — only `viSetFillColor(env->RGB)` sets
+FILL state that nothing consumes. → dropped → solid black.
+
+Explains every prior observation: `!Clouds` levels use a plain `gDPFillRectangle`
+(→ work today); `Clouds` levels (Surface, Statue `-level_22`, Frigate `-level_29`,
+Dam exterior) go through the RDPHALF stream (→ black). The projector
+`sub_GAME_7F097388` is pure float math off endian-clean matrices — not the bug.
+
+**No PD-port shortcut:** `pd_port/port/fast3d/gfx_pc.cpp:2512` has the identical
+no-op + comment and PD's `sky.c` emits the same stream; no port-side sky
+replacement exists anywhere in the PD tree. The comment is aspirational in both.
+
+**Fix (Phase 2, ~1 session, genuine new fast3d code):** decode the RDPHALF stream
+in `gfx_pc.cpp` — accumulate words, on a `G_RDPHALF_1` carrying a tri opcode start
+a primitive, synthesise screen-space `LoadedVertex[3]` (bypass model/proj
+transform like the 2D `gSPTextureRectangle` path; screen XY = value/4 − viewport;
+shade + ST from integrating the slopes over the vertex delta), rasterise with the
+current combiner/tile, close on `G_RDPHALF_2`. Full stream spec + the alternative
+`#ifdef PORT` sky.c option in the M-37 scratch note.
+
+Cheap first step: headless `-level_22` / `-level_29` — confirm also black (proves
+emit path, not a Surface asset).
+
 ---
 
 ## D177 — Ladders non-functional: `count`/`rooms` land in the high half of a widened pointer (M-36)
diff --git a/docs/dev/release-dropin-rom-plan.md b/docs/dev/release-dropin-rom-plan.md
new file mode 100644
index 00000000..c374f266
--- /dev/null
+++ b/docs/dev/release-dropin-rom-plan.md
@@ -0,0 +1,244 @@
+# Release plan — "drop in your ROM and play"
+
+Status: proposal / not yet scheduled. Owner: TBD.
+
+## Goal
+
+An end user downloads the GitHub release zip, unzips it, drops their
+legally-owned GoldenEye 007 N64 ROM (`.z64`, big-endian) into a folder,
+double-clicks the exe, and plays. No Python, no MSYS2, no manual
+asset-extraction, no command line.
+
+## Where we are today
+
+The Windows bundle produced by `tools_pc/bundle-win.sh` (and uploaded by the
+`windows-build` CI job) already contains only: the engine exe, its MinGW /
+SDL2 / zlib DLLs, a README, and licenses. It deliberately ships **no** ROM and
+**no** game assets. That part is fine.
+
+The gap is everything the runtime needs *alongside* the ROM before a level
+will render:
+
+| Dependency | Produced today by | Consumed at runtime by |
+|---|---|---|
+| `port/src/romassets_.s` — absolute cart-address symbols for every obseg/ramrom/music asset + `ge007.ld` segment markers | `python3 scripts/gen_romassets.py `, compiled **into** the exe | linker; `&symbol` + `romCopy()` in game code |
+| `data/pcmodels-/{pcmodels.bin,manifest.csv}` (~1.3 MB) — every model file re-laid-out to PC struct layout, RZ-compressed | `python3 tools_pc/d43_emit.py ` | `port/src/pcmodels.c` (`pcmodelsReserveSize`/`LoadSidecars`/`PatchTable`) |
+| `data/pccg-/{pccg.bin,manifest.csv}` (~3.6 MB) — every stage `bg/*.seg` + `Tbg_*_stanZ` re-laid-out to PC layout | `python3 tools_pc/d69_emit.py ` | `port/src/pccg.c` |
+| ROM present at `data/ge007..z64` (or `baserom..z64`) | user copies it | `port/src/romdata.c` maps it at `0x10000000` |
+
+Without the two sidecar trees the game shows the intro logos and then crashes
+in `modelPromoteNodeOffsetsToPointers` (finding D179).
+
+The full decomp asset-extraction toolchain (`scripts/extract_baserom.u.sh`,
+`tools/extractor`, MIPS binutils) is **not** on the runtime path and never
+needs to reach an end user — it only regenerates the committed `assets/**`
+`.inc.c` / `.bin` metadata that a plain `git clone` already has.
+
+### Important facts established while writing this plan
+
+1. **`gen_romassets.py` does not need the ROM.** It resolves every symbol for
+   `u`, `e`, and `j` from committed files (`scripts/filelist..csv`,
+   `assets/obseg/file_resource_table.inc.c`, `assets/ramrom/ramrom.s`,
+   `assets/music/*.s`, `ge007.ld`). The ROM is only opened to size the
+   trailing `images` segment, and there is already a CSV-derived fallback when
+   it is absent. So all three `romassets_.s` files can be generated in CI
+   with no ROM. (`docs/building.md` claims PAL/JP need "region-specific
+   prop/font offsets from that region's ROM" — the current generator code
+   shows no such ROM read; treat that sentence as stale, but verify before
+   relying on it — see Open questions.)
+
+2. **`d43_emit.py` / `d69_emit.py` are pure-stdlib Python 3.** They read only
+   the ROM plus committed repo files, and their output is a deterministic
+   function of the ROM. Both carry extensive built-in round-trip validation
+   (re-parse the emitted image, check every field against the N64 source) and
+   exit non-zero on any mismatch. `d43_emit.py` also runs a battery of
+   buffer-fit cross-checks.
+
+3. **The engine is region-locked at compile time.** `REGION_DEFS` in
+   `CMakeLists.txt` differ per region (`VERSION_US/EU/JP`, `BUGFIX_*`,
+   `LEFTOVERDEBUG`, …) and are used in 60+ game files. One exe cannot serve
+   all three ROMs; the release must ship three exes (or one launcher that
+   dispatches to three).
+
+4. **Data/config location is already sane.** `sysResolvePath()` maps `$S/` to
+   `./data/` (next to CWD if present, else next to the exe). `ge007.ini`,
+   `ge007.eep`, and the sidecar trees all live under `$S/`. First launch
+   already writes `ge007.ini` there. The sidecar loaders already search
+   `$S/`, `$E/`, and `./`.
+
+5. **ROM identity checking already half-exists.** `port/src/romdata.c`
+   validates magic / title / cart-ID / country byte. `tools_pc/romverify.c`
+   goes further (recomputes the `n64cksum` 6102 checksum over
+   `[0x1000,0x100000)` using `tools/n64cksum.c`). Canonical SHA-1s for all
+   three regions are in the README requirements table.
+
+## Recommended approach
+
+**Two-part: (A) fold the ROM-independent piece into CI now, and (B) add a
+native first-run converter to the engine so the sidecars are generated on the
+user's machine from their own ROM.**
+
+### Part A — ship all three regions, pre-generate all `romassets`
+
+Small, low-risk, unblocks a "download three exes, pick the one for your ROM"
+release even before Part B lands.
+
+- CI `windows-build` becomes a 3-way matrix (`ntsc-final`, `pal-final`,
+  `jpn-final`). Each leg runs `gen_romassets.py ` then `build-pc.sh`.
+- `bundle-win.sh` collects all three exes (`ge007.ntsc-final.exe`,
+  `ge007.pal-final.exe`, `ge007.jpn-final.exe`) into one bundle, with shared
+  DLLs.
+- Add a tiny **launcher** `ge007.exe` (≈150 lines C, its own CMake target):
+  reads `data/*.z64` (and `baserom.*.z64`), checks the header country byte +
+  SHA-1 against the three known-good hashes, then `CreateProcess`es the
+  matching region exe. On no ROM / unknown ROM it shows a message box naming
+  the accepted versions and hashes and the folder to drop the ROM in.
+  - Alternative if a separate launcher is unwanted: make each region exe, on
+    detecting a ROM whose country byte is for a different region, re-exec its
+    sibling. Slightly hackier; avoids a fourth binary.
+
+### Part B — native first-run sidecar converter
+
+Port `d43_emit.py` and `d69_emit.py` to C as a new engine module
+`port/src/pcconvert/` (files: `pcconvert.h`, `d43.c`, `d69.c`,
+`pcconvert.c` orchestrator). On startup, `romdataInit()` (after it has the
+mapped ROM and region) calls `pcconvertEnsureSidecars(region)`:
+
+1. If `$S/pcmodels-/pcmodels.bin` **and** `$S/pccg-/pccg.bin`
+   exist and their embedded format-version tag matches the build → return
+   immediately (the common case after first run).
+2. Otherwise show a "Preparing game data from your ROM (one-time, ~5 s)…"
+   splash / log line, run the two converters, write the four output files
+   into `$S/`, then continue boot. Cache a `pcconvert.stamp` with the ROM
+   SHA-1 + converter version so a ROM swap or engine upgrade re-runs it.
+
+The converters need, besides the ROM:
+
+- `scripts/filelist.u.csv`
+- `assets/obseg/file_resource_table.inc.c`
+- `assets/**/ModelFileHeader.inc.c` (NS/NT per model)
+- the stage `bg` / `stan` `.inc.c` metadata `d69` reads
+
+These are committed text/CSV, a few hundred KB total. **Embed them into the
+exe at build time** via a CMake codegen step (`file(READ ...)` →
+`.byte`/`const char[]`), so the release stays "exe + DLLs" with nothing loose.
+A build-file change is required here (new `add_custom_command` + generated
+source) — out of scope for this doc, but noted as the one unavoidable
+`CMakeLists.txt` edit.
+
+Reuse: `zlib` is already linked (RZ = `0x11 0x72` + raw deflate — the exact
+codec `pcmodels`/`pccg` already decompress). The engine already has robust
+byte-swap helpers in `romdata.c`. The Python round-trip validators should be
+ported too and run in debug builds / behind an env flag.
+
+**Effort estimate:** `d43_emit.py` is ~1000 lines of intricate model-graph
+simulation (LOD/SWITCH rewire, BSP splice, marker-expansion bounds, DFS
+placement) + validation; `d69_emit.py` ~500 lines. Call it a 2–4 week
+focused task with careful diffing of C output against the Python output
+byte-for-byte on all three ROMs before trust. This is the single biggest
+work item.
+
+### Interim option (faster Part B, if timeline pressure)
+
+Freeze the two emit scripts with **PyInstaller / Nuitka in the Windows CI
+job** (the runner has Python) into one `ge007-convert.exe`, ship it in the
+bundle, and have the engine `CreateProcess` it on first run when sidecars are
+missing. Pure-stdlib scripts freeze into a single clean exe with no user
+Python. Downsides: ~8–15 MB added to the bundle, Windows-only, and it is a
+second binary to sign / trust. Recommend this **only** as a stopgap that buys
+time for the real C port; it satisfies "no user-visible Python/MSYS2" but not
+"single native binary" or non-Windows.
+
+### Rejected options
+
+- **Precompute and ship the sidecars.** They are ROM-derived copyrighted game
+  data (`data/pcmodels-*`, `data/pccg-*` are gitignored precisely for this).
+  Redistribution is not acceptable. Rejected.
+- **Region-independent sidecars.** `d43`/`d69` output is a function of the
+  ROM; there is no region-independent form to precompute, and even if there
+  were it would still be ROM-derived data. Rejected.
+- **Bundle CPython + the .py scripts loose.** Meets the letter of "no MSYS2"
+  but not "no Python", ages badly, and is bigger than a frozen exe for no
+  gain. Rejected in favour of the interim freeze if a stopgap is needed.
+
+## CI / release changes
+
+1. `windows-build` → matrix over the three ROMIDs; each leg generates its
+   `romassets_.s` and builds.
+2. Commit all three `port/src/romassets_{u,e,j}.s` (or generate in each CI
+   leg — committing is simpler and lets non-CI builds skip the generator).
+   Add an `e`/`j` regen + `git diff --stat` check to the `validate` job
+   mirroring the existing US one.
+3. `bundle-win.sh`: bundle all three region exes + the launcher; keep the
+   "no ROM / size ≤ 60 MB" guards (raise the limit only if the interim frozen
+   converter is included).
+4. `release` job: unchanged shape (draft pre-release); update
+   `.github/release-notes.md` with the drop-in-ROM instructions + the
+   requirements/hash table.
+5. Add a CI check that runs `d43_emit.py --check-only` / a `d69` equivalent
+   against a **CI-only** ROM if one can be provided via encrypted secret;
+   otherwise this stays a manual pre-publish smoke test (the release job is
+   already a human-gated draft).
+6. Once Part B lands: a CI step that builds the C converter, runs it and the
+   Python converter on the same ROM (secret), and `cmp`s the outputs.
+
+## Code to be written
+
+| File(s) | Rough scope | Notes |
+|---|---|---|
+| `port/src/launcher/ge007_launch.c` + CMake target | ~150 lines | ROM detect, SHA-1 match vs 3 known hashes, `CreateProcess` region exe, message-box on failure |
+| `port/src/pcconvert/d43.c` | ~1000–1300 lines | C port of `tools_pc/d43_emit.py` incl. graph sim + validation |
+| `port/src/pcconvert/d69.c` | ~500–700 lines | C port of `tools_pc/d69_emit.py` |
+| `port/src/pcconvert/pcconvert.c` + `.h` | ~200 lines | orchestrator, stamp file, splash text, wired into `romdataInit()` |
+| CMake codegen for embedded metadata | ~40 lines CMake + generated `.c` | embeds `filelist.u.csv`, `file_resource_table.inc.c`, `ModelFileHeader.inc.c`, bg/stan `.inc.c` |
+| `tools_pc/bundle-win.sh`, `.github/workflows/ci.yml` | edits | matrix, 3 exes, launcher, notes |
+| SHA-1 helper (or reuse a vendored small SHA-1) | ~100 lines | for launcher + `pcconvert.stamp`; `romverify.c`'s n64cksum path is an alternative identity check |
+
+## ROM SHA-1 / region handling
+
+- Accept a ROM at `data/ge007..z64`, `data/*.z64` (any name — sniff
+  the header), or `baserom.{u,e,j}.z64`.
+- Verify: N64 magic + "GOLDENEYE" + "GE" + country byte (already done), then
+  SHA-1 against the README table. On a header-valid but hash-mismatched ROM
+  (bad dump, byte-swapped `.n64`/`.v64`, ROM hack): warn but allow a
+  `--skip-rom-check` / ini opt-out; byte-swapped images should be detected and
+  the user told to supply a true big-endian `.z64`.
+- Region is taken from the country byte (`E`→ntsc-final, `P`→pal-final,
+  `J`→jpn-final), which already drives `pcmodelsRegionForCountry()`.
+
+## Config / data location
+
+No change needed. `$S/` = `./data/` (or `/data/`). First run creates
+`ge007.ini`; Part B additionally creates `pcmodels-/`,
+`pccg-/`, and `pcconvert.stamp` there. Document that the user makes a
+`data/` folder next to the exe and drops the ROM in it (the launcher can
+offer to create it).
+
+## Open questions
+
+1. **Does PAL/JP `romassets` generation truly need nothing from the ROM?**
+   `docs/building.md` says it needs region-specific offsets; the code says
+   otherwise. Generate `e` and `j` in CI and diff against a
+   locally-ROM-generated copy to confirm before committing them.
+2. **Does `d43_emit.py` really use `scripts/filelist.u.csv` for all three
+   regions?** It hard-codes `FILELIST = "scripts/filelist.u.csv"` even when
+   `REGION` is `pal-final`/`jpn-final`. Either model file offsets are
+   US-identical across regions (plausible — models live in obseg which may not
+   shift) or PAL/JP model conversion is currently untested. Confirm before
+   promising PAL/JP support in the release.
+3. **Launcher vs self-re-exec vs three shortcuts** — pick one UX.
+4. **Splash UI for the first-run convert** — reuse the SDL window / a simple
+   progress line, or a pre-SDL Win32 dialog? ~5 s is short enough that a log
+   line + busy cursor may suffice.
+5. **Non-Windows.** Part B's C converter is portable; the launcher and
+   bundling are Windows-only today. Out of scope but the C port keeps the
+   door open.
+6. **Signing.** Unsigned exes + a "reads your ROM" first-run step will trip
+   SmartScreen. Not blocking, but worth a release-notes note.
+
+## Suggested milestone ordering
+
+1. Part A (3-region matrix + pre-generated `romassets` + launcher) — days.
+2. Interim frozen `ge007-convert.exe` wired to first-run — days, optional.
+3. Part B C port of `d43`/`d69` + embedded metadata + output diffing — weeks.
+4. Drop the interim binary; single-binary drop-in experience complete.
diff --git a/docs/img/bugs/1 fps.PNG b/docs/img/bugs/1 fps.PNG
new file mode 100644
index 00000000..9606bd02
Binary files /dev/null and b/docs/img/bugs/1 fps.PNG differ
diff --git a/docs/index.md b/docs/index.md
index 5019d7ec..19697a13 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -13,7 +13,7 @@ graphics coprocessor in software. It follows the architecture of the
 
 It is also a **research project on AI-agent collaboration in a large,
 unfamiliar, low-level codebase**: two coding agents — a local open-weight model
-(Qwen 3) on a single RTX 5090, and Claude / Claude Code — driven by one person
+(Qwen 3.8) on a single RTX 5090, and Claude / Claude Code — driven by one person
 part-time through ~230 translation units of unmodified big-endian MIPS game
 code, handing work back and forth through shared written notes.
 
diff --git a/include/PR/os.h b/include/PR/os.h
index 0a84cd5d..6df1d845 100644
--- a/include/PR/os.h
+++ b/include/PR/os.h
@@ -980,9 +980,16 @@ extern void		osAckRamromWrite(void);
 
 /* byte string operations */
 
+#if defined(PORT) && !defined(_WIN32)
+/* On the Linux/macOS port these resolve to the host libc; glibc 
+ * declares them with size_t sizes, which conflict with the N64 `int`
+ * prototypes below (GCC 14 hard error). Use the host declarations. */
+#include 
+#else
 extern void     bcopy(const void *, void *, int);
 extern int      bcmp(const void *, const void *, int);
 extern void     bzero(void *, int);
+#endif
 
 /* Miscellaneous operations */
 
diff --git a/port/fast3d/gfx_opengl.cpp b/port/fast3d/gfx_opengl.cpp
index 66e5aea1..532a2a69 100644
--- a/port/fast3d/gfx_opengl.cpp
+++ b/port/fast3d/gfx_opengl.cpp
@@ -1083,7 +1083,15 @@ extern "C" bool gfx_opengl_dump_bound_fbo(uint32_t width, uint32_t height, const
     size_t n = (size_t)width * height * 3;
     uint8_t* pixels = (uint8_t*)malloc(n);
     if (!pixels) return false;
+    glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
+    glReadBuffer(GL_BACK);
+    glPixelStorei(GL_PACK_ALIGNMENT, 1);
+    glFinish();
     glReadPixels(0, 0, (GLsizei)width, (GLsizei)height, GL_RGB, GL_UNSIGNED_BYTE, pixels);
+    {
+        GLenum e = glGetError();
+        if (e) sysLogPrintf(LOG_WARNING, "dump_bound_fbo: glReadPixels err 0x%x (%ux%u)", (unsigned)e, width, height);
+    }
     FILE* f = fopen(path, "wb");
     if (!f) { free(pixels); return false; }
     fprintf(f, "P6\n%u %u\n255\n", width, height);
@@ -1199,7 +1207,11 @@ bool gfx_opengl_start_draw_to_framebuffer(int fb_id, float noise_scale) {
         if (noise_scale != 0.0f) {
             current_noise_scale = 1.0f / noise_scale;
         }
-        glBindFramebuffer(GL_FRAMEBUFFER, fb.fbo);
+        /* fb_id 0 is the on-screen target: bind the window's default
+         * framebuffer (GL name 0), not framebuffers[0].fbo (a generated but
+         * attachment-less FBO -> incomplete -> nothing renders on a strict
+         * GL implementation like Mesa/llvmpipe). */
+        glBindFramebuffer(GL_FRAMEBUFFER, fb_id == 0 ? 0 : fb.fbo);
         current_framebuffer = fb_id;
         return true;
     } else {
diff --git a/port/fast3d/gfx_sdl2.cpp b/port/fast3d/gfx_sdl2.cpp
index 8bdac3e5..ce1a7650 100644
--- a/port/fast3d/gfx_sdl2.cpp
+++ b/port/fast3d/gfx_sdl2.cpp
@@ -427,10 +427,19 @@ static inline void sync_framerate_with_timer(void) {
     previous_time = t;
 }
 
+/* Optional hook run with the freshly-composited frame still in the back
+ * buffer, immediately before the swap. video.c uses it for GE_PCDUMP /
+ * screenshots: reading the back buffer AFTER SDL_GL_SwapWindow is undefined
+ * on buffer-exchange drivers (Mesa, WSLg) and yields a black capture. */
+extern "C" void (*gfx_pre_swap_hook)(void) = NULL;
+
 static void gfx_sdl_swap_buffers_begin(void) {
     if (target_fps) {
         sync_framerate_with_timer();
     }
+    if (gfx_pre_swap_hook) {
+        gfx_pre_swap_hook();
+    }
     SDL_GL_SwapWindow(wnd);
 }
 
diff --git a/port/include/hostsched.h.in b/port/include/hostsched.h.in
new file mode 100644
index 00000000..6d15a3f1
--- /dev/null
+++ b/port/include/hostsched.h.in
@@ -0,0 +1,9 @@
+#ifndef PORT_HOSTSCHED_H
+#define PORT_HOSTSCHED_H
+/* Generated by CMake from hostsched.h.in — do not edit.
+ * Includes the HOST  by absolute path so that port/shim/sched.h can
+ * give C++ TUs (fast3d, libstdc++'s /gthr headers) the real
+ *  — cpu_set_t, sched_yield — instead of the decomp's src/sched.h,
+ * which sits on the include path and would otherwise shadow it. */
+#include "@PORT_HOST_SCHED_H@"
+#endif
diff --git a/port/include/pc_protos.h b/port/include/pc_protos.h
index 15587143..6d4096e8 100644
--- a/port/include/pc_protos.h
+++ b/port/include/pc_protos.h
@@ -201,6 +201,11 @@ void fileUpdateSelectedBondInSave();
 s32 fogGetPropDistColor();
 void fogLoadLevelEnvironment();
 void free();
+/* D38: the N64  stub declares neither, and unlike MinGW a strict
+ * host GCC does not leak them — so getenv()'s 64-bit pointer return is
+ * truncated to int (crashed configGetFrameDump on Linux). */
+char * getenv();
+void * realloc();
 int frontGetPlayersFavoriteWeaponInHand();
 void generate_player_thrown_grenade();
 void generate_player_thrown_knife();
@@ -389,9 +394,10 @@ s32 sizepropdef();
 Gfx * skyRender();
 void skySetStageNum();
 void skyTick();
-/* Exact match of GCC's built-in (and MSVCRT) prototype; size_t is
- * `unsigned long long` on this MinGW toolchain. */
-int snprintf(char *, unsigned long long, const char *, ...);
+/* Match the C library prototype. size_t (from  above) is
+ * `unsigned long long` on MinGW but `unsigned long` on Linux/glibc — spelling
+ * it `size_t` keeps this compatible with both libcs' . */
+int snprintf(char *, size_t, const char *, ...);
 PropRecord * something_with_generating_object();
 void speedgraphInit();
 void speedgraphMarkerUpdate();
@@ -446,7 +452,9 @@ s32 texLoadFromGdl();
 s32 texReadUncompressed();
 void texSelect();
 s32 texShrinkNonPaletted();
-long long time();
+#if defined(_WIN32)
+long long time(); /* MinGW:  is shadowed by the N64 stub here; POSIX gets the real one */
+#endif
 const char * tokenFind();
 s32 tokenReadIo();
 void transform3Dto2DWithZScaling();
diff --git a/port/shim/sched.h b/port/shim/sched.h
new file mode 100644
index 00000000..256c003d
--- /dev/null
+++ b/port/shim/sched.h
@@ -0,0 +1,18 @@
+/*
+ * PC port shim for .
+ *
+ * The decomp ships src/sched.h (the N64 OSSc* scheduler client interface),
+ * which is on the compiler include path. libstdc++'s  / gthr headers
+ * (pulled into the fast3d C++ TUs) do `#include ` and need the HOST
+ * header — cpu_set_t, sched_yield — so C++ TUs must be routed to the real
+ *  by absolute path (generated hostsched.h, same trick as
+ * port/shim/string.h). C TUs keep resolving  to the decomp's own
+ * src/sched.h, exactly as on the console.
+ *
+ * Inert in the N64 build (port/shim is not on its include path).
+ */
+#if defined(__cplusplus)
+#include "hostsched.h"
+#else
+#include_next 
+#endif
diff --git a/port/shim/stdarg.h b/port/shim/stdarg.h
index 962bd243..5cc58941 100644
--- a/port/shim/stdarg.h
+++ b/port/shim/stdarg.h
@@ -4,16 +4,35 @@
  * The N64 decomp's include/stdarg.h does `#include ` (the console
  * dev-kit convenience: one header pulls in all of libultra). On the PC that
  * means any HOST header that internally includes  (SDL_stdinc.h,
- * parts of MinGW's own headers, ...) drags PR/os.h into the middle of host
- * header parsing. If MinGW's  has already run (it defines the
- * `errno` macro), PR/os.h's controller structs — which have fields literally
- * named `errno` — fail to parse ("field '_errno' declared as a function").
- *
- * Parse the N64 header with the errno macro suppressed; restore it after.
- * Inert in the N64 build (port/shim is not on its include path).
+ * parts of the CRT's own headers, glibc's , ...) drags PR/os.h into
+ * the middle of host header parsing.
  */
 
+#if defined(_WIN32)
+/*
+ * MinGW: parse the N64 header with the errno macro suppressed (PR/os.h has
+ * fields literally named `errno`); restore it after. MinGW's CRT headers do
+ * not need __gnuc_va_list.
+ */
 #pragma push_macro("errno")
 #undef errno
 #include "include/stdarg.h"
 #pragma pop_macro("errno")
+
+#else
+/*
+ * Linux/macOS: do NOT pull  into system-header parsing. glibc's
+ *  needs both va_list and the __gnuc_va_list alias that GCC's real
+ *  would provide; the decomp header defines neither. Provide the
+ * whole set straight from the always-available compiler builtins.
+ */
+#ifndef _PORT_SHIM_STDARG_H
+#define _PORT_SHIM_STDARG_H
+typedef __builtin_va_list va_list;
+typedef __builtin_va_list __gnuc_va_list;
+#define va_start(ap, last) __builtin_va_start(ap, last)
+#define va_end(ap)         __builtin_va_end(ap)
+#define va_arg(ap, type)   __builtin_va_arg(ap, type)
+#define va_copy(dst, src)  __builtin_va_copy(dst, src)
+#endif
+#endif
diff --git a/port/shim/stddef.h b/port/shim/stddef.h
index fe0d0a40..c36a0361 100644
--- a/port/shim/stddef.h
+++ b/port/shim/stddef.h
@@ -25,6 +25,41 @@
 #undef __need_NULL
 #undef __need_wint_t
 #include "hoststddef.h"
-#else
+#elif defined(_WIN32)
+/*
+ * C TUs on MinGW: keep the empty N64 include/stddef.h stub. MinGW's CRT
+ * headers leak size_t/ptrdiff_t transitively, and its own  drags in
+ * the `#define errno (*_errno())` CRT macro, which collides with PR/os.h's
+ * `u8 errno;` struct field — so routing to the host header is not an option
+ * here.
+ */
 #include "include/stddef.h"
+#else
+/*
+ * C TUs on Linux/macOS: the N64 include/stddef.h stub defines nothing, glibc
+ * only defines these in the compiler's shadowed builtin , and the
+ * MinGW hoststddef trick (bin/../include/stddef.h) does not exist on Linux.
+ * Provide the standard types straight from the always-predefined GCC/Clang
+ * builtins — no header, so nothing to collide with. C11 permits the benign
+ * identical redefinition if a real  is ever reached another way.
+ */
+#ifndef _PORT_SHIM_STDDEF_TYPES
+#define _PORT_SHIM_STDDEF_TYPES
+typedef __SIZE_TYPE__    size_t;
+typedef __PTRDIFF_TYPE__ ptrdiff_t;
+/* SDL's headers (SDL_stdinc.h, SDL_hidapi.h) and glibc's 
+ * reference wchar_t, which in C is a library typedef owned by the compiler's
+ * shadowed . Provide it from the builtin, guarded by the same
+ * macro GCC/glibc use so a real  reached later is a no-op. */
+#ifndef _WCHAR_T
+#define _WCHAR_T
+typedef __WCHAR_TYPE__ wchar_t;
+#endif
+#ifndef NULL
+#define NULL ((void *)0)
+#endif
+#ifndef offsetof
+#define offsetof(t, m) __builtin_offsetof(t, m)
+#endif
+#endif
 #endif
diff --git a/port/shim/stdlib.h b/port/shim/stdlib.h
index 868956c1..5512a05f 100644
--- a/port/shim/stdlib.h
+++ b/port/shim/stdlib.h
@@ -17,4 +17,15 @@
 #include "hoststdlib.h"
 #else
 #include "include/stdlib.h"
+#if !defined(_WIN32)
+/* The N64 stub declares only lldiv_t/ldiv_t + lldiv/ldiv. MinGW's other CRT
+ * headers leak getenv() etc.; a strict host GCC (Linux/macOS) does not, so
+ * getenv()'s 64-bit pointer return is assumed int and truncated (crashed
+ * configGetFrameDump). Declare the pointer-returning stragglers the port +
+ * game code use that nothing else declares. K&R form = compatible with any
+ * real prototype. (malloc/calloc/free are in port/include/pc_protos.h and
+ * a few port TUs' own local decls — not repeated here to avoid conflicts.) */
+extern char *getenv();
+extern void *realloc();
+#endif
 #endif
diff --git a/port/src/crash.c b/port/src/crash.c
index c064ddf4..aced06e4 100644
--- a/port/src/crash.c
+++ b/port/src/crash.c
@@ -402,6 +402,39 @@ static void crashHandler(int sig, siginfo_t *siginfo, void *ctx)
     sysFatalError("Crash!\n\n%s", msg);
 }
 
+/* D38: called by the kernel heartbeat watchdog (port/src/libultra.c) when a
+ * hang is detected. A full cross-thread backtrace on POSIX would need a
+ * signal-based stack-walk of every peer thread; for now log the live thread
+ * roster plus the calling (watchdog) thread's own backtrace, which is enough
+ * to see the watchdog fired and which threads were still up. */
+void crashDumpThreads(const unsigned long *tids, const char **names, int count)
+{
+    char msg[CRASH_MAX_MSG + 1] = { 0 };
+    unsigned msglen = 0;
+
+    CRASH_MSG("THREAD DUMP (%d live):\n", count);
+    for (int i = 0; i < count; ++i) {
+        CRASH_MSG("  tid=0x%lx  %s\n", tids ? tids[i] : 0UL,
+                  (names && names[i]) ? names[i] : "?");
+    }
+    CRASH_MSG("\nWATCHDOG THREAD BACKTRACE:\n");
+    {
+        void *frames[CRASH_MAX_FRAMES] = { NULL };
+        const int nframes = backtrace(frames, CRASH_MAX_FRAMES);
+        char **strings = (nframes > 0) ? backtrace_symbols(frames, nframes) : NULL;
+        for (int i = 0; i < nframes; ++i) {
+            CRASH_MSG("#%02d: %s\n", i,
+                      (strings && strings[i]) ? strings[i] : "?");
+        }
+        free(strings);
+    }
+    sysLogPrintf(LOG_ERROR, "%s", msg);
+    {
+        FILE *f = fopen(CRASH_LOG_FNAME, "ab");
+        if (f) { fprintf(f, "%s", msg); fclose(f); }
+    }
+}
+
 #endif /* PLATFORM_WINDOWS / PLATFORM_LINUX */
 
 int g_CrashEnabled = 0;
diff --git a/port/src/libultra.c b/port/src/libultra.c
index 3a20d356..d4fe542f 100644
--- a/port/src/libultra.c
+++ b/port/src/libultra.c
@@ -173,8 +173,14 @@ static uint64_t portNextTimerUs(void);
 
 static void portHeartbeatCheck(void)
 {
+    /* A real hang is a permanent stall; transient >3 s gaps are normal on a
+     * slow/software-GL/debugger setup (level loads, gdb pauses, SIGTERM
+     * teardown) and were spamming ge007.crash.log with dozens of thread
+     * dumps. Use a longer window and stop after a few reports. */
+    static int fired = 0;
     uint64_t now = sysGetMicroseconds();
-    if (now - g_lastFrameUs > 3000000 && now - g_lastHeartbeatUs > 2000000) {
+    if (fired < 3 && now - g_lastFrameUs > 8000000 && now - g_lastHeartbeatUs > 5000000) {
+        fired++;
         g_lastHeartbeatUs = now;
         sysLogPrintf(LOG_ERROR,
             "kernel heartbeat: no frame rendered for %llu ms (frames=%d); state:",
@@ -334,6 +340,29 @@ void osCreateThread(OSThread *t, OSId id, void (*entry)(void *), void *arg,
     t->state = OS_STATE_STOPPED;
 }
 
+#if !defined(_WIN32)
+#include 
+/* The decomp aligns/compares stack-buffer pointers by truncating to u32
+ * (e.g. `(u32)compbuffer` in image.c texLoad) — an N64 assumption. glibc
+ * puts pthread stacks above 4 GiB, so those truncations corrupt. Force each
+ * game-thread stack into the low 2 GiB with MAP_32BIT so every such idiom
+ * works exactly as on the console. Windows pthread stacks are already low. */
+static void *portAllocLowStack(size_t sz)
+{
+#if defined(MAP_32BIT)
+    void *p = mmap(NULL, sz, PROT_READ | PROT_WRITE,
+                   MAP_PRIVATE | MAP_ANONYMOUS | MAP_32BIT
+#if defined(MAP_STACK)
+                   | MAP_STACK
+#endif
+                   , -1, 0);
+    if (p != MAP_FAILED)
+        return p;
+#endif
+    return NULL;
+}
+#endif
+
 void osStartThread(OSThread *t)
 {
     PortThread *pt = portFind(t);
@@ -344,7 +373,15 @@ void osStartThread(OSThread *t)
     pthread_attr_t attr;
     int rc;
     pthread_attr_init(&attr);
+#if !defined(_WIN32)
+    void *lowStack = portAllocLowStack(PORT_THREAD_STACK);
+    if (lowStack)
+        pthread_attr_setstack(&attr, lowStack, PORT_THREAD_STACK);
+    else
+        pthread_attr_setstacksize(&attr, PORT_THREAD_STACK);
+#else
     pthread_attr_setstacksize(&attr, PORT_THREAD_STACK);
+#endif
     rc = pthread_create(&pt->th, &attr, portThreadWrapper, pt);
     pthread_attr_destroy(&attr);
     if (rc != 0) {
@@ -765,6 +802,7 @@ static int dramHostAddrValid(uintptr_t addr, u32 size)
      * buffers (e.g. ramrom_data_target), stack compbuffers, sidecar images.
      * Truncated wild addresses (0x40xxxxxx from s32 pointer math) are not
      * committed, so VirtualQuery still catches them. */
+#if defined(PLATFORM_WINDOWS)
     {
         MEMORY_BASIC_INFORMATION mbi;
         if (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi)) == sizeof(mbi) &&
@@ -774,6 +812,13 @@ static int dramHostAddrValid(uintptr_t addr, u32 size)
             return 1;
     }
     return 0;
+#else
+    /* No cheap committed-memory probe on POSIX; this is a TEMP D60 diagnostic.
+     * Be permissive so legitimate .bss/stack/sidecar DMA targets are not
+     * flagged as fatal (a genuinely wild target still faults in the memcpy). */
+    (void)size;
+    return 1;
+#endif
 }
 
 static void piServiceDma(s32 direction, u32 srcPA, void *dstVA, u32 size)
@@ -817,6 +862,7 @@ static void piServiceDma(s32 direction, u32 srcPA, void *dstVA, u32 size)
                 wp += snprintf(wp, win + sizeof(win) - (wp - win),
                                " %p", (void *)sp[i]);
             }
+#if defined(PLATFORM_WINDOWS)
             {
                 PVOID tlow = NULL, thigh = NULL;
                 GetCurrentThreadStackLimits(&tlow, &thigh);
@@ -826,6 +872,12 @@ static void piServiceDma(s32 direction, u32 srcPA, void *dstVA, u32 size)
                              ((uintptr_t)dstVA >= (uintptr_t)tlow &&
                               (uintptr_t)dstVA < (uintptr_t)thigh));
             }
+#else
+            /* TEMP D60 diagnostic: no portable committed-stack query without
+             * _GNU_SOURCE (pthread_getattr_np); the raw stack window above and
+             * the crash handler's register dump are enough to symbolicate. */
+            sysLogPrintf(LOG_ERROR, "D60 thread stack: (n/a on POSIX)\n");
+#endif
             sysLogPrintf(LOG_ERROR,
                          "D60 BAD DMA TARGET dst=%p src=0x%08X size=0x%X "
                          "stack@rbp:%s\n",
diff --git a/port/src/main.c b/port/src/main.c
index 878a947c..4b92576e 100644
--- a/port/src/main.c
+++ b/port/src/main.c
@@ -115,6 +115,7 @@ int main(int argc, char **argv)
      *     loader loads at 0x140000000 and those relocations are no-ops; if we
      *     ever got relocated, every such slot would be silently corrupted.
      *     Fail loudly instead. */
+#if defined(PLATFORM_WINDOWS)
     if (sysImageBase() != 0x140000000ul) {
         sysLogPrintf(LOG_ERROR,
             "image loaded at %p, expected preferred base 0x140000000; "
@@ -122,6 +123,11 @@ int main(int argc, char **argv)
             (void *)sysImageBase());
         return 1;
     }
+#else
+    /* Linux/ELF no-PIE: dram_syms.s absolute symbols resolve to their literal
+     * values independent of the image base (no .refptr indirection), so the
+     * load address is not constrained. sysImageBase() is a stub here anyway. */
+#endif
 
     /* 2b. Reserve the N64-DRAM region: s32-safe view @ 0x70000000 (cfb_16,
      *     mempools) + KSEG0 mirror @ 0x80000000 (see port/src/dram.c). */
diff --git a/port/src/n64stubs.c b/port/src/n64stubs.c
index d85aa77c..dfc01bae 100644
--- a/port/src/n64stubs.c
+++ b/port/src/n64stubs.c
@@ -106,10 +106,14 @@ u8 (*tlbmanageGetTlbAllocatedBlock(void))[TLB_BLOCK_SIZE]
     return (u8 (*)[TLB_BLOCK_SIZE])0x70700000;
 }
 
-/* --- K&R libc helpers (IDO provided these; the host libc does not) ------- */
-/* Signatures mirror include/PR/os.h:983 / include/bstring.h exactly.        */
+/* --- K&R libc helpers (IDO provided these; MinGW's libc does not) -------- */
+/* Signatures mirror include/PR/os.h:983 / include/bstring.h exactly.
+ * glibc still exports bcopy/bzero (declared in  with size_t), so
+ * on the POSIX port we use those and must NOT redefine them here. */
+#if defined(_WIN32)
 void bcopy(const void *src, void *dst, int n) { memmove(dst, src, (size_t)n); }
 void bzero(void *s, int n)                     { memset(s, 0, (size_t)n); }
+#endif
 
 /* --- libm internals (IDO's libm exposed these; MinGW's does not) --------- */
 /* Quiet NaN float, used by gu/cosf.c and game/zlib.c. The VALUE is the same  */
diff --git a/port/src/pccg.c b/port/src/pccg.c
index 39c1a1d3..7ae501fa 100644
--- a/port/src/pccg.c
+++ b/port/src/pccg.c
@@ -22,7 +22,7 @@
 #include 
 
 extern void *malloc(unsigned long long size);
-extern int   snprintf(char *str, unsigned long long maxsize, const char *format, ...);
+extern int   snprintf(char *str, size_t maxsize, const char *format, ...);
 
 #include "platform.h"
 #include "system.h"
diff --git a/port/src/pcmodels.c b/port/src/pcmodels.c
index 247a8694..045e4a16 100644
--- a/port/src/pcmodels.c
+++ b/port/src/pcmodels.c
@@ -24,7 +24,7 @@
 /* D38: / resolve to the decomp's N64 stubs via the
  * include path; declare the host functions this file uses. */
 extern void *malloc(unsigned long long size);
-extern int   snprintf(char *str, unsigned long long maxsize, const char *format, ...);
+extern int   snprintf(char *str, size_t maxsize, const char *format, ...);
 
 #include "platform.h"
 #include "system.h"
diff --git a/port/src/romdata.c b/port/src/romdata.c
index dac3561a..b1be80bb 100644
--- a/port/src/romdata.c
+++ b/port/src/romdata.c
@@ -23,7 +23,7 @@
 /* D38: / resolve to the decomp's N64 stubs via the include
  * path; declare the host functions this file uses. */
 extern void *malloc(unsigned long long size);
-extern int snprintf(char *str, unsigned long long maxsize, const char *format, ...);
+extern int snprintf(char *str, size_t maxsize, const char *format, ...);
 
 #include "platform.h"
 
@@ -32,6 +32,11 @@ extern int snprintf(char *str, unsigned long long maxsize, const char *format, .
     #define WIN32_LEAN_AND_MEAN
   #endif
   #include 
+#else
+  /* POSIX: mmap the ROM at the fixed cart address (mirrors the VirtualAlloc
+   * path).  is a host header the decomp include path does not
+   * shadow. */
+  #include 
 #endif
 
 #include "system.h"
@@ -50,6 +55,9 @@ extern int snprintf(char *str, unsigned long long maxsize, const char *format, .
 static u8  *rom = NULL;        /* heap copy (fallback path) */
 static u32  romSize = 0;
 static int  mappedAtCartBase = 0;
+#if !defined(PLATFORM_WINDOWS)
+static unsigned long long mappedLen = 0;  /* munmap() needs the length */
+#endif
 
 /* D34 (docs/internals.md): PE image base, low 32 bits zero. On N64 the
  * animation_data segment links at VMA 0, so game code computes record
@@ -105,6 +113,35 @@ static int romHeaderValid(const u8 *h, char *err, size_t errsz)
 
 static u32 romdataBswap32(u32 v); /* defined below; used by the D55 fixup */
 
+/* The caller has placed a writable region of (romSize + sidecars) bytes at
+ * CART_BASE. Populate it, load the sidecars, apply the D55 RLE-header fixup,
+ * and mark the ROM live. Shared by the Windows (VirtualAlloc) and POSIX (mmap)
+ * fixed-address paths so the post-map logic can never drift between them. */
+static int romdataFinishCartMap(const char *tok, u8 *img,
+                                u32 sideTotal, u32 cgTotal)
+{
+    memcpy((void *)(uintptr_t)CART_BASE, img, romSize);
+    free(img);
+    mappedAtCartBase = 1;
+    sysLogPrintf(LOG_INFO, "romdataInit: %s (%u bytes) mapped at 0x%08X "
+                 "(cart base)%s%s", tok, romSize, CART_BASE,
+                 sideTotal ? ", + model sidecars" : "",
+                 cgTotal ? ", + bg/stan sidecars" : "");
+    pcmodelsLoadSidecars(CART_BASE, romSize);
+    pccgLoadSidecars(CART_BASE + romSize + pcmodelsTotalSize());
+
+    /* D55: the RLE folder-menu background at `unknown2` has a big-endian w/h
+     * header that rle_expand_8bit() reads little-endian; match the N64 .data
+     * copy by swapping it in place (no-op if it already reads as LE). */
+    {
+        extern void *unknown2; /* absolute cart address */
+        u32 *hdr = (u32 *)&unknown2;
+        if ((u16)*hdr > 512 || (u16)(*hdr >> 16) > 512)
+            *hdr = romdataBswap32(*hdr);
+    }
+    return 0;
+}
+
 int romdataInit(void)
 {
     char err[128] = "";
@@ -120,10 +157,11 @@ int romdataInit(void)
         uintptr_t p = (uintptr_t)&probe;
         uintptr_t hi = p & ~(uintptr_t)0xFFFFFFFFu;
         if (hi == 0) {
-            sysLogPrintf(LOG_ERROR, "romdataInit: image base 0x%llX is not "
-                         "4GiB-aligned; ANIM_DATA_* offsets would be wrong",
-                         (unsigned long long)p);
-            return -1;
+            /* Low image base (Linux no-PIE loads at 0x400000). The ANIM_DATA_*
+             * lvalues are only ever address-taken — `(s32)&ANIM_DATA_x` yields
+             * the offset, never a dereference — so g_pc_animdata_base just has
+             * to be some 4GiB-aligned address whose low 32 bits are zero. */
+            hi = (uintptr_t)0x100000000ull;
         }
         g_pc_animdata_base = (u8 *)hi;
     }
@@ -168,51 +206,44 @@ int romdataInit(void)
          * sidecar extension. */
         u32 cgTotal = pccgReserveSize(img);
 
-        /* Map at the cart address so absolute asset symbols are live. */
+        /* Map at the cart address so absolute asset symbols are live. On
+         * failure both paths fall through to the heap copy below (degraded:
+         * anything that dereferences a cart address directly, rather than via
+         * romdataGetRom()/the PI shims, will read wrong memory). */
+        {
+            u32 maplen = romSize + sideTotal + cgTotal;
 #if defined(PLATFORM_WINDOWS)
-        void *at = VirtualAlloc((LPVOID)CART_BASE, romSize + sideTotal + cgTotal,
-                                MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
-        if (at == (void *)(uintptr_t)CART_BASE) {
-            memcpy(at, img, romSize);
-            free(img);
-            mappedAtCartBase = 1;
-            sysLogPrintf(LOG_INFO, "romdataInit: %s (%u bytes) mapped at "
-                         "0x%08X (cart base)%s%s",
-                         tok, romSize, CART_BASE,
-                         sideTotal ? ", + model sidecars" : "",
-                         cgTotal ? ", + bg/stan sidecars" : "");
-            pcmodelsLoadSidecars(CART_BASE, romSize);
-            pccgLoadSidecars(CART_BASE + romSize + pcmodelsTotalSize());
-
-            /* D55 (docs/internals.md): the RLE folder-menu background at
-             * `unknown2` (romassets_.s) is stored in the ROM with a
-             * big-endian w/h header (01 B8 01 2B = 440x299), but
-             * rle_expand_8bit() reads it little-endian. The N64 build embeds
-             * this asset into .data via assets/romfiles2.s from an extracted
-             * .bin whose header is byte-swapped (title2.c hardcodes 440x299
-             * row rendering, and this is the only decodable 440x299 RLE
-             * stream in the ROM), so make the cart copy match it: swap the
-             * w/h word in place. The guard makes the fixup a no-op if a
-             * region's copy already reads as a plausible LE size. */
-            {
-                extern void *unknown2; /* absolute cart address */
-                u32 *hdr = (u32 *)&unknown2;
-                if ((u16)*hdr > 512 || (u16)(*hdr >> 16) > 512)
-                    *hdr = romdataBswap32(*hdr);
-            }
-            return 0;
-        }
-        if (at)
-            VirtualFree(at, 0, MEM_RELEASE);
-        sysLogPrintf(LOG_WARNING, "romdataInit: could not reserve 0x%08X; "
-                     "using heap copy — direct ROM reads will fail",
-                     CART_BASE);
+            void *at = VirtualAlloc((LPVOID)CART_BASE, maplen,
+                                    MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
+            if (at == (void *)(uintptr_t)CART_BASE)
+                return romdataFinishCartMap(tok, img, sideTotal, cgTotal);
+            if (at)
+                VirtualFree(at, 0, MEM_RELEASE);
+            sysLogPrintf(LOG_WARNING, "romdataInit: could not reserve 0x%08X; "
+                         "using heap copy — direct ROM reads will fail",
+                         CART_BASE);
 #else
-        /* Best effort: fixed-hint mmap. If the hint is refused we fall back
-         * to a heap copy (same limitation as above). */
-        sysLogPrintf(LOG_WARNING, "romdataInit: fixed-address mapping not "
-                     "implemented on this platform; using heap copy");
+            /* POSIX: anonymous fixed-address mmap. MAP_FIXED_NOREPLACE (Linux
+             * 4.17+) fails instead of clobbering an existing mapping; where it
+             * is unavailable the plain hint is advisory and the == check below
+             * catches a relocated result. */
+            int flags = MAP_PRIVATE | MAP_ANONYMOUS;
+#ifdef MAP_FIXED_NOREPLACE
+            flags |= MAP_FIXED_NOREPLACE;
 #endif
+            void *at = mmap((void *)(uintptr_t)CART_BASE, maplen,
+                            PROT_READ | PROT_WRITE, flags, -1, 0);
+            if (at != MAP_FAILED && at == (void *)(uintptr_t)CART_BASE) {
+                mappedLen = maplen;
+                return romdataFinishCartMap(tok, img, sideTotal, cgTotal);
+            }
+            if (at != MAP_FAILED)
+                munmap(at, maplen);
+            sysLogPrintf(LOG_WARNING, "romdataInit: could not map 0x%08X "
+                         "(ASLR/kernel refused the fixed address); using heap "
+                         "copy — direct ROM reads will fail", CART_BASE);
+#endif
+        }
 
         rom = img;
         return 0;
@@ -225,12 +256,15 @@ int romdataInit(void)
 
 void romdataDestroy(void)
 {
-#if defined(PLATFORM_WINDOWS)
     if (mappedAtCartBase) {
+#if defined(PLATFORM_WINDOWS)
         VirtualFree((LPVOID)CART_BASE, 0, MEM_RELEASE);
+#else
+        munmap((void *)(uintptr_t)CART_BASE, (size_t)mappedLen);
+        mappedLen = 0;
+#endif
         mappedAtCartBase = 0;
     }
-#endif
     free(rom);
     rom = NULL;
     romSize = 0;
diff --git a/port/src/video.c b/port/src/video.c
index 88f2a7da..48afc02b 100644
--- a/port/src/video.c
+++ b/port/src/video.c
@@ -126,6 +126,10 @@ static u32 frames = 0;
 /* Set by the host event pump (F12), consumed on the render thread in
  * videoEndFrame where a GL context is current. */
 static volatile int screenshotReq = 0;
+
+/* Pre-swap capture hook (defined below, registered in videoInit). */
+static void videoPreSwapCapture(void);
+extern void (*gfx_pre_swap_hook)(void);
 static double fpsWindowStart = 0.0;
 static int fpsNumFrames = 0;
 static float vidAvgFPS = 0.f;
@@ -189,6 +193,8 @@ int videoInit(void)
      * still needs a current context on this thread. */
     gfx_sdl_release_context();
 
+    gfx_pre_swap_hook = videoPreSwapCapture;
+
     initDone = 1;
     sysLogPrintf(LOG_INFO, "video: %dx%d window (native %dx%d)",
                  (int)gfx_current_dimensions.width, (int)gfx_current_dimensions.height,
@@ -330,18 +336,13 @@ void videoSubmitCommands(Gfx *cmds)
     gfx_run(cmds);
 }
 
-void videoEndFrame(void)
+/* Runs from gfx_sdl_swap_buffers_begin with the composited frame still in the
+ * back buffer, just before SDL_GL_SwapWindow. Reading the back buffer after
+ * the swap is undefined on buffer-exchange drivers (Mesa/WSLg) -> black. */
+static void videoPreSwapCapture(void)
 {
-    if (!initDone) {
-        return;
-    }
-    gfx_end_frame();
-
-    /* TEMP D70 (env-gated, debug-only, env-gated): GE_PCDUMP="first-last"
-     * or "first-last:step" dumps the presented frame as ./ppm/frame_NNNNNN.ppm
-     * (one frame behind real time — read happens after SwapWindow). Used to
-     * pixel-verify the intro's 3D content. Also honours [Debug] FrameDump in
-     * ge007.ini (env var wins). */
+    /* GE_PCDUMP="first-last" / "first-last:step" -> ./ppm/frame_NNNNNN.ppm.
+     * Also honours [Debug] FrameDump in ge007.ini (env var wins). */
     const char *pcdump = configGetFrameDump();
     if (pcdump) {
         static int lo = -1, hi = 0, step = 1;
@@ -376,6 +377,14 @@ void videoEndFrame(void)
             sysLogPrintf(LOG_WARNING, "video: screenshot failed");
         }
     }
+}
+
+void videoEndFrame(void)
+{
+    if (!initDone) {
+        return;
+    }
+    gfx_end_frame();
 
     ++frames;
     ++fpsNumFrames;
diff --git a/src/game/image.c b/src/game/image.c
index 86cca08e..56daa0d9 100644
--- a/src/game/image.c
+++ b/src/game/image.c
@@ -2435,7 +2435,14 @@ void texLoad(s32 *updateword, struct texpool *pool)
 
     if (tex == NULL)
     {
+#ifdef PORT
+        /* compbuffer is a stack array; (u32) truncates the 64-bit host stack
+         * address (glibc pthread stacks live above 4GiB). Align at full
+         * pointer width -- same result on N64, correct on the port. */
+        alignedcompbuffer = (u8 *) (((uintptr_t)compbuffer + 0xF) & ~(uintptr_t)0xF);
+#else
         alignedcompbuffer = (u8 *) (((u32)compbuffer + 0xF) >> 4 << 4);
+#endif
 
         if (alignedcompbuffer);
         if (tex);
diff --git a/src/game/propobj.c b/src/game/propobj.c
index 43a1a701..d7de6fef 100644
--- a/src/game/propobj.c
+++ b/src/game/propobj.c
@@ -9921,7 +9921,14 @@ void sub_GAME_7F04F244(PropRecord* prop, rect4f** polygon, s32* edges, f32* top,
 }
 
 
+#ifdef PORT
+/* GCC 14 rejects the vestigial u8* params (enum/int -> pointer is a hard
+   error, not downgraded by -fpermissive). param2/param3 are unused; match
+   the two callsites' actual types. Codegen-neutral. */
+void append_text_picked_up(u8 *buffer, AMMOTYPE param2, u32 param3)
+#else
 void append_text_picked_up(u8 *buffer,u8 * param2,u8 * param3)
+#endif
 {
   u8 *str;
 
diff --git a/src/game/vtxstore.c b/src/game/vtxstore.c
index fca37a65..f3052be0 100644
--- a/src/game/vtxstore.c
+++ b/src/game/vtxstore.c
@@ -159,7 +159,7 @@ void sub_GAME_7F09BAC4(s32 find, s32 replacement) {
             while (var_a1 != NULL) {
                 val = var_a1->Opcode & 0xFF;
                 if (val == 0x18) {
-                    temp_v0_2 = modelGetNodeRwData(var_v0->chrflags, var_a1);
+                    temp_v0_2 = modelGetNodeRwData((Model*)var_v0->chrflags, var_a1);
                     if (find == *temp_v0_2) {
                         *temp_v0_2 = replacement;
                     }