# GoldenEye 007 — PC Port: Preliminary Research This document captures the preliminary research done to plan a PC port of GoldenEye 007, based on the decompilation in this repository (`n64decomp/007`). It is modelled on the approach used by the [Perfect Dark PC port](https://github.com/perfect-dark-pc-port/perfect_dark), which ports the same Rare "Indy" engine (a later revision) to modern platforms. --- ## 1. Executive summary * **Feasibility: HIGH.** GoldenEye and Perfect Dark share the same Rare engine family. The PD port proves the whole approach works end-to-end (singleplayer + split-screen multiplayer, mouse-look, widescreen, 60 FPS, mods). GE is, if anything, a *simpler* graphics pipeline than PD in some respects, though it has a few custom RSP extensions (see §5). * **Recommended strategy: fork this decomp and add a `port/` layer**, exactly mirroring the PD port's layout. The N64 build (Makefile + IDO) stays untouched; the PC build is a parallel CMake build that compiles the same game C sources against a set of PC shims. * **The single hardest component is the RSP (Reality Signal Processor) emulator.** The PD port ships a complete, working "fast3d" software RSP (`port/fast3d/`, ~7k lines of C++) that interprets GBI display lists and emits OpenGL calls. It **already handles `G_TRI4`**, which is the main custom triangle command GE uses. GE's other custom command, `G_SETTEX`, appears **unused** (its emitter `gsSPUseTexture` is never called), so the remaining GE-specific work is mostly **verifying the custom render/color-combiner modes** against the RSP ucode. * **Audio is straightforward.** GE uses the standard libaudio (AL) engine on the R4300, the same as PD. The PD port's SDL audio + mixer approach carries over directly. * **Saves** are **EEPROM-based** (controller EEPROM via `osEeprom*`), not Memory Pak. The PFS/motor code in `joy.c` is only for accessory (Rumble Pak / Memory Pak) detection and can be stubbed. Phase 4 is therefore a file-backed EEPROM plus no-op PFS/motor shims — simpler than a full Memory Pak emulation. --- ## 2. What the PD port actually does (the reference architecture) The PD port is a **fork of the PD decomp** with one added top-level directory, `port/`. The original game code (`src/game/*.c`, `src/lib/*.c`) is compiled **unmodified** for the PC. All N64-hardware dependencies are satisfied by the `port/` layer: ``` port/ ├── fast3d/ # Software RSP: interprets GBI display lists -> OpenGL │ ├── gfx_pc.cpp # the RSP interpreter (matrices, lights, tris, fog) │ ├── gfx_opengl.cpp # OpenGL rendering backend (shaders, textures, zbuf) │ ├── gfx_sdl2.cpp # SDL2 window-manager backend │ ├── gfx_cc.cpp # color-combiner -> GLSL shader compiler │ └── ... ├── include/ # port-facing headers │ ├── audio.h config.h fs.h input.h mixer.h mod.h │ ├── romdata.h system.h video.h utils.h platform.h └── src/ ├── main.c # PC entry point (replaces boot.s / _start.s) ├── libultra.c # shims for the libultra OS API (threads, time, VI, PI, SI, SP, AI) ├── video.c # SDL2 window + GL context + frame pacing ├── audio.c # SDL audio device, buffer queueing ├── mixer.c # audio mixing / RSP-audio-buffer processing ├── mod.c # MOD music playback ├── input.c # SDL2 keyboard/mouse/gamepad -> N64 controller structs ├── fs.c # filesystem abstraction over the ROM + data dir ├── romdata.c # loads the .z64 ROM from disk, sets up segments ├── config.c # INI config (pd.ini) ├── system.c # platform primitives (time, logging, paths) ├── crash.c # crash handler / stack traces ├── pdsched.c # replacement RCP scheduler that drives the software RSP └── utils.c ``` ### 2.1 The key architectural insight: emulate the RSP, bypass the RDP On real hardware the render path is: ``` R4300 builds a Gfx display list | v RSP (coproc 0) runs "fast3d" ucode, turns GBI cmds into RDP cmds | v RDP (coproc 1) rasterizes into the framebuffer ``` The PD port **emulates the RSP in software** (`fast3d/gfx_pc.cpp`) and **bypasses the RDP entirely**: the software RSP translates GBI commands directly into OpenGL calls. The R4300 game code is run natively and is unaware of the difference — it just builds display lists and "starts the RSP" as usual. The frame flow (from `port/src/pdsched.c` + `port/src/video.c`): ``` videoStartFrame() -> gfx_start_frame() game builds display list (Gfx*) videoSubmitCommands(Gfx* cmds) -> gfx_run(cmds) # <- the software RSP runs here videoEndFrame() -> gfx_end_frame() # swap buffers, FPS accounting ``` ### 2.2 How the RSP is invoked The game's RCP scheduler (`src/lib/sched.c` in PD, `src/sched.c` in GE) calls `osSpTaskLoad()` + `osSpTaskStartGo()` to hand a task (gfx or audio) to the RSP. The port: * **Excludes** the original `libultra/io/sptask.c` (and the other `io/*.c` hardware drivers) from the PC build. * Provides its own scheduler (`pdsched.c`) that, instead of writing to RSP registers, calls `gfx_run()` on the display list for gfx tasks and feeds the audio buffer for audio tasks. * Shims the rest of the libultra OS API in `libultra.c` (threads become no-ops / single-threaded, `osGetTime` maps to a host clock, VI/PI/SI/AI functions map to the port's video/audio/fs/input layers). ### 2.3 Build system The PC build is **CMake** (the N64 build stays Make + IDO). Dependencies: **SDL2, zlib, OpenGL** (plus `stdc++`, `m`, `dl`). It compiles: * `src/game/*.c` (all of it) * a curated list of `src/lib/*.c` (the engine helpers that are platform-independent: mema, memp, model, music, snd, vi, rdp, etc.) * `src/lib/ultra/audio/*.c` (libaudio — runs fine on the CPU) * `src/lib/ultra/gu/*.c` (gu matrix helpers) * `port/src/*.c` + `port/src/*.cpp` + `port/fast3d/*.cpp` It deliberately **excludes** the N64 I/O drivers (`src/lib/ultra/io/*.c`), the boot/TLB assembly, and the RSP microcode (`.s` under `rsp/`). ### 2.4 PD-port copy-candidate audit (session 2026-08-22) The PD port (`PD_PORT_CHECKOUT`) is a **standing reference** for this project (see AGENTS.md): same Rare "Indy"/Bond engine family, and its port layer solves the same problem classes we are currently hitting. Audited against our remaining work items: **Direct copy candidates (Phase 3/4 — our stubs were scaffolded to be replaced by these):** | PD file | Lines | Replaces our | Notes | |---|---|---|---| | `port/src/mixer.c` | 722 | 31-line stub | libaudio→SDL final mixer; same RareAL family. Adapt to GE's `audi.c` wiring (sample rate/buffering) | | `port/src/input.c` | 1551 | 48-line stub | SDL_GameController backend: hotplug, rumble, keyboard fallback. Remap the button table to GE's scheme (C-buttons/Z-trig differ from PD) | | `port/src/fs.c` | 294 | 81 lines | File-backed save dir (`--savedir`, `$S` path expansion) — foundation for Phase 4 EEPROM/PFS file backing (ours are no-op stubs today) | | `port/src/audio.c` | 75 | 70 lines | Near-identical; diff when Phase 3 starts | **Reference implementations (Phase 2 — the D32/D37/D43 class).** PD's port layer has a `port/src/preprocess/` module (~4,125 lines): a per-ROM-segment `preprocessfunc` table in their `romdata.c` that converts N64-layout asset data to PC-native layout *at load time* — a systematic solution to the problem class we have been fixing ad-hoc per asset type. - `filemodel.c` (1,114) — the direct **D43** analogue: same `PROMOTE` idiom, **same vma 0x5000000**. NOT a drop-in copy (GE's `ModelRoData` union + opcode set differ — PD has stargunfire/headspot/gundl node types), but the node-walk order, placement/alignment choices, and record handling are Rare-validated against near-identical data: adapt rather than design from scratch. - `filebg.c` (524) — BG tables (an expected D43 follow-on fault). - `segaudio.c` (362) — audio bank-tree conversion; cross-check for our D37 `romdataFixupAudioBank`. - `filelang.c`, `filetiles.c`, `filepads.c`, `filesetup.c`, `gbi.c`, `segfonts.c` — the remaining asset types we will hit. **Not copyable:** GE-specific `ModelRoData` record map; fast3d CC/RM correctness (GE's custom GBI modes vs `gmain.s` do not exist in PD); PD extras (`config.c`, `optionsmenu.c`, `mpsetups.c`, `mod.c`) — options menu, MP setup, MOD player; not part of 1:1 fidelity. **Caveats:** copy **port-layer files only** — their decomp was modified for PC (e.g. `types.h` keeps N64 offset comments while using real pointers); our non-negotiables are unchanged. Same family ≠ identical format: validate every copied conversion per-field against GE headers + ROM ground truth (the standing D32 procedure). --- ## 3. GoldenEye decomp: what we're porting * **Repo:** `n64decomp/007` (this repo). WIP decomp that byte-matches the US/EU/JP ROMs. Game code lives in `src/game/` (~242 files) plus engine files in `src/`. * **Engine layout** (same family as PD): * `src/sched.c` — RCP scheduler (`osCreateScheduler`, `__scExec`, `__scYield`). Structurally near-identical to PD's `src/lib/sched.c`. * `src/game/rsp.c` — builds the Gfx display list, wraps it in an `OSScTask`, and starts it via the scheduler (`rspGfxTaskStart`). * `src/fr.c` — frame/VI management, video modes (`osViSetMode`). * `src/audi.c`, `src/snd.c`, `src/music.c` — libaudio-based sound + music. * `src/joy.c` — controllers, **EEPROM** save handling (via `joyGamePak*` -> `osEeprom*`), plus PFS/motor code for accessory (Rumble Pak / Memory Pak) detection only. * `src/init.c` — `init()` -> `mainproc()` -> `bossEntry()` (game entry). **Compiled for the PC** (provides `mainproc()`); its N64-only `init()` is stubbed (see A3). * **Custom RSP microcode:** `rsp/graphics/gmain.s` (1545 lines) — a modified fast3d. This is the RSP-side code that consumes the display list. For the PC port we do **not** run this; we replace it with the software RSP. But it is the authoritative reference for which GBI commands GE actually emits. ### 3.1 Entry / main loop ``` boot.s -> init() [src/init.c] -> decompress data segment -> osInitialize(), TLB setup -> osCreateThread(mainThread, mainproc) mainproc() -> idleCreateThread(), piCreateManager(), rmonCreateThread() -> schedulerInitThread() # osCreateScheduler + osScAddClient(gfxClient) -> bossEntry() # [src/boss.c] real game start ``` The port's `main.c` replaces `boot.s`/`init()` and drives `mainproc()` (or `bossEntry()`) directly after setting up video/audio/input/ROM. ### 3.2 The two libultra trees: `src/libultra/` and `src/libultrare/` GE ships **two** libultra trees. `src/libultra/` is the standard Nintendo libultra; `src/libultrare/` holds Rare's modified/replacement files. The N64 build selects which files to compile via `src/libultrare/Makefile.libultrare`, which explicitly lists each file as *original* (from `libultra/`) or *Rare* (from `libultrare/`). The Rare files **override** the originals where they overlap. What the PC build must do with each: * **`src/libultra/audio/*.c`** — standard libaudio (AL). Runs on the CPU; **compile** (the game's `audi.c`/`snd.c`/`music.c` depend on it). * **`src/libultrare/audio/*.c`** (`drvrNew.c`, `env.c`, `reverb.c`) — Rare's audio "New" driver. `synthesizer.c` calls `alSaveNew`/`alAuxBusNew`/ `alMainBusNew`/`alLoadNew`/`alResampleNew`/`alEnvmixerNew`/`alEnvmixerParam`/ `alFxParam`, all defined here. Runs on the CPU; **compile**. * **`src/libultra/gu/*.c`** — matrix helpers (`guMtxF`, `guOrtho`, ...). **Compile**. * **`src/libultrare/io/vitbl.c`** — defines `osViModeTable` (a data table), referenced ~15× by `fr.c`. **Compile** (it's data, not a driver). This is the *only* `io/` file we compile. * **All other `src/libultra/io/*.c` and `src/libultrare/io/*.c`** — hardware drivers (PI, SI, SP, DP, VI, controller, PFS, motor, EEPROM, ...). **Exclude and shim** in `port/src/libultra.c`. Notably `pfsinit.c`/`pfsisplug.c` (`osPfsInit`/`osPfsIsPlug`) and `motor.c` (`osMotor*`) are called by `joy.c` only for accessory detection, so they are shimmed as no-ops (see §7). * **`src/libultra/os/*.c` + `src/libultrare/os/*.c`** — OS core (threads, message queues, timers, cache, TLB). **Exclude and shim** in `port/src/libultra.c` (+ `n64stubs.c` for the boot/TLB/FPU symbols). > The exact original-vs-Rare file lists are in > `src/libultrare/Makefile.libultrare` (`LIBULTRA_*_C_FILES` vs > `LIBULTRARE_*_C_FILES`). Use it as the ground truth when curating the PC > build's source list. --- ## 4. N64 hardware surfaces that must be shimmed | Subsystem | GE API used | Port strategy (from PD) | |-----------|-------------|--------------------------| | RSP (coproc 0) | `osSpTaskLoad/StartGo/Yield`, display lists | Software RSP (`fast3d`) — **main work** | | RDP (coproc 1) | `osDpSetStatus`, `osDpSetNextBuffer`, render modes | Bypassed; OpenGL backend | | VI (video) | `osViSetMode`, `osViVSyncCallback`, `osViWaitVSync` | SDL2 window + GL context + frame pacing | | AI (audio) | `osAiSetFrequency/SetNextBuffer/GetLength` | SDL audio device + queue | | PI (peripheral) | `osPiRawStartDma`, `osPiCreateManager`, cart reads | Read from ROM file on disk | | SI (controller) | `osContInit/StartReadData`, `osEeprom*`, `osPfs*`/`osMotor*` | SDL input + file-backed EEPROM; PFS/motor stubbed (accessory detection only) | | OS core | threads, msg queues, timers, `osGetTime`, cache | Single-threaded shims + host clock | | R4300 specifics | TLB, cache, FPU csr, K0/K1 segments | No-ops / identity (native x86-64) | --- ## 5. GE-specific graphics differences vs PD (the RSP work) GE's `include/gbi_extension.h` defines the custom GBI surface. Compared to the PD port's `fast3d`, the deltas are: 1. **`G_TRI4`** — draws up to 4 triangles in one command using 4-bit vertex indices (0–15). A memory-saving optimization (Rare packed 4 tris into the slot normally used for `G_TRI2`). * **Status: already supported** by the PD `fast3d` (`gfx_sp_tri4`, and the PD game itself uses it). ✅ 2. **`G_SETTEX`** (`0xc0`) — "use texture from bank": selects a texture by bank/tile with detail/mipmap type, min-level, detail-id and texture-id. Emitted only by `gsSPUseTexture` (`include/gbi_extension.h:193`). * **Status: appears UNUSED.** `gsSPUseTexture` is defined but never called anywhere in the game code or the RSP ucode (`rsp/graphics/gmain.s`), and `G_SETTEX` appears nowhere outside its `#define`. The game uses the standard texture commands (`gSPTexture`, `gSPTextureL`, `gSPTextureRectangle`, `gSPTextureRectangleFlip`) — all already handled by the PD `fast3d`. **If this holds (verify the decomp is complete), Phase 2 shrinks to verifying the custom CC/RM modes and the PD fast3d may work largely as-is.** Keep a `G_SETTEX` decode path as a safety net, but it is no longer the headline task. 3. **Custom color-combiner modes** — `G_CC_MODULATEIFADE`, `G_CC_FADE`, `G_CC_DECALFADE`, `G_CC_BLENDRGBFADEA`, etc. (fade/blend variants). * **Status: handled generically** by the fast3d color-combiner -> GLSL shader compiler (`gfx_cc.cpp`), which compiles arbitrary CC equations. Needs verification that each GE mode produces correct GLSL. 4. **Custom render mode** — `G_RM_CUSTOM_AA_ZB_XLU_SURF` (AA_ZB_XLU with `Z_UPD`). * **Status: handled** by the render-mode -> GL state mapping; verify. 5. **`gDPLoadTLUT06/07`** — custom TLUT load variants. * **Status: minor**, map onto the existing `G_LOADTLUT` path. **Conclusion:** the PD `fast3d` is a strong starting point. `G_SETTEX` appears unused (see item 2), so the concrete new RSP work is **verification of the custom CC/RM modes** against `gmain.s`, with `G_SETTEX` as a low-priority safety net. Everything else (matrices, lights, fog, tri1/tri4, texture load, sync/flush) is shared. > Note: GE's on-hardware RSP ucode is `rsp/graphics/gmain.s`. We do not run it > on PC, but it is the ground truth for the exact command encodings GE emits — > use it to validate the software RSP's command decoding. --- ## 6. Audio * GE uses **libaudio (AL)** on the R4300 (`src/audi.c`, `src/snd.c`, `src/music.c`) — identical architecture to PD. Sound synthesis (ADPCM decode, mixing, FX) runs on the CPU; the RSP audio ucode is a thin pass-through to the AI DMA. * The PD port runs libaudio natively and routes the mixed output through `audio.c` (SDL device) + `mixer.c`. **Carries over directly.** * Music: GE uses the same MOD-style music system; PD's `mod.c` is the model. * Output rate: PD uses 22020 Hz stereo s16. GE's `OUTPUT_RATE` in `audi.c` should be matched. --- ## 7. Input & saves * **Controllers:** `src/joy.c` uses `osContInit` / `osContStartReadData`. The port maps SDL2 keyboard/mouse/gamepad into the N64 `OSError`/controller structs. PD implements 1964GEPD-style and Xbox-style bindings — a good default for GE too (fire=Z, aim=R, etc.). * **Saves:** GE saves are **EEPROM-based**, not Memory Pak. The actual save I/O (`src/game/file2.c`) uses `joyGamePakProbe/LongRead/LongWrite`, which map to `osEepromProbe/LongRead/LongWrite` (controller EEPROM). The PFS/motor code in `joy.c` (`osPfsInit`, `osMotorInit/Start/Stop`) is only for accessory (Rumble Pak / Memory Pak) detection. The port therefore: * backs the EEPROM with a file in the data dir (e.g. `eeprom.bin`), and * stubs PFS/motor to report "no accessory" so the game proceeds without rumble/mempak. This is simpler than a full Memory Pak/PFS emulation. --- ## 8. Recommended plan ### Phase 0 — Scaffolding (this change) * Add `port/` directory structure + headers + stub sources. * Add a top-level `CMakeLists.txt` (+ `cmake/` modules) for the PC build, parallel to the N64 `Makefile`. * This document + `port/README.md`. ### Phase 1 — Boot to a window * Port `system.c`, `fs.c`, `romdata.c`, `config.c`, `main.c`. * Port `video.c` + a minimal `fast3d` (clear color, present). * Goal: load `ge007.u.z64`, open a window, draw a clear color. ### Phase 1.5 — Boot to first frame (ABI reconciliation) * Get mainThread through all of `bossInitMainthreadData()` → `bossEntry()` → `bossMainloop()` and render frame 1. (Done — D34–D58; see §G.) * Recurring mechanism: **ROM-data struct ABI/layout fixes** (the D32 pattern) — a 32-bit-pointer struct read as a 64-bit-pointer struct misaligns; convert embedded pointer fields to `u32` + cast at use sites (PD ground truth), then verify the load-time rebase yields valid DRAM addresses. See §H for the step-by-step procedure and the standing "non-negotiable #2 ABI exception." * Goal: title screen / first level actually drawn on the GL surface. ### Phase 2 — RSP / rendering * Bring in the PD `fast3d` (gfx_pc / gfx_opengl / gfx_cc / gfx_sdl2). * Verify the custom CC/RM modes against `gmain.s`; add a `G_SETTEX` decode path only if it turns out to be used (see §5 — it appears unused). * Port `pdsched.c` (GE variant) to drive the software RSP. * Goal: render the title screen / first level. ### Phase 3 — Audio + input * Port `audio.c`, `mixer.c`, `mod.c`; wire libaudio output to SDL. * **Decide the ASP strategy** (see §11 C2): if audio runs the `aspMain` microcode, wire `aspMainTextStart`/`aspMainDataStart` to the real microcode bytes in the ROM; if audio is CPU-only, the `port/src/ucode.c` dummies stand. * Port `input.c` (keyboard/mouse/gamepad). * Goal: playable with sound. ### Phase 4 — Saves + polish * File-backed EEPROM (saves); PFS/motor already stubbed as no-ops. * Widescreen, mouse-look, FPS options, config file, crash handler. * Goal: feature-parity with the original + QoL extras. --- ## 9. Open questions / risks 1. **`G_SETTEX` (low priority)** — appears unused (`gsSPUseTexture` is never called). If a future decomp revision or a missed call site shows it is used, reverse the texture-bank selection (bank -> image, tile, detail/mipmap type) from `gmain.s` + `tex.c` / `initmttex.c`. Not currently the main unknown. 2. **Custom CC correctness** — the GLSL compiler must handle every fade/blend mode GE uses; validate per-mode against the original. This is now the main rendering unknown. 3. **Scheduler fidelity** — GE's `sched.c` has speed-graph / profiling hooks (`speedgraphMarkerHandler`) and a slightly different task model than PD; the port scheduler must reproduce the gfx/audio interleaving and VSync pacing. 4. **Decomp completeness** — the decomp is WIP; some functions may still be stubs/`unk_`. The PC port inherits whatever state the decomp is in. Track the upstream `n64decomp/007` and rebase. 5. **Endianness / alignment** — GE, like PD, is big-endian N64 code with unaligned accesses; the PD port handles this with careful casts and `-fno-strict-aliasing`. Reuse those flags. --- ## 10. References * PD port: https://github.com/perfect-dark-pc-port/perfect_dark * PD decomp: https://github.com/n64decomp/perfect_dark * GE decomp (this repo): https://github.com/n64decomp/007 * GE docs: https://github.com/kholdfuzion/goldeneye_docs * N64 devkit / libultra: `include/PR/*.h` in this repo * GE custom GBI: `include/gbi_extension.h` * GE RSP ucode: `rsp/graphics/gmain.s` --- ## 11. Scaffolding review: findings & resolutions The initial scaffolding was reviewed against the sources. Each finding below was verified and resolved in the same change. Code comments in `CMakeLists.txt` and `port/src/` reference these labels (A1–A4, B1–B4). ### A. Build-blocking issues * **A1 — Region/version macros.** The N64 `Makefile` defines a full per-region macro set (`VERSION_*`, `LANG_*`, `REFRESH_*`, `LEFTOVERDEBUG`, `LEFTOVERSPECTRUM`, `BUGFIX_R*`, `BYTEMATCH`) that the game code `#ifdef`s heavily (e.g. `VERSION_EU` in 60+ files). **Resolved:** `CMakeLists.txt` now emits the matching `REGION_DEFS` per `ROMID`, mirroring the Makefile exactly. * **A2 — Missing source files.** The initial `SRC_ENGINE` list + "exclude all `io/*.c`" strategy missed files the included code depends on. **Resolved:** added `src/cfb.c` (defines `cfb_16`, the framebuffer), `src/libultrare/audio/*.c` (`drvrNew`/`env`/`reverb` — the audio "New" driver `synthesizer.c` calls), and `src/libultrare/io/vitbl.c` (defines `osViModeTable`, referenced ~15× by `fr.c`). `motor.c` / `pfsinit.c` / `pfsisplug.c` are **not** compiled — `joy.c` calls `osMotor*`/`osPfs*` only for accessory detection, so they are shimmed as no-ops (see B2). * **A3 — `mainproc()` / `init.c` + the excluded engine files' symbols.** `mainproc()` is defined only in `src/init.c`, which the initial build excluded, contradicting `main.c` calling it. **Resolved:** `init.c` is now **compiled** (it provides `mainproc()` + the thread-setup helpers). Its N64-only `init()` is compiled but not called. Compiling `init.c` (and the other included engine files) pulls in a set of symbols that live in the EXCLUDED files (`sched.c`, `rmon.c`, `vi.c`, `stacks.c`). Each is now provided: * **`src/stacks.c`** (added to `SRC_ENGINE`) — thread-stack arrays `sp_boot`/`sp_rmon`/`sp_idle`/`sp_shed`/`sp_main`/`sp_audi` (+ `sp_debug` under `LEFTOVERDEBUG`). Pure data; the `SP_*_SZ` sizes in `bondconstants.h` match exactly, so `sizeof()` is correct. * **`port/src/gesched.c`** (replaces `sched.c`) — scheduler globals `os_scheduler`, `gfxClient[3]`, `g_schedViCurrentFrameBuffer`, `g_ViChangeVideoModes`, `g_ViModes`, `g_ViModePtrs`, plus `get_counters()` and `permit_stderr()`. * **`port/src/n64stubs.c`** — `init()`'s N64 symbols (segment starts, segment boundary pointers, `jump_decompressfile`, TLB, FPU CSR) and the rmon host-I/O functions (`rmonMain`, `osReadHost`, `osWriteHost`, `rmonGetToken`, `rmonStatus`, `osSyncPrintf`). * **`port/src/libultra.c`** — the libultra OS calls `init.c` makes, plus `osTvType` (region-based), `viInit`, `vi_c_debug_MQ` (from the excluded `vi.c`), and `osPiReadIo` (cartridge token read, called by `token.c`). Every external symbol referenced by the compiled set was cross-checked against its definition; no duplicates (all other `sp_*`/`rmon*` references are `extern` decls). * **A4 — `libultra.c` missing the high-level message API + others.** The initial stub had low-level `osEnqueueMesg`/`osDequeueMesg` but not `osSendMesg`/`osRecvMesg` (used in 13/26 files) or several others. ** Resolved:** `port/src/libultra.c` now provides `osSendMesg`/`osRecvMesg`/ `osSetEventMesg`, the controller query/read API, `osPfs*`/`osMotor*`, `osSetTimer`/`osStopTimer`/`osSetIntMask`, `osViBlack`/`osViSetYScale`/ `osViSetXScale`/`osViSetEvent`, `osUnmapTLB`, `__osGetFpcCsr`/ `__osSetFpcCsr`, `piCreateManager`, and `viDebugRemoved`. The scheduler API (`osCreateScheduler`/`osScAddClient`/`osScGetCmdQ`) lives in `port/src/gesched.c` (it's game scheduler API, not libultra). ### B. Research-doc corrections * **B1 — `G_SETTEX` appears unused.** `gsSPUseTexture` (the only emitter) is never called in the game code or `gmain.s`; the game uses standard texture commands the PD `fast3d` already handles. **Resolved:** §5, §8 (Phase 2), and §9 updated — Phase 2 shrinks to verifying the custom CC/RM modes; a `G_SETTEX` decode path is kept only as a safety net. * **B2 — Saves are EEPROM-based, not Memory Pak/PFS.** The save I/O (`src/game/file2.c`) uses `joyGamePak*` -> `osEeprom*`; the PFS/motor code in `joy.c` is accessory detection only. **Resolved:** §7 and Phase 4 updated — Phase 4 is a file-backed EEPROM + no-op PFS/motor shims. * **B3 — `src/libultrare/` was unmentioned.** **Resolved:** new §3.2 documents the two libultra trees and exactly which files the PC build compiles vs. shims, citing `Makefile.libultrare` as ground truth. * **B4 — `src/spectrum.c` does not exist.** **Resolved:** removed from the `CMakeLists.txt` excluded-files comment. ### C. Assembly (`.s`) file symbols The A/B sweeps covered excluded **`.c`** files. A second symbol source is the MIPS assembly (`src/*.s`), also not compiled for the PC. Sweeping every global label in the `.s` files against the compiled C set found **10 more** undefined symbols, in two distinct classes: * **C1 — PRNG (`src/random.s`) — ported to C, not stubbed.** `randomGetNext`, `randomGetNextFrom`, `randomSetSeed`, and `g_randomSeed` are used for real gameplay logic: `randomGetNextFrom()` feeds the CRC (`src/game/crc.c`), `g_randomSeed` is persisted in replay state (`src/game/ramromreplay.c`), and `randomGetNext()` drives `RANDOMFRAC()`/`RANDOMGETNEXT_F32()`. A no-op would silently corrupt CRCs and replays. **Resolved:** `port/src/random.c` ports the three functions **verbatim** (each MIPS instruction mirrored with explicit `dsll32`/`dsrl32` masks for bit-exactness) and defines `u64 g_randomSeed = 0xAB8D9F7781280783ULL` (the two `.words` in `random.s`). Signatures match `src/random.h`. Verified bit-exact against a Python simulation of the assembly (the PRNG is effectively 32-bit stored in a u64; the initial seed's high 32 bits are exercised only on the first call). * **C2 — RSP/ASP/GSP microcode segment markers — dummy definitions.** `rspbootTextStart`/`rspbootTextEnd` (`rspboot.s`), `gsp3DTextStart`/ `gsp3DDataStart` (`gspboot.s`), `aspMainTextStart`/`aspMainDataStart` (`aspboot.s`) are ROM addresses of the RSP/ASP microcode, referenced by `src/audi.c` and `src/game/rsp.c` (declared `extern long long int []`). **Resolved:** `port/src/ucode.c` defines all six as `long long int [1] = {0}`. The graphics path is safe permanently (fast3d interprets the GBI list directly and never runs the gsp3D microcode, so the size differences are never read). **Carried into Phase 3:** the audio path is the one place it could matter — if the port emulates the ASP by *running* the `aspMain` microcode, those two markers must point at the real microcode bytes in the ROM (PD `pd.ld` `RSP_TEXT_SEGMENT` model); if audio is CPU-only, the dummies are fine. A `TODO(Phase 3)` on the `aspMain*` dummies records this decision. ### D. Host-compiler portability (GCC 16 vs IDO) The N64 build targets IDO (MIPS). The PC build targets GCC 16 (x86-64). IDO tolerates a number of non-standard constructs that the decompilation relies on; GCC rejects them as hard errors. Each finding below was verified and resolved in the same change. Where a fix could not be made purely in `port/`, the minimal `src/` change is `-DPORT`-gated (N64 build untouched) and flagged with a `PC port:` comment. These are **portability** fixes, not game-logic changes. * **D1 — `inherits` = struct inlining + duplicate member name.** `src/bondtypes.h:44` does `#define inherits struct`, so `inherits X;` inside a struct becomes `struct X;`. IDO resolves this by **inlining** X's members into the enclosing struct (C++-style base), which the decomp depends on (offset comments like `CCTVRecord.unk84` at `0x84`, and positional initializers like `New_CCTVRecord(pad)` = `{New_PropDefHeaderRecord(6), 0, pad+0}` only line up with the inlined layout). GCC 16 also inlines `struct X;` (correct layout) but **hard-errors** on duplicate member names; Clang treats `struct X;` as a no-op nested tag (wrong layout). No flag/pragma downgrades the duplicate-name error. A sweep of `bondtypes.h` found exactly **one** parent/child member-name collision: `CCTVRecord` redeclares `pad` (s32 @0x80) which `ObjectRecord` also has (s16 @0x08). The game code's `->pad` (e.g. `setupCctv`, prop.c) is used as the **pad index**, which `New_CCTVRecord` stores at 0x08 (the *inherited* pad), so IDO resolves the ambiguous `->pad` to the inherited one; CCTVRecord's own `pad` (0x80, "lookpad") is never accessed by name. **Resolved:** under `-DPORT` only, CCTVRecord's own `pad` is renamed to `lookpad` (layout byte-identical); the N64 build keeps `pad` (IDO tolerates it). * **D2 — `port/shim/PR/gbi.h` needed an include guard.** The shim redefines the `Gfx` union members as little-endian `G*_le` typedefs. With no guard, a TU that includes it twice (directly + via another header) re-creates the anonymous `Gdma_le`/`Gtri_le`/… struct types → "conflicting types for 'Gdma_le'". The real header's guard is `_GBI_H_`; the shim now uses a distinct `_PORT_SHIM_GBI_H_`. * **D3 — `New_Vector()`/`New_Coord3d()` called with zero args.** The decomp's macros are declared with exactly 3 params (`x,y,z`) and use the `IF_ELSE(IS_EMPTY(..))` trick to default each to 0, but the game code calls them with **zero** args (`New_Vector()` at chrai.c:1358/1378, `New_Coord3d()` at chrai.c:4491), relying on IDO's leniency with empty macro args. GCC rejects `New_Vector()` against a 3-param macro. A sweep found these are the **only** call sites (all zero-arg). **Resolved in `port/`:** `port/shim/bondtypes.h` redefines both as `#define New_Vector(...) {0,0,0}` / `New_Coord3d(...) {0,0,0}` after including the real header (the shim is found first via the include path). * **D4 — local `#define osSyncPrintf()`/`(x)` arg-count mismatch.** Five files locally disable `osSyncPrintf` with a fixed-arity macro (`#define osSyncPrintf()` or `(x)`) but then call it with more args (2–4), relying on IDO's leniency. GCC errors ("passed N arguments, but takes just M"). **Resolved:** the five local defines (bg.c, debugmenu_handler.c, debug_camera.c, deb_loadallmodels.c, initexplosioncasing.c) are made variadic (`#define osSyncPrintf(...)`); they still expand to nothing, so behavior is unchanged. * **D5 — array-initialized-from-array.** `chraction.c:2485` had `s16 mrs[3] = metal_ricochet_SFX;` (a local array "initialized" by a global array). C requires a constant-expression initializer; IDO treated it as a runtime copy. ** Resolved:** replaced with an explicit 3-element copy (no new includes needed). * **D6 — flexible array member in a nested context.** `bg.c` declared `s_special portal specialportalarray[]` where `s_specialportal` has a flexible array member (`u8 portallist[]`). C forbids initializing a FAM in a nested context (an array element); GCC enforces it, IDO tolerated it. The code treats the data as a flat byte array (cast to `u8*` in `sub_GAME_7F0B37EC`, walked byte-by-byte). ** Resolved:** defined `specialportalarray` as a flat `u8[]` with the identical byte sequence. * **D7 — AI X-macro system (`chraidata.c`) — RESOLVED.** The AI command system (`bondaicommands.h` + `aicommands.def` + `CPPLib.h`) uses deep preprocessor metaprogramming (`SWITCH` with 49 fixed params + `IF_VA`/`IS_EMPTY`, `DEFINED(SETUPSUBROUTINES(ID))` token-pasting, `_AI_CMD_POLYMORPH` redefined per-include) that relies on IDO-specific `##`/expansion behavior. GCC rejects several of these (e.g. `pasting ')' and '_'` in `DEFINED(SETUPSUBROUTINES(ID))`, `SWITCH requires 49 arguments, but only 20 given`). Note `src/aicommands2.h` is a **pre-generated** header (from `tools/cmdbuilder.c`) already included by `bondaicommands.h:864`; the failing path is the *raw* `aicommands.def` include (bondconstants.h:731 for the `AI_CMD` enum, chrai.c:172/920 for the command table). A general GCC-clean reimplementation of `SWITCH()` was investigated and ruled out: its content arguments are single preprocessor arguments that expand to top-level comma lists, and the C preprocessor cannot detect where one content ends and the next `CASE`/`VAL` begins (arity is not recoverable after expansion). The three active `SWITCH()` call sites in chraidata.c (m_IdleAnimations, m_BashKeyboard, m_RunToBondPersistent) each have a **hand-written equivalent already present in the file behind `#if 0`** — the author's own reference form. Each was verified byte-identical to the IDO expansion of the adjacent `SWITCH` call (including the `IFNewRandomGreaterThan(N, lbl)` == `SetNewRandom()` + `IFRandomGreaterThan(N, lbl)` identity, confirmed against the runtime check in chrai.c:1605). **Resolved:** under `-DPORT` the three `#if 0` blocks are activated (the `SWITCH` calls become dead `#else` branches); `port/shim/bondaicommands.h` keeps the original 49-param `SWITCH` defined but replaces it with a marker that fails loudly if any *new* game code uses `SWITCH()` on the port. See also D8–D11 for the sibling paste/comma issues in the same macro system. * **D8 — `MODELSKELETON`/`New_ModelSkeleton` paste failure.** The model-record macros in bondconstants.h write `SKELETON(##NAME##)`-style pastes that IDO tolerates but GCC hard-errors on ("pasting does not give a valid preprocessing token"). **Resolved:** `port/shim/bondconstants.h` re-emits the affected macros with equivalent byte-identical expansions. * **D9 — `CPPLib.h` helpers.** The CPPLib metaprogramming header's `IS_EMPTY`/`IF_VA`/`DEFINED` family uses paste tricks that break under GCC. **Resolved:** `port/shim/CPPLib.h` provides a paste-free reimplementation with identical results for every usage in the tree (intercepted via the include path; inert on N64). * **D10 — file-record macros paste `&` onto NAME.** `CHRFILERECORD`/ `GUNFILERECORD`/`SUIT_LFRECORD` and `GUNSTATS` write `{& ## NAME ## _header, …}`. IDO tolerated the failed `&##NAME` paste; GCC hard-errors. **Resolved:** `port/shim/bondconstants.h` rewrites them with the `&` kept out of the paste; expansion is byte-identical (`&NAME_header`, `&NAME_stats`). * **D11 — generated `CALL()` double trailing comma.** The pre-generated `CALL()` (aicommands2.h) concatenates `SetReturnAiList()` and `SetChrAiList()`, each ending in its own trailing comma, then appends its own separator → `…, ,` inside the array initializer. IDO accepted it; GCC hard-errors. The artifact byte is never executed: `AI_SetChrAiList(CHR_SELF)` switches to the called list at offset 0 and `AI_Return` resumes the return list at offset 0 (chrai.c), so anything after the `SetChrAiList` record in a `CALL` is dead. **Resolved:** `port/shim/bondaicommands.h` re-emits `CALL` without the artifact byte. * **D12 — 64-bit pointer→integer in static initializers.** On a 32-bit target (MIPS) storing an array address in a 32-bit field is fine; on x86-64 GCC 16 makes it a hard error ("initializer element is not computable at load time") that no warning flag suppresses. Two sites, two fixes: * `process_monitor_animation_microcode` jump targets: the monAnim script tables (chrai.h) stored raw `monAnim*` array addresses in the tvcmd word's 32-bit field. **Resolved:** under `-DPORT` the initializers store an *index* into `_PORT_monAnimPtrs[]` (defined in propobj.c after all 35 monAnim arrays, via a shared `_PORT_MONANIM_LIST` x-macro in chrai.h) and the interpreter resolves index→pointer at `TVCMD_SETCMDLIST`/`TVCMD_RANDSETCMDLIST`. On-script layout unchanged (12-byte tvcmd words, same opcode bytes). * `assets/obseg/setup/{u,e,j}/UsetuplenZ.c`: the `intro[]` table stored `&credits_data_0` in an `s32` slot. **Resolved:** PORT-gated replacement with `0` (the N64 initializer is kept in the `#else` branch). The value is only consumed by romCopy-style size arithmetic, which is inert until Phase 2. (These files are currently excluded from the PC build per D16; the patch keeps them compilable should any setup data be pulled into the host link.) * **D13 — assorted IDO leniency hard errors.** Small strictness failures with no semantic content: * `front.c:2405`: bare `return;` in an `s32` function (GCC error). The sole caller ignores the value → PORT-gated `return 0;`. * `audi.c` `audioInit`: C++-style array initializer `s32 sp48[…] = CUSTOM_FX_PARAMS_N;` (IDO accepted, GCC rejects) → PORT-gated explicit `memcpy` of the same bytes. * **D14 — `_Printf` prototype clash (xstdio.h vs xprintf.c).** The IDO printf engine is compiled for the PC (`src/libultrare/libc/xprintf.c` + helpers `src/libultra/libc/xlitob.c`/`xldtob.c`, added to CMakeLists) because `src/sprintf.c`'s `sprintf()` calls `_Printf` directly. xstdio.h declares it with `u8 *` params; xprintf.c defines it with `char *` — IDO-compatible, GCC-fatal. **Resolved:** `port/shim/libc/xstdio.h` renames the declaration (`_Printf_u8decl`) before pulling in the real header via `#include_next`, so xprintf.c's definition is the sole prototype. The only caller (sprintf.c) doesn't include the header and passes its char*-based outfun, matching the definition exactly. * **D15 — host libc lacks IDO/K&R symbols.** MinGW provides none of: `bcopy`/ `bzero` (declared in PR/os.h with `int` sizes), `__libm_qnan_f` (quiet-NaN helper used by gu/cosf.c and game/zlib.c), the `tlbmanage*` API (boss.c calls two of them; tlb_manage.c is excluded — N64 TLB management), and `g_ViXScales`/`g_ViYScales` (defined in vi.c on N64, written at runtime by fr.c). Also `chrObjRandomGetNext/SetSeed` + `g_chrObjRandomSeed` live in `src/game/chrObjRandom.s` (not built for PC). **Resolved:** all provided in `port/src/n64stubs.c` / `port/src/libultra.c`; the chrObj PRNG is ported verbatim from chrObjRandom.s into `port/src/random.c` (same xorshift as `randomGetNext`, separate state). * **D16 — asset data strategy for the single host link.** The N64 build links each model/level file into its own ELF and `.incbin`s compressed ROM blobs; none of that transfers. Three buckets: * **Compiled for real** (self-contained, unique symbol names): `assets/animationtable_data.c`, `animationtable_entries.c`, `oddtextures.c`, `font_chardatae.c`, `font_chardataj.c`, `font_dl.c`, `rarewarelogo.c` → added to CMakeLists (`SRC_ASSETS`). Real font/image/ animation-table data is therefore live in the binary. * **Excluded — symbol collisions:** every `assets/obseg/{bg,brief,setup,stan}/*.c` defines generic file-local globals (`header`, `room_data_table`, `intro`, `padlist`, …) that collide in a single link. The N64 build isolates them by per-file ELF linking; the PC build cannot. Their top-level symbols are stubbed instead. * **Stubbed — ROM-derived / absent:** `port/src/assetstubs.c` defines the 758 model/level/text symbols referenced by ob.c's `file_resource_table` (one zero word each), the 14 `ramrom_*` replay pointers (NULL), and the `unknown2`/`unknown2_end` pair (same address → zero-length romCopy in title.c). All `*Segment*` linker-script markers (normally from ge007.ld) are defined as NULL `u32 *` in n64stubs.c, so address arithmetic computes zero lengths and any Phase-1 romCopy is a safe no-op. Real data arrives with ROM loading in Phase 2 (`port/src/romdata.c`), which will also replace the table's address arithmetic. * **D17 — fast3d placeholder.** `port/fast3d/` contains only `gfx_api.h`; the real software RSP lands in Phase 2. `port/src/gfxstub.c` provides no-op implementations of every entry point (plus `gfx_current_dimensions`) so video.c links. **Delete it when the real fast3d sources are added** (same symbols → duplicate-definition error if both remain). * **D18 — GE ROM header offsets.** The N64 header is GE-specific (`src/rom_header.s`): magic `0x80371240` @0x00, ROM name "GOLDENEYE" @0x20, cartridge ID "GE" **@0x3C**, country byte **@0x3E** ('E' US / 'P' EU / 'J' JP), version @0x3F. The standard-N64 offsets (ID @0x38, country @0x3A) are wrong for GE — verified against `baserom.u.z64` (hexdump: `... 47 45 45 00` at 0x3C–0x3F). romdata.c validates all four fields. * **D19 — music table is big-endian in the ROM.** The `.music` section of `assets/music/music.s` (the `RareALSeqBankFile` header + 63 × 8-byte `music_table_entry` records) is stored in the ROM in **big-endian** word order: at 0x419790 the bytes are `00 3f 00 00 | 00 00 01 fc` = BE u16 seqCount=63 (= NUM_MUSIC_TRACKS), BE u16 unk=0, then BE u32 offset 508 for the first track. The N64 build evidently assembles that section with big-endian output; the PC port never reassembles it — the CSV offsets are unaffected and `musicSeqPlayerInit()`'s romCopy + `musicSeqFileNew()` patching work on the raw bytes unchanged. * **D20 — US music tracks are RLE-compressed in ROM.** The `music_file` macro incbins `build/u/assets/music/.rz` under `.ifdef VERSION_US`, so every M* track lives in the ROM 1172/RLE-compressed; the filelist sizes are the compressed lengths and per-track `end_` symbols = start + csv_size are correct boundaries. Decompression happens at playback (Phase 3 audio), not at load — no Phase-1/2 action needed. * **D21 — scanner-prefixed manifest names.** `filelist..csv` contains entries whose file column is prefixed with the scan origin, e.g. `assets/ge007.u.117880.jfont_dl.bin` (real name `jfont_dl.bin`). gen_romassets.py aliases such names to their suffix after the `ge007...` prefix, so font/image/oddtexture segments resolve. * **D22 — CMake silently skips `.s` files without ASM enabled.** The first assembly source in the PC build (`romassets_u.s`) was added to the target but never compiled: `project(ge007 C CXX)` had no ASM language, and CMake does not error on unknown extensions — it just drops them, surfacing only as a wall of undefined asset symbols at link. Fixed with `project(ge007 C CXX ASM)`. Lesson: when adding a new source *extension*, verify the object file actually appears in the build graph. * **D23 — absolute cart-address asset symbols (Phase 1 approach).** Instead of zeroed stubs (D16), every ROM asset symbol is now an ABSOLUTE cart address: `scripts/gen_romassets.py` emits `port/src/romassets_.s` defining all obseg labels (in `file_resource_table.inc.c` order — ob.c computes file sizes as `table[i+1].hw_address - table[i].hw_address`, so order matters), ramrom files, music tracks/markers, and the ge007.ld segment markers as `0x10000000 + rom_offset` from `filelist..csv`. romdata.c maps the .z64 at exactly 0x10000000 (VirtualAlloc preferred address), so `&symbol` is a live host pointer and the PI shims (`osPiStartDma`/`osPiRawStartDma` in libultra.c) service reads as bounds-checked memcpys. This makes both DMA'd assets (models, banks) and direct-read assets (fonts, image display lists) work with unmodified game code. `assetstubs.c` is deleted; n64stubs.c keeps only the pure-RAM segment symbols (`_bssSegmentEnd`, `_csegment*`, `_inflate/_gameSegmentVaddr*`). US-only for now: EU/JPN manifests have naming inconsistencies (trailing-Z add/drop, region subdirs) that need the same treatment before those regions build. ### E. Compile + link milestone (status) All ~235 translation units compile and the target **links**: clean build from scratch is `236/236` steps, zero errors (`ninja ge007 -k 0`), producing `ge007.x86_64.exe`. Remaining warnings (~4.5k) are expected IDO-leniency noise (int-conversion, implicit declarations, etc.), demoted via the CMake warning flags. **Phase 1 (boot to window) is done:** `romdata.c` loads and validates the ROM (`baserom.u.z64` at the repo root, or a final .z64 in `data/` / next to the exe), maps it at cart base 0x10000000 (D23); `video.c` opens an SDL2 640×480 window with a GL context and clears/presents every frame; `main.c` runs a demo loop (ESC quits) in place of `mainproc()`, which is deferred until the software RSP + scheduler can service real frames. Verified: ROM header passes validation, 12 MB mapped at 0x10000000, window renders for the full test duration. Asset symbol spot-checks against the ROM confirm correct offsets (e.g. music bank BE seqCount=63 @0x419790, first track offset 508 → Mno_music @0x41998C; sfx/instrument banks start with GE's `B1` header). ### F. Phase 2 runtime findings (threads, DRAM, addresses) **Dxx index — jump to a finding, do not linear-read this section.** §F covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | Range | Topic | Status | |---|---|---| | D24–D30 | green threads / kernel / crash handler | resolved | | D31–D42 | boot ABI: ANIM_DATA, music seq-table, libaudio banks, Globalimagetable rebase, GL context, rsp toggle | resolved | | D43–D50 | model-file loading → offline sidecar "Plan B" (`d43_emit.py`); D50.6 texCopyGdls full-slot copy | resolved | | D51–D58 | font pixeldata, osGetCount tick rate, model RW pools, cseq BE header, synth slots, RLE menu bg, watch raw offsets, gun-barrel DL | resolved | | D59–D68 | intro render: blood-RLE clobber, DMA validate, OSMesgQueue, HEADS/BODIES sentinels, romCopy width, image_entry, Globalimagetable BE→LE | resolved | | D69 · D78–D84 | stage load (`load_bg_file`): bg/stan offline sidecar (`d69_emit.py`) + StandTile/bg_room_data ABI | resolved | | D70–D74 | intro-logo pixels: C-array bswap, UV path, sinf/cosf `DVAL()`, texture-import truncation | resolved | | D75 · D77 | front-end 3D model transforms · audio | OPEN (parked, `GRAPHICS-BACKLOG.md`) | | D76 · D164 | disclaimer/legal screen only draws line 1 — **root-caused (M-31)**: `constructor_menu00_legalscreen` text loop bounds `legal_text_end` on `&legalscreen_MRD`, a linker-adjacency assumption that mingw breaks (`legalscreen_MRD` links 0x60 *before* `legalpage_text_array`) → `do{}while(ptrIndex1` reads `Index2`'s value, `Index2` reads 0 → switch-doors / dual-weapons / locked-doors / safes silently fail their validity guard (non-crash; guard failure also prevents the under-sized-record `->next` overflow). | proposed fix (not applied) — see below | | D157 | **Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").** The type-23 objective record's `MinDificulty` is a big-endian `s32` at word 3; on N64 the 0–3 value sits in its low byte == offset `0xF`, and `struct objective_entry.difficulty` (`bondtypes.h`) reads `s8` @0xF. `d88_propdefs.py` `_bswap32`'s that word (correct — it IS a 32-bit field), moving the value to offset `0xC` on LE → the `s8`@0xF read yields **0 for every objective**. `get_difficulty_for_objective()` → Agent(0) for all → `objectiveIsAllComplete()` on Agent evaluates objectives that should be difficulty-gated out (Dam "Neutralize all alarms" = Secret Agent, "Install covert modem" = 00 Agent) → they're never complete → `bossReturnTitleStage` sees `objectiveIsAllComplete=0` → `end_of_mission_briefing()` (the `fileUnlockStageInFolderAtDifficulty` → EEPROM write) never runs. The D151 sibling — same struct, the field D151 said it "left unchanged". Confirmed via GE_SAVELOG playtest + a direct ROM dump of Dam's records (word 3 = 1/2/2/0). | FIXED (`src/bondtypes.h`, `#ifdef PORT` — reorder `objective_entry`'s post-swap tail so `difficulty` reads @0xC, `unkD` @0xE; N64 layout under `#else`; sizeof unchanged 16). Also fixed `tools_pc/dump_objectives.py` (same byte-offset bug — read `buf[off+12]` = BE high byte = always 0 → showed "Agent" for every objective) + regenerated `docs/LEVEL-OBJECTIVES.md`. **User-confirmed:** Dam Agent → `objdiff=1/2/2/0` → `objectiveIsAllComplete=1` → `fileWriteSave` with the completion time; Agent checkmark shows in mission-select and persists across kill+restart. New `GE_SAVELOG=1` / `GE_UNLOCK_ALL=1` `#ifdef PORT` env diagnostics added along the way. 21/21 sweep PASS. PORT-LEARNINGS §C. | | D160 | **Dam level-end cutscene (Bond off the dam / bungee) never plays — cuts straight to the MISSION COMPLETE report (M-30/M-31, user bug D148).** Progression is unaffected: `bossReturnTitleStage` fires, the report + next briefing load, EEPROM save lands (D157). So the *transition* works; only the scripted cutscene step is skipped or silently fails. Static trace: Dam's end sequence is `UsetupdamZ` ailist `ai_24` (id 0x1004, gated `if_bond_in_room_with_pad(0x4a01)` — Bond reaches the exit) → lock controls, `bond_set_locked_velocity` (auto-walk off edge), wait `if_bond_y_pos_less_than` (Bond falling) → fade → `if_objective_all_completed` branch → `camera_switch`/`jump_to_ai_list` into the abseil ailist `ai_17` (id 0x0412: `screen_fade_from_black`, `music_xtrack_play`, `guard_play_animation(0xb100…)` = rappel anim on a puppet chr, three `camera_switch` w/ fades, `exit_level`). `camera_switch(TAG,…)` resolves a `CutsceneRecord` (propDef type 46, records 322/324/326 paired with Tags 10/11/12) via `tagGetCommandIndex` + `setupGetPtrToCommandByIndex` (a **command-index count walk over the propDef stream** using `sizepropdef()`). CutsceneRecord itself is all-scalar 28 B, correctly converted (`d88_propdefs.py` 46→28). **Leading hypotheses, unverified:** (a) the index-count walk desyncs because some earlier propDef type Dam emits (Vehicle 321 — D122 "medium confidence" tail; TintedGlass) has a wrong PC `sizepropdef` stride → `camera_switch` gets a bogus `cdef`/`TagIndex<0` → cutscene silently skipped, `ai_17` falls through to `exit_level`; (b) the trigger chr for `ai_24`/`ai_25` isn't spawned / `if_bond_in_room_with_pad` never true and the level ends via a different path; (c) D75/anim-model family — the puppet `guard_play_animation` rappel anim doesn't render (third-person Bond model absent, like the gun-barrel/cast models) so the cutscene "plays" invisibly but the user perceives it as skipped (less likely — user reports an instant cut, not a blank pause). | DIAGNOSTIC SHIPPED, not root-caused (`c95713f5`, `#ifdef PORT` + `GE_D160=1`): trace points in `bossReturnTitleStage` (entry + caller), `bondviewSetCameraMode` (mode/stage/`g_IntroSwirl`/intro-anim-idx/caller), and AI cmds `EndLevel`/`exit_level`, `CameraLookAtBondFromPad`, `CameraSwitch` (prints `cdef` ptr / `TagIndex<0` / tag `NOT FOUND`). **Next step:** user plays Dam to the exit with `GE_D160=1` (via `debug.ps1`); the trace answers (1) does `CameraSwitch`/`CameraLookAtBondFromPad` execute at all → trigger fires? (2) if yes, is `cdef` a sane pointer → propDef-walk / converter bug (a), same family as D122/D132/D157; (3) if the cam cmds never run → ai_24 trigger / chr-spawn bug (b). If it's (c), `bondviewSetCameraMode` shows POSEND being set + control returns normally. This is likely a WRITE-UP-then-fix (propDef stride) rather than a hack. Confidence in hypothesis (a): medium. | | D161 | **Depot (`-level_30`) ceiling renders as bright-blue speckle + radial "light-ray" streaks converging to a point instead of a dark corrugated roof (M-30/M-31, user bug B2 — `docs/TEXTURE-GLITCH-ANALYSIS.md`).** NOT filtering (3-point+trilinear+aniso didn't touch it), NOT RC2 mip-contamination, NOT the RGBA5551 decode. Root-caused with a `GE_TEXDUMP` PPM+param dump (`gfx_opengl.cpp` / `gfx_pc.cpp`): the offending surface is **one texture, `fmt=CI siz=8b, 16×16`**, uploaded with **`rdp.palette_fmt == G_TT_NONE` (0)** while every other CI texture that frame has `palfmt=0x8000` (`G_TT_RGBA16`). GE draws the Depot roof with the TLUT **disabled** (`gsDPSetTextureLUT(G_TT_NONE)` — a legit N64 idiom: a CI-siz tile with no LUT feeds the raw 8-bit TMEM texel straight into the colour pipe, i.e. it behaves as a plain **I8** texture). fast3d's `import_texture()` ignored `palette_fmt` and always did the palette lookup for `fmt==CI`, indexing a **stale `rdp.palette`** left over from a previous texture → the 16×16 image came out as pure blue/cyan/magenta noise; the "radial rays" were that high-frequency noise aliasing on the steeply-receding ceiling plane (which is also why filtering couldn't help — the data was garbage). Fix: in `import_texture()`, `fmt_eff = (fmt==G_IM_FMT_CI && rdp.palette_fmt==G_TT_NONE) ? G_IM_FMT_I : fmt`, and dispatch on `fmt_eff` (CI4→I4, CI8→I8). Narrow — only changes surfaces that are currently 100% garbage. Verified: Depot corridor + control-room ceilings render as clean dark/grey industrial roofs; `-level_09`/`-level_30`/`-level_34` sweep PASS, unregressed. `GE_TEXDUMP` (env-gated PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx` log line) kept as an inert diagnostic. Confidence: **high** (before/after screenshots + the palfmt=0 evidence). | FIXED (`port/fast3d/gfx_pc.cpp` `import_texture`, `port/fast3d/gfx_opengl.cpp` diag). PORT-LEARNINGS §D. | | D89–D92 | stage-load→frame: stan zero-fill overrun, portal address trunc, chr/AI spawn ptrs | resolved | | D93–D102 | struct-pun / hardcoded-size pass: player alloc, player.model inline Model, weapon Model pun, master-DL buffer | resolved (D95 pool bump partial) | | D103–D107 | BUNKER1 viewport height, depth-buffer clear (`G_CLEAR_DEPTH_EXT`), portal near-plane, LOD mip tile | resolved | | D108–D112 | skeletal models: `d43_emit.py put_f32` byte-reversal bug | resolved | | D113–D116 | portal BFS ok · matrix chain ok · player raw-offset audit + `gunfire.c` THROW* · HUD text X-mirror | D116 OPEN (parked — DO NOT re-static-trace) | | D117 | frame nondeterminism root-caused; `GE_DETERM` deferred; `framediff.py` structural | resolved (mode deferred) | | D118 | SDL input layer (`port/src/input.c`); M-24 mouse-look rework: mode-aware map (aim mode = analog stick, hipfire = digital C-pitch), `config.c` INI now real. D118b/c FIXED; D118a residual (hipfire pitch only) | resolved (D118a residual) | | D150 | watch OBJECTIVES / BRIEF page crash: `strcat(buf, langGet(id))` with `langGet` → NULL (unloaded bank) → NULL deref; `str.c` str* builtins elide a plain param NULL check, so guard via asm-laundered `GE_IS_NULL` | FIXED (`src/str.c` `#ifdef PORT`); interactive re-verify pending | | D137 | right-mouse (aim-sight) crash: `gunDrawSight` (`gunfire.c:6235`) `s32 sp54` holds a `Gfx*` the whole function (`sp54 = *gdl`; `texSelect(&sp54,…)`/`display_image_at_position(&sp54,…)` take `Gfx**`). As N64 `s32` it is 4 B, so `*(Gfx**)&sp54` reads 4 B of the adjacent stack float as the pointer high word → `_g = 0x03e4fca0_70081220` → wild DL write in `texSetRenderMode` (`othermodemicrocode.c:177`) the moment the crosshair raises. §A. | FIXED (`src/game/gunfire.c` `#ifdef PORT` — `sp54` is `Gfx *`) | | D119 · D120 | guard-attack `weapons_held[]->chr` pun crash · blood-stain `PointUsage[]` chain hang | D119 fixed · **D120 FIXED (M-30)** | | D120 | **Blood-decal `PointUsage[]` chain — converter now emits it (M-30).** `d43_emit.py` reserved the opcode-0x18 `PointUsage` region in the layout pass (`add_region(puo, 2*nv)`) but the **emit pass never wrote it** — every entry stayed zero. `chr.c`'s decal walk `index = PointUsage[index]` then cycled 0→PointUsage[0]→0 forever (capped by the `#ifdef PORT` guard at `chr.c:3328`, so decals were missing/wrong rather than a true hang). Fix: `op24_pointusage[puo] = nv` in layout + a byteswap loop (`put_s16(remap(puo)+2k, be16(src, puo+2k))` for `nv` = `numVertices` entries — a negative-terminated `s16` index chain, no remap) in the emit pass. Full regen chain re-run, round-trip validation passes. The `chr.c` guard stays as a safety net (SMALL-FIXES B3). | FIXED (`tools_pc/d43_emit.py`). Interactive verify pending — blood decals on a BUNKER1 firefight should now appear + no hang. | | D121 | WS1 frictionless per-level boot: bare `-level_XX` injects its `memallocstringtable[]` `-m*` row (`#ifdef PORT` in `boss.c`) | resolved | | D122 | per-level prop/item model-load crash (Dam/Facility/Runway `modelLoad`/`modelInitRwData`): `d88_propdefs.py` had no handler for 6 ObjectRecord-derived propDef types (47/39/40/45/13/20) → generic arm half-swapped the `[s16 obj][s16 pad]` word → OOB `PitemZ_entries[]` | converter fixed; residual chr/fast3d crashes on those levels are separate | | D123 | crash class C1: `chrIsNotDeadOrShot` NULL deref on 6 levels (Dam/Runway/Frigate/Statue/Streets/Cradle). D122's `OBJ_TAIL_DESC` zeroed the widened `VehichleRecord/AircraftRecord.ailist` slot (w32), but `prop.c:1764/1786` reads a pre-populated int AI-list id there → `ailistFindById(0)` → `GAILIST_AIM_AT_BOND` → `ai()` runs a CHR aim list with `ChrEntityp==NULL` | converter fixed (`OBJ_ID_WORDS`); C1 cleared on all 6, residual crashes are fast3d (C2) | | D125 | crash classes C3+C6 (Aztec/Bunker2/Surface2): **root cause found (M-16)** — `tools_pc/d88_emit.py:374` assigns a 4-byte literal to an 8-byte slice (`out[dst_o+0x30:dst_o+0x38] = b"\x00…"`, the stan zero-fill in `emit_pad`); every pad/boundpad record silently shrinks the output bytearray by 4 B, and once the tail falls below the boundpad plink string blob, later verbatim leaf writes hit Python out-of-range slice semantics (insert at current end, not relocated offset) → boundpad names drift/truncate (Aztec pad33 `p138d2`→`8d2`) → `stanPackId()` reject → `getposstan()` NULL stan → `setupDoor` leaves door `model=NULL` → crash `propobj.c:13601`. M-14's "propDefs zeros in RAM" was a misread; sidecar propdefs were always correct (M-15) and post-load RAM matches the sidecar byte-for-byte (M-16) | **resolved (M-16b)** — line 374 → 8 NULs; all 21 sidecars regen'd; **Aztec `-level_28` now PASSES** (was C3 CRASH). Bunker2 falls through to a separate DOOR-tail `linkedDoor` layout bug (C3 residual) | | D126 | crash classes C3r/C4/C6 (Bunker2 `-level_27` `door7F054FB4` propobj.c:13523, Depot `-level_30` prop.c:902, Surface2 `-level_43` loadobjectmodel.c:393): the objective sub-records `criteria_picture` (30), `criteria_roomentered` (32), `criteria_deposit` (33), `setup_objective_text` (35) each end in a `T *next` list pointer that the setup walk (`set_parent_cur_obj_*` / `setup_briefing_text_entry_parent`) writes unconditionally. On PC that pointer widens 4→8B and lands 8-aligned at offset 16 → struct is 24B/6w (N64 16/20). `d88_propdefs.py` emitted them at N64 size via the generic arm → the runtime 8-byte `->next` write clobbered the *next* record's header → propdef walk desynced, command indices drifted ~100, `linkedDoorOffset + arg2` resolved to the wrong record → door `linkedDoor` chain walked into garbage. | resolved — `d88_propdefs.py` PROPDEF_PC_BYTES[30/32/33/35]=24 + typed handler; `loadobjectmodel.c` sizepropdef PORT returns 6. Bunker2/Depot/Surface2 now PASS (13→16/21) | | D124 | crash class C2: fast3d bad texture pointer. **Jungle** (`0xabcd0824`): `gimgSyncCompiledGlobalDLs()` slot-detect keyed on the post-fixup marker, which `texLoad()` had already overwritten → compiled `globalDL_0xNNN` explosion DLs kept link-time `IMAGESEG` words → latent on every level, tripped by the first explosion-DL draw. **Facility** (`0x72181ee8`): see **D130** — the model-GDL-relocation hypothesis (M-14 addendum) was WRONG; real cause was `romdataFixupFont` | Jungle fixed (`port/src/gimgfixup.c`); Facility → D130 | | D131 | crash class C2m (Jungle `-level_37`): fast3d `gfx_sp_matrix` AV on a wild matrix pointer `0x401c68e0` (`gfx_pc.cpp:1046`) ~frame 300, when the first explosion/smoke prop renders. `explosionRenderPropSmoke` builds `gSPMatrix(gdl++, osVirtualToPhysical((void*)&dword_CODE_bss_8007A100), …MODELVIEW)` (+ `applyRoomMatrixToDisplayList`). `osVirtualToPhysical()` is a `u32`-returning shim (`libultra.c:1207`) → truncates the compiled `.bss` symbol's `0x1_00000000` module high word → w1 = `0x40xxxxxx` → `seg_addr()` returns it raw. Same class as D94 (chraction 32-bit ptr truncation), but in a GBI DL word. The projection matrix in the same DL is fine (`get_BONDdata_field_10E0()` is a runtime `0x70xxxxxx` ptr, fits u32); the `gSPDisplayList(&globalDL_0xNNN)` refs are fine (port `Gwords.w1` is 64-bit `uintptr_t`, no macro truncation). ~30 `osVirtualToPhysical()` sites exist (`explosion.c`, `glass*.c`, `blood_animation.c`, `bondview2.c`) — all latent until that effect first draws. | resolved — `seg_addr()` restores the module high word for any fallthrough w1 in `[0x40000000, 0x70000000)` (module fixed-based at `0x140000000`, no ASLR; DRAM/KSEG0/segmented/phys all handled earlier). Jungle renders to frame 2400+ clean; `-level_20`/`-level_24` unregressed. **18→19/21.** (`-level_09` has a separate pre-existing boot crash, see below.) | | D133 | intro render triage (M-20): the "intro renders mostly black" item in the M-18/M-19 handoff is **NOT a regression** — it is the D75/D76 parked-cosmetic steady state. M-17 (`9ec6121e`), whose handoff claimed "the entire intro renders — logos → gun barrel → cast", was built in a scratch worktree and captured with the same `GE_PCDUMP="20-900:20"` window: coverage is **pixel-identical** to HEAD `0b5f5d1a` (legal screen 6677 non-clear px / 2.17% on both; centred-logo frames ~7% on both; near-black between). 2D/text/texture layers draw; animated character-model layers (Nintendo-logo transform, gun-barrel Bond, cast models) never appear = D75 exactly. No cheap regression to fix; parked. Secondary: Facility `-level_34` + Jungle `-level_37` re-verify FAILED this pass (boot crash / frame-2 hang) — flagged in LEVEL-STATUS for a clean-machine re-check, not investigated (scope). | not a regression — parked (D75/D76, `GRAPHICS-BACKLOG.md`) | | D134 | **Frame-2 boot hang / sweep "NO-FRAMES" flakiness — root-caused and fixed (M-22).** `osSpTaskStartGo` (port/src/libultra.c) posted the SP/DP task-done events with `OS_MESG_NOBLOCK` into the scheduler's 8-slot `interruptQ`, which the 60 Hz VI pacemaker thread also fills with `VIDEO_MSG`. A gfx task runs the whole frame synchronously on the sched thread (fast3d); the first two frames take 30-80 ms, so the pacemaker queues several retraces meanwhile. Once the queue is full the RSP-done post is **silently dropped** -> `__scMain` never clears `sc->curRSPTask` -> the client never gets `OS_SC_DONE_MSG` -> `bossMainloop`'s `pendingGfx` never clears -> permanent stall (log: `frame 2 rendered`, then `kernel heartbeat ... frames=2`, retrace queue pinned at `valid=8/8 ret=-1`). Reproduced 2 of 3 `-level_09` boots on an idle machine, i.e. most of what M-13..M-21 wrote off as D117 nondeterminism / machine load. `OS_MESG_BLOCK` is NOT the fix (the done event is posted from the sched thread, the queue's only consumer -> self-deadlock): `portPostEventForce()` drops the OLDEST message (always a stale retrace) and retries, and `portPostVIEvent` now reserves 2 slots. Verified: 6/6 `-level_09` boots reach frame 600 with 0 heartbeats (pre-fix 1/3). | FIXED (`port/src/libultra.c`) | | D135 | **Firefight crash — `bgTestHitOnObj` (`propobj.c:8446`) is an unported N64 GBI parser (M-22, WS6 playtest).** The object bullet-hit test walks a model's display list command stream with hardcoded 8-byte-N64-`Gfx` byte/word indices (`*(s8*)gdl`, `((u32*)gdl)[1]`, `((u8*)gdl)[5..7]`, the G_TRI4 nibble reads, the backward `G_SETTIMG` scan). On PC the model GDL is the 16-byte `Gfx` layout (`d43_emit.py`, `{u64 w0; u64 w1}`), so every access reads the wrong bytes → the walk desyncs on the first `G_VTX`/`G_TRI1` → `v->coord.x` deref off the end of the DL → AV (`0x709ae02a`, a near-miss past a live DRAM ptr = the §B signature). Triggered by `propobjFindHit` every time a shot ray resolves a hit on an object model (guards' dropped weapons, attached objects, shootable props) — invisible to `level_sweep.sh` (no weapon fire). Two sibling faults seen the same session (`texSelect`/`texSetRenderMode` wild `gdl`, `objFreeEmbedmentOrProjectile` `prop->obj`) are likely the same corrupted-object cascade. PD `bgTestHitOnObj` (`pd_port/src/game/bg.c:3635`) is the fully-ported ground truth. | FIXED (`src/game/propobj.c`, `#ifdef PORT` — reads `gdl->words.w0/.w1` shifts). **Second fault, same walker:** after the parser fix the Facility firefight then hit `0xc0000094` (int div-by-zero) at `objHit` `propobj.c:9717` `randomGetNext() % impact_sounds->thing2_len` — the ported texnum-recovery (`*(s16*)(phys(w1) − 8)`) returned a bogus positive `texturenum` (`(u32)` cast truncated a 64-bit `words.w1`; the N64 "texnum 8 bytes before the texture data" layout isn't guaranteed for the port's converted/replaced GDLs) → `g_HitTypeSounds[texnum & 0xf]` picked an entry with `thing2_len == 0`. Fixed: the PORT texnum branch now always returns `-1` (→ `g_HitTypeSounds[0]` = `isnd_default`, `thing2_len == 1`, safe). `texturenum` only flavours the bullet-impact sound/decal, so generic-hit is an acceptable degradation (park with D77). **Follow-up:** the BG room-geometry sibling `sub_GAME_…` (`bg.c:~3373-3646`, walks the D85-widened `ptr_expanded_mapping_info`) has the identical unported parser pattern — latent, fires on shooting walls/floor; not yet ported. | | D136 | **Stage-unload crash — `objFree` `obj->prop` reads float garbage (M-22, WS6 Facility playthrough).** After D135 cleared the firefight crashes the level is completable; then on teardown `lvlUnloadStageTextData` → `cleanupObjects` (`cleanup_objects.c:42`) → `objFreePermanently` → `objFree` (`propobj.c:994`) → `objFreeEmbedmentOrProjectile(obj->prop)` faults at `:888` (`prop->obj` deref). Real gdb `bt`: `obj` = `0x701bd730` (valid) but `obj->prop` = `0x3e567400405c30c0` — two packed `f32` (~0.21, ~3.44), i.e. a `coord3d` read as a pointer → `ObjectRecord.prop` (N64 `/*0x10*/`, after `inherits PropDefHeaderRecord` + `s16 obj; s16 pad; u32 flags; u32 flags2;`) lands at the wrong PC offset for this object's propDef type, OR the record is undersized and setup's `->prop` store never happened / was clobbered. Same family as D122/D132 (`tools_pc/d88_propdefs.py` per-type record sizing vs the compiled `ObjectRecord`/subtype layout). Type not yet identified — needs a gdb run with a breakpoint on `objFree` to read `obj->type` (`cleanupObjects` frees WEAPON/AMMO/MAGAZINE/COLLECTABLE/MONITOR/RACK/AUTOGUN/HAT/ARMOUR/GAS_RELEASING/VEHICHLE/AIRCRAFT/GLASS/SAFE/TANK/TINTED_GLASS via this path). Mission completes; crash is before the next briefing loads. | ROOT-CAUSED → **D139** (it was NOT a layout bug — `cleanupObjects` read the type from the wrong byte on LE and walked off the propDef blob). | | D139 | **Stage-unload crash root cause + fix (M-23).** `cleanupObjects` (`cleanup_objects.c`) walks `g_CurrentSetup.propDefs` with `(u8)obj[0]` as the record type — correct only because the header word `[u16 extrascale][u8 state][u8 type]` is **big-endian** on N64, so `type` is the low byte. On little-endian PC the low byte is `extrascale`, so `(u8)obj[0]` is never `PROPDEF_END` → the `while` runs off the end of the blob, and the `switch` dispatches on garbage → `objFreePermanently()` on records that aren't `ObjectRecord`s → `obj->prop`/`obj->model` read adjacent float data (`0x3e567400405c30c0`) → AV in `objFreeEmbedmentOrProjectile` (`propobj.c:888`). `sizepropdef()` and every other propDef consumer already use `pdef->type` (struct member, offset 3) — only this one spot used the raw byte cast. Not a `d88_propdefs.py` layout issue at all. | FIXED (`src/game/cleanup_objects.c`, `#ifdef PORT` — `CLEANUP_PDTYPE(o)` = `((PropDefHeaderRecord*)o)->type`). **UNVERIFIED** — the fast teardown test (pause→abort) hit D140 first; `-level_09` unregressed. PORT-LEARNINGS §C. | | D140 | **Pause-menu crash (M-23 open → M-27 fixed).** `bondviewRenderWatch` (`bondview2.c:8604`) → `bondviewTransformManyPosToViewMatrix(g_CurrentPlayer->field_23C, objheader->numMatrices)` with `field_23C == NULL` → `matrix_4x4_copy(src=0x0)` AV. Root cause (NOT "cache never allocated"): `something_with_watch_object_instance` (N64 player +0x230) is a **`struct Model` (0xBC) + its RW-data pool (0xC8) punned into a 0x184-byte hole** (`field_234..field_3B0`); three of those "fields" are Model members the game reads by name — `watch_scale_destination`==`.scale`, `pause_watch_related_adjust`==`.animframe1`, **`field_23C`==`.render_pos`**. `render_pos` IS populated (by `instcalcmatrices` via `subcalcmatrices` at `:8524`), but on x86-64 `sizeof(struct Model)` grows (4→8B ptrs) so `field_23C` no longer overlaps `.render_pos` → reads Model padding (~0) → NULL. Same class as D100 (gait Model) / D102 (weapon Model) / D56 (this very watch Model's pool). Fix: `#ifdef PORT` makes `something_with_watch_object_instance` a real inline `struct Model` + `u32 watchRwPool[192]` in `struct player` (`bondview.h`); `bondview2.c` redirects the 3 named reads to the member via `GE_WATCH_{ANIMFRAME,SCALE,RENDERPOS}` macros (N64 `#else` = the verbatim field). The D56 branch of `sub_GAME_7F07E7CC` drops its `static u8 watchRwPool[0xC8]` for the inline pool. | FIXED (`src/game/bondview.h`, `src/game/bondview2.c`; `#ifdef PORT`). Exposed the next watch-render crash → **D141**. `-level_09`/`-level_20` unregressed (3/3). PORT-LEARNINGS §A. | | D118d | **Watch-menu list over-scrolls with keyboard (M-27, user bug report).** `game_options_inventory_navigation` / `sub_GAME_7F0A611C` (`options.c`) have a "slam the stick" fast-scroll: a raw *level* check `joyGetStickY(PLAYER_1) < -0x46` / `>= 0x47` that steps `watch_inventory_cursor_pos` by 1 **every frame** it's held. An N64 stick rarely sustains past ~0x46 on-axis so a human taps it; keyboard W/S (and SDL pads pegged at 0x80) sit there every frame → one keypress skips several items (breaks item selection needed to finish the game). Fix: `#ifdef PORT` macro `GE_WATCH_STICK_FAST{UP,DOWN}` → `0` for the port — stick list-nav then goes only through the latched single-step path (`watch_stick_y_pressed_*`, one step per press, existing game mechanism) and the -0x1e..-0x45 smooth-scroll band; D-pad/C-buttons keep edge-repeat. N64 `#else` = verbatim. | FIXED (`src/game/options.c`, `#ifdef PORT`). Build green, `-level_09` unregressed. **Feel to be confirmed in playtest** (probe couldn't reach the INVENTORY watch page headlessly). D118 input family. Latent sibling: `front.c` menu nav uses `joyGetStick*InRange` level checks the same way. | | D141 | **Watch-menu item-model crash (M-27), exposed by D140.** Pressing Start → watch page renders → `set_enviro_fog_for_items_in_solo_watch_menu` (`gunfire.c:1620`) → `modelGetNodeRwData(&model, root)` with `root` garbage → AV reading `root->Opcode` (`model.c:478`). `gunfire.c:1720-1743` walks `bodymodel->Switches[]` (a `ModelNode*` array) with **raw byte offsets** `((u8*)Switches) + j + 0x48` / `+ 0x5c` and `j += 4` — assumes 4-byte N64 pointers. `0x48/4=18`, `0x5c/4=23`, `k=j>>2=0..4` → intent is `Switches[18+k]` and `Switches[23+k]`. PC's 8-byte stride makes the raw math read misaligned garbage → bogus non-NULL `ModelNode*`. PORT-LEARNINGS §B (raw hardcoded stride into a pointer array, cf. D128). Fix: `#ifdef PORT` uses `bodymodel->Switches[18 + (j>>2)]` / `[23 + (j>>2)]`. | FIXED (`src/game/gunfire.c`, `#ifdef PORT`). Watch page now renders (weapon page verified — mirrored text D114/D116, dark weapon model D75, both parked cosmetic). `-level_09`/`-level_20` unregressed. | | D150 | **Watch objective/briefing page crash (M-28).** Opening the watch → BRIEF (any subpage) or OBJECTIVES page → AV in `strcat` (`str.c:25`, `Rdi/src = 0`). The pages assemble their display text with `strcpy`/`strcat(buf, langGet(id))` (`options.c:3940-4068`, `front.c:3538-3562`, `propobj.c`). `langGet()` returns **NULL** on PC for a string bank the menu flow never loaded (D129/D143 guards — briefing/objective banks in the in-level watch flow), so `strcat(buf, NULL)` derefs NULL. On N64 those ids always resolve so the decomp never guards. Twist: `str.c`'s `strcpy/strncpy/strcat` are `__nonnull__` **builtins** to GCC, so a plain `if (src == NULL)` on the parameter is optimised away as provably-dead (verified: `-Og` elided it, no `test` in the disassembly). Fix: `#ifdef PORT` NULL guards that launder the pointer through an empty `__asm__` (`GE_IS_NULL()` / `ge_launder_ptr()`) so the check survives — NULL src → treat as empty string (blank text, not a crash), NULL dst → return. Same philosophy as the D143 textRender/textMeasure NULL guards, one layer down. Inert on N64. | FIXED (`src/str.c`, `#ifdef PORT`). Build green (`ntsc-final`); `-level_09` 600+ frames @ 91.67% unregressed. Watch-page repro is interactive — user to re-verify. PORT-LEARNINGS §C. | | D152 | **Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).** User killed Trevelyan in Facility (Ctrl = fire, no crouch bind) → failed the objective → screen faded to black and never came back; process stayed alive under `debug.ps1`. `gdb.txt` (kept running): rendering stops dead at frame 12600 (`frames=12657` frozen across 206 heartbeat dumps / ~7 real min), VI pacemaker still posts. **Deadlock on `s_imLock`** — the D147 recursive-mutex that backs `osSetIntMask`. Both consumer threads are blocked in `pthread_mutex_lock(&s_imLock)` and neither owns it: `mainThread` = `sndSetScalerApplyVolumeAllSfxSlot` → `sndApplyVolumeAllSfxSlot` → `sndSetSfxSlotVolume` → `alEvtqPostEvent` (`event.c:82`) → `osSetIntMask(OS_IM_NONE)` (`libultra.c:1232`); `amMain`/audioThread = `amHandleFrameMessage` → `alAudioFrame` → `sndPlayerVoiceHandler` → `alEvtqNextEvent` (`event.c:47`) → `osSetIntMask(OS_IM_NONE)`. `sndSetScalerApplyVolumeAllSfxSlot` is the **mission-failed audio fade-out** (ramps `g_sndSfxVolumeScale` → 0 each frame, `AL_SNDP_RELEASE_EVT` posted per active `ALSoundState` per frame) — so the fade-to-black *is* the intended fail transition, and it hammers the event queue hard enough that the `osSetIntMask` lock is left owned by a party not in the 3-thread dump (a leaked unbalanced `OS_IM_NONE` — libaudio has early-return mask paths — or a transient thread that acquired it and exited; `gdb.txt` shows `New Thread`/`exited` churn every few k frames). D147's "the decomp never blocks while holding OS_IM_NONE → cannot deadlock" assumption is **falsified** by this path. Same family as D147 / PORT-LEARNINGS §D4. | MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only). `osSetIntMask` is now a **self-healing** logical lock: `OS_IM_NONE`/`OS_IM_ALL` acquire/release a process-wide recursive critical section tracked by `s_imHeld`/`s_imOwner`/`s_imDepth` under a short bookkeeping mutex `s_imMx` + condvar. Normal (balanced, microsecond) audio critical sections behave exactly as the D147 recursive mutex did. A waiter that blocks **> 2 s** (`OS_IM_STUCK_NS`) concludes the holder leaked it, logs `LOG_ERROR` with the stale `s_imOwner` + the stealing caller's return address, and **steals** the section — so a leaked/unbalanced `OS_IM_NONE` (or an acquire-and-exit transient thread) can no longer wedge the game forever; worst case is a ~2 s audio hiccup + a log line naming the leak. Builds clean. Still OPEN: the exact leaking call site (find it from the `D152: ... stealing from owner=` log next time it fires) and the proper narrow fix (dedicated `ALEventQueue` lock, or restore-on-thread-exit). Regression check deferred — `-level_09`/`-level_20` both flaky under the current machine load (D117/D134), and clean `master` itself has a separate nondeterministic `-level_09` frame ~900-1400 `0xc0000005` (see below). | | D154 | **`bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).** `bgTestRayIntersectionInRoom` (`bg.c:3331`, called by `bgTestBulletHitBackground` on every shot that resolves against background geometry) is the D135 sibling flagged since M-23: raw `((u8*)gdl)[k]` / `((u32*)gdl)[i]` byte/word indexing into what is now the 16-byte PC `Gfx` room DL (D85 `bgWidenRoomGdl` + `texLoadFromGdl`) → desync → OOB `vtxbase[idx]` / walk-off AV the first time you shoot a wall or floor. Ported `#ifdef PORT` (N64 verbatim under `#else`): `vtxoff`/`vtxbase`/`op` via the `.dma` view (matches the already-ported `bgBuildRoomVtxBounds`); G_TRI1 indices from `(w1>>16/8/0)&0xff /10`; the 12 G_TRI4 nibble extractions from `(u32)gdl->words.w0/.w1` (each mapped from the N64 byte/halfword layout — table in the code comment, cross-checked twice); both `texturenum` recoveries → `-1` (the KSEG0 `*(u16*)(w1-8)` deref is invalid for the port's converted GDLs, same as D135; texnum only picks the impact decal/sound, parked D77). Builds clean. **Verification blocked** — a no-input `GE_PCDUMP` capture never fires a bullet so it never calls this function; needs a real firefight into a wall on an idle machine (this session's box was thrashed → D153/driver crash on every run). | UNVERIFIED — build green, `#ifdef PORT` only. Next: idle-machine wall-shoot test + `-level_09` framediff, then commit. PORT-LEARNINGS §B (D135 corollary). | | D156 | **Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** After D155 shipped the user re-ran the playthrough and it hung again at the end of Facility — same stack (`modelSetAnimFrame2WithChrStuff` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender` ← `bossMainloop`) but this time **`frames=12962` frozen CONSTANT across ~200 heartbeat dumps / minutes** — a true infinite loop, not the D155 slow-catch-up spiral. Root: the `while (1)` at `model.c:3131` in `modelSetAnimFrame2WithChrStuff` steps the root-motion accumulation one anim frame at a time from `framea` (`model->animframe1`) to `frameb` (`frame`, accumulated in `modelTickAnim` as `frame += playspeed * speed` per tick). If `frameb` arrives **NaN or blown up** — a cutscene anim whose blend/transition math produces a huge/non-finite `model->speed`/`playspeed`/`timespeed` (`model.c:3436` `/ model->unkb0`, `:3477` `/ model->timespeed`, both guarded `> 0` but not against tiny values) — then `floorFloatToInt(frameb)` yields garbage, `endframe - curframe` ≈ 2^31, and the loop (each iteration: `sub_GAME_7F06D3F4` joint transform + `cosf`/`sinf` + `modelConstrainOrWrapAnimFrame`) runs effectively forever. **Likely deeper cause (unconfirmed, needs the repro):** these speed/blend fields are `Model` struct members; if this model is punned/inline (D100/D140 family) and `sizeof(Model)` growth misaligned them, they read garbage exactly when a cutscene sets an unusual anim transition. | GUARDED (`src/game/model.c`, `#ifdef PORT`): (1) in `modelSetAnimFrame2WithChrStuff`, if `frameb` is non-finite or `|frameb| >= 1e6` snap it to `framea` (loop then does ~0 iterations; the `modelConstrainOrWrapAnimFrame` at :3211 still clamps `model->frameb` to a valid frame); (2) in `modelTickAnim` after the tick loop, if `frame`/`frame2` went non-finite/absurd fall back to the pre-loop `model->animframe1`/`animframe2` so the NaN can't poison model state every frame. `!(x > -1e6f && x < 1e6f)` catches NaN too. Anim frames are 0..few-hundred so 1e6 is a safe ceiling. Verified: build green; `-level_09` frame 200 unregressed (guards never fire in normal play). **User to re-verify the Facility outro.** If it still hangs, the NaN source is upstream — dump `model->speed/playspeed/timespeed/unkb0` in `modelTickAnim` during the cutscene. PORT-LEARNINGS §E. | | D155 | **Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).** User watched the Facility outro cutscene; at the end the game froze (process alive, kernel-heartbeat dumps). Symbolicated `mainThread` stack from the port's own hang dump: `modelConstrainOrWrapAnimFrame` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender`, sampled there across *every* heartbeat dump. Root cause: `waitForNextFrame()` (`src/game/frametiming.c`) sets `nextFrameTime = (osGetCount() − prev + 387937) / 775875` and passes it **unclamped** to `updateFrameCounters` as `deltaFrames`. `osGetCount()` is wall-clock on the port (D117), not VI-locked, so a real-time stall — the asset load at the cutscene→debrief boundary, made far worse by the user running a local LLM on the same box — makes `nextFrameTime` balloon to hundreds/thousands. That becomes `speedgraphframes` → `g_ClockTimer`, which drives `modelTickAnim(model, g_ClockTimer, 1)`'s `while (numticks-- > 0)` loop once per character per render, plus dozens of `for (i = 0; i < g_ClockTimer; i++)` sim loops (`bondhead.c`, `bondview2.c`, `explosion.c`, `options.c`…). One render then does thousands of anim ticks × N characters → multi-second frame → heartbeat trips, and the slow frame feeds an even bigger delta next time → unrecoverable spiral. The N64 was physically VI-bound and never produced `deltaFrames > ~2`. Timing-compensation class, cf. D117/D134. | FIXED (`src/game/frametiming.c`, `#ifdef PORT` — clamp `nextFrameTime` to `FRAMETIMING_PORT_MAX_CATCHUP` = 6). After a hitch the sim just resumes at real-time-ish pace instead of spiralling, exactly as the console did when it dropped frames under load. N64 path verbatim. Confidence: high (stack + arithmetic both point here). PORT-LEARNINGS §E. | | D153 | **`-level_09` frame ~900–1400 `0xc0000005` = D117/D134 load flakiness, NOT a bug (M-28).** Reproduced 6/6 while the machine was under heavy concurrent build+run load; on a **quiet** machine `-level_09` ran past frame 2400 with 0 heartbeats, no crash. Same false-positive class the docs warn about every session (M-13..M-22, M-25 Silo ~frame 300 freeze). The crash PC was in a system DLL (a `memcpy` off a buffer) and the frame varied run-to-run — both consistent with the port's host-scheduling jitter under load (`osGetCount` is wall-clock, variable-timestep sim), not a deterministic defect. `romdataFixupMusicSeqTable: seqCount 63 exceeds blob capacity 1` is an EXPECTED benign warning on the header-only first `romdataFixupMusicSeqTable` call (music.c:736; the full-size second call at :742 is silent) — not related. **Lesson: don't regression-test while builds/other runs are in flight.** | NOT A BUG — closed. Run anchors on a quiet machine. | | D151 | **Watch data all blank — objectives / mission background / M / Q / Moneypenny (M-28, user bug report).** Not a crash (D150 fixed that); every line renders empty. The watch text lives in `propDefs` record types **35** (`WatchMenuObjectiveText`) and **23** (`ObjectiveStart`). In the setup stream each record's 3rd word is a **plain 32-bit language slot id** (see `assets/obseg/setup/Usetup*Z.c` `propDefs[]`: `_mkword(0,_mkshort(0,35)), menu, 11281, 0`). The decomp structs `struct watchMenuObjectiveText` / `struct objective_entry` decode that word as `u16 reserved; u16 text;` and read `text` at offset `0xA` — which only works on the N64 because the id sits in the **low 16 bits of a big-endian word**. On LE, `tools_pc/d88_propdefs.py` `_bswap32`'s the whole word (correct for a 32-bit field), moving the id to the low bytes, so `text` (u16 @0xA) reads `0x0000` → `langGet(0)` → NULL → `get_ptr_text_for_watch_breifing_page` / `get_text_for_objective` return NULL/blank. This is the "briefing/objective text renders blank" side-effect left open under D143 / D150. The per-level lang bank itself IS loaded in-level (`langLoadToAddr(langGetLangBankIndexFromStagenum(stageId))`, `prop.c:1274`). | FIXED (`src/bondtypes.h`, `#ifdef PORT` — `text` is a full `u32` covering the whole word in both structs; N64 `u16 reserved; u16 text;` kept under `#else`). No converter change, no sidecar regen. `nextentry`/`difficulty`/`unkD` unchanged. PORT-LEARNINGS §C. Build compiles clean; interactive re-verify pending (open the watch in any level). | | D130 | crash class C2 (Facility `-level_34`, Runway `-level_35`): `import_texture_i8` AV on a wild texture pointer (`0x72181ee8`), baked into the HUD glyph DL by `gDPLoadTextureBlock(gdl, curchar->pixeldata, …)` when the level-title string ("Chemical Warfare Facility #2") renders `#`/`"`. Root cause: `romdataFixupFont` (`port/src/romdata.c`) converts the N64 24B fontchar array to the PC 32B array **in place**, forward-field, backward-glyph. PC−N64 stride = 8, so for glyphs 0/1/2 `d` overlaps `s` by less than the 24B read span and an early field write clobbers a later field's source mid-`for k<5` loop → glyph 1's `width` = `bswap(index)` = `0x01000000`, glyph 2's `pixeldata` = `pcPixOff+(index−pixStart)` ≈ `0x020002e8` → `+= font_base` → wild → fast3d AV. On N64 the array is read-only rodata, never re-laid-out, so no bug there. NOT the model-GDL relocation (`sub_GAME_7F0762E0` / `texLoadFromGdl`) the D124-Facility addendum suspected — `gdl` there is still a segmented `0x05xxxxxx` value so the `& 0x00ffffff` masks are correct, and `texLoadFromGdl` never copies a `G_SETTIMG` for these levels. | resolved — stage all 6 N64 fields in a local `f[6]` before writing any. Facility + Runway now PASS (16→18/21). Probes reverted. | | D164 | **Disclaimer / legal screen only draws the first line (D76 root cause, M-31).** `constructor_menu00_legalscreen` (`src/game/front.c:1523`) renders the 12-entry `legalpage_text_array[]` with `legal_text_end = (struct legal_screen_text *)&legalscreen_MRD;` then `do { render; ptr++; } while (ptr < legal_text_end);`. This is a **linker-adjacency assumption**: on N64 `.data` the two file-scope globals are emitted in source order so `&legalscreen_MRD == legalpage_text_array + 12` and the loop count is exactly the array length. mingw/GCC reorders them — in `build-pc/ge007.x86_64.exe` `legalscreen_MRD` links at `0x1401305e0`, **0x60 bytes before** `legalpage_text_array` at `0x140130640` (verified via `nm`). So `legal_text_end < legal_text_ptr` from the start; the `do/while` runs the body **once** (entry 0, "TWYCROSS BOARD OF GAME CLASSIFICATION" — long + CENTER_ALIGN so it wraps to ~2 visual lines = the "classification line + one below" the user saw), then exits. The other 11 legal lines never render. NOT a D68 image-table bug — the legal screen references **zero** `sImageTableEntry`s; it is pure `langGet()` font text via `display_aligned_white_text_to_screen`→`textRender`, plus the 3D `logoinst` model (`subdraw`, absent — D75 family, parked). The D68 fixup and the `GE_IMGT` avenue in the brief are both dead ends here. | **FIX PROPOSED, NOT APPLIED** (`front.c` is owned by another agent this session). One-line `#ifdef PORT` in `constructor_menu00_legalscreen`: `legal_text_end = legalpage_text_array + ARRAY_COUNT(legalpage_text_array);` (N64 `&legalscreen_MRD` cast under `#else`). Same linker-layout class as the D75 loop-bound / D142 enum family. Confidence: **high** — mechanism confirmed by symbol addresses. | | D159 | **Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).** The user's menu showed a garbled venetian-blind Bond photo on the folder / mode-select screen; earlier the same model DL walked into garbage (`D146` opcode spam) — that crash is already contained by the D144/D146 guards, so what's left is purely the texture. Root-caused with `GE_DTEX`: the photo is a **65×65 I4** (also seen: 65×65 I8, 95×32 IA8) with a full mip chain; `import_texture_i4` uploads it `line_size_bytes*2` (= padded stride) wide and `size_bytes/line` tall — geometry fine. The comb is **`texSwapAltRowBytes` (`src/game/image.c:2191`)**: it pre-swaps every **odd** texture row in 8-byte (one-`u32`) groups to compensate for the N64 RDP's odd-line TMEM address XOR (bit 2) during 4-byte-word `gDPLoadBlock` loads. fast3d does **not** emulate that XOR (no odd-row handling anywhere in `gfx_pc.cpp`), so the pre-swapped odd rows stay scrambled in the uploaded GL image. It's called on *every* level of *every* texture on *every* path (§4), but only bites where the texture is large enough and viewed 1:1 to see the 8-texel comb — glaring on the ~big front-end photos, ~invisible on small HUD glyphs / distant in-level surfaces (which is why it was written off as "front-end model" work for many sessions). Same class as the K0-fold / interrupt-mask hardware-quirk shims — belongs in `port/`, not game logic. | **FIXED** (`src/game/image.c`, `#ifdef PORT` early-`return` in `texSwapAltRowBytes` — fast3d wants a plain linear image; N64 body kept verbatim under it). Verified: wallet-Bond photo renders as a clean recognisable grayscale portrait (still 180°-rotated — D75 model-transform family, parked); `-level_09` frame 200 coverage 91.62 vs 91.66 golden (D117 noise) and a visual frame is clean (tiled floor / monitors / world-map screen all correct). Confidence: **high** (GE_DTEX + before/after screenshots). The residual over-tall-mip (RC2) and non-PoT wrap (RC3) issues are separate and still parked; this only removes the row-swap. PORT-LEARNINGS §C. | | D165 | **Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).** M-30 added a "menu pointer mode" that fed mouse *velocity* into the stick axes, but `front.c frontUpdateControlStickPosition` *integrates* the stick as a velocity into a screen-pixel cursor position (`cursor_h_pos += (stickx*0.075 ± 0.5) * g_GlobalTimerDelta`, ±5 deadzone, ±70 clamp, cursor clamped into the ~320×240 virtual rect − 20px margin). Velocity-in → velocity² cursor. **Fix (port-only, no `front.c` edit):** `input.c` now runs a P-controller — it keeps its own estimate `menuEst{H,V}` of where the game cursor is, integrated each poll with the *exact same recurrence* as `frontUpdateControlStickPosition` (`menuCursorStep()`), accumulates a `menuTgt{H,V}` target from raw mouse motion (clamped to the same rect), and emits `stick = clamp(MENU_P_GAIN·(target−est), ±70)`. `MENU_P_GAIN = 6.0` → per-poll error decay ≈ 0.45 (`0.075·6`), so no overshoot and ~8-poll settle; stick falls under the game's ±5 deadzone within ~1px of target so the cursor parks with zero jitter/drift. The estimate re-syncs to the true cursor whenever the target is held at a screen edge (both clamp). `Input.MenuPointerSpeed` scales the mouse→target mapping (bump toward 150–200 if the sweep feels short — `est` steps once per controller poll ≈ 2×/rendered-frame vs `front.c`'s 1×/frame, a rate mismatch absorbed by this knob). `Input.MenuPointerMode` (0 = old velocity, 1 = pointer, **default 1**) is the escape hatch. `GE_INPUTLOG` prints an `menuptr est=/tgt=/eff=/stick=` trace line for tuning. With no mouse motion `target==est==centre` → `eff=0` → emitted stick unchanged → prior behaviour exactly. | FIXED (`port/src/input.c`; `Input.MenuPointerMode`, `Input.HipfirePitchSpeed` added). Build green, `-level_09` + `GE_STARTMENU=6` smoke crash-free. Feel-check owed (headless can't exercise the mouse). | | D166 | **Hipfire mouse pitch is digital while yaw is analog — inconsistent feel (user bug B7 / D118a, "cheaper alternative").** In hipfire GE takes yaw from the analog stick-X but pitch only from digital C-up/C-down, so `input.c` emitted one C-button per poll once `|mouseΔY|` crossed a fixed threshold — an on/off step. **Fix (port-only):** emit the C-button as a *duty-cycled pulse* whose firing frequency scales with mouse-Y speed — `duty = |dyLook|·(HipfirePitchSpeed/100)/HIP_PITCH_FULL` (clamped ≤1), phase-accumulate, fire when the phase wraps. Fast mouse → `duty≈1` → C-button every poll (solid hold, as before); slow mouse → sparse taps proportional to speed. `HIP_PITCH_FULL = 6.0` px/poll for a solid hold; `Input.HipfirePitchSpeed` (10–500, default 100) tunes it. Aim-mode (RMB, already fully analog) untouched. No mouse motion → no pulse → prior behaviour. | FIXED (`port/src/input.c`). SMALL-FIXES B7. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software RSP (`port/fast3d/`). The boot path now runs: ROM map → DRAM reserve → video init → mainThread (real pthread) → bossEntry → bossInitMainthreadData through mempool init, VI init, rspInit, joyInit + controller-init timers, stanInit, gameInit — and currently dies inside `langInit()` (D31). * **D24 — setjmp/longjmp green threads are unusable on MinGW x64.** The first kernel used setjmp/longjmp context switches; under Windows x64 + MinGW the longjmp path corrupts FPU/MC state when it interacts with SEH unwinding (observed: STATUS_DATATYPE_MISALIGNMENT-class crash inside a resumed "thread" with garbage register state). PD's port does not use setjmp at all. **Resolved:** `port/src/libultra.c` now runs every game thread as a real pthread (`PortThread` side table, 8 MB stacks). Message queues keep the N64 `OSMesgQueue` layout and get a `PortQueue` side table (mutex + condvar, max 64 queues); all osSendMesg/osRecvMesg paths lock it. A dedicated tick thread posts one VI retrace per frame (NTSC 60 Hz) and services the software timers (both OS_MESG_NOBLOCK — the tick thread can never deadlock). `idleThread` is intercepted by ID and parked; `mainproc` runs as a real pthread so the host main thread is free to pump SDL/OS events (`videoPumpEvents()` in the host loop — on Windows WndProc only runs on the window-creating thread). The GL context is made current on shedThread per frame via `gfx_sdl_make_context_current()`. Verified live: VI retrace flowing, all 4 controller-init timers fire, bossmq loop completes. * **D25 — dual-mapped N64 DRAM region.** Game code needs two incompatible address forms of the same RAM: `osVirtualToPhysical`/s32 arithmetic wants live host pointers that fit in a positive s32, while `offset | 0x80000000` rebuilds (bg.c:3184/3322, propobj.c:8578/8699, title.c:476) want a KSEG0 view at 0x80000000. **Resolved:** one 8 MB backing store mapped twice — V1 @ 0x70000000 (all game RAM symbols live here) and V2 @ 0x80000000 (byte-identical mirror). `port/src/dram.c`: Windows uses `CreateFileMappingW(INVALID_HANDLE_VALUE,…)` + two `MapViewOfFileEx(…, base)` calls (the documented kernel32 API that maps a section at an exact address; `NtMapViewOfSection` fails with STATUS_MEMORY_NOT_ALLOCATED 0xc0000045 and segfaults on wrong SECTION_INHERIT values — do not retry it); POSIX uses memfd + two MAP_SHARED|MAP_FIXED mmaps. Both views sanity-checked to alias. `port/src/dram_syms.s` pins the absolute symbols: `cfb_16` @ 0x70000000 (0x4B000), `_bssSegmentEnd` @ 0x70050000 (mempool start), tlb block end @ 0x702F4400 (= page_align_down(0x803AB400) − 93×0x2000, the exact N64 value; the tlbmanage stub returns it so `mempInit`'s pool size is correct — a NULL return here made the mempool spin in `while(1)` at src/memp.c:164). * **D26 — address-width shims (K0 sign-extension).** N64 K0 addresses have bit 31 set; passing them through s32 parameters sign-extends to invalid x86-64 pointers. With the dual map, live RAM sits below 0x80000000 so identity is safe: `port/shim/PR/R4300.h` → `PHYS_TO_K0(x) = (x)`; `port/shim/PR/os.h` → `OS_K0_TO_PHYSICAL(x) = (u32)((char *)x - 0x70000000)` (GBI w1 words then carry small offsets) and `OS_PHYSICAL_TO_K0(x) = (x)`. fast3d's `seg_addr()` (gfx_pc.cpp) resolves both forms: full host pointers pass through, values < 0x800000 get +0x80000000 (landing in V2). Same remap applied to the G_MW_SEGMENT handler. * **D27 — ASLR must be OFF for this exe.** `src/bondgame.h:8` declares `extern u32 *_bssSegmentEnd;` (pointer type), so `&_bssSegmentEnd` in game code emits a `.refptr._bssSegmentEnd` slot holding the absolute value with a BASE relocation. Under ASLR the loader rebases it to runtime_base + 0x70050000 = garbage, and `mempCheckMemflagTokens` then spins/AVs with poolAreaStart ≈ 0xF0xxxxxx. (On MIPS `&abs_symbol` is the symbol value itself; x86-64/PE cannot do that.) **Resolved:** MINGW link flag `-Wl,--disable-dynamicbase` (note: `--disable-dynamic-base` is NOT recognized by GNU ld 2.47); the image now loads at its preferred base 0x140000000 and relocations are no-ops. `main.c` fails loudly at startup if `sysImageBase() != 0x140000000ul` so any future ASLR regression is a clean error, not silent DRAM corruption. * **D28 — ninja stale-object hazard with new shim headers.** If a shim header did not exist when a .obj was last built, it is absent from that object's .d dependency file and ninja will NOT rebuild the TU after the header appears — the old (unshimmed) code silently persists. Observed: boss.c.obj still contained `or $0x80000000,%eax` (original PHYS_TO_K0) days after the shim landed. **Rule:** whenever anything under `port/shim/` changes, delete all `.obj` files and full-rebuild. Verify with `gcc -E` using the exact ninja flags (`ninja -C build-pc -t commands `) plus objdump of the rebuilt object. * **D29 — `osPiStartDma` must post its completion message on every path.** The N64 PI posts the caller's OSMesgPI to mq when a DMA completes; GE never inspects the message but always blocks on it (`romReceiveMesg()` → `osRecvMesg(OS_MESG_BLOCK)` in ramrom.c:44). The shim did the memcpy synchronously and posted nothing → mainThread deadlocked in the first file load. **Resolved:** post unconditionally after `piServiceDma()` (also for skipped/dropped DMAs) with OS_MESG_BLOCK, posting the caller's OSMesgPI. * **D30 — crash handler without SEH.** MinGW GCC 16 has no `__try`/`__except` and does not recognize `-fseh-exceptions`, so the unhandled-exception filter cannot rely on structured exceptions to protect symbolication. **Resolved:** `port/src/crash.c` phase 1 writes raw fault info (registers, modules) to `ge007.crash.log` without touching dbghelp (SymInitialize/StackWalk64 can allocate and re-fault inside the filter); phase 2 does a validated manual EBP-chain walk (`-fno-omit-frame-pointer` on all TUs; each frame is [saved RBP, return address], stop if saved_fp ≤ fp or outside thread stack limits). `TerminateProcess` instead of abort (SIGABRT can re-enter the exception machinery). `crashDumpThreads()` (Toolhelp32 snapshot + SuspendThread + GetThreadContext + StackWalk64 per thread) is called from the heartbeat error dump with TID→name matching; StackWalk64 cannot read DWARF unwind info on MinGW, so multi-frame traces depend entirely on the EBP chain. Symbols are recovered offline: `addr2line -e build-pc/ge007.x86_64.exe -f -C ` (the log's "rel" = address − actual load base). * **D31 — langInit SIGSEGV: `zlib_huft_build` overflows load_resource's frame via the x86-64-grown `struct huft` (RESOLVED).** After D24–D29, mainThread reaches `langInit()` and dies on the **first** file load (`_fileNameLoadToBank(LnameX_lookuptable[LGUN][…])` → index 670 → `fileIndexLoadToBank` → `mempAllocBytesInBank` → `load_resource`). Crash signature: RIP = 0x140330003 (inside `.bss`, in `g_Props`), RSP only ~0xD8 below langInit's entry RSP (a *shallow* chain — the stack pointer itself is fine), and the frame region above load_resource's return address is filled with 8-byte slots `{u32, 0x00000001}` where each u32 is the **low 32 bits of a live .bss pointer**, alternating between `g_Props` (0x14033xxxx) and `resource_lookup_data_array` (0x14034xxxx). The crash is a `ret` popping one of these truncated pointers as the return address (observed RIP equals the table value exactly, high word 0x00000001). Something in the load chain is storing 64-bit pointers into s32/int storage (fine on 32-bit MIPS, truncating on x86-64) and that storage overlaps a live frame. Established facts (all under gdb, `gdb -batch -ex "handle SIGSEGV stop"`): * All arguments at `load_resource` entry are valid: ptrdata=0x702aa400 (mempool), srcfile->hw_address=0x108ed250, rom_size=0x720, source= 0x702f3ce0. `romCopy` (PI shim memcpy) completes. * `zlib_inflate()` itself **completes normally** (`finish` → returns 0, rz_wp=3872; the RZ stream is plain deflate after a 2-byte header, so endianness is not an issue in the bitstream). At its entry rz_outbuf = ptrdata ✓, rz_inbuf = source+2 ✓, and the huft table base (tl) sits inside load_resource's 8 KB local `buffer` (rz_hlist). * **Root cause (confirmed):** `struct huft {u8 e; u8 b; union{u16 n; struct huft *t} v;}` is **8 B on MIPS** (4-byte pointer in the union) but **16 B on x86-64** (8-byte pointer, 8-aligned). GE's gzip-1.2.4 inflate builds its Huffman tables contiguously into load_resource's fixed local `u8 buffer[0x2100]` (8448 B), sized for the 32-bit layout (~1056 entries); on x86-64 only ~528 entries fit, so a stream needing more (observed `rz_hufts` ≈ 797) writes past the buffer and clobbers load_resource's frame — including the return-address slot. The earlier "truncated-pointer table" reading was a misattribution of this same overflow (the 8-byte `{u32,0x1}` slots are huft records spilling over the frame). * Ruled out by code inspection: `mempAddEntryOfSizeToBank` (only touches pool pos/prevpos), `fileGetIndex` (reads only), `decompressdata` epilogue (returns rz_wp). * `load_resource` prologue: `push rbp/rdi/rsi/rbx; mov $0x2128,%eax; call ___chkstk_ms; sub %rax,%rsp; lea 0x80(%rsp),%rbp` — RBP is a **fake frame pointer** (RSP+0x80); the real return address is at [RBP+0x20E8]. The compiler reuses pushed-register slots as locals (verified: the saved- rbx slot [RBP+0x20D0] legitimately receives `buffer+12`). Watchpoints on RBP-relative offsets are therefore easy to misplace — compute from entry RSP instead. * Hardware watchpoint on the real retaddr slot (set at the first instruction, where [RSP] = retaddr): first write changes it from a valid .text return address to **`buffer+12`** (a pointer into load_resource's own local array), reported RIP at the prologue boundary (`push %rdi`) — i.e. either a concurrent write by another thread (hardware watchpoints are global) or an 8-byte-attributed access. A `thread apply all bt` at that moment showed every other game thread parked (RtlUserThreadStart / sleep syscalls; gdb could not unwind their stacks further), so the writer is not yet identified. **Resolution (fix in `port/`, game code untouched).** Mirrors the Perfect Dark port, which replaces its assembly rzip with a real-zlib C impl (`pd_port/src/lib/rzip_c.c`: `inflateInit2` + `inflate`). We exclude `src/game/decompress.c` + `src/game/zlib.c` from the PC build (CMake `list(REMOVE_ITEM SRC_GAME …)`) and add `port/src/rzdecomp.c`, which backs the two externally-referenced entry points with host zlib: * `decompressdata(src,dst,hlist)` → `inflateInit2(-15)` (raw deflate), `next_in = src+2` (GE's RZ = 2-byte header `0x11 0x72` + raw deflate, no size field), loop on `Z_OK`, return `total_out`. * `rzipGetSomething()` → returns the stored `next_in` (consumed input). A generated `port/include/realzlib.h` (from `realzlib.h.in`) `#include`s the **absolute** host `` path, because the game's `src/game/zlib.h` shadows `` on the `-I` path. **Verified:** rebuild links clean (238/238); the exe has the new `decompressdata`/`rzipGetSomething` and no `zlib_inflate`/`zlib_huft_build`. Under gdb, mainThread now boots **past langInit** (all language files load) all the way into `bossInitMainthreadData()` (boss.c:233) → `initWeaponAnimGroups` — i.e. the original first-file-load SIGSEGV is gone. The next blocker is D32. * **D32 — ROM-serialized structs with embedded pointers have divergent N64/x86-64 layout (OPEN).** After the D31 fix, mainThread reaches `bossInitMainthreadData()` and dies in `init_weapon_animation_groups_maybe()` (boss.c:233) → `initWeaponAnimGroups` → … → `modelAnimReadRootMotionValue` (model.c:914), faulting on `desc->bitCount` where `desc = anim->bitDescriptors`. **Mechanism.** N64 pointers are 32-bit; x86-64 pointers are 64-bit with 8-byte alignment. `struct ModelAnimation` (src/bondtypes.h:575) declares `ModelAnimBitField *bitDescriptors` and `u8 *bitStream`. In the N64 ROM layout these are 4-byte fields at 0x08 and 0x10; on x86-64 the compiler lays them out as 8-byte fields — `ptype /o` (gdb) shows bitDescriptors @0x08, a 4-byte hole, then bitStream @**0x18**, sizeof = **80** (vs 64 on N64). The animation data is loaded by `alloc_load_expand_ani_table()` (initanitable.c:263) via `romCopy(ptr_animation_table, &_animation_dataSegmentRomStart, size)` — raw N64-layout bytes. `expand_ani_table_entries()` rebases the **low 32 bits** of bitDescriptors/bitStream but (a) leaves the high 32 bits as adjacent-field junk and (b) writes bitStream's rebase to offset 0x10, which on x86-64 is *not* where the C struct reads bitStream (0x18). Observed under gdb: anim = 0x702adad4 (base 0x702ad8c0 + 0x214), `*(u32*)(anim+8)` = 0xc82bd8c0 → as a 64-bit pointer the high word is junk → SIGSEGV. **Scope.** Any ROM-serialized struct that (i) contains a pointer field and (ii) has other fields after it misreads the same way: each 64-bit pointer's high word holds adjacent-field bytes, and every post-pointer field shifts by the pointer-width delta. ModelAnimation is the first hit; bondtypes.h has ~13 struct blocks with pointer fields (fewer are actually romCopy'd from ROM). **PD ground truth.** PD keeps its ROM-data structs at N64 layout on both targets by storing embedded pointers as **u32** — e.g. `struct animtableentry { … u32 data; }` (pd_port/src/include/types.h:5124) — and casting to a real pointer at the use site. GE's decomp instead uses real pointers in ModelAnimation, so it diverges on x86-64. **Resolution — Option 1 chosen (user-approved), in progress.** The user approved the PD pattern after confirming PD does it (`animtableentry.u32 data`, raw-DMA'd then resolved at use sites). Note PD's *exact* mechanism (u32 ROM offset + on-demand DMA cache) differs from GE's rebased-DRAM-pointer ModelAnimation, so we adopt the **principle** (u32 embedded address, cast at use), not PD's code. This required refining non-negotiable #2 (see §H). **Part A — struct layout: DONE & proven.** Changed `ModelAnimation`'s two pointer fields to u32 (src/bondtypes.h:575): `u32 bitDescriptors` @0x08, `u32 bitStream` @0x10; added casts at the only use sites (model.c:913/921). `ptype /o` now shows sizeof = **64** with fields at the N64 offsets — layout matches. `expand_ani_table_entries()` writes s32 to 0x08/0x10 via its own `struct anim_entry`, which now aligns exactly (no change needed there). **Part B — pointer-rebase correctness: ROOT-CAUSED (see D33), fix designed, implementation pending.** The garbage values are *not* a bug in `expand_ani_table_entries`'s arithmetic — the rebase is correct N64 code fed wrong bytes, plus one genuine x86-64 ABI stride bug. Both root causes and the full fix design are in **D33** below. * **D33 — ROM file stores structured fields big-endian; animation load needs a per-field endianness fixup + an x86-64 loop-stride fix (root cause of D32 Part B).** **Discovery.** The `.z64` ROM file is not a plain image: multi-byte *structured* fields (record headers, tables) are stored in **big-endian byte order**, while bit-packed data streams are raw. Evidence chain: 1. `baserom.u.z64` is byte-for-byte identical to `data/ge007.ntsc-final.z64`; the ROM header is GE-specific (magic `80 37 12 40` from `src/rom_header.s`, BE u32 CRC at 0x10/0x14 written by `tools/n64cksum.c`) — not the standard N64 header layout. 2. Build pipeline: `$(LD)` → ELF → `objcopy -O binary --gap-fill=0xff` → RareZip cdata compression (`data_compress.sh`) → `n64cksum`. No byte-swap step; the mips64-elf-gcc toolchain (SGI-style BE default) emits BE bytes. 3. `tools/utils.h` documents the convention: `swap_bytes()` = "convert from v64 to z64 ordering", `reverse_endian()` = "convert from n64 to z64 ordering" — the project's ".z64" is a word-swapped cart image (community ".n64" format). Real cart = bswap32(file). 4. Animation-blob analysis: reading headers as BE u32/u16 yields self- consistent values (frame counts, bit widths 0–31, offsets that tile the blob exactly); LE reads yield garbage. **Per-field transform rule** (applied at load time to the `animation_data` segment): u32 fields → bswap32; u16 fields → bswap16; u8 fields → identity; bit-packed streams → identity. No single uniform word transform exists (mixed field widths). **Blob layout** (fully mapped; all 173 entries verified): each C array (`animation_table_ptrs1/2` in `src/game/initanitable.c`) is a sequence of 20-byte records at the `PTR_ANIM_*` offsets. Record fields: +0x00 address (entries-segment offset, u32), +0x04 frame count (u16), +0x06 angle bit width (u8, used by `modelAnimReadBitsAsU16Angle`), +0x07 loop flag (u8), +0x08 bitDescriptors blob offset (u32), +0x0C bitsPerFrame root motion (u16, used by `sub_GAME_7F06D2E4`: `scaled = unk0C * frame`), +0x0E frame size in bits (u16, used by `loadAnimationFrame`: `frameSize = unk0E >> 3`), +0x10 bitStream blob offset (u32). Tail (+0x14..+0x3C) is unused by any code — the effective record size is 20 bytes (= `struct anim_entry`, 5×s32). Interleave rule: entry *i*'s payload [descriptors][stream] sits at [PTR_ANIM_{i-1}+0x14, PTR_ANIM_i); all regions are disjoint; stream size = ceil(frameSizeBits × frames / 8) — exact fit on every entry (fire_standing: 544 bits × 106 frames → 252 bytes). Descriptor array = `ModelAnimBitField` {u16 bitOffset, u8 bitCount, u8 pad, u16 valueOffset} ×4 (6 bytes each); after the transform all 692 descriptor arrays pack bitOffsets sequentially from 0. Null entries use sentinel **1** (not 0) — `expand_ani_table_entries` skips `*var_v0 == 1`. **Validation.** Simulated the full transform in Python over all 173 entries: 0 bad descriptor ranges, 0 non-sequential arrays. Root-motion simulation for fire_standing frames 0–3 (widths 6/7/6/0, bitsPerFrame=19) gives smooth per-frame deltas (x = 7, 10, 12, 11; y ≈ 1086–1088) — proving the stream bytes are identity-correct (no transform). The previously observed garbage is reproduced exactly: file bytes `[00 00 01 58]` at fire_standing_fast's bd → LE read 0x58010000 + base 0x702ad8c0 = **0xC82BD8C0**, the exact value seen under gdb. The "zeros at odd indices" observation was a gdb display artifact — the compiled `.data` array is dense (verified by objdump of build-pc/ge007.x86_64.exe). **Second root cause: x86-64 stride bug in `expand_ani_table_entries`.** The loops iterate with `s32** var_v0; var_v0++` — +4 bytes/iter on N64, but `s32*` is 8 bytes on x86-64. Verified in the compiled binary (`add $0x8,%rdx`) : only even-indexed entries are rebased; odd entries keep raw small offsets (e.g. fire_standing, index 1, would stay 0x144 → instant SIGSEGV on use). This pattern occurs nowhere else in the compiled set. **Fix design (implementation pending — this is the immediate task):** 1. New port function `romdataFixupAnimationData(u8 *blob, u32 blobSize, const s32 *tableA, const s32 *tableB)` in `port/src/romdata.c` (+ decl in `port/include/romdata.h`): for each non-null (≠0, ≠1) offset in both tables — bswap the 20-byte record header per-field (u32: +0x00/+0x08/ +0x10; u16: +0x04/+0x0C/+0x0E; u8: +0x06/+0x07 untouched); then transform the descriptor array at [bd, bs) step 6 (bswap16 on words 0 and 4 only). Guards: skip if bd==0 && bs==0; require bd < bs ≤ blobSize and (bs−bd) % 6 == 0. 2. Call it in `alloc_load_expand_ani_table()` (initanitable.c ~line 265) **between** the `romCopy` and `expand_ani_table_entries` — one line + `#include "romdata.h"` (`port/include` is already on the include path). Mechanical ABI edit per non-negotiable #2; must run *before* expand (expand reads/writes the fields as LE after fixup). 3. Fix the stride in `expand_ani_table_entries()` (initanitable.c:233): iterate with `s32 *` instead of `s32**` — mechanical ABI fix, semantics- preserving on N64 where it was already correct. **Open item.** The entries segment (per-frame joint angles; file offset 0x124AC0, 0x169EC0 bytes) is left as identity for Phase 1.5 — not consumed at boot (only during rendering). Verify visually in Phase 2 when Bond first animates. **D34** `ANIM_DATA_*` address placeholders break on x86-64. On N64 the `animation_data` segment links at VMA 0, so `(s32)&ANIM_DATA_x` is a small segment offset; on PC `&ANIM_DATA_x` is a high PE address and the game code's ubiquitous s32 truncation yields garbage. Audit: all 82 uses are address-taking only (`(s32)&…`, or through `ANIM_FRAC()` / `ANIM_FRAC_MUL_FIRST()` in chraction.c/bondview2.c/title.c/ initBondDATAdefaults.c/initactorpropstuff.c) — no data dereferences, no sizeof, no static initializers. Fix: on x86-64 each `ANIM_DATA_x` is redefined as an lvalue at `g_pc_animdata_base + offset` where the base is the PE image address with its low 32 bits zeroed (derived in `romdataInit()` from a static probe; fail-fast if not 4 GiB-aligned), so `(s32)&ANIM_DATA_x == PTR_ANIM_x` exactly. Files: `assets/animationtable_data.h` (guarded macro branch, N64 externs kept under `#else`), `port/src/romdata.c` (`g_pc_animdata_base` + init), `CMakeLists.txt` (excludes `assets/animationtable_data.c` + `animationtable_entries.c` on PC — the arrays are address-only placeholders; the ROM is the data ground truth via romCopy). Verified under gdb: `D_80030984 = ANIM_FRAC(ANIM_DATA_walking)` executes; the walking record at `ptr_animation_table + 0x4018` has entry field `0x10177dcc` (entries base `0x10124AC0` + `PTR_ANIM_ENTRY_walking`) and frame count 0x25; after `initWeaponAnimGroups()` the derived globals hold sane floats (4.59/11.25/16.71/16.19). **D35** Music sequence table: pointer-width + endianness. `musicSeqPlayerInit()` faulted in `romCopy()` with a NULL destination: `tblSegmentSize = sizeof(RareALSeqData) * seqCount + 4` was garbage because (1) `RareALSeqData.address` was `u8 *` — sizeof 16 on x86-64 vs the ROM's 8-byte records — and (2) the header `seqCount` and entry fields are stored big-endian in ROM but read as LE (63 → 0x3F00 = 16128). Fix: `address` → `u32` (`src/music.h`; sizeof 8 on both platforms) with `(void *)` casts at the three use sites in `src/music.c`; new `romdataFixupMusicSeqTable()` in `port/src/romdata.c` bswaps `seqCount` + per-entry `address`(u32)/ `uncompressed_len`(u16)/`len`(u16), called after both romCopys in `musicSeqPlayerInit()`. Verified: the header-only (0x10-byte) copy logs the expected `seqCount 63 exceeds blob capacity 1` clamp; boot passes the old fault. **D36** Music heap / PERMANENT pool sizing on x86-64. `alCSPNew()` SIGSEGV'd because the music bump heap was oversubscribed: x86-64 libaudio runtime structs are larger (ALVoiceState 88B, ALSeqPlayer 248B, ALCSPlayer 224B, …) and the debug-token state (`tokenFind(1,"-level_")==NULL` → `g_DebugAndUpdateStageFlag=1` → stale `-ml0 -me0` tokens) forces the fixed-size mempool branch with PERMANENT at 296 KiB. Measured under gdb: pre-music PERMANENT usage 0x1BCA0 left 189,280 B for music vs a measured init demand of 0x31660 (shortfall 13,056). Fix (PC-only guards): `MUSIC_ALLOCATION_BYTES` 0x2E000 → 0x32000 (`src/music.c`); PERMANENT fixed branch 296/308 KiB → 320/340 KiB (`src/memp.c`, non-JP/JP). STAGE absorbs the difference. Verified: boot reaches `bossMainloop()`. **D37** libaudio bank trees must be re-laid out, not patched in place. The audio thread SIGSEGV'd in `__initFromBank()` (`b=0xffffffff00000000`): `ALBankFile/ALBank/ALInstrument/ALSound/ALWaveTable/…` are serialized in ROM as big-endian scalars + 4-byte packed table-relative offsets, while the x86-64 C structs use 8-byte pointers — so BE values were LE-misread and 4-byte offset arrays were walked with 8-byte stride, producing wild rebase pointers in `alBnkfNew()`/`_bnkfPatch*()`. In-place conversion is impossible: each struct's expanded tail overlaps whatever follows it in ROM (e.g. the ALWaveTable book slot at +24 lands inside the ALADPCMBook that sits 12 bytes after the wavetable). Fix: `romdataAudioBankPcSize()` + `romdataFixupAudioBank()` in `port/src/romdata.c` — a two-pass DFS re-layout into a compact image where every sub-struct is placed once (8-byte aligned, children before parents) and each pointer slot stores the sub-struct's NEW offset from the image start, zero-extended; `alBnkfNew()`/`_bnkfPatch*()` then rebase unmodified (`ptr + (s32)file`). `ALWaveTable.base` stays an offset into the separate wavetable data segment — `_bnkfPatchWaveTable()` adds its `table` argument, verified at runtime (sfx: base+table = 0x102F19A0 = `_sfxtblSegmentRomStart`). `music.c` PC branch allocates the re-layout size and fixups after romCopy. Memory: bank images grow +0x1460 (sfx) / +0xA70 (instruments); init-time heap demand measures 0x33530, so `MUSIC_ALLOCATION_BYTES` → 0x38000 and PERMANENT → 352/368 KiB (PC only). Verified under gdb: runtime tree matches ROM ground truth (SFX instCount=1, soundCount=261, bendRange=200, sampleRate=22050; INSTR instCount=75; all env/keymap/wavetable/book/loop pointers valid heap addresses; book order=2 npredictors=1); 25 s soak with every thread alive. Implementation bugs caught during bring-up: a double bswap (afRd* already decodes BE), afWr32 initially wrote BE, and the size pass used an uninitialized visited array (undercount) — all fixed. **D38** Implicit function declarations truncate pointer returns on x86-64. ~72 game TUs call ~400 functions with no visible prototype (missing `#include` of the declaring header). Under C11 an implicit declaration assumes `int f()`: harmless on N64 (32-bit pointers) but on x86-64 it silently truncates every pointer return to 32 bits — e.g. `tokenFind()` in `set_mt_tex_alloc()` returned a low-32-bit "pointer" that faulted in `strtol()`. Fix: a port-layer prototype shim, `port/include/pc_protos.h` (398 declarations; one-shot generator `scripts/gen_pcprotos.py` — the committed header is the source of truth, hand-adjusted after generation), declaring each function with its TRUE return type and an empty parameter list — purely additive (only the returned value's width changes). Anchored in `port/shim/PR/ucode.h`, the LAST include of ``: anchoring earlier (e.g. `gbi.h`) poisons libaudio.h because the bondtypes chain reaches `snd.h → ` while ultra64.h is still mid-parse. C-only guard: C++ fast3d TUs reach this header via `SDL_stdinc.h → → port/shim/stdarg.h`, and pulling the bondtypes/bondconstants chain into C++ brakes on `struct ALSoundState*` in `src/bondtypes.h`; no C++ TU needs a fix here. C11 gotcha: an empty parameter name list `()` cannot match a true prototype with default-promoted parameters (`s8`/`u8`/`s16`/…), so the 11 such functions get full prototypes with their TRUE parameter types (substituting `int` for `s8` is NOT compatible). Also: `src/bondconstants.h` defines function-like macros `ntohl`/`ntohs` over `CharArrayTo32/16`, which collide with MinGW `` in TUs that parse both — neutralized in the port shim, real host functions provided in `port/src/pc_netorder.c` with winsock-compatible signatures (`u_long` is 64-bit on LLP64!). No game-TU edits. Result: build clean, zero warnings; the old `strtol()` crash is gone. **D39** Globalimagetable rebasing idiom breaks on x86-64. `texReset()` / `texLoadFromDisplayList()` compute ROM-layout pointers as `globalbank_rdram_offset + (u32)&sym` with `globalbank_rdram_offset = (u32)pGlobalimagetable + 0xFE000000`; on N64 `(u32)&sym` = `0x02000000 + off` and the 0x02 base cancels the 0xFE. On PC `(u32)&sym` is a truncated PE address → garbage DL pointers. Fix (in `src/game/image_bank.c`, PC branch): an enum of all 49 segment offsets (17 Gfx display lists + 32 sImageTableEntry tables) and `#define GIMG_OFF(sym) (0x02000000u + g_pc_gimg_off_##sym)` — exactly the N64 `(u32)&sym`; all 49 texReset() sites replaced. No changes needed in gunfire.c / texLoad / texSelect: all game RAM is s32-safe (V1 view, 0x70xxxxxx < 4 GiB), so the u32 math and pointer casts stay lossless. Also fixed the segment markers: the CSV asset `Globalimagetable.bin` (0xAC8) is TRUNCATED — the real linker segment (`ge007.ld`: oddtextures.o `.data`) spans 0x13F8 = Gfx DLs (0xAC8) + ITE tables (0x930); the CSV's `rarewarelogo` entry is mis-split (the logo actually starts at ROM 0x29E560). `_GlobalimagetableSegment{Rom}End` 0x1029DC28 → 0x1029E558 in `port/src/romassets_u.s` + a size override in `scripts/gen_romassets.py` (regeneration verified stable). All 49 offsets verified against the ROM: the symbols tile `[0x29D160, 0x29E558)` exactly with no gaps; `gun.c` ammo IconImage values cross-check. Verified under gdb: all 17 texLoadFromDisplayList diffs correct (0, 0x78, 0x120, …); boot passes texReset. Known Phase-2 follow-up: `explosion.c`'s `g_ExplosionDisplayLists[]` is a static table of EXE addresses to Gfx arrays (16-byte entries on PC) — it must point at the ROM-layout copies in pGlobalimagetable before explosions render. **D40** N64-sized BSS placeholder pool overflows with pointer-stride structs. `initModelHitEntryFreeList()` (initunk_005450.c:38, called from lvlStageLoad) walks 600 ModelHitEntry records writing next/prev; the decompiler-emitted BSS chain in objecthandler.c (`char g_ModelHitEntries[0xC];` …dwords… `char g_ModelHitEntriesPenultimate[0x28]`) reproduces N64's 12 000-byte region (600 × 20 B), but PC ModelHitEntry is 40 bytes → the free-list init writes 24 000 bytes, a 12 KB .bss overflow that clobbered `is_ramrom_flag` (writer caught with a hardware watchpoint) → the demo-replay path in bossMainloop ran with `address_demo_loaded == NULL` → SIGSEGV at ramromreplay.c:341. Fix: PC branch declares a properly sized pool (`char g_ModelHitEntries[600 * sizeof(ModelHitEntry)]`); the N64 chain stays verbatim under `#else`; the sentinel assignment in initunk_005450.c is computed directly on PC (`entries[LEN-1].prev = &entries[LEN-2]` — N64's g_ModelHitEntriesPenultimate labels the start of entry 598). Verified: no more SIGSEGV; boot progresses into GL rendering. **D41** Cross-thread GL context binding. WGL allows a context to be current on only ONE thread at a time. The window+context are created and made current on the host main thread (`videoInit → gfx_sdl_init`), but all rendering runs on the game's scheduler thread; while main still holds the context, `wglMakeContextCurrent` from the game thread fails with "The requested resource is in use" — and this SDL2 build (2.32.10, MSYS2) swallows it: `SDL_GL_MakeCurrent` returns true on failure / 0 on success (an inverted int-as-bool ABI artifact; confirmed by a minimal repro and gdb `$1 = 255`). The silent failure left `wglGetCurrentContext() == NULL` on the game thread → `glCreateShader` returned 0 → "Vertex shader compilation failed" with an empty info log. Fix: new `gfx_sdl_release_context()` (`SDL_GL_MakeCurrent(NULL, NULL)`) called at the end of `videoInit()` after `set_swap_interval` (the last GL work on main); the scheduler thread re-binds per frame via the existing gfx_sdl_make_context_current() in videoStartFrame(). Do NOT branch on the MakeCurrent return value (unreliable). Verified: frame 1 renders (69.7 ms), several frames before the next (separate) fault. **D42** rsp.c task-settings toggle idiom truncates pointers. `g_gfxTaskSettingsList = (GfxInfo_s*)((u32)list ^ (u32)&g_gfxTaskSettings[0] ^ (u32)&g_gfxTaskSettings[1])` toggles between two adjacent settings structs by XOR — fine on 32-bit, but on x86-64 the u32-truncated PE addresses XOR to garbage → the next frame's `((GfxInfo_s*)g_gfxTaskSettingsList)->cfb = …` (fr.c:458) SIGSEGV'd. Fix: explicit toggle under `#if defined(__x86_64__)` (`list == &[0] ? &[1] : &[0]` — same semantics, the list is always one of the two); N64 line verbatim under `#else`. **D43 (OPEN)** Model-file loading ABI mismatch. Stage object load faults in `modelPromoteNodeOffsetsToPointers()` (model.c:5688). Model files are ROM-serialized with N64 layout (ModelFileHeader 24 B, ModelNode 20 B, 4-byte pointer fields) but on PC are read as 8-byte-pointer structs (48 B each): `load_object_fill_header()` derives RootNode from `numtextures` at the wrong offset, and the `PROMOTE` rebase (`(u32)var + diff`) then operates on misaligned fields. Needs a D37-style re-layout of the model file image (header + switches array + texture table + node tree + ModelRoData records). Phase-2 project; see §H. Reference implementation: PD port's `port/src/preprocess/filemodel.c` — same PROMOTE idiom, same vma 0x5000000 (§2.4); adapt + per-field validation, not a drop-in copy. **D44 (closed 2026-08-22)** Crash-handler Phase 2 backtrace self-faulted before printing. `crashStackTraceSym()` in `port/src/crash.c` calls `GetCurrentThreadStackLimits(low, high)` passing the NULL pointer *values* rather than `&low, &high` (Phase 1's `crashStackTraceRaw` passes them correctly). The API writes the stack limits to address 0x0 → access violation *inside the SEH filter* → Windows terminates the process without a second dispatch. Symptom: `ge007.crash.log` contains only Phase 1 (EXCEPTION/PC/ MxCsr/thread-stack/MODULE lines) and no `BACKTRACE:` section — verified twice live; this is why every D3x fault so far cost a full gdb launch-mode session. Fix: one line, `GetCurrentThreadStackLimits(&low, &high)` — applied. The build already compiles with `-fno-omit-frame-pointer` (CMakeLists.txt:193), so every crash auto-logs an EBP-chain backtrace to console + ge007.crash.log, symbolicable offline with addr2line. **Verified live:** the D43 run now logs `BACKTRACE:` with #00 = `modelPromoteNodeOffsetsToPointers` (model.c:5688) and #01 = `load_object_fill_header` (objecthandler_2.c:110) — the documented chain, no gdb needed. Note: frames beyond the true call chain can be stale stack data (the walk validates fp against stack bounds but not ret_addr against .text; one such frame observed at #02). addr2line takes the full absolute address directly (`addr2line -e build-pc/ge007.x86_64.exe -f -C 0x14007a31a`); image base is still 0x140000000 (re-verified post-rebuild via nm). **D45 (OPEN — sizing for D43)** Model-file buffers must grow for PC: the GDL region doubles (8 B Gfx slots → 16 B) and texture-marker expansion in texLoadFromGdl() emits full RDP setup sequences whose size is data-driven. Per-file worst-case final size P_final = B_pc + 2×(D_n64−g1) + 16×Σ_markers (K_t−1), where K_t is the exact worst-case command count per marker type t (tex.c helpers, maxlod≤8, all texTry* state guards emitting, valid=FALSE — the realistic branch since D_800483C4 is a gunfire texture index): type0/LOD 37, type1/DETAIL 46, type2/MIPMAP 36, type3/TILE 18, type4/TILE_PRESWAP 15 (preamble = PipeSync + gSPTexture ≤ 2; water check ≤ 1). Marker type = w0&7 of the 0xC0-top-byte slot. Verified over all 512 model files (build-pc/d43_sizes.py). Texture *pixel* data is unaffected by Gfx width, so texpool sizes can stay at their N64 values — only the model-file regions grow. Required buffer edits (all D40-class ABI-forced size constants, semantics unchanged): gun.c:106 size_item_buffer 0x14820→**0x23000** (bondview body+head+held-prop chain worst 0x1DB9A; suit pool 0xA0B0 + R 0x18000 = 0x220B0); gun.c:109 D_80032464 0x7530→**0xF000** (GautoshotZ 0xE788); gun.c ITEM_SUIT_LF_HAND R 0xBD70→**0x18000** (Csuit_lf_handZ 0x16F9C) with pool expr size−0x18000; gun.c ITEM_TRIGGER/ITEM_WATCHLASER R 0xAFD0→**0x17000** (GtriggerZ 0x16030); front.c load_walletbond R 0xA000→ **0x17000** (PwalletbondZ 0x1664C; stays below the +0x28000 DL region); front.c cast screen bufferRemaining 0x18160→**0x1C000** (cast chain worst 0x19CA0 — zbuf at 0x19000+region must not be clobbered); initmenus.c:34 logo buffer 0x78000→**0x7C000** (texpool 0x19000 + region 0x1C000 + zbuf 440×330×2=0x46B80). Unchanged: title/gunbarrel chain (0x16DF0 ≤ 0x23A00), front.c logos (≤0x9BCC ≤ 0x3C000), dst=0 fileLoad path (allocates ALL remaining STAGE space and gives the tail back via mempAddEntryOfSizeToBank — stage props/NPC bodies are bounded only by live STAGE headroom, ~2.3 MB). The converter will also assert P_final ≤ R at load time with a clear error instead of overflowing. **D46 CORRECTION:** the cast-screen values above are superseded — front.c bufferRemaining is **0x25000** (not 0x1C000) and the initmenus.c logo buffer is **0x85000** (not 0x7C000); see D46. **D46 (RESOLVED — overlap safety + final buffer sizing for D43)** The GDL expansion in sub_GAME_7F0762E0 is in-place: output starts at the first-GDL offset and grows rightward; input is read from the mirror copy texCopyGdls() made at the region tail ([A+R−P_conv+g, A+R)). The initial gap is R−P_conv and the cumulative output-minus-input excess is ≤ 16·Σ(K_t−1) over markers processed so far (monotone; 0xba "texture already set" skips only reduce it). Since Gfx is 16 B for BOTH input and output on PC, the no-clobber condition **R ≥ P_conv + 16·M_actual** is identical to the fit condition — one constraint, not two. (The N64 game satisfies the same identity with 8-B slots.) The converter therefore sets `poolRemaining = P_conv = B_pc + 2×(D−g1)` EXACTLY (the pre-expansion image; markers are expanded at runtime, consuming [P_conv, R)). **Strict bound from N64 ground truth.** The N64 game works, so per file 8·M_a ≤ R_share_N64 − B_n64 − E. PC slots are 2× wide with the same M_a (tex.c logic + texture data identical), hence **P_final_actual(PC) ≤ B_pc + 2×(R_share_N64 − B_n64)** (E cancels). N64 R_share values (game code): suit 0xBD70, trigger/watchlaser 0xAFD0, wallet 0xA000, weapons 0x7530, bondview chain 0x14820 (size_item_buffer), cast chain 0x18160 (front.c bufferRemaining). For chains ΣR_share = bufferRemaining EXACTLY: each subsequent load's R is the previous file's post-load poolRemaining via `get_pc_buffer_remaining_value()` (front.c:7809-7825), so the shares tile the N64 budget with no margin assumption beyond "N64 works". **tex.c expansion state machine** (for reference / any future simulation): `sub_GAME_7F0CC4C8()` resets g_TexTileStates[8], g_TexTileSizes[8] and g_TexLutMode at the START OF EVERY texLoadFromGdl call → texTry*/texSetLutMode dedup only works WITHIN one GDL. Per-marker slot counts (lutmodeindex=0): type0/LOD ≤ 2 + 6 (LoadToTmemAddr: SETTIMG+SETTILE≤1+LoadSync+LoadBlock+PipeSync) + 4 (TileFromDef: PrimColor+LUT≤1+SETTILE≤1+SETTILESIZE≤1) + 3×min(maxlod,7) (TileLods basetile=1) + 3 (CycleType/TxLOD/Detail); type2/MIPMAP ≤ 2 + 6 + 3×(maxlod + [maxlod==1]); type1 ≤ 2 + 6 (Zero) + 1 (TileSync) + 6 + 4 + 3×min(maxlod,7) + 3; type3 ≤ 2+6+4+4; type4 ≤ 2+6+4. With LUT formats the LoadToTmem* helpers double to ≤12 — see next item. **Texture scan (build-pc/d43_lutscan.py), all 512 files:** 1071 valid texture-table refs (TID < MAX_TEXTURES=3001; the rest are skipped by texLoadFromModelFileHeader, and markers whose texnum was never loaded get tex==NULL → PipeSync only). Image headers parsed via imagelist.u.csv (order = assets/images.def order): **NO LUT textures** (formats 9-12) exist in any model file — only format 0 (×1069) and 8 (×2) — so lutmodeindex=0 universally and the ≤6-slot LoadToTmem bound holds. maxlod distribution {0: 1063, 6: 7, 7: 1} → texWriteTileLods emits NOTHING for 99% of markers. The D45 K_BOUND table {0:38, 1:46, 2:36, 3:18, 4:15} is verified a true worst-case bound under these conditions. **Strict-bound results (build-pc/d43_chainbound.py):** suit_lf_hand 0x1002C ≤ 0x18000 ✓; trigger/watchlaser 0xED20 ≤ 0x17000 ✓; walletbond 0x105BC ≤ 0x17000 ✓; worst weapon GmapZ 0xE91C ≤ 0xF000 ✓; bondview chain (worst body spicebond + headbrosnan + autoshot) 0x1CE5C ≤ 0x23000 ✓; **cast chain: rifle 0x240DC / pistol 0x23F24 > 0x1C000 — SHORT by ~0x8000** (worst: body spicebond B_pc=0x8A14/B_n64=0x7D60 + head headbrosnan 0x1D90/0x1D50 + rifle autoshot 0x4828/0x3E28 / pistol wppksil 0x45B0/0x3DC8). The D45 worst-K estimate (0x19CA0) was below the strict bound because it assumed Rare's N64 buffer had zero margin. **Resolution (two one-line constant changes, both already PORT-guarded):** front.c init_menu18_displaycast bufferRemaining 0x1C000 → **0x25000** (covers 0x240DC); initmenus.c logo buffer 0x7C000 → **0x85000** (texpool 0x19000 + region 0x25000 + zbuf @ALIGN64(0x3E000) size 440×330×2=0x46B80 → 0x84B80, rounded). MEMPOOL_STAGE headroom is ample (total stage usage at menu init ≈ 0xD0040 vs ~2.3 MB pool). The converter should additionally assert `P_conv + 16×Σ_markers(K_t−1) ≤ R` at load with a clear error (K_BOUND table; provably ≥ actual expansion). **D47 (RESOLVED — D43 converter contract finalized; capstone endianness fix)** Session that closed every open question on the model-file converter. 1. **Capstone endianness bug (environment gotcha).** All earlier-session disassembly of the ROM used capstone's default LITTLE-endian mode against a big-endian MIPS image → garbage register flow. MUST use `CS_MODE_MIPS32 | CS_MODE_BIG_ENDIAN`. Compaction + accessors were re-disassembled cleanly; where BE disassembly is still ambiguous, the byte-matched C in `src/` is ground truth. 2. **Compaction state values (BE disasm of 0x7F0762E0 + byte-matched C).** P = entry.poolRemaining, R = entry.rom_remaining, **delta = R − P** (not P−R); `texCopyGdls(F+G, F+R−P+G, P−G bytes)`; per-GDL count = off_{i+1}−off_i bytes (or P−off_last); final fileSetSize = ((rep&0xFFFFFF)+0xf)&~0xf. Both accessors (0x7F0BD11C / 0x7F0BD100) compute `0x80090000 + idx*20` → **resource_lookup_data_array actually lives at 0x80090000**; the ob.c comment (0x800888B0) is stale. 3. **memp allocator (src/memp.c).** Bump allocator; `mempAddEntryOfSizeToBank` rewinds `pool->pos` for the most recent allocation → `fileSetSize(reallocate=1)` returns the post-compaction tail to the bank. `load_resource` decompresses INTO ptrdata (=F), reading the compressed source from the block's TAIL. Fresh dst==0 load: P := S_bank (all remaining stage bank) → F := alloc(S_bank) → R := S_bank → load_resource sets P := D_N64. 4. **Reload hazard + PC decision.** `fileIndexLoadToBank` takes S_bank only when poolRemaining==0; fileSetSize leaves P=R=post-compaction size, so a reload within one stage would alloc(P_old) which can be < D_N64 (source pointer before the block; decompress past it — latent on N64 too). PC fix: PORT-guarded reset of `poolRemaining = 0` immediately before `_fileNameLoadToBank` in load_object_fill_header → every load gets fresh- bank semantics: staging space = whole remaining bank, steady-state bank usage identical (fileSetSize rewinds to the same place). 5. **G_VTX w0 encoding (ROM-data proven).** GBI1/gDma1p style: w0 = 04<<24 | dst<<16 | (16·n), dst = ((n−1)<<4)|v0. All 2826 model-file instances have v0=0, n≤16 (batches of ≤16 verts — matches G_TRI4's 4-bit indices). fast3d's `gfx_sp_vertex(C0(0,16)/sizeof(Vtx), C0(16,4), …)` is correct AS-IS; the converter bswaps w0 only (no field remap). The F3DEX_GBI_2 `gsSPVertex` block in GE's gbi.h is a red herring — the asset toolchain emits GBI1 style. 6. **G_TRI4 = standard 4-bit indices.** The "out-of-range seg-5 w1" values found by scanning are G_TRI4 index data, not addresses (w1 bits 20-31 hold the last two 4-bit indices). **Opcode-aware remap rule: only {G_VTX=0x04, G_SETTIMG=0xFD, G_LOADBLOCK=0xF3} carry addresses in w1.** Never remap TRI4/TRI1/TEXTURE/SETOTHERMODE/CLEARGEO/SETGEO/syncs. 7. **Render-time segment bindings (model.c).** seg5 (COL1) = BaseAddr = F on every model path; seg4 (VTX) = the record's Vertices base (or a runtime buffer in dorottex). Hence G_VTX seg4 w1 = displacement from that array → NO remap needed (array order preserved); G_VTX seg5 w1 = absolute file offset — 2804/2805 verified to land inside a vertex array at vo+d with d%16==0 → remap via the unified region map. 8. **Vertex format (all 512 files).** Normal: bswap s16 x,y,z,index,s,t; bytes @C-F raw (rgba/normal are single bytes). Collision (34,580 verts): bswap s16 x,y,z,index; LinkedTo u32@8 is ALWAYS 0 or 0x05xxxxxx (node vma); CollisionRelatedIndex s16@C (range −1..113) + reserved s16@E bswapped. 9. **No embedded texture blobs in any model file** — texconfig TextureID seg-5 count = 0 across all 512 (the earlier "3 title files" note was the vestigial logo SETTIMG refs already covered by d43_cover.py). No blob handling needed in the model converter. 10. **Zero-count vertex arrays.** PexplosionbitZ is the ONLY file with nv=0 and non-null Vertices (0x98); its GDL uploads 16 verts from there via absolute seg-5 ref. Rule: emit the array sized up to the next object offset (→ [0x98,0x198) = exactly 256B here). 11. **GDL tight packing is safe.** fast3d `case G_ENDDL: return` — trailing junk after ENDDL is never executed at render time. Spans: 2064 tight, 238 with trailing bytes (1904B total). Converter emits up to and including ENDDL; each PC span = exactly 16·slots → compaction counts are exact. 12. **Reference validator: `build-pc/d43_convert.py`** implements the full conversion spec (DFS walk with LOD/SWITCH rewiring, region map, per-opcode record conversion, remap checks) and runs it on all 512 files: **ALL CLEAN** — every pointer remap resolves, layout invariants hold, max D_PC = 0xB7E0, max D_PC/D_N64 ratio = 1.31 → staging headroom is trivially satisfied vs the ~2.3 MB stage bank. This script IS the spec for the C implementation. 13. **Final converter contract.** Emit `[switches NS×8B LE][texconfigs NT×12B (bswap TextureID; 0x05xxxxxx would remap — never occurs)]` → nodes+records in DFS preorder (PC layout via struct assignment; every promoted pointer field emitted as zero-extended u64 of 0x05|new_off; Primary/Secondary GDL ptrs remapped but NOT "promoted"; BaseAddr emitted 0) → vertex arrays immediately after their record (PointUsage = 2×numVertices s16 after op24 CollisionVerts) → **GDLs LAST, contiguous, visit order, 16B LE slots**: w0'=bswap32(w0); w1' = bswap32(raw) EXCEPT for seg-5 of {0x04,0xFD,0xF3} → remap low24 via the region map; **no LSB set** (ROM convention; fast3d's extended seg_addr case handles unmarked segmented addresses). Stage at F+R−D_PC, memmove to F. Set `poolRemaining = D_PC` exactly (never touch rom_remaining). 14. **N64 record field offsets** come from the bondtypes.h comments (which preserve N64 offsets); all 14 opcodes present in ROM files — 1,2,4,8,9,10, 12,13,15,18,21,22,23,24 — verified against RSZ sizes. PC layout: assign through the real structs (compiler packs); don't hand-compute. 15. **PointUsage** = 2×numVertices s16 entries, indexed by MAIN-vertex index, chain terminated by −1 (chr.c:3309-3331). **D48 (REVIEW — D43 re-plan: offline pre-conversion "Plan B"; process fix)** Independent review session that audited the D47 handoff against code + ROM before implementation. Verdict: the remaining task was mis-scoped as a 1:1 C port of a script that does not emit bytes; a cheaper, lower-risk path exists that reuses the existing ROM load chain. Plan B is now the default; Plan A (D47.13 C converter) is the fallback. 1. **`d43_convert.py` does not emit bytes.** It computes the layout/region map and validates that every pointer remap resolves (512/512 clean, re-run and confirmed this session), but there is no emission pass in ANY language. The byte-level contract (PC struct assignment, per-field bswaps, GDL slot writing) is unimplemented and unvalidated. "Port d43_convert.py 1:1 to C" would have the next session write ~250-300 lines of new emission code in C and debug it at runtime (crash → backtrace cycles). The emission must be written once either way — do it in Python where iteration is seconds, validate offline, then ship the data. 2. **Plan B: offline pre-conversion through the existing ROM load path.** Generate 512 PC-layout RZ sidecar files with a Python emit pass; serve them by patching `file_resource_table[i].hw_address` + `resource_lookup_data_array[i].rom_size` from the port layer. Verified mechanical facts (all re-checkable in <1 h): - **RZ format is trivially reproducible**: 2-byte header (`0x11 0x72`) + raw deflate; `decompressdata()` (port/src/rzdecomp.c) skips the header and inflates with a generous avail_in bound. A sidecar `[0x11 0x72][raw-deflate(PC image)]` works through `load_resource` UNMODIFIED — and it sets `poolRemaining = D_PC` automatically (the decompressed size), so no fixup call is needed at all. - **`romCopy` on PC is a host memcpy**: src/ramrom.c → `osPiStartDma` → port shim `piServiceDma` (port/src/libultra.c), gated only by `romdataCartAddrValid()` (port/src/romdata.c:212). The ROM is VirtualAlloc'd at CART_BASE 0x10000000 sized romSize — extend the reservation by the sidecar total, place sidecars at [CART_BASE+romSize, …), extend the validity check. - **`file_resource_table` is a plain writable global** (included in src/game/ob.c:22). ALL 512 model loads funnel through `load_object_fill_header` (dst==0 → `_fileNameLoadToBank`; custom-buffer callers in front.c/gun.c/bondview2.c/initmenus.c → `_fileNameLoadToAddr`) — both read `hw_address` via `load_resource`. No game code dereferences C*/G*/P*Z symbols directly outside the table (verified by grep; symbols exist only in the table + romassets_.s markers). - **The patch must run AFTER `obInit()`** (called at src/boss.c:179): obInit computes `rom_size` from adjacent-entry hw_address DELTAS (ob.c:122), so a pre-obInit patch would corrupt rom_size. Lazy one-shot at the PORT hook site in load_object_fill_header is simplest — by first model load, obInit has definitely run. - **Footprint**: 512 files = 1,277,088 B compressed total (1.2 MB), 3,289,344 B decompressed N64 total; PC decompressed ≤ 1.31× per file (D47.12). - **The indy path is NOT used** (`resource_load_from_indy`, ob.c:56): it is the N64 host-protocol loader gated by `indy_ready` (src/game/indy_comms.c, dormant on PC), and its pPayload placement underflows on reload when poolRemaining == pc_size exactly. Table patching reuses the proven ROM path. - **What Plan B eliminates**: the C converter (~300 lines), two-pass staging + memmove, staging-space guard, and the "emission bug only visible at runtime" risk class. What it keeps (all already committed): ABI edits, fast3d seg-5 case, D46 buffer sizing — plus the one-line poolRemaining=0 reset (item 3). - **Sidecars are region-specific** (derived from the region ROM); generator must take the region and write `data/pcmodels-/`. 3. **The poolRemaining=0 reset is STILL needed under Plan B.** fileSetSize leaves P=R=post-compaction size (ob.c:346-347); a same-stage reload would then alloc S_bank' = P_old, which can be < round8(compressed)+8 → `load_resource` hits the `source − ptrdata < 8` branch → poolRemaining=0 → silent load failure. The D47.4 reset before `_fileNameLoadToBank` covers both plans. 4. **Plan A flaw (if runtime conversion is kept):** D47.13's staging guard "fail if D_PC > avail" is too weak — the staging region [F+R−D_PC, F+R) overlaps the live N64 image [F, F+D_N64) whenever S_bank < D_N64 + D_PC, and emission would then read corrupted bytes. Correct guard: `D_N64 + D_PC ≤ avail`. Low probability in practice (files load at stage start; ~2.3 MB bank vs ~82 KB worst case), but the stated guard silently misses it. 5. **Process fix:** the 23 d43_*.py investigation scripts (incl. the reference converter) lived in gitignored `build-pc/` — moved to tracked `tools_pc/` and committed, so the spec is versioned and reviewable. d43_cover.py's one internal path reference updated; converter re-run from new location: ALL CLEAN. 6. **Review checklist for the next session** (confirm each item against code + ROM before executing Plan B; record results as D49): see docs/HANDOFF.md §Task 1 — eight claims, each with a falsification criterion. If any fires, fallback to Plan A with the corrected guard from item 4. **D49 (REVIEW — Plan B verification: D48 checklist R1–R8 all CONFIRMED)** Independent review session executed the D48.6 checklist against code + ROM (NTSC `data/ge007.ntsc-final.z64`). **Verdict: 8/8 confirmed — Plan B is cleared for execution (HANDOFF Task 2); no fallback to Plan A.** Per item: 1. **R1 RZ format — CONFIRMED.** `decompressdata()` (port/src/rzdecomp.c) skips the 2-byte header and raw-deflates (`inflateInit2(-15)`, avail_in ≤ 0x400000). All 512 model files (C*/G*/P*Z in `assets/obseg/file_resource_table.inc.c`; table↔`scripts/filelist.u.csv` set identity exact, 512=512) start `0x11 0x72` and inflate cleanly. Σ round8(compressed) = **1,277,088 B — exactly D48's claimed number**; max single-file compressed 0x421E; max decompressed/compressed ratio 4.75. 2. **R2 romCopy is a host memcpy with no 0x10C00000 limit — CONFIRMED.** `romCopy` → `doRomCopy` → `osInvalDCache` (no-op shim) + `osPiStartDma` → `piServiceDma` (port/src/libultra.c): the only gate is `romdataCartAddrValid()` (port/src/romdata.c:212), then plain memcpy. The ROM is VirtualAlloc'd at CART_BASE 0x10000000 sized exactly romSize (romdata.c:156-159); nothing in the port layer bounds the region, so placing sidecars at [CART_BASE+romSize, …) needs only a reservation-size extension + validity-check extension in romdataInit. 3. **R3 the table is the single chokepoint — CONFIRMED.** All 512 model loads funnel through `load_object_fill_header` (src/game/objecthandler_2.c:89) → `_fileNameLoadToBank`/`_FileNameLoadToAddr` → `fileIndexLoad*` → `load_resource`, which read `hw_address`/`rom_size` from `file_resource_table` + `resource_lookup_data_array`. The C*/G*/P*Z symbols are `.set` markers in `port/src/romassets_.s` (gen'd by `scripts/gen_romassets.py`); no game code dereferences them outside the table (grep: all hits are string literals/comments). Callers audited: bondview2.c, chr_b.c, ejectedcartridges.c, front.c, gun.c, loadobjectmodel.c. 4. **R4 obInit runs once, before any model load — CONFIRMED.** `obInit()` is called exactly once at src/boss.c:179 inside one-shot `bossInitMainthreadData()` (before the infinite main loop); it computes `rom_size` from adjacent-entry `hw_address` DELTAS (ob.c:122). `rom_size`/ `hw_address` are touched only in ob.c. The D43 crash stack was post-obInit. A lazy one-shot table patch at the port hook site in `load_object_fill_header` is therefore safe. 5. **R5 buffers fit — CONFIRMED (quantified).** Fresh dst==0 loads: max round8(compressed)+8 = 0x4228 ≪ STAGE bank (max 0x24C400 = poolArea 0x2A4400 − me 352 KiB NTSC; ≥ ~1.3 MB even after conservative pre-model stage usage — BG stan ≤ 0xA3E0 per stage). Worst-case post-compaction size across all 512 files (D_PC + 16·Σ(K_t−1) with K_BOUND {0:38,1:46,2:36,3:18,4:15}) = **0x16F74** (Csuit_lf_handZ) — 25× headroom vs a fresh bank. dst!=0 callers: every buffer ≥ 0xF000 > 0x4228; `tools_pc/d43_chainbound.py` re-run against the CURRENT constants all pass: suit 0x1002C≤0x18000, trigger/watchlaser 0xED20≤0x17000, wallet 0x105BC≤0x17000, worst weapon GmapZ 0xE91C≤0xF000, bondview chain 0x1CE5C≤0x23000, cast rifle/pistol 0x240DC/0x23F24 ≤ 0x25000 (front.c:7795), logo 0x85000 (initmenus.c:38). `poolRemaining := D_PC` is automatic (`load_resource` ← `decompressdata` return, ob.c:61). 6. **R6 poolRemaining hazard + reset — CONFIRMED.** Mechanism verified in current code: `fileIndexLoadToBank` (ob.c:219-247) allocates `poolRemaining` when non-zero (the post-compaction P left by fileSetSize), and `load_resource` (ob.c:49-53) needs round8(rom_size)+8 ≤ bytes or it sets poolRemaining=0 → **silent load failure**. The data CAN trigger it (e.g. PlegalpageZ: round8(C)+8 = 0xFC8 > D_PC = 0x5F0; its own path is a custom buffer so it is safe, but the condition exists for any file reloaded in the same stage). Hazard window = same-stage reloads only: poolRemaining is zeroed at stage entry/exit (boss.c:415-417 / 639-641), and all consumers of `get_pc_remaining_buffer_for_index` / `get_pc_buffer_remaining_value` run post-load+compaction; the PROMOTE walk (sub_GAME_7F075A90) does not touch lookup data. The addr path is unaffected (bytes = caller's fixed buffer). The one-line poolRemaining=0 reset before `_fileNameLoadToBank`/ `_FileNameLoadToAddr` in `load_object_fill_header` is **pending** (not yet in tree) and closes the hazard for both plans. 7. **R7 per-region sidecars + directory matching — CONFIRMED (with notes).** Region is build-time-fixed: `build-pc.sh` ROMID → CMake ASSET_REGION {u,e,j} → `romassets_.s`; the loaded ROM token (`ge007..z64` / `baserom..z64`) uniquely determines the region and romHeaderValid enforces the country byte, so romdataInit can derive `data/pcmodels-/` with the same token logic (baserom.u → ntsc-final, baserom.e → pal-final). Name sets are NOT identical across regions (filelist: u=512, **e=465**, j=512) — the generator must map whatever files exist, and the patch loop only patches names present in the manifest. Notes: (a) romdataInit has **no JP candidate** (GE007_IS_PAL comes from versioninfo.h.in) — PC cannot boot a JP ROM today, so JP sidecars are moot until that is added; (b) missing sidecar directory → warn + continue in ROM-only mode (Task 2 step 2). 8. **R8 emission-spec completeness — CONFIRMED (with one explicitness gap).** Walked every `ModelRoData_*Record` for the 14 opcodes present in ROM (1,2,4,8,9,10,12,13,15,18,21,22,23,24) in src/bondtypes.h: every field is covered by Verified facts + D47.13 (pointer-promotion list D47.5; f32 bswap; TextureID bswap32; vertex arrays D47.8; GDL slots D47.13). A fresh C probe (mingw gcc with the EXACT flags from build-pc/compile_commands.json) re-verified all 15 PC record sizes + ModelNode (0x30) + ModelFileHeader (0x38) + Vertex (0x10) and every pointer-promotion offset — **no drift** vs d43_convert.py's PC_REC / the spec. Gap: Verified facts enumerate bswap for f32, vertex-array s16s and the TextureID u32, but do not explicitly enumerate the **u16/s16 record scalars** (AnimPart, MatrixIndex, JointID, MatrixIDs, Group1/2, RwDataIndex, op4 numVertices@0x10, op24 nv/ncv/ModelType/ RwDataIndex). Data check: these hold small BE values (JointID 1–11, nv ≤ 73) that raw emission would corrupt (LE read → ×256); all research tooling reads them BE (`bu16`/`be16`). The rule follows from the D33 per-field endianness convention (u16 → bswap16), so this is an explicitness gap, not a missing rule — but Task 2's emit pass must bswap16 every u16/s16 record scalar (padding/reserved may be zeroed), and the HANDOFF Verified facts should be amended to say so. **D50 (RESOLVED — Plan B executed: offline sidecars + port plumbing; boot advances to first model GDL execution)** Execution session ran HANDOFF Task 2 end-to-end. All 512 NTSC model files converted offline and served through the existing load path; frames render and model display lists execute. Sub-items (all `#ifdef PORT`-guarded or port-layer only; N64 build untouched): 1. **D50.1 Emit pass (`tools_pc/d43_emit.py`, tracked).** Per file: decompress the N64 image from the ROM (filelist row); build the node map; run the EXACT `modelIterateDisplayLists` visit simulation (LOD/SWITCH rewire, BSP splice — d43_gdlorder logic) → gdl_seq; layout `[switches NS×8][texconfigs NT×12][DFS nodes 48B + records PC_REC + vertex arrays] …[GDLs packed contiguously in gdl_seq order, 16B per N64 slot]`; byte-exact emit (bswap32/16; promoted pointers → zero-extended u64 `0x05xxxxxx` VMAs; GDL Primary/Secondary raw VMAs, NOT promoted; BaseAddr=0); **round-trip re-parse validation of every field against the N64 source + region tiling (no gaps)** — 512/512 pass. Compression: `0x11 0x72` + raw deflate level 6 (`zlib.compressobj(6, DEFLATED, -15)`); escalate to 9 only on a dst!=0 fit violation (none occurred). Cross-checks all pass: per-file dst!=0 buffers (`round8(C)+8 ≤ buf` AND `D+round8(C) ≤ buf` — inflate overlap safety), G* hand-weapon worst case vs 0xF000, cast/title/bondview chain cumulative P_final bounds, totals (Σ round8(C) = 1,277,088 B, matches D49). Output: **single concatenated image** `data/pcmodels-/pcmodels.bin` (sidecars at 16-aligned offsets) + `manifest.csv` (`name,offset,size`, decimal — the C parser uses strtol base 10; file_resource_table.inc.c order). Deviation from D48's per-file-sidecar sketch: one blob + manifest; pcmodels.c copies the whole image to `[CART_BASE+romSize, …)` and patches `hw_address = cartBase+romSize+off`. Regenerate: `python tools_pc/d43_emit.py [ntsc-final|pal-final|jpn-final|--check-only]` (needs the region ROM in data/; sidecars are gitignored with data/). 2. **D50.2 Port plumbing (`port/src/pcmodels.c` + `port/include/pcmodels.h`, new; romdata.c/h extended).** `pcmodelsReserveSize(romImg)` derives `data/pcmodels-/` from the ROM country byte (+0x3E), parses the manifest, returns total bytes (0 → warn, ROM-only mode); romdataInit reserves `romSize + sidecarTotal` at CART_BASE and `romdataCartAddrValid` accepts the extension; `pcmodelsLoadSidecars( cartBase, romSize)` copies the blob; `pcmodelsPatchTable()` — one-shot, called from `load_object_fill_header` (hook block, objecthandler_2.c) after obInit has run — redirects every manifest row's `file_resource_table[i].hw_address` + sets `resource_lookup_data_array[i].rom_size` to the PC compressed size. The same hook block resets `poolRemaining = 0` for dst==0 loads (closes the D48.3/R6 reload hazard). Boot log: `[INFO] pcmodels: table patched (512 model entries)`. 3. **D50.3 Language banks (runtime C fixup).** Banks carry a big-endian offset table; `romdataFixupLangBank(blob, decompressedSize)` decodes it in place. Called from language.c after each of the 7 langInit loads and lazily per-id in the `langGetJpnCharPixels` paths (idempotent via poolRemaining check). 4. **D50.4 Fonts (runtime C fixup).** `load_font_tables` PC branch: allocate `romdataFontPcSize()` (PC C layout of struct font: kerning[169] + chars[94] + glyph pixel data), romCopy the N64 size, then `romdataFixupFont` re-lays out, shifting the pixel block below the expanded char array; `pixeldata` fields left as relative offsets — the existing `pixeldata += base` loop promotes them. 5. **D50.5 Legal-screen UB exposure (front.c).** `constructor_menu00_legalscreen` reads an uninitialized pointer at a lookat call — on N64 the register happens to hold a readable address and the result is zeroed by `* 0.0f` anyway; the x86-64 compiler folds the UB read to NULL → fault. PORT branch seeds it with `legalpage_text_array` (the value assigned a few lines later); identical lookat. 6. **D50.6 `texCopyGdls` copies only w0 on PC — first model-render crash (RESOLVED).** `Gfx` is a union whose last member is `long long int force_structure_alignment`: 8 bytes on N64 (= the whole slot), but on x86-64 the slot is 16 bytes, so `arg1->force_structure_alignment = arg0->…` copies only the low half. Compaction flow (sub_GAME_7F0762E0): texCopyGdls mirrors the GDL block `[G,D)` to tail scratch `[B−D+G,B)`, then texLoadFromGdl reads the scratch and writes expanded output back at `[G,…)` via full-slot `*(out++)=*(in++)` — propagating the scratch's stale w1s into the final GDLs. **Byte proof:** PlegalpageZ (NS=0, NT=5, D=0x2638) loaded at 0x7012EA38; first 64 RAM bytes match the decompressed sidecar exactly; the executed GDL at file offset 0x2488 has w0s matching the sidecar exactly (0xB10000BA/0xE7000000/0xFD900000/0xE6000000) but RAM w1s = 0x50362B58/0x66D73339/0xACAAB819/0x55BDF769 (stale mempool contents; pad2 dwords also nonzero) vs sidecar w1s 0x0000A898/0/0x050012C8/0 — the garbage G_SETTIMG w1 → `seg_addr` → OOB read in `import_texture_rgba16`. Fix: `*arg1 = *arg0;` under `#ifdef PORT` (tex.c). Audit: tex.c is the only `force_structure_alignment` use in game code (model.c:1510 is an unrelated local 8-byte union); every other Gfx copy is full-struct. Post-fix: PlegalpageZ's sub-DLs execute to completion; crash moves on (D51). **D51 (RESOLVED — font `pixeldata` fixup wrote the pointer at the wrong offsets; frame-5 `import_texture_i8` SIGSEGV)** The G_TEXRECT tile-0 upload's source was a **font glyph** (I8, loaded via `gDPLoadTextureBlock`). Root cause in `romdataFixupFont` (port/src/romdata.c): it wrote each char's pixel pointer at blob offsets d+20/d+24, but on PC `struct fontchar.pixeldata` is a **u64 at char offset +24** — the low word landed in padding and only half the pointer was written, so glyph sources resolved to stale/invalid addresses → OOB read in `import_texture_i8`. Fix: write the blob-relative offset into d+24 (low) and zero-extend d+28 (high). Verified: glyphs load, SIGSEGV gone. The old D51 hypothesis list (seg_addr / tile staleness) was wrong; its GE opcode facts remain valid reference material (G_IMMFIRST=−65; DMA G_MTX=1 / G_MOVEMEM=3 / G_VTX=4 / G_DL=6; IMM TRI1=0xBF … ENDDL=0xB8; GE extension G_TRI4=0xB1, 8-bit packed indices — NOT an address carrier; RDP pass-throughs G_SETTIMG=0xFD, G_SETCIMG=0xFF, **G_TEXRECT=0xE4 / G_TEXRECTFLIP=0xE5** — GE-specific values, not libultra's 0x46/0x45; width-1 stored in the 12-bit field, fmt I=4, G_TX_LOADTILE=7; main DL is game-built per frame with gSP* macros; segment table via `gMoveWd(G_MW_SEGMENT, seg*4, base)` → w0=(0xBC<<24)|(seg*4<<8)|6, w1=data; fast3d's gfx_sp_moveword stores data as-is when ≥ 0x800000 else +0x80000000). **D52 (RESOLVED — `osGetCount` tick-rate mismatch → non-deterministic post-frame-2 hang)** Plain runs sometimes hung after frame 2 (kernel watchdog: "no frame rendered for 3006180 ms") instead of crashing at frame 5. Root cause: port/src/libultra.c `osGetCount()` returned **microseconds** (1M/s), but GE's pacing assumes the N64 RSP counter rate ≈ **46.5525 ticks/µs**: `MAIN_LOOP_TICK_INTERVAL` = 387,937 ticks (boss.c: NTSC `INTERVAL_INTER_MATH - 2688U`; PAL `frameDelay*(CYCLES_PER_FRAME-6450)-(INTERVAL_INTER_MATH-3225)`), and `waitForNextFrame()` (frametiming.c) waits for `(elapsed+interval)/775875` ticks (NTSC frame = 775,875; PAL 931,050). At 1M/s the steady-state 16,667 µs between retraces < 387,937 → bossMainloop's gate never passes → no DL built → hang. Frames 1–2 rendered only because stage loading took >388 ms of real time. Fix: `osGetCount()` returns `(u32)(((uint64_t)sysGetMicroseconds()*465525ull)/10000ull)` (rate derived from GE's own constants: 775875/16666.67µs = 931050/20000µs); wraps every ~92 s like the HW counter. `osGetTime()` still returns µs (osSetTimer/ OSTime). PD's port has the same µs implementation but lacks GE's cycle-based pacing gate — do not copy it blindly. **D53 (RESOLVED: model RW-data pool addressing on PC; frame-5 SIGSEGV in `modelInitRwData`)** Post-D51/D52, the frame-5 SIGSEGV moves to `modelInitRwData` (model.c ~6131): first BSP node → `movl $0x0,(%rax)` right after `call modelGetNodeRwData` — writing `visible=FALSE` through NULL. Two compounding PC layout bugs, both in the D32 class: - **D53.1 (applied; necessary but not sufficient): `Model.datas` word stride.** `RwDataIndex` values are **4-byte word offsets** into the RW-data pool (`modelCalculateRwDataIndexes` accumulates `len += sizeof(record)/4`; pool = round16(numRecords×4) bytes, allocated in modelmgrInstantiateModel(WithAnim)). On N64 `&data[index]` with `union ModelRwData **data` strides 4 B; on PC it strides 8 B → every non-zero index addresses the wrong record. Fix (3 files, all #ifdef PORT): `Model.datas` → `u32 *datas` (bondtypes.h — layout unchanged: single pointer field); casts in model.c (`modelGetNodeRwData` local + return `(union ModelRwData *)&data[index]`, parent-walk `data=(u32*)tmp->RwDatas`, `modelAttachPart`) and propobj.c:7301/14343. - **D53.2 (root-caused; fix designed, NOT applied): `ModelSlot` / `AnimModelSlot` ↔ `Model` type-pun breaks on PC.** The game puns the slot structs and Model in both directions: `slot.unk08@8` ↔ `Model.obj@8` (the **in-use flag** — `modelInit`'s `objinst->obj = header` marks a slot), `slot.unk10@0x10` ↔ `Model.datas@0x10` (RW pool), `slot.unk02@2` ↔ `rwdatalen@2`. On N64 all pointers are 4 B → offsets agree. On PC there is no pack pragma — natural alignment (probe-verified with the exact CMake flags, see vsize.c): **Model = 0xE8 B**: chr@8, **obj@0x10**, render_pos@0x18, **datas@0x20**, scale@0x28, attachedto@0x30, anim@0x40; the slot structs still have unk08@8 / unk10@0x10. (An earlier draft of this section said obj@0xC/datas@0x1C — that assumed packed layout and is wrong; the log evidence below only fits the natural-alignment offsets.) Consequences: (a) `modelInit` writes obj@0x10 — the free check reads unk08@8 (low word of Model.chr, never written) → slots are never marked in use → every model reuses slot 0; (b) the legal-screen logo model takes slot 0, then `update_menu00_legalscreen()` (front.c:1430) → `clear_model_obj(logoinst)` writes obj=NULL at @0x10..0x17 — exactly where `slot.unk10@0x10` lives; the next instantiation passes `modelmgrCanSlotFitRwdata` on stale Model.datas@0x20 (non-NULL) + real unk02=20 ≥ 17, then `rwdata = g_ModelSlots[i].unk10` = NULL → `modelInit(model, header, NULL)` → fault. **Log evidence** (TEMP D51 trace in model.c → d52rw.log): ``` INST model=0x7020ac48 header=0x140141c40 numRecords=0 rwdata=0x7020b0f8 rwdatalen=20 lvreset=0 INST model=0x7020ac48 header=0x140142700 numRecords=17 rwdata=NULL rwdatalen=20 lvreset=0 GND obj=0x7020ac48 datas=NULL idx=0 rwdatalen=-1 op=2 data=NULL res=NULL ``` (same Model address both times = slot reuse; 20 = MODEL_SPARE_RWDATALEN spare-slot pool.) - **D53.2 fix APPLIED and verified.** objecthandler.h under #ifdef PORT: both slot structs re-laid out on top of the PC struct Model — `unk02`@2, in-use marker `unk08`@**0x10** (low word of Model.obj), pool pointer `unk10`@**0x20** (Model.datas), then `char pad28[sizeof(struct Model)-0x28]` so each slot is exactly sizeof(Model) = 0xE8 (a full Model, including animInit's writes up to PC offset 0xE3, fits). Member names kept; N64 layout verbatim under #else. model.c heap fallbacks bumped PORT-guarded: 0x20 → sizeof(struct Model) (non-animated), 0xC0 → sizeof(struct AnimModelSlot) (animated). Verified with the d52rw.log probe: both INSTs get valid pools (rwdata=0x7020c778, non-NULL), all 17 records initialize, frame-5 modelInitRwData crash gone; game runs to ~frame 102 and past the Nintendo-logo transition. Note: the bump allocator's `pos` drifts with the new sizes → some slots land on 4-byte boundaries; misaligned 8-byte field stores are functionally fine on x86-64 (individual field accesses, no faults) — accepted without a PORT alignment bump in memp.c. Size/offset probe: `vsize.c` at repo root (untracked scratch — delete before commit) prints sizeof + field offsets of Model / ModelSlot / AnimModelSlot / struct player; compile with the CMake include order (port/shim first, then ., include, include/PR, src, src/game, src/libultra, port/include) plus `-DPORT=1 -DAVOID_UB=1 -std=c11`. Re-run after any slot-struct edit. Environment notes: standalone gcc needs `/c/msys64/mingw64/bin:/c/msys64/usr/bin` on PATH (cc1 fails SILENTLY without it — exit 1, zero diagnostics), and `-std=c11` is required (the CMake flag; under the default gnu23 `typedef s32 bool` in bondtypes.h breaks). Also: `include/stddef.h`'s body is `#if 0`'d — offsetof/size_t are unavailable in game TUs (use pointer-difference arithmetic in probes). **D54 (RESOLVED: cseq ALCMidiHdr endianness; audio-thread SIGSEGV in `__getTrackByte`)** After D53, the first music load (M_INTROSWOOSH, seq 44) crashed the audio thread in `__getTrackByte`. Root cause: a decompressed compact-sequence file starts with `struct ALCMidiHdr` — 16 **big-endian** u32 trackOffset values + a BE u32 division. N64 reads them natively; on an LE host alCSeqNew() does not swap them (track-0 offset 0x44 becomes 0x44000000), builds "valid" curLoc pointers ~1 GB past the buffer, and the first track-byte read faults. The rest of the stream is byte-oriented (varlens, MIDI bytes, BE loop offsets assembled byte-by-byte) so only the 17 header words need fixing. Fix: port- layer `romdataFixupCseq(u8 *blob)` (port/src/romdata.c — bswap32 of the first 17 u32s; declared in port/include/romdata.h), called under #ifdef PORT after each of the 3 `decompressdata` calls in musicTrack1Play/2/3 (src/music.c). Verified: __getTrackByte crash gone; full 60 s run (exit=124 timeout) with no audio fault, past the Nintendo-logo transition. **D54b (RESOLVED: synthesizer param-slot sizing; audio-thread SIGSEGV in `alLoadParam`)** Next audio crash: `alLoadParam` dereferenced a corrupted free-list slot. Root cause: alSynInit's "build the parameter update list" allocates `c->maxUpdates` slots of `sizeof(ALParam)` and the game type-puns those slots as several AL*Param structs. On N64 all of them are 0x1C bytes (one slot each); on x86-64 **ALStartParamAlt is 0x28** (two 8-byte pointers: next + wave) vs ALParam = 0x20 — every start-voice update wrote `wave` into the neighbouring slot, corrupting the free list. Fix in synthesizer.c alSynInit under #ifdef PORT: allocate `maxUpdates * sizeof(ALStartParamAlt)` and stride the init loop by that size (cast each slot to ALParam* for the next-pointer link); N64 verbatim under #else. Cost +8 B/slot × 0x80 slots = +1 KB vs MUSIC_ALLOCATION_BYTES — no alHeapAlloc failure observed. Verified: alLoadParam crash gone; full 60 s run clean. **D55 (RESOLVED: RLE folder-menu background header endianness; SIGSEGV in `rle_expand_8bit`)** After the audio fixes, the game ran to the gun-barrel intro (~frame 654, ~23 s) and crashed in `rle_expand_8bit` (src/game/rle.c:30, the `*dst++` store). Caller: title.c `sub_GAME_7F008DE4` (the initializeGunBarrelIntro path) romCopies the asset at `unknown2` (romassets_.s, NTSC cart 0x102A4D50, size 0x1A580) and RLE- decodes it into a 0x40400-byte buffer. Root cause: the asset is the title folder-menu background; its raw ROM header is **big-endian** `01 B8 01 2B` (w=440, h=299) + 6 pad bytes + a valid RLE stream (decodes to exactly 440×299 = 131560 bytes). rle_expand_8bit reads w/h as **LE** u16s (byte- matched N64 code): the raw header gives w=47105, h=11009 → remaining ≈ 518 MB written into a 256 KB buffer → SIGSEGV. The N64 build embeds this asset into .data via `assets/romfiles2.s` (`.incbin "assets/ge007.u.2A4D50.usedby7F008DE4.bin"`) from an extracted .bin whose header is byte-swapped — title2.c hardcodes 440-wide I8 rows × 299, and this is the only decodable 440×299 RLE stream in the ROM (a full-file 16-bit swap was ruled out: it zeroes the first RLE count at +0xC), so only the 4-byte header differs between raw ROM and the N64 .bin. Fix (port-layer, romdata.c romdataInit, after the cart-base mapping): bswap32 the first word at `(u32 *)&unknown2` in place — the image is a writable VirtualAlloc at CART_ BASE — guarded by "only swap if the LE-read w or h > 512" so an already-LE region copy is a no-op. rle.c/title.c untouched (D37/D54 pattern). Verified: RLE crash gone; game renders 600+ frames past the gun-barrel background into the watch intro, where it hits D56. **D56 (RESOLVED: watch-intro embedded Model/RW-pool raw offsets into struct player; SIGSEGV in `modelSetScale`)** Post-D55, the game renders 600+ frames (~10 s into the gun-barrel/watch intro) then SIGSEGVs in `modelSetScale` (src/game/model.c:778, `objinst->scale = scale`) with a garbage Model* (crash-log return frame was corrupted; caller inferred — see below). Prime suspect, and the only code passing a **raw N64 offset into struct player** as a Model*: `sub_GAME_7F07E7CC` (bondview2.c:3102-3116, called from bondview2.c:3400 whenever the pause/watch transition completes — every watch-menu open, and during the gun-barrel intro): ``` animInit((Model *)((u8 *)g_CurrentPlayer + 0x230), itemheader, (u32 *)((u8 *)g_CurrentPlayer + 0x2ec)); modelSetScale((Model *)((u8 *)g_CurrentPlayer + 0x230), c_item_entries[41].scale * 0.1f); modelSetAnimation((Model *)((u8 *)g_CurrentPlayer + 0x230), …ANIM_DATA_bond_watch…); *(s32 *)((u8 *)g_CurrentPlayer + 0x220) = 0; // = step_in_view_watch_animation ``` Layout facts (probe-verified where noted): - struct Model (bondtypes.h:1482): 8 pointer fields (chr, obj, render_pos, datas [u32* under PORT, D53.1 — note the in-code comment there still says "D52", flagged for rename in HANDOFF Task 3], attachedto, attachedto_objinst, anim, anim2). sizeof_N64 = **0xBC**; sizeof_PC = **0xE8** (vsize probe: chr@8, obj@0x10, render_pos@0x18, datas@0x20, scale@0x28, attachedto@0x30, anim@0x40 — note the 8-byte pointer alignment padding after the two leading s16s). - N64 struct player: the **watch Model is embedded at +0x230** (its first word is the anonymous s32 `something_with_watch_object_instance`), size 0xBC, so it ends exactly at +0x2EC where the **RW-data pool** begins. The pool region runs to `buttons_pressed`@0x3B4 = **0xC8 bytes** of capacity; every field in [0x2EC, 0x3B4) is an anonymous s32 (no pointers → no extra PC shift inside the region). - On PC the embedded Model sits at X = offsetof(struct player, something_with_watch_object_instance) (≥ 0x230 + 5×4: cameratile@0x34, prop@0xA8, bodyModel@0xD4, autoaim_target_y@0x130, autoaim_target_x@0x140 are the pointer fields before it) and spans [X, X+0xE8). Remaining capacity before buttons_pressed is only 0x184−0xE8 = **0x9C < 0xC8** → the pool does NOT fit embedded on PC. - modelInit stores the pool pointer in `Model.datas` and ALL rwdata access goes through `modelGetNodeRwData(model, node)` via `model->datas` — so redirecting the pool to separate storage is safe; only sub_GAME_7F07E7CC references +0x2EC directly (whole-tree grep). - The N64 capacity 0xC8 bounds the watch model's real pool size (the game works on N64), so a fixed static buffer of that size is safe. **Applied as designed + verified:** bondview2.c sub_GAME_7F07E7CC under #ifdef PORT takes the Model by field name — `Model *watch = (Model *)&g_CurrentPlayer->something_with_watch_object_instance;` (probe-verified at +0x24C on PC — not 8-aligned; unaligned pointer stores are fine on x86-64 per D53.2) and hosts the pool in `static u8 watchRwPool[0xC8]` (N64 capacity; N64 embeds it at player+0x2EC); animInit/modelSetScale/ modelSetAnimation on `watch`; the +0x220 store becomes `g_CurrentPlayer->step_in_view_watch_animation = 0;`. N64 raw-offset path kept verbatim under #else. Verified: the watch path no longer crashes — but the same `modelSetScale` SIGSEGV remained, and an env-gated probe (GE_D56, logging `__builtin_return_address(0)` in modelSetScale) proved the real caller was **not** the watch path: `initializeGunBarrelIntro` (title.c) calling `modelSetScale(NULL, 0.18779343f)` because `setup_chr_instance()` → `modelmgrInstantiateModelWithAnim()` returned NULL for BODY_Brosnan_Tuxedo — see D57. **D57 (RESOLVED: pointer-grown rwdata records overflow the N64-sized spare pools; `modelmgrInstantiateModelWithAnim` returns NULL → SIGSEGV in `modelSetScale(NULL, …)`)** The Brosnan tuxedo's computed PC `numRecords` is **153** words vs the N64- sized anim spare-pool capacity of **140** (`ANIM_MODEL_SPARE_RWDATALEN = 0x8C`). Cause: two rwdata record structs contain pointer fields and grow 8 → 16 bytes on x86-64 — `ModelRwData_HeadPlaceholderRecord` (ModelFileHeader* + void*) and `ModelRwData_DisplayList_CollisionRecord` (Vertex* + Gfx*). Since `modelCalculateRwDataLen()` accumulates sizeof(record)/4 per node, every HEAD/DLCOLLISION node adds +2 words vs N64. Fix (two parts, both #ifdef PORT): (1) initunk_005520.c: spare capacities grown with headroom — `MODEL_SPARE_RWDATALEN 0x14→0x38`, `ANIM_MODEL_SPARE_RWDATALEN 0x8C→0xA8` (N64 values kept under #else); (2) model.c: in the non-LvResetting branches of both `modelmgrInstantiateModel()` and `modelmgrInstantiateModelWithAnim()`, a dynamic slot+pool fallback mirroring the existing LvResetting path (`mempAllocBytesInBank(sizeof(struct ModelSlot/AnimModelSlot))` + 16-aligned pool of numRecords words) — the slot is untracked (never reused), acceptable because with the grown capacities it should not trigger. A u32-field approach for the two pointer records was rejected: `ModelFileHeader` pointers there are exe-resident globals (>0x80000000 on PC) and would truncate. Verified: Brosnan gets a dynamic slot, `modelSetScale` succeeds, game proceeds to rendering — where it hits D58. **D58 (RESOLVED: gun-barrel DL — K0 vertex-pointer idiom + 16-byte Gfx overflow of the N64-sized reservation; SIGSEGV in `gfx_sp_vertex`, then FATAL "Unknown GBI opcode 0x00")** Two distinct PC-layout breaks in the same buffer (initializeGunBarrelIntro, title.c), both found via env-gated probes (GE_D57: per-command VTX/CALL/JMP log + entry-time hexdump of the barrel DL): - **Part A (vertex pointer):** title.c passed `barrelDisplayListPtr + 0x80000000` to sub_GAME_7F01BFF8, which embeds it verbatim in each G_VTX w1 (GE's gDma1p writes `(uintptr_t)(v)` — no LSB). On N64 the mempool pointer was physical, so +0x80000000 gave the RSP-visible KSEG0 address; on PC it is a V1 pointer (0x70xxxxxx, dram.c) and +0x80000000 lands at 0xF0xxxxxx — unresolvable by fast3d's seg_addr() → SIGSEGV reading the vertex array. Fix: rebuild the exact N64 value — `(Vtx *)(OS_K0_TO_PHYSICAL((void *)barrelDisplayListPtr) | 0x80000000u)` (→ 0x80xxxxxx; seg_addr passes it through to the KSEG0 mirror; segments 7/8 are never registered, so the unmarked-segment path is skipped). - **Part B (DL reservation):** on PC `sizeof(Gfx) == 16` — the union's trailing `long long` (gbi.h documents it: "except on 64-bit, where it is exactly 128 bit"), same class as D50.6. Both writers (`gdl++`) and fast3d (`++cmd`) advance by 16, so all game-written DLs are 16-byte-wide — but the barrel-DL reservation `bufferSize -= 0x100` was sized for N64's 8-byte Gfx. sub_GAME_7F01BFF8 emits 31 Gfx (2×VTX + 28×TRI + ENDDL) = 496 B, so slots 16–30 (second TRI batch + ENDDL) overflowed into the RLE region at +0x300, and sub_GAME_7F008DE4's expand then clobbered them with image data. At render the RSP executed VTX/TRI×14/VTX fine, then hit slot 16 = RLE pixels (w0=0x00000001 → opcode 0x00; fast3d has no G_SPNOOP case) → FATAL. Fix: reserve 0x200 under PORT. The 0x200 vertex reserve still fits (30 Vtx × 16 B = 0x1E0). NOTE for future asset work: any other N64-sized reservation for game-written DLs/vertex arrays must be re-checked against the 16-byte Gfx / 16-byte Vtx widths (recurring class, cf. D50.6/D53.2). Verified: barrel DL executes to ENDDL; game proceeds past the gun-barrel hole into model rasterization — where it hits D59. **D59 (OPEN — current blocker: SIGSEGV inside an external GL DLL during the first real model rasterization after the gun-barrel hole)** Post-D58, the barrel DL runs clean (probe-verified: VTX@+0, TRI×14, VTX@slot15(+0xF0 in 16-byte form), … ENDDL) and the game crashes shortly after with EXCEPTION 0xc0000005 at PC 0x7ff8d42a44d3 — inside a DLL loaded at 0x7ff8d4230000 (offset +0x744d3; almost certainly the OpenGL driver, not yet confirmed). The crash-log backtrace frame #1 (main+0x227cc0) is a BSS symbol (`memoryMesgMB`) — garbage stack, no usable caller. Draw path: gfx_flush() (gfx_pc.cpp:299) → `gfx_rapi->draw_triangles(buf_vbo, buf_vbo_len, buf_vbo_num_tris)` → gfx_opengl.cpp:813-816 `glBufferData(GL_ARRAY_BUFFER, sizeof(float)*buf_vbo_len, buf_vbo, GL_STREAM_DRAW); glDrawArrays(GL_TRIANGLES, 0, 3*buf_vbo_num_tris)`, where `buf_vbo` is the static `float buf_vbo[MAX_BUFFERED*(32*3)]`. Hypotheses: (a) buf_vbo overflow while accumulating transformed vertices for the first real model (Brosnan) — check the vertex-append site in gfx_pc.cpp (the transform loop after ~line 1118) for a missing bounds check against MAX_BUFFERED; (b) garbage buf_vbo_len/num_tris; (c) bad texture/shader state on first model draw. Next: env-gated probe logging buf_vbo_len/ buf_vbo_num_tris at every gfx_flush + identify the DLL (PowerShell module list during a run, or the GL vendor string in the log). **D59 RESOLVED (sessions G–I).** The "external GL DLL" crash was not a driver bug: it was an msvcrt.dll `memcpy` faulting on a wild source — the gun-barrel sub-DL region at `ptr_logo_and_walletbond_DL + 0x200` had been clobbered by the unbounded RLE write of D64 (below). With D64 fixed, the barrel renders and the game advances; no fast3d/vertex-buffer change was needed. The crash handler gained permanent improvements along the way: FAULT ADDR (ExceptionInformation[1]), a 16-qword STACK@RSP window, and a module list in `ge007.crash.log` (crash.c + psapi). **D60 RESOLVED — DMA target validation (port layer).** `osPiStartDma` (libultra.c) now validates ROM-read targets: the N64 PI can DMA to any KSEG address, but on PC an unmapped target is a wild memcpy. `dramHostAddrValid()` accepts DRAM V1/V2 and any host-committed region (VirtualQuery), so legitimate `.bss`/`.data` targets (e.g. `ramrom_data_target`) pass while s32-truncated wild addresses are rejected with a logged FATAL instead of a silent crash. Also added the GE_D60 sidecar-read tracer and GE_D61 per-ROM-read log (`d61dma.log`). **D62 RESOLVED — OSMesgQueue/OSScMsg layout (port layer).** The shim's message-queue bookkeeping had to match the PC struct widths: OSMesgQueue is 40 bytes on PC (two OSThread* + 3×s32 + OSMesg*), OSScMsg stays 32 bytes. Scheduler-thread message flow (retrace/pre-NMI/interrupt/cmd queues in `os_scheduler`, g_AudioManager frame/reply queues) verified against those layouts. **D63 — TEMP diagnostics (to strip).** GE_D63-gated probes in gfx_pc.cpp / blood_animation.c / front.c / rsp.c tracing the VTX-pool bump pointer (`g_GfxMemPos`), the gun-barrel sub-DL slot word, dram-branch targets and rspGfxTaskStart hand-off. Used to prove D64's clobber path and to rule out VTX-pool overflow; no permanent change. **D64 RESOLVED — blood RLE sentinel (src/game/blood_animation.c).** The N64 build places `die_blood_image_end` in the same section directly after `die_blood_image_1[]`; the RLE decoder's guard `bloodImgNxt < &die_blood_image_end` relies on that adjacency (only the address is used). On PC a zero-init symbol lands in `.bss` ~1 MB away, so the guard never fires and the decoder writes unbounded past the array — it clobbered the gun-barrel sub-DL at `ptr_logo_and_walletbond_DL + 0x200`, which is what surfaced as the D59 "GL DLL" crash. Fixed under #ifdef PORT by defining `die_blood_image_end` as one-past-the-end of the array. **D65 RESOLVED — `enum HEADS` signed sentinels (src/bondconstants.h).** The N64 toolchain gave this enum a signed underlying type, so `HEAD_FIXED == -1` and `head >= 0` guards were real branches. PC GCC 16 picks `unsigned int` for enums whose enumerators are all non-negative (0xFFFFFFFF > INT_MAX), making every `head >= 0` always true and turning `c_item_entries[HEAD_FIXED]` into a wild 64-bit OOB read (SIGSEGV in init_menu18_displaycast). Under PORT the sentinels are now negative literals (`HEAD_FIXED = -1`, `HEAD_RANDOM = -97`) — identical bit pattern, signed semantics restored. **D65b RESOLVED — `enum BODIES` signed sentinel (src/bondconstants.h).** Same class as D65: ROM tables store 0xFFFFFFFF in `body` fields and the cast-end check compares `intro_char_table[f].body < 0`; PC's unsigned underlying type deleted the reset branch, so the cast screen rendered the terminator entry and `langGet(0)` dereferenced a NULL bank. Added `BODY_FIXED = -1` under PORT (forces signed underlying type; no existing value changes). **D66 RESOLVED — romCopyAligned pointer width (src/ramrom.c/.h) + ramrom replay truncations (src/game/ramromreplay.c).** The N64 build did all of `romCopyAligned` in s32; on PC targets live in `.bss` above 4 GiB, so `(s32)target` truncated (0x1401C6F00 → 0x401C6F00) and the DMA went to a wild address. PORT version uses uintptr_t throughout and returns `void *` callers assign straight to pointers. The ramrom replay path had the same class of `(s32)` truncation on `ramrom_data_target`. **D67 RESOLVED — struct image_entry layout (src/game/image.h).** The decompiled field order cannot be right: texLoad() reads `*(s32*)&entry & 0xFFFFFF` as the data offset (dataoffset must occupy bits 0-23 of word 0) while chrprop.c indexes entries with an 8-byte stride (sizeof == 8). Under PORT the struct is re-declared with all-u32 bitfields and `dataoffset : 24` first, so GCC packs it to exactly two words on both targets and the raw word read is satisfied. The IMAGE() macro initializer order in image.c is adjusted to match under PORT. **D68 RESOLVED — Globalimagetable endianness (port/src/gimgfixup.c + src/game/image_bank.c + src/game/image.c).** The ROM-copied Globalimagetable segment (texReset) is N64 big-endian, but PC code reads its CPU-interpreted u32 fields natively: the IMAGESEG-marked G_SETTIMG w1 words (`IMAGESEG(id) = 0xABCD0000 | id`) and the `sImageTableEntry.index` field of all 32 table arrays. Unfixed, texLoad computed texnum from byte-swapped ids (e.g. 52651 for IMAGE_SMOKE_11 = 2106) → out-of-range offsets → a 925 KB ROM read into the 4000-byte stack compbuffer (FATAL at boot). Fix: `gimgFixupGlobalimagetable()` bswaps exactly those u32s in place after the romCopy (17 Gfx DLs walked op-by-op for the AB CD marker; table entry counts fall out of the D39 symbol layout, 12-byte stride); everything else in the segment is byte-level (opcodes, single-byte fields, raw pixel blocks referenced via 0x02xxxxxx segmented addresses) and untouched. Two consequences handled under PORT: (1) texLoadFromDisplayList's marker scan now checks bytes 6..7 (CD AB — the LE encoding of 0xABCDxxxx) instead of 4..5; (2) explosion.c executes the *compiled* globalDL_0xNNN shadows via g_ExplosionDisplayLists[], so `gimgSyncCompiledGlobalDLs()` copies the texLoad()-patched IMAGESEG w1 values from the ROM copy into those arrays (command j of the 8-byte ROM DL maps to Gfx slot j of the 16-byte compiled array; D39 verified them byte-identical). Verified: 137 texLoads with valid in-range ids (2106, 2084, …), real offsets/sizes from g_Textures, and the game runs the full ~3.5-minute intro (logo → gun barrel → cast) at ~59 fps to the first stage load. **D69 (OPEN — current blocker: BG-file big-endian headers at stage load)** Post-D68 the game plays through the entire intro and crashes in `load_bg_file` (src/game/bg.c:830) when loading the first stage (BUNKER1, "bg/bg_sev_all_p.seg", cart 0x10438660). The header IS loaded correctly — `obLoadBGFileBytesAtOffset` works: `&fileentry->hw_address[offset]` evaluates to `hw_address + offset` (a valid cart address; the compiled absolute asset symbols point into the ROM mapped at the cart base, and the PI shim memcpys from there). The bug is interpretation: BG-file offsets are N64 big-endian u32s in segment-0x0F form. Header word 1 in the ROM is `0F 00 00 14` (BE value 0x0F000014 → file offset 0x14 after BG_SEG_TO_PTR's `+ 0xF1000000` fold); PC reads it LE as 0x1400000F, so `ptr_bgdata_room_fileposition_list = header + 0x1400000F - 0xF000000` lands ~0x5 MB past the stack buffer and `...[1].pPointTableBin` faults (EXCEPTION 0xc0000005, FAULT ADDR ≈ header + 0x500000F). The whole stage-load path (bg .seg headers/room tables + Tbg_*_stanZ geometry files) is riddled with BE u32 fields — the same class as D68 but a far larger format surface. This is the "next asset type" milestone anticipated in AGENTS.md. Strategy options: (a) offline per-region conversion of all bg/*.seg + Tbg_*_stanZ files into sidecars (the D43/Plan-B pattern; requires fully decoding GE's BG/stan formats from bg.c/stan.c — note PD's preprocess/filebg.c describes a *different*, zipped multi-section format; same family ≠ identical, validate per field); (b) runtime port-layer fixup after each load (same format knowledge, placed in port/). Either way the first task is reverse-engineering the formats: header words 0..3 are pointers (rooms/portals/bgcmds/lights-style tables per the D69 probe: word1=0x14 room-fileposition list), bg_room_data records carry more 0x0Fxxxxxx offsets (pPointTableBin at record+0x28, see crash disasm), and stanZ files go through stanDetermineEOF/stanLoadFile. TEMP D69 probe in ob.c (GE_D69) logs name/index/rom_size/hw_address per BG load. ### G. Phase 2 status snapshot (HISTORICAL — frozen ~D74) > Stale point-in-time snapshot. **Current status: `docs/HANDOFF.md`.** > Finding detail: §F index above + §H below. Done through **D68**: PD fast3d integrated (`port/fast3d/`); GE's real `src/sched.c` + pthread kernel; dual-mapped DRAM; ROM mapped at cart base; SDL2 window; full boot chain (D31–D42); **Plan B executed (D50)** — all 512 NTSC model files offline-converted to PC-layout RZ sidecars, served through the existing load path via a port-layer table patch; runtime C fixups for language-bank BE offset tables (D50.3) and font re-layout (D50.4); legal-screen UB seed (D50.5); `texCopyGdls` w1 partial-copy bug fixed and byte-proven (D50.6). **D51–D58 resolved** (font pixeldata fixup, osGetCount tick rate, model RW-data pools, cseq BE header, synth param slots, RLE folder-menu background, watch-intro raw offsets, spare-pool capacities, gun-barrel DL idiom + reservation). **D59 resolved** (the "GL DLL" crash was D64's unbounded blood-RLE write clobbering the barrel sub-DL), **D60–D62 resolved** (DMA target validation, OSMesgQueue layout), **D63** TEMP diagnostics, **D64 resolved** (blood RLE sentinel adjacency), **D65/D65b resolved** (HEADS/BODIES enum signed sentinels under PORT), **D66 resolved** (romCopyAligned + ramrom replay 64-bit pointer width), **D67 resolved** (struct image_entry N64 layout reconstruction), **D68 resolved** (Globalimagetable BE→LE fixup for IMAGESEG Gfx words + sImageTableEntry.index; compiled globalDL shadows synced after texLoad). The game now boots, plays the intro music, and renders the **entire intro** (Nintendo logo → gun barrel with Brosnan → cast screen; ~frame 2100, ≈2 min wall-clock at the current ~20 fps clean-run rate), then crashes in `load_bg_file` on the first stage load — **D69, the milestone blocker**: BG-file headers are N64 big-endian (segment-0x0F offsets) and PC reads them LE. Details in §F/D59–D69. **D70–D74 (intro-logo pixel work):** D70 env-gated PPM frame capture (`GE_PCDUMP` → `./ppm/`) for numerical visual debugging; **D71 resolved** — C-array texture sources (the four rarewarelogo.c RGBA16 images) were byte-swapped on LE PC (pink/green logo); port-layer per-source bswap in `import_texture`. **D72 resolved** the UV path (GE always uses authored tc[] UVs; `lookat_enabled` defaults false). **D73 resolved** — root cause of D72.3: sinf/cosf `du` double constants are big-endian word pairs, garbage on LE PC → guMtxF2L emitted −32768 for every sin/cos entry → logo triangles projected off-screen; DVAL() macro fix in guint.h/sinf.c/cosf.c under PORT. **D74 resolved** — texture import fallback no longer truncates valid gDPLoadBlock data (mip chains + sub-tiled textures), TextureCacheKey gains `size_bytes`, and the VBO path now wraps UVs by tile size for WRAP sub-tiles (N64 semantics). The logo now renders its four gold letters on the dark-blue plate (PPM-verified at frame ~555); a final pixel-perfect comparison against N64 reference footage is still open. **D69 remains the milestone blocker** (stage load). **Committed through D74**: D51–D74 fixes; this session's TEMP probes are stripped, but previously committed TEMP diagnostics (D63 blocks, GE_D71LOG, and the older D51–D66 leftovers) are still in the tree — strip list in HANDOFF Task 3. Build is GREEN. ### H. Finding log continued (D32 procedure, D70–D121) > This section began as a per-session "handoff & plan" and grew into the > D70–D121 finding log. The **D32 repeatable fix procedure** below is > permanent reference; the rest is the finding archive (use the §F index). > **Current task and plan: `docs/HANDOFF.md` + > `docs/PLAN-linear-level-sweep.md`.** Session narrative: > `docs/HANDOFF-ARCHIVE.md`. Full paste-ready brief: **docs/HANDOFF.md** (primary thread: D69 BG/stan stage loading — the milestone blocker; secondary: final pixel check of the now-rendering intro logo). For D69: reverse-engineer GE's bg .seg + Tbg_*_stanZ formats from the decompiled consumers (bg.c, stan.c), then choose offline sidecar conversion (Plan-B pattern, D43) vs runtime port-layer fixup; PD's preprocess/filebg.c is a reference for the *approach* only — its BG format is different (zipped multi-section). Summary: **State.** D50–D74 resolved and verified (D73 = sinf/cosf endianness, D74 = texture import fallback + sub-tile UV wrap); committed through the D74 milestone. The game boots, plays intro music, and renders the entire intro (logo → gun barrel with Brosnan → cast screen) — the Rareware logo now shows its four gold letters on the dark-blue plate (D71–D74; PPM-verified at frame ~555, final pixel-perfect check vs N64 footage still open). TEMP diagnostics from earlier sessions are still in the tree (strip list in HANDOFF Task 3); visual-debug tooling: `GE_PCDUMP` frame capture. Build is GREEN. **Thread 1 — D69 (milestone blocker, now primary).** `load_bg_file` (src/game/bg.c:830) faults on the first stage load (BUNKER1): BG-file header words are N64 big-endian segment-0x0F offsets; PC reads them LE, so the room-fileposition-list pointer lands ~5 MB past the stack header buffer. Full analysis + strategy options in §F/D69. This is the next milestone: decode the bg .seg + Tbg_*_stanZ formats, convert/fix for PC, get a stage to load and render. **Thread 2 — logo final check (low priority).** Compare the rendered logo frames (ppm/, frame ~550–560) against N64 reference footage; if any letter is still off, re-add a lightweight triangle/texture attribution probe (the D74 probes are stripped; the import sizes and sub-tile wrap are verified at the data level). **After D69:** get a stage to load + render (bg .seg + Tbg_*_stanZ format work), then continue the diagnose→fix→verify loop through gameplay; strip TEMP diagnostics at each milestone; pixel-assert soak (PPM dump + tools_pc/pixcount.py) once a stage is stable. **D32 repeatable fix procedure** (apply to any ROM-serialized struct that faults on a pointer-field read): 1. At the fault, `ptype /o ` in gdb. If a pointer field's offset/size diverges from the N64 offset comment (e.g. an 8-byte pointer where N64 has 4), it is this bug class. 2. Change the embedded pointer fields to `u32` in the struct (keeps N64 layout on x86-64). Add casts at every use site (`(T *)field`). Document as D3x. 3. Verify the load-time rebase/fixup writes valid **V1** DRAM addresses (< 0x80000000) into those u32 fields; if not, debug the fixup (see Part B below). 4. Rebuild and confirm the boot advances past this struct to the next init step. 5. If the struct is a *tree* of packed sub-structs (like the libaudio banks, D37, or model files, D43), an in-place BE→LE patch will not fit — the expanded 8-byte pointer slots overrun the following ROM data. Re-lay it out into a fresh compact image and rewrite each pointer slot as the sub-struct's new offset (zero-extended), so the existing `ptr + (s32)base` rebase still works. 6. If the fault is NOT in ROM-loaded data but in an address arithmetic idiom over exe-resident symbols (`(u32)&sym`, XOR toggles, pointer-delta math — D39/D42), guard a PC branch that reproduces the N64 32-bit value exactly (e.g. keep the 0x02000000 base) or replace the idiom with an explicit equivalent; N64 line stays verbatim under `#else`. 7. If two structs are **type-punned** (cast back and forth, e.g. ModelSlot ↔ Model), verify their layouts still agree on PC — pointer-width changes break puns silently: fields the game reaches "through" one struct land at wrong offsets in the other (D53.2). Also check any fixed-size allocation that must contain the grown struct (the 0x20-byte model heap fallback, D53). 8. If a hang (not a crash) appears with no thread making progress, suspect a **timing/pacing gate**: GE's loop gates on `osGetCount()` deltas in N64 RSP counter ticks (~46.5525/µs), not µs (D52). 9. If the fault is a store through a pointer built from a **raw byte offset into a struct that contains pointers** (`(u8 *)ptr + 0xNNN`, D56): every pointer field before 0xNNN shifts +4 on PC (plus 8-byte alignment padding), so the offset no longer lands on the intended object. Fix under #ifdef PORT via the named field (`&s->field`); if the embedded sub-object has grown (sizeof_PC > sizeof_N64) and its trailing companion storage (e.g. a model RW pool packed right after it in the struct) no longer fits before the next live field, relocate that companion to a static buffer of the N64 capacity — safe when all access goes through a pointer stored in the sub-object at init (modelInit → Model.datas) and only the one site references the raw offset. **D70 (dev tooling, TEMP):** env-gated PPM frame capture for numerical visual debugging: `GE_PCDUMP="first-last[:step]"` dumps the bound FBO to `./ppm/frame_NNNNNN.ppm` from `videoEndFrame()` (`gfx_opengl_pcdump_enabled()`/ `gfx_opengl_dump_bound_fbo()` implemented in gfx_opengl.cpp). Established the intro timeline numerically (legal text f20–100, Rareware logo f380–580, iris/gun-barrel f900–1220, red region f1240–1360, cast f1520+) and confirmed the user's "pink and green" report at f480 (left half RGB≈(135,78,129), right half (82,104,0)). Strip per HANDOFF Task 3. **D71 (RESOLVED):** the Rareware logo rendered as two flat colors — pink-red and bright green — instead of gold lettering. Root cause: the four RGBA16 logo images in assets/rarewarelogo.c (`imgRAre_0x0020` etc.) are `u32` C arrays compiled into the exe `.data`; on LE PC each N64 texel pair is stored as a little-endian u32, so `import_texture_rgba16`'s big-endian u16 read produced byte-swapped texels (raw 0xED0F gold → 0x4FCC green + 0x0FED pink — exactly the two observed colors). All other texture sources are raw N64 BE byte streams (ROM cart map 0x10xxxxxx, model sidecar 0x10Cxxxxx, KSEG0/V1 buffers 0x70–0x90xxxxxx) and must not be touched. Fix (port/fast3d/gfx_pc.cpp): `gfx_tex_source_is_c_array()` classifies by address range; `gfx_tex_normalize_source()` bswaps each u32 once per source into a stable cached buffer (cache key stays the original address) and `import_texture()` decodes from it. Verified: exactly two sources normalized in a full run (the logo image banks); logo now renders gold on dark blue. One-shot per-source log gated by `GE_D71LOG`. **D72 (RESOLVED + D72.3 OPEN):** the logo UV path. - D72.1: removed the PD-inherited normal/lookat-based UV overwrite in `gfx_sp_vertex()` — GE always uses authored per-vertex `tc[]` UVs: no GE code sets G_TT_BASE/G_TT_CLAMP (only G_TT_NONE), and stage geometry has gSPLookAt set every frame yet N64 textures are fixed to surfaces, so the normal-derived path is wrong for GE in general. - D72.2: `rsp.lookat_enabled` now defaults false — N64 boots with RSP memory zeroed; no lookat exists until gSPLookAt writes one (the intro logo has none). GL UV convention confirmed as U.5 (texel×32) from the LUT/rect path, so DL_RAREWARETEXT's identity-scale corner UVs 0x0010..0x03F0 map to full 32×32 coverage with a half-texel inset. - D72.3 (OPEN): after D71+D72 the logo still does not appear: DBGTRI traces show ALL logo triangles (plate fan + letter quads) project off-screen (clip x=y≈−1.6e11, w<0, screen ≈(32776,32776)), yet the frame shows a large flat dark-blue (0,0,64) region — rows 150..479, cols 1..639 — filled as a PERFECT checkerboard (50/50 pixel parity), i.e. broken rasterization of some big triangle drawn after clear_framebuffer_black. Identity not yet established (`GE_DBGTALL` all-triangle trace captured; analysis pending). Ruled out: matrix-format mismatch — this codebase's guMtxF2L is Rare's modified variant writing the interleaved hi16/lo16 s32 Q15.16 packed format that gfx_sp_matrix decodes exactly (FTOFIX32=×65536); `D_8002A7D0` is a zero-init u32 so `[D_8002A7D0]`==`[0]`; alloc_intro_matrices() runs from initmenus. **SUPERSEDED by D73** — the off-screen projection was never a matrix-format or UV problem: guMtxF2L's sinf/cosf inputs were garbage on PC. **D73 (RESOLVED — root cause of D72.3):** GE's `sinf`/`cosf` (src/libultra/gu/sinf.c, cosf.c) build their double constants through the `du` bit-union (`{ struct { u32 hi; u32 lo; } word; double d; }`) with big-endian word pairs `{hi, lo}` (rpi, pihi, and the P[] polynomial tables). On an LE PC the `.d` read has the two words swapped: `rpi.d` ≈ 2^733, `pihi.d` ≈ 2^257. Range reduction then overflows: `dn = dx*rpi.d` huge → ROUND(dn) saturates in cvttsd2si (n = −2^31) → `dx = dx − dn*pihi.d` ≈ 2^288 → xsq ≈ 2^576 → the result overflows float → sinf/cosf return ±inf/NaN. FTOFIX32's `(int)(±inf·65536)` is another cvttss2si saturation → 0x80000000 (−32768). Every sin/cos-derived entry of guMtxF2L became −32768, so the Rareware logo's guRotate matrix was garbage and all its triangles projected off-screen (the D72.3 symptom; the "checkerboard" was a separate large triangle rasterizing over the empty logo region). The logo scene is the first visually verifiable consumer of guMtxF2L sin/cos output in the intro; other scenes that build matrices via guRotate/guLookAt are affected by the same bug and were fixed by this change. Fix (narrow ABI exception — constant interpretation only, algorithm verbatim): `duD()` static-inline + `DVAL(x)` macro in src/libultra/gu/guint.h under #ifdef PORT re-pack hi/lo into the correct LE double; the 5 `.d` reads in sinf.c and 4 in cosf.c now use `DVAL(...)`. The N64 build is untouched (#else branch = original expression). Only sinf.c/cosf.c use `du` (grep-verified); `fu` (single u32) constants are endian-safe. Verified numerically: P decodes to the expected perspective matrix, Rot(−40°Y) gives clean cos=0.766/sin=−0.643, and logo vertices clip to small finite on-screen NDC values. (tools_pc/mtxtest.c is a standalone scratch harness for the matrix-convention question that was ruled out along the way — GE stores all matrices transposed; row-vector ≡ column pipeline.) **D74 (RESOLVED — final pixel check pending):** with D73 in, logo geometry was correct but the texture side was still wrong: a dim red-brown blob plus a flat vertical bar at the right edge, one letter visible at a time. Three port-layer bugs, all in port/fast3d/gfx_pc.cpp (with gfx_pc.h): - **Import fallback truncation.** `import_texture`'s old condition `(rdp.tex_lod && tile >= rdp.first_tile_index + rdp.tex_detail) || !loaded_texture.addr` overwrote valid gDPLoadBlock tmem data with `line_size_bytes * tile.height`. For the logo: letter mip chains 2744 B → 2048 B (mips 1–5 dropped), and D_02005FF0's 32×32 → 192 B (32×3, stale tile.height=3 left by `gsDPSetTileSize(0, 46, 116, 124, 124)` in DL D_02004758). Fix: fall back only when `!loaded_texture.addr` (N64 TMem is persistent — a populated slot is the faithful source). - **Cache poisoning.** TextureCacheKey lacked the upload size, so the first (truncated) import of an address poisoned every later import. Added `size_bytes` to the key (gfx_pc.h + both key initializers + the bucket-only aggregate in gfx_texture_cache_delete). - **Sub-tile UV wrap period.** N64 wraps UVs by the TILE size when a render tile is a sub-region of the image; the port wrapped at the full uploaded image size (GL_REPEAT). D_02004758's 20×3 tile at offset (11.5, 29) therefore sampled row 0 instead of rows 29–31 — the flat bar's color matched texel [0,4] of D_02005FF0 exactly. Fix: per-vertex pre-wrap in the VBO path — for a WRAP sub-tile (`tex_width2 < tex_width`, where `tex_width2 = (lrs−uls+4)/4` is the tile window in texels), `u = fmodf(u, tw); if (u<0) u += tw; u += uls/4.0f` (same for v). Known limitations: half-texel edge bleed at the sub-tile window edges (GL bilinear neighbors may sample just outside [O, O+W)); mirror sub-tiles are not handled (the logo doesn't use them; audit if another scene regresses). Verified: runtime import sizes correct (D_02005FF0 = 2048 B, imgRAre = 2744 B); PPM frame ~555 shows the four-letter RARE band with gold/warm colors and per-letter segmentation; clean `GE_PCDUMP` runs are stable past 1156 frames. Final pixel-perfect confirmation against N64 reference footage is still open (see HANDOFF Task 1). All env-gated probes added for D72.3/D74 (GE_DBGUV/GE_DBGTRI/GE_DBGTALL/GE_DBGMAT/GE_D74IMP/GE_D74DUMP/GE_DBGLOAD) were stripped with this change; the previously committed TEMP D63 blocks and the GE_D71LOG normalize log remain on the HANDOFF strip list. **D75 (OPEN: 3D rendering — mispositioned / missing 3D models throughout the front end).** Post-D74 the Rareware logo is correct (gold letters on dark blue), but every other intro 3D element is still wrong. Confirmed symptoms (user observation, 2026-08-28 session L): - **Nintendo logo** renders but is **positioned wrong**. - **Gun-barrel intro**: the **James Bond figure is entirely missing** (the animated walk-and-shoot character model). Barrel/spiral effect status not separately confirmed. - **Intro credits / cast roll**: the per-character 3D models (each shown beside their actor/character name) **do not appear at all** — names draw, models do not. Pattern: 2D/texture and text elements draw; **skeletal/animated character models never appear**, and non-animated 3D (logos) appears but with a bad transform. This strongly suggests **(b) below is the dominant bug** — the animated-model path is broken independently of the matrix sin/cos fix. Two candidate causes — distinguish before any fix: - **(a) D73 scope gap.** D73 (`DVAL()` in `src/libultra/gu/guint.h`, PORT-only) was documented as fixing *"all scenes using guRotate/guLookAt-derived matrices … not just the logo."* If these elements still fail, either their matrix path bypasses that fix or a second coordinate defect remains. - **(b) Separate model/RW-pool path.** The player models specifically may be on the `animInit` + embedded raw-offsets-into-`struct player` path (cf. D56), which is *not* the guMtxF2L sin/cos path — i.e. an independent bug, not "the same coordinate issue." Files to check: `src/libultra/gu/guint.h`, `src/game/model.c`, `bondview2.c` (animInit / modelSetScale sites), `port/fast3d/gfx_pc.cpp`. Verify: determine whether the Nintendo logo and intro player models build matrices via guRotate/guLookAt (→ 75a) or another transform (→ 75b); capture `GE_PCDUMP` frames across the logo transition and the gun-barrel/cast segments to localize. **D76 (OPEN: 2D graphics — disclaimer/legal screen only partially drawn). ROOT-CAUSED (M-31) → D164.** The image-table/D68 hypothesis is **wrong**: the legal screen (`constructor_menu00_legalscreen`, `front.c:1523`) references **zero** `sImageTableEntry`s — it is 12 lines of `langGet()` font text (`display_aligned_white_text_to_screen` → `textRender`) plus one 3D `logoinst` model (`subdraw`, D75 family, parked). The "only 2 lines render" is a **linker-adjacency bug** in the text loop — see **D164**. **D77 (OPEN: audio — music runs in code but no audible output on PC).** Intro music is processed without fault, but **nothing reaches the PC speakers.** This is distinct from the earlier audio work: D54/D54b only stopped the audio-thread SIGSEGVs (`__getTrackByte`, `alLoadParam`); they do not imply the mixed output is routed to a device. Per §6 Audio the intended path is libaudio (CPU synth) → PD's `audio.c` (SDL device) + `mixer.c`; the likely gap is that the SDL audio device / mixer queue is never opened or fed — synthesis runs but the AI-DMA→device handoff never happens. Files to check: `port/src/audio.c`, `port/src/mixer.c`, `src/audi.c` (`OUTPUT_RATE`), and the AI shim in `port/src/libultra.c`. Verify: confirm an SDL audio device is opened and the mix buffer is written/pushed; check `OUTPUT_RATE` match (PD = 22020 Hz stereo s16) and that the AI-DMA shim feeds it. **Cross-cutting (Q1 — shared blocker?):** before ordering D75 vs D77, spend one check on whether any single root cause touches both audio and rendering. Current evidence says **independent** (audio = libaudio→SDL device; 3D = fast3d RSP emulation; 2D = image-table fixup + texture import) — default to render-first if no shared cause is found. **Non-negotiable #2 refinement (applied to AGENTS.md).** The original "game code compiles unmodified / fix belongs in port/" is too absolute: pointer-width layout cannot be isolated in `port/` (no hook between the romCopy and the first read). Refined to "game **logic** is unmodified" with a narrow, documented exception for mechanical, semantics-preserving ABI/layout changes forced by the 32→64-bit transition (embedded pointers → u32 + cast at use; PC-guarded pool sizing), following PD ground truth. No logic/behavior changes; each such edit is logged in §F/D3x. **D78 (RESOLVED — StandTile bitfield ABI, `#ifdef PORT` layout exception).** `StandTile` (`src/bondtypes.h`) declares `u32 id : 24;` immediately followed by a non-bitfield `u8 room;`. On MIPS/GCC (N64) these share one 4-byte storage unit (id = bits 31:8, room = the low byte) giving an 8-byte tile header (id/room word + `mid` u16 + `tail` u16) — the stride `list_of_tilesizes[]` (0x20…0x58 = `8 + 8*pointCount`) and `stanFillin`'s `link << 3` addressing both hard-depend on. x86 GCC never lets a non-bitfield member share a bitfield's storage unit, so the stock declaration compiles to a **10-byte** header on PC (`room`@4, `mid`@6, `tail`@8 — confirmed via `offsetof()` probe against the real project headers/flags) — every `tile->room`/`tile->mid`/`tile->tail`/`tile->points[]` access would silently misalign, independent of any byte-swapping. `id` is provably dead (no `.id`/`->id` read or write anywhere in the compiled game code — grep-verified across `src/`), so under `#ifdef PORT` it is widened to `u8 id[3]` (order irrelevant, decorative-only) with `room` immediately following as a plain byte. Verified: this restores the exact N64 stride (`room`@3, `mid`@4, `tail`@6, `points`@8). Layout-only, no behavior/logic change — same class as D53.2. This is a prerequisite for D69 (byte-swapping alone cannot fix stan tile reads if the struct itself is misaligned). **D79 (RESOLVED — `bg_room_data` pointer-width ABI, `#ifdef PORT` layout exception).** `bg_room_data` (`src/game/bg.h`) declares `pPointTableBin` / `pPriMappingBin` / `pSecMappingBin` as `void *`. These are ROM-serialized as plain 4-byte N64 segment-0x0F offset values and are **never dereferenced** anywhere in the codebase (grep-verified: every use in `src/game/bg.c` casts to `(u32)`/`(s32)`/`(u8*) + int` for arithmetic, never `->` or `*`). On x86-64 `void *` is 8 bytes, silently growing the 24-byte N64 room record to 40 bytes and breaking every `ptr_bgdata_room_fileposition_list[i]` array index. Fixed under `#ifdef PORT` by declaring them `u32` instead — verified via `sizeof()`/`offsetof()` probe: `sizeof(bg_room_data)` == 24, field offsets 0/4/8/12 (matching N64 exactly), so the room table needs no resizing in the offline conversion, only in-place bswap32. No behavior change (every existing use site already treats the value numerically); same class as D53.1/D66. **D80 (bg `.seg` format spec — converter spec of record).** Header (`s32 header[0x10]`, only words 0–4 consulted by `load_bg_file`): word0 must be 0 (bswap32, harmless either way); word1/2/3/4 are `0x0Fxxxxxx` self-relative offsets (masked `&0xFFFFFF`) to: room-fileposition list, portal-data-entry table, envdata table (0 = absent), and an optional f32 array (only meaningful if word3 != 0). **Table order in the file is not index order** — verified across all 34 unique NTSC bg files (`bg/*.seg` referenced from `levelinfotable`): word3 (envdata) < word2 (portal) in every sample; room-table extent = `[word1, min(word2, word3 if word3>word1 else word2))`; in every sampled file this divides evenly by 24 (`bg_room_data` record size) and word4 was always 0 (f32-array path unexercised in this ROM — converter asserts word4==0 and errors loudly if a future region violates this, rather than silently mishandling it). - `bg_room_data` (24B): 3× `u32` offset fields (D79) + `coord3d pos` (3×f32). All 6 words are plain numeric — blanket bswap32, **no resize** (PC stride == N64 stride after D79). - `bg_envdata_entry_local` (8B, local to `load_bg_file`): `u8 type` + `pad[3]` (untouched) + `s32 data` (bswap32). Terminated by `type==0`. **Exception:** when `type==ENVIRONMENTDATA_ALT` (100), `data` is not arbitrary — it is compared post-rebase against `g_BgPortals[i].offset_portal` (`getIndexOfPORTALID`), i.e. it lives in the *same offset space* as `bg_portal_data_entry.offset_portal` (the portal point-data blob, below) and must receive the identical `+portal_delta` relocation, in addition to bswap32. - `bg_portal_data_entry` (N64 8B: `u32 offset_portal` + 4× `u8`). Unlike `bg_room_data`, `offset_portal` **is** dereferenced pervasively elsewhere in `bg.c` (`->numPoints`, `->point`, portal/room-visibility walks) — declaring it `u32` would require touching dozens of call sites, so it is left as the native `bg_portal_entry *` pointer type (no header edit): PC `sizeof(bg_portal_data_entry)` is **16B** (`offset_portal`@0 8B, `connectedRoom1/2`+`controlbytes1/2`@8-11, 4B pad) — confirmed via probe. The **offline converter** (not game code) re-lays the table at 16B/record (N portal records + 1 zero terminator record), writing `offset_portal` as an 8-byte field: low 4 bytes = bswapped original offset value **+ portal_delta**, high 4 bytes = 0. `portal_delta = 8 * (N+1)` (the extra bytes inserted by 8B→16B growth). The `bg_portal_entry` point-data blob that follows the portal table in the file (target of every `offset_portal`/ALT-envdata value) needs **no per-record resize** — PC `sizeof(bg_portal_entry)` is 16B, identical to N64 (`u8 numPoints` + `pad[3]` + `coord3d point`, no pointer fields) — it is simply relocated by `+portal_delta` as a block, with `numPoints`/`pad` copied verbatim and `point` (3×f32) bswapped. Net effect: the whole `.seg` file grows by exactly `portal_delta` bytes; nothing outside the portal table/blob region needs remapping (room table, envdata, and the header's word1/word3 all sit *before* word2 and are untouched). **D81 (`Tbg_*_stanZ` format spec — converter spec of record).** RZ-compressed (`0x11 0x72` + raw deflate, same scheme as models — decompress/recompress around the conversion). Decompressed layout: `struct StanPrefixRecord { s32 stanfile; StandTile *ptr_firstroom; }` is dereferenced directly against the raw loaded buffer (`stanLoadFile`/`stanDetermineEOF` receive the file pointer itself as `StanPrefixRecord *`), so — same class as D78/D79 — the struct's PC-compiled layout must match the file's byte layout. N64: `stanfile`@0 (4B) immediately followed by `ptr_firstroom`@4 (4B pointer). PC: pointer-alignment forces an implicit 4B pad after `stanfile`, so `ptr_firstroom` compiles to offset **8**, not 4 — and each subsequent room-offset array slot is a real 8-byte pointer (`stanDetermineEOF`'s `void **roomPtr; roomPtr++` walk and in-place `*roomPtr = *roomPtr + delta` rebase already use genuine pointer-width semantics — **no code change needed there**, only the file's data layout). Converter fix (constant shift, no game-code edit): insert a 4B zero pad after `stanfile` (array now starts at file offset 8, matching PC struct layout), and widen every room-offset array slot from 4B to 8B (low 4 bytes = bswapped original file-offset value + `array_delta`, high 4 bytes = 0; the terminator NULL slot becomes 8 zero bytes). `array_delta = (8 + 8* (N+1)) - (4 + 4*(N+1)) = 4*(N+2)` where N = room-offset entries before the terminator. Everything from the old tile-data start to EOF shifts by `+array_delta` as a block; **tile records need no resize** (D78 restored the exact N64 8-byte header stride, and `StandTilePoint`/`link` addressing is already relative to `standTileStart`, computed at runtime — unaffected by where the tile-data block sits in the file). Per-tile conversion: the 4-byte id/room word is copied **verbatim** (D78 makes the PC struct byte-identical to N64 there — no swap needed, it's a byte array not a scalar); `mid.half` and `tail.half` (s16, top nibble of `tail` = `pointCount` selecting record size via `list_of_tilesizes[]`: `8 + 8*pointCount`, pointCount 3–10 → 0x20…0x58) are bswap16; each of the `pointCount` `StandTilePoint` entries (8B: x/y/z s16 + link u16) are bswap16 per field. The **N64-order (still-BE) tail half must be read to size each record** while walking — same discipline as the D50 model-node walk. Net: the whole stan file grows by exactly `array_delta` bytes before recompression; the RZ-compressed sidecar size (recorded in the manifest, patched into `rom_size`) differs from the N64 compressed size, same as pcmodels (D50) — this is expected and fine, nothing in the load path assumes N64 compressed size. **D82 (converter + port wiring — see `tools_pc/d69_emit.py` / `port/src/pccg.c`).** Implements D80/D81 above: per NTSC bg/stan file referenced by `levelinfotable`, converts and concatenates into `data/pccg-ntsc-final/pccg.bin` + `manifest.csv` (`name,offset,size` decimal, `file_resource_table` order) — same manifest shape as `pcmodels.bin` (D50/`d43_emit.py`). Port layer (`port/src/pccg.c`, cloned from `pcmodels.c`): `pccgReserveSize`/`pccgLoadSidecars`/`pccgPatchTable`, wired into `port/src/romdata.c`'s cart-reservation extension alongside `pcmodels*`, and `romdataCartAddrValid`/`libultra.c`'s D60 DMA-source bounds check extended to cover the pccg byte range. One-shot patch call: `pccgPatchTable()` from the same `load_object_fill_header` hook site as `pcmodelsPatchTable()` (idempotent, matches every table entry by filename, rewrites `hw_address`/`rom_size`). Regenerate: `python tools_pc/d69_emit.py [ntsc-final|pal-final|jpn-final]`; only `ntsc-final` regenerated/verified this session (PAL/JPN ROMs not present in this environment) — `data/pccg-*/` is gitignored like `pcmodels-*`. **D83 (RESOLVED — StandTileHeaderMid/StandTileHeaderTail bitfield ABI, found during D69 verification).** After D78-D82 landed, a clean run reached `stanBuildRoomData` (`stan.c:245`) without faulting, but then **hung forever** (kernel heartbeat: no frame rendered, stuck at the same PC across repeated snapshots). Root cause: same MIPS-BE-vs-x86-LE bitfield-packing class as D78, but in a struct D78 didn't touch. `StandTileHeaderTail { s16 pointCount:4; s16 headerC:4; s16 headerD:4; s16 headerE:4; }` — on N64/MIPS the FIRST-declared field occupies the HIGH bits (`pointCount` = top nibble); x86 GCC packs the first-declared field into the LOW bits (`pointCount` = bottom nibble instead). `tile->tail.hdrTail.pointCount` is read pervasively (`list_of_tilesizes[]` tile-size lookup used for navigation, edge walks, `stanBuildRoomData`'s bounds loop) — with the stock declaration this silently read the wrong nibble on PC. An env-gated probe (`GE_D69STAN=1` in `stanBuildRoomData`, TEMP, kept) proved it directly: tile tail=`0x03dc` (N64: pointCount=0, top nibble) decoded to `pointCount=12` on PC (bottom nibble) — `list_of_tilesizes[12]` is out-of-bounds (table has 12 entries, 0-11) and happened to read a stray 0, so `tile` never advanced — infinite loop. Fixed under `#ifdef PORT` by declaring both `StandTileHeaderMid` and `StandTileHeaderTail`'s fields in **reverse order**: x86's low-to-high packing then lands each field in the same bit position MIPS's high-to-low packing does (byte-identical numeric result, verified via a union/probe against `0x03dc` returning `pointCount=0`). `StandTileHeaderMid`'s fields (`special`/`r`/`g`/`b`) are never read via their bitfield names either (only via `.mid.half >> 0xc` elsewhere in stan.c) so that half of the fix is precautionary. Same narrow-ABI-exception class as D78; no logic change. **D84 (RESOLVED — bg.c hand-inlined segment-fold 64-bit-pointer overflow, found during D69 verification).** With D83 in, `stanBuildRoomData` completed and the game proceeded into room streaming (`bgCheckIfRoomModelNeedsLoad` → `bgLoadRoomModelData` → `bgLoadRoomVtxData`/`bgLoadRoomPrimaryGdl`/`bgLoadRoomSecondaryGdl`), which then **segfaulted** at a fixed, reproducible fault address (`0x7104561d`, identical across runs) inside `bgBuildRoomVtxBounds` (`bg.c:2852`, reading `vtx[i].v.ob[0]`). Root cause: `bgLoadRoomVtxData` / `bgLoadRoomPrimaryGdl` / `bgLoadRoomSecondaryGdl` each hand-roll the `BG_SEG_TO_PTR` fold instead of calling the macro: `offset = (((u8 *)room->pPointTableBin + ptr_bg_data) - ptr_bg_data) + 0xf1000000;` (and the Pri/Sec-mapping equivalents). On N64 this "+base -base" cancellation is a no-op inside 32-bit pointer arithmetic that wraps for free. D79 made `pPointTableBin`/`pPriMappingBin`/`pSecMappingBin` plain `u32` fields (never dereferenced, matching every other use site), but these three call sites still cast them to `(u8 *)` and did the arithmetic as real 64-bit pointers: `+0xf1000000` no longer wraps at 32 bits the way `BG_SEG_TO_PTR`'s explicit `(u32)` cast does, so the computed `offset` came out roughly 4 GiB too large, corrupting every downstream room-file read (compressed-data location and size). Fixed under `#ifdef PORT` by doing the fold as plain `u32` math at all three sites, matching `BG_SEG_TO_PTR` exactly (`offset = (u32)room->pPointTableBin + 0xf1000000;`, no pointer involved) — same narrow ABI-exception class as D79/D69's original BG_SEG_TO_PTR fix, no logic change. (Root-caused via an env-gated probe, `GE_D69BB=1` in `bgBuildRoomVtxBounds`/ `bgLoadRoomPrimaryGdl`, TEMP, kept — confirmed the compressed room-DL bytes now start with the correct `11 72` RZ magic at the right file offset.) **D85 (OPEN — room primary/secondary DL binaries decode to garbage after D84; safety-netted, not crash-fixed at the geometry level).** With D84 in, the compressed room DL binary loads and decompresses correctly (verified: `11 72` RZ header at the right offset, plausible decompressed size), but the **content** `texCopyGdls`/`texLoadFromGdl` produce from it is not a valid GBI command stream (`GE_D69BB=1` dump: `cmd=00`, `01`, `02`, `52`... none of these are display-list opcodes actually present in the source bytes — the raw N64 bytes are untouched by the offline converter (D80: the whole per-room DL/point-index blob is a byte stream, deliberately left unconverted, out of scope for this milestone) and `texLoadFromGdl` is the *same, already-working* model-GDL runtime converter (`bgLoadRoomPrimaryGdl` calls it identically to the model-loading path) — so either room GDLs use a BG-specific command/marker convention `texLoadFromGdl`'s marker-expansion logic doesn't handle, or something upstream of it (compression alignment, `csize_primary_DL_binary`/`csize_secondary_DL_binary` delta sizing) is still off. Not yet root-caused; full triage is D75-class 3D-pipeline work, out of scope for this session. **Crash prevented, not geometry fixed:** added a `#ifdef PORT` bounds check in `bgBuildRoomVtxBounds` before every `vtx[i]` dereference (`vtxOff`/`vtxEnd` must fit inside `usize_point_index_binary`) — a garbage command stream now produces an empty/degenerate bounding box for that vertex batch instead of an out-of-bounds read, so a bad room fails to render sanely rather than segfaulting. Follow-up: decode what `texLoadFromGdl` actually does with room-specific opcodes (`bgApplyDynamicCCRMLUT`/`ptrDynamic_CC_RM_LUT`/ `DL_LUT_PRIMARY_ADDFOG` suggest room GDLs carry CC/RM-LUT-selection markers models don't use) and verify `csize_*_DL_binary` sizing end-to-end. **D86 (RESOLVED — `modelInitRwData` crash was a single truncating pointer cast in the player's embedded gait/arm model init, unrelated to bg/stan).** Root-caused with a new env-gated trace (`GE_D86=1`: node-walk trace in `modelInitRwData` + a load-identity probe in `load_object_fill_header`, both TEMP, left in place). The trace showed the crash node's low 32 bits were `(header_ptr & 0xFFFFFFFF) + 0x1E0` with the high 32 bits zeroed, and that this header was **never** loaded via `load_object_fill_header` (no matching probe line) — pointing at a statically-embedded model, not a dynamically-loaded one. `src/game/initplayergaitobject.c:5` does `player_gait_object_header.RootNode = (int)&player_gait_hdr;` — a same-width (32-bit) pointer→int→pointer round trip that's a no-op on N64, but on PC `(int)` truncates the real 64-bit `&player_gait_hdr` to its low 32 bits, and the implicit int→pointer conversion back into `RootNode` zero-extends it, dropping the executable's load-base high bits (module maps at `0x140000000`, so the truncated pointer silently loses the `0x1`). `init_player_gait_object()` runs once from `boss.c:236`, and `player_gait_object_header` is only used once real gameplay starts (`initBondDATAdefaults.c:99` `animInit`s the player's gait model) — never exercised while the game only ever got as far as the intro/cast screens. Fixed with a `#ifdef PORT` branch in `initplayergaitobject.c` that assigns the real pointer directly (behavior-identical to the N64 assignment, ABI-width fix only). Verified: BUNKER1 now loads past this point with a clean, deterministic repro via `-level_09` (see D88). **D87 (RESOLVED — attract-mode demo playback (`ramrom_replay_handler`) crashed on a big-endian `ramromfilestructure` read with no byteswap).** Found while re-verifying D86: an idle front-end run (no player input) eventually calls `select_ramrom_to_play()` (`ramromreplay.c`), which picks a random compiled-in demo blob from `ramrom_table[]` (`ramrom_Dam_1`, `ramrom_BunkerI_1`, etc. — genuine shipped attract-mode assets, not a debug-only feature; the debug-menu replay path, `DEB_REPLAYRAMROM`, is structurally unreachable in this `ntsc-final`-equivalent build since `DEBUGMENU` isn't defined — confirmed with a `gdb -p ` **attach** hardware watchpoint on `is_ramrom_flag`, which resolved cleanly and quickly this session; attach mode works fine for a non-timing-dependent write, unlike the launch-mode-only guidance logged after the D56 session — worth a retry next time attach seems useful). `replay_recorded_ramrom_at_address` loads `ramromfilestructure` via `romCopyAligned()`, a raw byte copy (by design, D66) from a real ROM-compiled asset — so, like every other N64-compiled ROM asset, its multi-byte fields are big-endian, and nothing byte-swaps them on read. A real `size_cmds` of 2 (BE bytes `00 00 00 02`) read as native LE prints as `33554432` (`0x02000000`); that garbage then drives the loop bound and pointer arithmetic in `iterate_ramrom_entries_handle_camera_out`/`ramrom_replay_handler`, which walks far outside the small `ramrom_blkbuf_2`/`ramrom_blkbuf_3` scratch buffers and segfaults reading `temp_v0->stick_x` (`ramromreplay.c:301`/`ramrom.c` callers). Root-caused with a new env-gated probe (`GE_D87=1`, left in place). Fixed with a `#ifdef PORT` `ramromFixupEndian()` in `ramromreplay.c`, called once right after the `romCopyAligned()` in `replay_recorded_ramrom_at_address` (same pattern as the D54 cseq-header fixup): byte-swaps every multi-byte field (`u64`/`u32`/enum fields via `__builtin_bswap64`/`32`, `save_data.options` via `bswap16`); `save_data`'s single-byte fields and the `times[]` byte array are left alone. The **downstream** per-frame chunks (`ramrom_seed`/`ramrom_blockbuf`, read via the same `romCopyAligned` pattern in `iterate_ramrom_entries_handle_camera_out`) are all-`u8` structs and need no swap. Not BUNKER1-specific — this is a front-end/ attract-mode path that can select any of the 7 demo locations at random; use `-level_09` (see D88) to skip the front end entirely for deterministic BUNKER1 testing instead of waiting on/fixing attract mode. **D88 (OPEN — root-caused, next blocker: per-level `Usetup*Z` "stage setup" file is raw N64-endian/width ROM bytes read directly through a PC-widened struct, with no conversion at all).** Found immediately after D86/D87 while re-verifying BUNKER1 specifically — launch with `-level_09` (`boss.c:199-339` decodes `-level_XX` into `g_StageNum`, bypassing the front end/attract-mode entirely for a fast, deterministic repro; NTSC `LEVELID_BUNKER1 = 9`, and the token's two digit-chars are consumed as raw ASCII bytes, so `"09"` → `'0'*10 + '9' - 0x210 = 9`) reaches the exact same crash as the random attract-mode run, immediately and reproducibly: `proplvreset2` (`prop.c:1306`) segfaults reading `g_CurrentSetup.pathwaypoints[i1].padID`. `prop.c:1267-1282` loads the level's `"UsetupZ"` file with `_fileNameLoadToBank` (raw ROM bytes, **not** run through any PC-layout converter — unlike bg/stan (D69/D80-82) and models (D43/D50), this asset type has zero PC porting work done on it) into `local_stage`, then rebases 10 top-level fields (`pathwaypoints`/`waypointgroups`/`intro`/`propDefs`/`patrolpaths`/ `ailists`/`pads`/`boundpads`/`padnames`/`boundpadnames`, plus nested `neighbours`/`waypoints`/`ailist` pointers inside the sub-tables) with `(void *)(((u32) local_stage) + ((u32) local_stage->pathwaypoints))` — i.e. by reading the *raw file bytes* directly through the live `struct stagesetup` (`bondtypes.h:4091`), whose 10 fields are declared as real pointers. This is worse than a plain missing-byteswap bug (cf. D87): on N64 those 10 fields are 4 bytes each (40-byte header, correctly self-describing "byte offset from file start" per the code's own comment), but the PC struct widens every pointer field to 8 bytes (an 80-byte header) — the same class as D79 (`bg_room_data` pointer growth) — so field N's read doesn't even land on the right *bytes* of the file past field 0, before even considering that the 4 meaningful bytes it does read are big-endian. Confirmed no PORT/byteswap handling exists anywhere in `prop.c` (`grep` for `bswap`/`#ifdef PORT` in the file: zero hits). **Not fixed this session** — this is format-conversion work at the same scale as D69 (a whole ROM asset type needs a byte-accurate spec + either an offline converter sidecar, the established preferred pattern per AGENTS.md, or a careful runtime fixup pass that parses the raw 40-byte N64-packed header by explicit byte offset, byte-swaps each field, and writes the results into the PC-widened `stagesetup` struct — plus the same treatment for every nested sub-table referenced from it (`waypoint`/`waygroup`/`PropDefHeaderRecord`/`PathRecord`/`AIListRecord`/ `PadRecord`/`BoundPadRecord`/`pname`, each of which likely has its own internal offsets/BE fields not yet audited). **This is the actual next blocker to a rendered BUNKER1 frame** — reachable deterministically via `-level_09` in well under a minute, no attract-mode wait required. **D88.1–D88.3 RESOLVED / VERIFIED (2026-08-28, session L).** The `Usetup*Z` offline converter (`tools_pc/d88_emit.py`, 531 lines) was written and run in a prior interrupted session; this session verified its output is correct and consumed at runtime: - `port/src/pccg.c` `PCCG_MAX_FILES` grown 128 → 256 so the sidecar image can also carry the 21 `Usetup*Z` rows (manifest now has them, e.g. `UsetuparchZ,3375808,19265`). - The converter delta-relocates the 8 growing tables (header 40→80B, waypoint 16→24, waygroup 12→24, PathRecord 8→16, AIListRecord 8→16, PadRecord 44→56, BoundPadRecord 68→80, pname 4→8) and bswaps the s32 ID/offset fields — same technique as D80/D81, generalized to many interleaved regions. - `src/bondtypes.h` `SetupIntroCamera`: `lang1c`/`lang20`/`prev` ROM-serialized pointer-shaped fields kept narrow (`u32`) under `#ifdef PORT` so `sizeof` stays 40 and the fixed-stride intro-record walk in `bondview_r.c`/`bondview2.c` still matches the 40-byte file records; use sites cast `(char *)(uintptr_t)` at each read. Same class as D79/D53.1. **Write-before-read verified**: `bondviewLoadSetupIntroSection` (`bondview_r.c:276-300`) writes `prev` (list link) and both `lang_ptr` members (via `langGet()`) before the only subsequent reads. - **Verified via `-level_09` + `GE_D88=1` probe**: `proplvreset2` now walks the entire pads table correctly — plink name strings (`p1988e`, `p12295e`, …) and sane BUNKER1 world coordinates for every pad; the crash at `prop.c:1306` (`pathwaypoints[i1].padID`) is **gone**. **D88.4 RESOLVED / VERIFIED (2026-08-28, session M).** The `propDefs` polymorphic record stream is now converted N64→PC by the offline sidecar. `-level_09` no longer crashes in `setupDoor`/`modelLoad` (or anywhere else) — BUNKER1 loads its full stage setup and renders **1000+ frames continuously** with zero FATAL/EXCEPTION (only the pre-existing unrelated `romdataFixupMusicSeqTable` warning). Key facts established: - The `propDefs` stream is a flat `s32[]`. Each record's **serialized N64 word count is fixed per `type` byte across all 21 levels** — verified by parsing the getools C sources (`assets/obseg/setup/Usetup*Z.c`), which tile every `propDefs` region byte-for-byte against the retail ROM (BUNKER1: 206 records, 6477 words, end offset lands exactly on `intro`). Table: `PROPDEF_N64_WORDS` in `tools_pc/d88_propdefs.py`. - Every pointer member inside a serialized record is **`0` in the file** (runtime-populated by the `New_*Record` macros). So there is no garbage-pointer / delta-relocation problem in the file — only that on PC those slots widen 4→8B, growing records that contain pointers and changing the `sizepropdef()` walk stride. - The brief's "narrow the trailing pointers `#ifdef PORT`" idea (D88.1 pattern) is a **poor fit**: `ObjectRecord.prop`/`.model` are mid-struct, and `ObjectRecord`/`DoorRecord`/`GuardRecord` are used in ~350 runtime sites — narrowing would touch core gameplay code broadly. - The `#if 1` branch of `sizepropdef()` was **already N64-correct for every type except `OBJ_COPY_ITEM`** (returned 1, real serialized size 3). Fix (chosen: **converter grows records to native PC layout**, no struct changes): 1. `tools_pc/d88_propdefs.py` — rewrites each record to its native PC struct size: header word (`u16 extrascale`+`u8 state`+`u8 type`) and `_mkword` half-pairs byte-swapped independently, scalars `bswap32`'d, pointer slots widened to 8 zero bytes (8-aligned), runtime areas zero-filled. Per-type PC size = `PROPDEF_PC_BYTES`, sourced from the compiler-verified `tools_pc/d88_layoutprobe.c` (`sizeof`/`offsetof` against the real port include chain). 2. `tools_pc/d88_emit.py` — feeds the converted stream + its growth into the cumulative delta so `intro` and every later sub-table shift. 3. `loadobjectmodel.c` `sizepropdef()` — `#ifdef PORT` branch returns `PROPDEF_PC_BYTES/4` so the in-place walk (`prop.c`, `loadobjectmodel.c`, `objective_status.c`) matches the emitted stride. `intro` conversion was already correct (blanket per-word `bswap32`; the type discriminant is a full `s32`). **Not yet done:** `PROPDEF_PC_BYTES` for `VEHICHLE`/`AIRCRAFT`/`TANK`/`AMMO`/`DEPOSIT_IN_ROOM` are placeholder guesses (not used by BUNKER1) — probe them before those levels load. **D88.5 (WATCH — stan tile name lookups all miss during pad setup).** With the `GE_D88` probe on, every `stanMatchTileName` call from `proplvreset2`'s pad loop walks the full ~2599-tile room and returns "no match". May be benign at load (a NULL `pad->stan` is tolerated by the reset path), but the stan-id derivation (`stanIdHi`/`stanIdLo` from the pad `plink` name) could be another victim of a residual endian/width bug. Re-check after D88.4; do not treat as resolved just because it doesn't crash. **D88.5 (RESOLVED — stan tile-name byte-swap in the converter).** With the `GE_D88` probe on, every `stanMatchTileName` call during pad setup missed (0 matches / 276 misses). Root cause: `stanMatchTileName` reads a tile's packed name id through a `StandTilePoint` alias — `(u16)tile->x == stanIdHi` and `*((u8 *)&tile->y) == stanIdLo`. D78 left the 4-byte id/room word as a verbatim byte array on the premise "`id` is provably dead", which missed this aliased *scalar* read: on little-endian PC the `(u16)` load of the big-endian id-hi bytes comes back byte-swapped. Fix (converter, `tools_pc/d69_emit.py` stan path): swap bytes 0–1 of the id/room word; byte 2 (`stanIdLo`) and byte 3 (`room`, read as `tile->room`) stay put. Verified 273/273 name matches after the fix; pads now resolve real stan tiles. Needs sidecar regen. Committed. **D88.6 (RESOLVED — intro CAMERA `lang1c` is a `u16` pair).** `SetupIntroCamera.lang1c` is `union { u16 lang_index[2]; u32 lang_ptr; }` and `bondview_r.c:295` reads `lang1c.lang_index[1]`. `d88_emit.py`'s intro converter `bswap32`'d the whole word, which swaps element 0 with element 1 — the consumer then read the wrong language-slot id, indexed `g_LangBanks` out of range and crashed in `langGet` (`language.c:421`) on a NULL bank. Fix: byte-swap each `u16` of `lang1c` in place; `lang20` (a real `s32`) still gets `bswap32`. Needs `d88_emit.py --regen`. Committed. **D89 (RESOLVED — two crashes between stage-load and first frame).** (a) `init_path_table_links` (`initpathtablelinks.c:144`): `validationGroupCursors[-3]` is a decomp artifact — a constant negative index into a 1-element stack array standing in for a plain cursor local. GCC proves it OOB and emits a trap → SIGILL on PC. Fixed under `#ifdef PORT` by pointing the name 3 elements into a real 4-element backing buffer (identical `[-3]` expressions, now in bounds); N64 build keeps the plain array. (b) `sub_GAME_7F0B0914` (`walkTilesBetweenPoints`): callers like `domakedefaultobj` pass `&pad->stan`, legitimately NULL when a pad's stan name doesn't resolve. On N64 the walk reads ~0 for `pointCount` and returns TRUE via the `crossings==0` early-out; on PC the near-NULL read faults. Guarded `*tileStack == NULL → return TRUE`. Committed. **Session-M-2 infra fixes (committed).** Two port-layer gaps that made `-level_09` a no-op were fixed: (1) `osPiReadIo` was stubbed to 0 so the cartridge-token read always yielded an empty string — N64 debug switches were silently ignored and only attract-mode demo playback could reach a level. Now synthesised from `argv[1..]` (`sysGetTokenString` in `system.c`, served from the 0xFFB000 range in `libultra.c`). (2) `pccgPatchTable`/`pcmodelsPatchTable` were called lazily from the first model load; a direct `-level_XX` boot loads a stage first and `load_bg_file` read raw big-endian ROM. Moved the one-shot calls to the end of `obInit()`. NOTE: a bare `-level_09` still needs the per-level memory args too (`-ml0 -me0 -mgfx100 -mvtx50 -mt700 -ma150` for BUNKER1, from `boss.c`'s `memallocstringtable`) — the `-level_` branch skips the default `-m*` string. TODO: auto-inject from `memallocstringtable` in the port so bare `-level_XX` works. **D90 (RESOLVED — `stanTileDistanceRelated` zero-fill overran the caller's stack).** Symptom: after D88.5/D88.6/D89, `-level_09` loaded BUNKER1 and faulted in `stanIsSpecialBit1Set` (`stan.c:2364`, `arg0 == NULL`) on the first player collision tick (`bondviewCalcUpdatePlayerCollision` → `bondviewTrySimpleMovePlayerCollision` → `bondviewTryMoveToStan` → `stanTileDistanceRelated` → `sub_GAME_7F0B1DDC` → `callbackA(NULL, …)`). Root cause was NOT the pad→stan resolution (GE_D90 probe confirmed all 159 BUNKER1 pad names resolve and the player spawn pad #102 has a valid stan). It was `stanTileDistanceRelated`'s N64 "HACK" init loop: it zero-fills `((s32*)arg4)[0..19]` — **80 bytes** — while `sizeof(StandTileLocusCallbackRecord)` is 16B. On N64 the 64-byte overrun landed in adjacent stack scratch; on PC the frame layout differs (and locals are pointer-widened), so the fill zeroed `bondviewTryMoveToStan`'s live `sp90` (= `field_488.current_tile_ptr`) right before it was passed as `&sp90` to the walk. Fix: `#ifdef PORT` clears exactly the 4 record fields (every consumer only uses those four — cf. `sub_GAME_7F0B21B0`). Committed. GE_D90 probes left in place (env-gated). **D91 (RESOLVED — bg portal-descend truncated an array-element address).** `sub_GAME_7F0B7F84` (both variants, `bg.c`): `i = (s32) &D_800442FC[ portalnum];` then later `*((u8 *) i) = depth;`. The `(s32)` cast drops the top 32 bits of the array address on PC, so the byte store faulted during portal occlusion culling. `i` is only used as an `if (i);` no-op after the cast, so under PORT keep it a plain value and write `D_800442FC[portalnum] = (u8) depth;` directly. Committed. **D92 (RESOLVED — two truncated pointers on the chr/AI spawn path).** (a) `chrAllocate`'s 5th parameter was declared `s32` but both call sites pass `ailistFindById()`'s `AIRecord *`. The 64-bit pointer was truncated binding to `s32 arg4`, then forwarded to `init_GUARDdata_with_set_values`'s `AIListRecord *arg5` → `chr->ailist` held e.g. `0x40127640` instead of `0x140127640`, and `ai()` faulted on `(AiListp + Offset)->cmd` at the first AI tick. Param widened to `AIListRecord *` under `#ifdef PORT` (`chr.c` + `chr.h`). (b) `Model.unka0` is a 32-bit field that on N64 holds a function pointer — always `sub_GAME_7F01FC10` (`chr.c:1618` stores `(s32)sub_GAME_7F01FC10`, the only value the setter ever gets). `model.c` `subcalcpos` calls it back through a cast → truncated jump target. Widening the field would shift the rest of `Model`, so under PORT the setter stores a nonzero flag and `subcalcpos` calls `sub_GAME_7F01FC10` directly. Committed. **D85 revisited (OPEN — now the live blocker on `-level_09`).** With D90–D92 in, BUNKER1 loads and ticks all the way to the **first render**, which immediately hits `sysFatalError("Bad size for RGBA texture in tile 0: 00")` (`port/fast3d/gfx_pc.cpp:967`) — a `G_SETTILE` with `fmt=RGBA siz=0` (invalid). This is the room-GDL-decodes-to-garbage problem from D85 (the raw N64 per-room DL/point-index blob is left unconverted, D80), surfacing in the texture path this time rather than `bgBuildRoomVtxBounds`. Attract mode's "~2100 frames" never hit this because those frames were HUD/menu screens, not room geometry. This is the render-milestone work: decode what `texLoadFromGdl` does with room-specific opcodes / CC-RM-LUT markers, and verify `csize_*_DL_binary` sizing (see the original D85 entry above). Interim option if a fresh session wants to keep moving past it: soften the four `sysFatalError` "Bad size…" guards in `gfx_pc.cpp` to skip-with-warning (same safety-net philosophy as the D85 `bgBuildRoomVtxBounds` bounds check) so the frame renders with placeholder textures instead of aborting. **D93 (RESOLVED — null-room (room 0) NULL-deref sat in front of the D85 texture wall).** Committed `164d7f99`. On `-level_09` the visible-room draw list includes room 0, which has no geometry (`csize_*_DL_binary == 0`), so `bgLoadRoomModelData` never assigns `ptr_expanded_mapping_info` and it stays NULL. Two consumers then walk it unconditionally: `bgApplyDynamicCCRMLUT` (called `start=NULL, end=NULL` → the `end==NULL` sentinel-scan branch derefs address 0) and `bgBuildRoomVtxBounds` (`while (gdl[i].dma.cmd != G_ENDDL)` on `gdl=NULL`). On N64 address 0 is readable RDRAM so both walks wander harmlessly; PC page 0 is unmapped. Fixed with narrow `#ifdef PORT` NULL guards (same safety-net class as the D85 `bgBuildRoomVtxBounds` vtx-bounds check). `-level_09` now reaches the documented D85 `sysFatalError("Bad size for RGBA texture in tile 0: 00")`. **D85 root cause CONFIRMED (was "not yet root-caused").** The decompressed per-room primary/secondary DL blob is **raw N64 data with 8-byte big-endian `Gfx` slots**, but every PORT-patched consumer (`texCopyGdls`/`texLoadFromGdl` in `tex.c`, `bgApplyDynamicCCRMLUT`, `bgBuildRoomVtxBounds`) was patched under `#ifdef PORT` to assume 16-byte little-endian PC `Gfx` slots — so they stride at 2× the real rate and read the middle of each N64 command pair as an opcode (the observed `cmd=00,01,02,52…` garbage), and even at the right stride the `w0/w1` words are unswapped BE. The model-GDL path is immune only because its offline sidecar (`tools_pc/d43_*.py`) pre-widens + byteswaps every GDL; D80 explicitly left the per-room DL/point-index blob unconverted. **Fix chosen: runtime fixup**, not a sidecar — the blob is RZ-compressed inside the bg `.seg` and delta-sized (its size is only the offset delta between consecutive rooms' `pPriMappingBin`), so an offline widen would force rewriting the whole bg-header offset table / recompression. The transform is purely mechanical (8→16 byte widen + `bswap32` each word); room GDLs need no pointer remapping (`G_VTX` seg addresses are resolved at runtime via `SEGMENT_OFFSET(...) + (u32)vertices`, textures via the `G_NOOP`+`texnum` marker). Implemented in `bgLoadRoomPrimaryGdl` / `bgLoadRoomSecondaryGdl` between `bgDecompress` and `texCopyGdls`, plus a `Vtx` short-field bswap in `bgLoadRoomVtxData` (positions/uv are BE; the 4 rgba `u8`s are fine). Watch the alloc budget — the PC blob is 2× the decompressed size. Also fix the pre-existing bug at `bg.c:2448`: `texLoadFromGdl((Gfx *)scratch, (Gfx *)expanded_size, ...)` casts the size arg to a pointer. **D85 widen fix IMPLEMENTED (committed `ea8a37a0`/`c732425d` = master `c732425d`).** `bgWidenRoomGdl()` — in-place back-to-front 8→16 widen + `bswap32` per word — runs in `bgLoadRoomPrimaryGdl`/`bgLoadRoomSecondaryGdl` right after `bgDecompress`; the doubled size flows into `texCopyGdls`/ `texLoadFromGdl`/`usize_*_DL_binary`. `bgSwapRoomVtx()` `bswap16`s the 6 leading `u16`s of each `Vtx` in `bgLoadRoomVtxData`. The `bg.c:2448` size-cast-to-pointer bug is fixed under `#ifdef PORT`. Verified: room GDLs now decode to real GBI (`GE_D69BB` dump: `E7` RDPPIPESYNC / `BA` SETOTHERMODE_H / `B9` SETOTHERMODE_L / `FC` SETCOMBINE / `BB` TEXTURE / `B7` SETGEOMETRYMODE), G_NOOP markers decode to sane texnums. Alloc budget needs no change (first-load block is `memaGetLongestFree`-sized, then shrunk from post-widen `used`). **D85 texture pool FIXED (committed `2b3ee6e7` → master `6f0208d6`).** `ptr_texture_alloc_start` is declared `struct texpool *` but every use takes its address and treats the storage *as* a `struct texpool` (`texInitPool` writes 4 members through `&…`, `texLoad`/`texFindInPool` read them back). N64: 4-byte members, 16-byte struct, works by layout luck. PC: 32-byte struct (4 widened pointers) → `texInitPool` smashes 24 bytes of trailing BSS and `->leftpos`/`->rightpos` read back garbage → stage pool looks permanently exhausted (`texFreeBytesInBuffer() < 0`) → every room `texLoad` bails → `texFindInPool` NULL for every texnum → `Bad size for RGBA texture` abort. Fix: `#ifdef PORT` define it as a real `struct texpool` (`image.c:14`, `image.h:98`); all call sites already `&…` it. Verified: pool fills, ~630 room textures resolve, FATAL gone. **Latent (not fixed):** `sizeof(struct tex)` is 24 on PC vs 16 N64 (widened `u8 *data` + bitfield align); the pool is a dual stack (pixels up from `start`, `struct tex` headers down from `end`), so the stacks collide ~`bytes/3` early. BUNKER1 `-mt700` doesn't hit it; texture-heavier levels will need a separate header allocation or a per-level `-mt` bump. (Enlarging the pool alloc starves `MEMPOOL_STAGE` and hangs `mempAllocBytesInBank`'s OOM `while(1)` — don't.) **D85 texture wall CLEARED (session M-3, verified).** With sidecars present and `493c9838`+`6f0208d6` in, `-level_09` **no longer hits `Bad size for RGBA texture`** — BUNKER1 renders multiple full frames (`GE_D85GDL` probe: `g_BgNumberOfRoomsDrawn=5`, roomids 9/10/11/15/17, `b_min/b_max` 0/2, frame GDL advances ~3 KB/frame cleanly). The room-GDL stream-decode + texture-pool layers of D85 are done. What remains is a cluster of **non-deterministic** crashes newly reachable now that the render loop + guard AI actually run in-level (4-run sample: 3 distinct fault sites). Treat as a fresh crash-chain, not D85: 1. **`bgScissorCurrentPlayerView` frame-GDL overrun** (`bg.c:1355`, fault `0x70800000` = 1 byte past the 8 MB emulated DRAM). Hits ~1/4 runs. The frame GDL write pointer runs off the end — either the PC frame-DL buffer is undersized for real room geometry, or a room GDL without a clean `G_ENDDL` drives a runaway append somewhere upstream (`sub_GAME_7F0B3C8C` double loop over `chrpropsRenderPass` / `bgRenderRoomPrimary`). NOTE: an *earlier* "deterministic overrun" reading was a red herring — it was `data/` sidecars missing (see the `data/` deletion note below), which faults in `load_bg_file:847` on raw BE bg-header bytes. 2. **`chrlvInitActAttack` bad pointer** (`chraction.c:1316`, fault `~0x40123350`). Hits ~2/4 runs — a guard starting an attack derefs a truncated/garbage pointer (`0x401xxxxx` looks like a 64-bit pointer with the high word lost, cf. D86/D92 class). 3. **`gfx_sp_matrix` unrelocated segment addr `0x90000000`** (`gfx_pc.cpp:1077` via `gfx_run_dl`). Hits ~1/4 runs — a room-GDL `gsSPMatrix` w1 carries N64 segment 9 unresolved. Room-GDL matrix arg relocation still needed (the widen byte-swaps the words but does not remap seg addresses; `G_VTX` seg-addrs are resolved elsewhere via `SEGMENT_OFFSET`, but `gsSPMatrix` is not). 4. **`bgTestRayIntersectionInRoom`** (`bg.c:3302` `((u32*)gdl)[1]`, `:3383/3388/3521/3526` `*(u8*)gdl`) still reads opcodes/w1 N64-style; post-widen the opcode is byte 3 and w1 is `((u32*)gdl)[2]`. Operated on garbage before (so "worked"); needs PORT accessors. Hitscan, not render — lower priority. 3. **Frame-GDL buffer overrun in `chrpropsRenderPass`** (`bgScissorCurrentPlayerViewDefault`, `chrprop.c:569` → `bg.c:1355`, write fault ~`0x70800000`). Timing-dependent, masked behind #1. Prop/character render path (D75-adjacent), newly reachable. **D94 (RESOLVED — truncating `(s32)` pointer cast in `chrlvInitActAttack`).** Committed `63204a27`. `chraction.c:1221`/`1231` compute the firing-anim table entry as `(s32)arg1[anim_index]->table + (s32)(idx*sizeof(...))` — `table` is `weapon_firing_animation_table (*)[]`, and the `(s32)` cast truncates the 64-bit pointer, then the cast back zero-extends, dropping the `0x1_00000000` module-base bit → `panim_float ≈ 0x4012xxxx` → `panim_float->anim.anim` faults there when a guard starts an attack in-level. Replaced both with plain array indexing (`&(*table)[idx]`) under `#ifdef PORT` — the code's own comments say that's the intent. **D95 (OPEN — the `-mgfx` master-DL buffer is half-capacity on PC; the naive fix OOMs `MEMPOOL_STAGE`).** `dyn.c:56` sizes `g_GfxBuffers` from `-mgfx` (a byte budget from `boss.c`'s per-level `memallocstringtable`, sized for N64 8-byte `Gfx`). On x86-64 a `Gfx` is 16 bytes, so the master display list holds **half** the commands for the same budget. Every render fn appends with a bare `gdl++` and **no bounds check**, so once BUNKER1 emits real room geometry the list overruns `g_GfxBuffers[1]`/`[2]`, runs off the stage mempool, and faults writing a GBI command at the top of the 8 MB emulated DRAM (`0x70800000`) — **non-deterministically**, and scribbling GBI across DRAM on the way (the run-to-run `Unknown GBI opcode 0x3f/0xffffb9` and `gfx_sp_matrix` seg-9 `0x90000000` faults were all downstream corruption from this one overrun). Doubling the `g_GfxBuffers` allocation (`* sizeof(Gfx)/8`) under PORT **stops the overrun** (verified: `-level_09` then runs 90 s+ / 5000+ VI posts, no crash) **but** the extra ~100 KB starves `MEMPOOL_STAGE` so `zbufAllocate` → `mempAllocBytesInBank` (`memp.c:204`) spins in its OOM `while(1)` and no frame ever renders (`kernel heartbeat: frames=0`; mainThread stuck in `lvlRender`→`viClearZBufCurrentPlayer`→`zbufInit`). Committed as `70784f80`, reverted `2a506284`, **re-applied `f35eba91`** alongside the mempool-ceiling fix below. **`malloc`-the-gfx-buffer is NOT viable** — `osVirtualToPhysical` / `OS_K0_TO_PHYSICAL` in the port `(u32)`-truncate and subtract `0x70000000`, so any pointer that flows through them (sub-DL branches built in the gfx buffer, etc.) must live in the `0x70000000` DRAM window. **Mempool ceiling fix (`933ba52b`, kept).** `port/src/n64stubs.c`'s `tlbmanageGetTlbAllocatedBlock` returned the N64-fidelity ceiling `0x702F4400`, leaving ~5 MB of live mapped DRAM unused below the 8 MB top (only `animations_frame_buffer` @ `0x707FFD30` up there). Raised to `0x70700000`; the ~4 MB gain goes to `MEMPOOL_STAGE` (`boss.c:218`). This + the re-applied 2x `g_GfxBuffers` removes the OOM hang: `-level_09` now **renders ~5 frames** (`frame N rendered` logs, VI posts climbing). The "runaway GDL append at ~frame 5" that D95 chased turned out to be **two memory-corruption bugs**, both now fixed: **D96 (`d86ec483`) — prop room-list stack overflow.** `chrpropUpdateRoomList` + helpers build room lists of up to 7 entries, then write `prop->rooms[0..n]` + a `0xff` terminator. `PropRecord.rooms` and `chrpropsRenderPass`'s `s32 sp48[…]` local are both `PROPRECORD_STAN_ROOM_LEN` = **4**. BUNKER1 patrol guards routinely span ≥4 rooms (`[1a 13 14 10]`, …) → 4 IDs, no terminator inside the array → `chraiGetPropRoomIds`'s `for (i=0; self->rooms[i] != 0xff; i++)` walks off the end, overflowing the caller's stack frame → garbage `gdl` → GBI write fault. Every *other* `chraiGetPropRoomIds` caller already used `s32[8]`. Fix: `PROPRECORD_STAN_ROOM_LEN` → 8 under `#ifdef PORT` (`bondconstants.h`), + a defensive bound in `chraiGetPropRoomIds`. N64 unchanged. **D97 (`2fbcc556`) — `bondviewPlayerTickDamageAndHealth` negative `damagetype`.** US build (unlike EU/JP) has no low clamp; `damagetype = (s32)(health*8)` goes negative on a lethal hit → OOB `g_DamageTypes[]` read → segfault when a guard shoots Bond (~frame 5). Extended the EU/JP clamp to PORT. **D98 (`000ed6af`) — `initBONDdataforPlayer` under-allocates the player struct.** It `mempAllocBytesInBank`s a hardcoded `0x2A80` (N64 `sizeof(struct player)`, `0x2A70` EU). The PC struct is much larger (pointer fields widened 4→8). The player block sits directly below `g_GfxBuffers[0]` in `MEMPOOL_STAGE`, so writes past ~offset `0x2A08` (`bondviewRenderDebugBondView`'s `g_CurrentPlayer->field_2A08 = ft4`, run every frame since `debug_render_raster` defaults to `DEB_BOND_VIEW`) scribbled the zbuf-clear `gsDPSetRenderMode`'s `w1` onto master-DL slot 11 → `Unknown GBI opcode 0xffffb9`. Fix: allocate `(sizeof(struct player) + 0xF) & ~0xF` under `#ifdef PORT`. **D99 (`253caa23`) — `modelTickAnim` garbage function-pointer call, FIXED.** `struct Model.animflipfunc` (`bondtypes.h:1640`, "0x98") is `s32` but `modelSetAnimFlipFunction` (`model.c:2840`) stores a `void *` → truncated on PC → `((void(*)(void))animflipfunc)()` at `model.c:3534` jumped to `0x00010100` (`bheadFlipAnimation` at module+0x10100). It is *only* ever set to `bheadFlipAnimation`, for `g_CurrentPlayer->model` (`initBondDATAdefaults.c:198`, `bondhead.c:430`). Fix (D92/`unka0` pattern): under `#ifdef PORT` the field is a bool flag and `modelTickAnim` calls `bheadFlipAnimation()` directly. `-level_09` now reaches VI post ~601 (was ~421). **D100 (`8eaad547`, PARTIAL) — `struct player.model` is an inline `struct Model`, not a pointer.** Every use is `&g_CurrentPlayer->model` passed to a `modelXXX(struct Model *)` fn. The decomp splits it as `Model *model;` + ~45 `s32 field_59C..field_650` (≈ N64 `sizeof(struct Model)` ~0xB8). PC `struct Model` is ~0x2A8, so `animInit(&model, …)` overran into `field_654` (the gait RW-data pool `animInit` was *also* handed), `bondheadmatrices`, and the viewport fields → garbage `model->datas` → the bit-32 fault. Fix (PORT only): `model` becomes an inline `struct Model`; a dedicated `u32 gaitRwData[256]` at the end of `struct player` replaces the `&field_654` gait pool; `initBondDATAdefaults.c` points `animInit` there. `sizeof` grows ~0x2A0 — D98's `sizeof(struct player)` alloc already covers it, and `field_59C..field_650` are grep-verified dead (only `field_654` was used). **⚠️ LANDMINE this exposes:** `struct player` has raw hard-coded-offset accessors above 0x594 that are NOT PORT-adjusted and are now further off — `gunfire.c:4934-4945` `THROWMTX/THROWPOS` at `g_CurrentPlayer + handoffset + 0xAD8`, used for grenade/knife throwing. Those were *already* PC-wrong (PC struct ≠ N64 before D100 too); D100 doesn't regress a working path, but a real `struct player` PC-offset pass is owed. (`bondview2.c:3165` `+0x230` watch model and `+0x2ec` are below 0x594 → unaffected.) **D101 (`b2234f82`) — `sub_GAME_7F06DB5C` `(s32)` pointer stash, FIXED.** The anim2-blend variant of `modelBuildGroupMatrices` did `sp1C = (s32)arg2->Parent` then `modelFindNodeMtx(arg1, (ModelNode*)sp1C, 0)` — the exact 32-bit-pointer idiom the sibling was already cleaned up for. `(s32)` drops the `0x140000000` base of a static `ModelNode` → `modelFindNodeMtxIndex` deref `0x4012c9c0`. Also stashed `&sp48[sp54]` (a `RenderPosView*`) the same way and mis-called `g_ModelJointPositionedFunc` with a 3-arg cast. Under `#ifdef PORT`, full-width pointer work + the real 2-arg callback (mirrors `modelBuildGroupMatrices`). **D102 (`1b078f6d`) — 1P weapon `Model` / RW-pool `struct hand` overlap, FIXED.** `gunfire.c` punned the weapon `Model` onto `hand->field_B68` and its RW-data pool onto `hand->modeldatas` (a fixed run at `field_B68 + 0x20`). N64 `sizeof(struct Model)` ~0xBC keeps `modelInit`'s header writes clear of the pool; PC `struct Model` is 0xE8, so `modelInit` writes `objinst->datas` (0x20) **directly onto `&hand->modeldatas`** — the field and pool base alias, and the first `modelInitRwData` record write clobbers `datas` (bit 32 set) → the `modelInitRwData` fault (`0x1_70076514`). `objinst->obj` (0x10) lands on `hand->mtxlist` likewise. Fix: `struct hand.weaponModel` (inline `struct Model`) + `weaponRwPool[192]` under `#ifdef PORT`, routed through `HAND_WEAPON_MODEL`/`HAND_WEAPON_RWPOOL` macros; set `weaponModel.render_pos` explicitly (N64 aliased `hand->mtxlist`). Same class as D53.2 (watch) / D100 (player gait model). The `d43` record layout was ruled out — `GE_D51` proved it was `Objinst->datas` *field* corruption, not a garbage `RwDataIndex` (`idx` was 0/1, valid). **RENDER CHAIN CLEAR (session M-3).** With D93–D102 in, `-level_09` boots BUNKER1 and **renders continuously with no fault** (60 s+, 3500+ VI posts, full framerate; attract mode also clean). ~46 % non-clear pixels, content biased to the lower half of the screen. The stage-load→first-frame→in-level crash chain (the blocker since D69) is **resolved**. Remaining: **D75** — 3D-model rendering quality (animated/skeletal character models), a separate track; and the `struct player` / `struct hand` raw-hardcoded-offset landmine above (grenade/knife throw). `struct tex` headers 24 B vs 16 B on PC is a separate open pool-pressure item — a PC memory-budget pass is owed. **Still open, separate:** `struct tex` headers are 24 B vs 16 B on PC (texpool-triage) — pool-pressure; a real PC memory-budget pass should cover it. **D103 (session M-4) — native-resolution height, FIXED.** `port/src/libultra.c` `osViSetMode` hard-coded the fast3d native resolution to the *visible scanline count* (`pal ? 400 : 480`) instead of the CFB space GE authors its GBI in. fast3d's `SCREEN_WIDTH`/ `SCREEN_HEIGHT` (`gfx_pc.h`) alias `gfx_current_native_viewport`, and `RATIO_X/RATIO_Y` (`gfx_pc.cpp:53-54`) scale the game's 320×240-space `gSPViewport`/`G_SETSCISSOR`/2D-texrect coords onto the 640×480 window. With height 480 the ratios were asymmetric — `RATIO_X = 640/320 = 2.0` but `RATIO_Y = 480/480 = 1.0` — so every viewport/scissor came out a 220 px band (≈45.8 % of the frame → the "~46 % non-clear" figure), placed in the upper GL region and shown (post `invert_y`) in the **lower half of the screen**. `gfx_current_native_aspect` was likewise wrong (320/480 = 0.67). Fix: recover the real CFB height from `vm->fldRegs[0].yScale` (GE sets it to `bufy * YSCALE_MAX(0x800) / SCREEN_HEIGHT_MAX(480)`, `fr.c:417`) with a `pal ? 272 : 240` fallback; `comRegs.width` already carried `bufx` so width was fine. Verified: `-level_09` `GE_PCDUMP` frames go from ~46 % / lower-band to **91.7 % / bbox (0,20)-(639,459)** — the full 10..230 viewport ×2 with correct letterbox, HUD ammo counter correctly placed top-right. Purely a transform fix — no per-model work. **D104 (session M-4) — investigation, folded into D105.** Chased "flat dark-blue fill + HUD, no room geometry" after D103. Ruled out (probes + subagent trace): segment 0x0E vtx resolution, room-DL chaining/execution, per-room scissor (`GE_D104` shows bbox `(1,10)-(319,230)` full-view, `viewleft/top/x/y = 0/10/320/220`), the view-projection matrix (`field_10E0` decodes to a sane perspective×lookat — rows ≈ unit-scale 3×3, translate row `518,-22,430,459`), and the room GDL decode (valid F3DEX2, no embedded `G_MTX`). A temporary fast3d tri counter proved ~160 k room triangles WERE being submitted to GL each frame — yet the final image was 90 % exactly `(0,16,65)` (BUNKER1's `env->{Red,Green, Blue}` sky-fill from `skyRender`, `sky.c:326`, the 1P no-clouds `gDPFillRectangle` path). Geometry drawn, invisible → depth. `GE_D104` room-render-pass probe left in `bg.c` (capped, gated). **D105 (session M-4) — depth buffer never cleared, FIXED.** `zbufClearCurrentPlayer` (`src/game/viewport.c:89`) clears the N64 Z buffer with the classic "point the colour image at the Z buffer and `gDPFillRectangle` it with a packed-Z fill colour" idiom. fast3d does **not** emulate that: `gfx_dp_fill_rectangle` (`gfx_pc.cpp:2281`) bails when `color_image_address == z_buf_address` on the assumption the depth clear already happened via `glClear` — but the frame-start clear is `clear_framebuffer(true, false)` (`gfx_pc.cpp:2855`), **colour only**. fast3d expects the game to emit `G_CLEAR_DEPTH_EXT` (opcode `0x44`, `gfx_pc.cpp:2678`) for a real depth clear, and **nothing in GE ever emits it** (grep-confirmed). Worse, here the two addresses don't even match (`gDPSetDepthImage(z_buffer & ~0x3F)` vs `gDPSetColorImage( OS_K0_TO_PHYSICAL(z_buffer))`), so the bail isn't taken and the packed-Z fill colour gets scribbled onto the real framebuffer instead. Net: every in-level frame renders against a stale/garbage depth buffer, all ~160 k room triangles fail the Z test, and only `skyRender`'s background fill survives. Fix: `#ifdef PORT` branch in `zbufClearCurrentPlayer` emits a bare `G_CLEAR_DEPTH_EXT` Gfx word and returns, skipping the N64 idiom (which is a no-op / actively harmful in fast3d anyway). Verified `-level_09`: frame goes from 90 % flat sky-colour to **2585 distinct colours, sky down to 16 %** — recognisable BUNKER1: textured walls, storage racks, floor. 70 s run, crash-free. **D106 (session M-4) — portal near-plane projection garbage culled the next room, FIXED.** After D105, many camera angles in BUNKER1 showed a large flat sky-fill void where an adjacent room should be visible through a doorway. A `GE_D104` probe (per-frame visible-room list) showed the portal-visibility BFS returning only 1–3 rooms; a `GE_D106` probe in `sub_GAME_7F0B7F84` showed the plane-side metric cull (`bg.c:4135/4144`) and `zfar` (10000, fine) were NOT the cause — portals were dying in `sub_GAME_7F0B5864`'s screen-space projection. When the player straddles a portal plane, `sub_GAME_7F0B5528` emits z==0 near-plane clip points and `transform3Dto2DWithZScaling` (`bondview.c:730`) uses `inv_z = -1e20`, projecting them to ±1e20-scale coords. On N64 the two clip points bracket the view symmetrically → the degenerate-box check (`bg.c:1713`) or the downstream `screensize` clamp yields a full-screen box and the room is kept. On x86-64 the exact garbage can come back `min > max` on only one axis (or non-finite), slipping past `bg.c:1713` → portal dropped → room vanishes → sky-fill through the doorway. Fix: `#ifdef PORT`, any wildly out-of-range / non-finite projected bound is treated as degenerate → full `screensize` (matches the N64 outcome). Verified: per-frame visible-room count 1–3 → 2–4; rooms past near doorways (r11 from r27, etc.) now render. `c_screenleft/top/halfwidth/halfheight/ recipscalex/recipscaley` were all confirmed correct (0/10/160/110/ 190.53/190.53), so the projection *fields* are fine — this was purely the z==0 edge case. `GE_D104` probe (visible-room list) left in `bg.c`, gated + capped; `GE_D106` probes removed. **D107 (session M-4) — fast3d sampled an unloaded mip tile for GE's LOD textures (blurry surfaces), FIXED.** GE room GDLs emit `G_TL_LOD + G_TD_DETAIL` for mip-mapped textures, but `gfx_detail_textures_enabled` is false for this port. `gfx_lod_tile_offset` (`gfx_pc.cpp:1258`) then ran `rdp.tex_lod ? rdp.tex_detail : i` → returned `tex_detail` (1) for every texel → fast3d sampled GE's first mip (render tile 1). GE loads the whole mip chain with one `G_LOADBLOCK` to TMEM 0 and points tiles 1..5 at offsets inside it; fast3d keys `loaded_texture[]` by TMEM addr and has no entry for tile 1's offset, so `import_texture` fabricated a 16×16 from the top-left 256 B of the base image and magnified it across the polygon — the "blurry brown/grey ceilings & wall panels" in BUNKER1. Fix: with detail textures disabled, `gfx_lod_tile_offset` always returns 0 (the base render tile — the only correctly-loaded level; matches the N64 near-surface look). Verified: BUNKER1 ventilation room ceilings/walls/light panels render crisp. Single-level textures (racks, room 1) were already fine, unaffected. (Root-caused by a subagent — the `gfx_detail_textures_enabled=false` + GE's unconditional `G_TD_DETAIL` contradiction; hi-confidence, matches every symptom + the room-1-vs-storage-room split.) A residual minor vertical squish from `G_LOADBLOCK` size including the mip bytes is noted but not fixed. **Storage-room sky void — root-caused, NOT a visibility bug (session M-4).** The large void in rooms 27/28/29 is **closed doors**: portals 25 and 26 (the storage-area doorways) carry `controlbytes1 & 1` (`PORTALFLAG_DISABLED`) at runtime — verified correct: `load_bg_file` clears bit 0 for every portal at load (`bg.c:1015`), then `bgToggleDataPortalsContrlBytes1Bit1` (`bg.c:5502`) re-sets it for doors that start closed. So `sub_GAME_7F0B7F84` correctly refuses to see through them (`bg.c:4147`). The bug is that **the door prop/model that should fill each closed doorway does not render** — `chrpropsRenderPass` (`chrprop.c:482`) *runs* with `n=3..10` props per room (`GE_D96`), but the frame GDL barely advances (~7 `Gfx` for 9 props), i.e. props are enumerated but emit almost no geometry. This is the prop/character model-rendering track (D75(b) + the `struct player`/prop raw-offset landmines), not portal/visibility work. Portal visibility itself is healthy post-D106 (room 18 → 8 rooms deep; enabled doorways traverse correctly). Other residuals: low-frequency stray green/yellow polygons (1–2 per room — a degenerate vertex, likely one bad entry per room-vtx table via `bgSwapRoomVtx` or the point-index blob length); front-end text draws mirrored. Next: prop/door model rendering (unblocks the void AND the missing weapon), then D75(b) skeletal models. **D108–D112 (session M-5) — the "props emit no geometry" premise was stale; the real bug was a converter byte-swap off-by-one, FIXED.** Re-probing `chrpropsRenderPass` / `chrpropRender` / `modelRenderNodeDl` (`GE_D96*`) showed props *do* emit complete leaf DLs now (~15k `nodeDl` EMITs/run, valid `gdl`/`vtx`/`BaseAddr`; fast3d transforms ~300–600 prop verts/frame with sane `w`). Two separate residual bugs were isolated: (1) in the BUNKER storage area the per-frame visible-room count drops to 1–2 while `cameramode` goes 1→0 and `g_RoomLoadBudget` 200→3 — but the 200→3 is *intentional* N64 behaviour (`bgRoomVisibilityRelated`: 0xC8 for intro/swirl cams, 3 for FP) and `currentPlayerGetProjectionMatrix()` + `g_CurrentPlayer->screensize` were both verified valid, so this is a portal-BFS under-reach, still open. (2) **Skeletal characters rendered as a "3D line" / not at all** (also visible in attract mode): `drawjointlist` → `subcalcmatrices` → `modelUpdateMatrices` → `process_02_position` → `modelBuildGroupMatrices` produced `render_pos[]` joint matrices with **sane rotations but garbage / 1e27 / NaN positions**. `basemtx` (`camGetWorldToScreenMtxf()`) was a clean orthonormal lookat; joint *rotations* from the anim bitstream were fine; but `group->Origin` (`ModelRoData_GroupRecord.Origin`, a `coord3d`) read as garbage for every joint whose bytes weren't zero — while the adjacent `JointID`/`MatrixIDs` (u16/s16) read fine. Root cause: **`put_f32` in `tools_pc/d43_emit.py` had an off-by-one in its BE→LE byte reversal** — `buf[o:o+4] = src[doff+4:doff:-1]` yields bytes `doff+1..doff+4` (dropping the value's MSB, pulling in one byte of the next field) instead of `doff..doff+3`. This corrupted *every* f32 field in converted model rodata (joint `Origin`s, LOD near/far, BSP planes, bounding radii) for every sidecar-loaded model; the compiled-in front-end intro models (native-LE C structs, not run through the converter) were unaffected, which is why the Rareware logo looked right but every in-level character collapsed. Fix: `src[doff:doff+4][::-1]`. After regen (`python tools_pc/d43_emit.py ntsc-final`), all 20 `render_pos` entries of a BUNKER guard are coherent (~±60 units around the body), monitor screens draw their content, and character models render as recognisable humanoids (still some pose/orientation polish owed — a guard appears inverted — a separate matrix-handedness item). `d43_emit.py`'s verify pass only checks pointers/opcodes, not float values — a float spot-check is worth adding. Probes `GE_D96GATE / D96DL / D108 / D109 / D110 / D111 / D112 / GE_TRICNT` were all removed after the fix. **D113 (session M-6) — "portal-BFS under-reach / storage-room void" was a STALE premise; there is no BFS bug.** A subagent dumped BUNKER1's runtime portal table and traced every accept/reject decision of `sub_GAME_7F0B7F84` (non-EU/LEFTOVERDEBUG copy, `bg.c:4091`; queue = 5-arg `bgQueuePortalTraversal` `bg.c:3843` — confirmed the compiled ones). With the camera in room 29 the BFS visits portals 25,26,27,28: portals 25 (r29↔r26) and 26 (r29↔r27) carry `PORTALFLAG_DISABLED` (`controlbytes1 & 1`) because door props obj 153/154 spawn `openPosition==0` → `doorDeactivatePortal`, and nothing re-opens them in the fly-through — so they are culled at `bg.c:4138`, a site that is **not** `#ifdef PORT`-guarded and behaves identically on N64. Result: a stable 3 visible rooms {29,28,25} (D104 probe agrees), which is the correct answer for this topology, not "1–2". No s32/pointer truncation, no `min>max`/NaN reaching an accept flip, no depth-cap (`D_8004489C=0xF`) or visited-set (`>=9`) early-out firing. `sub_GAME_7F0B5864` is fine post-D106. **The void = the two closed-door models (obj 153/154) filling rooms 26/27's doorways not rendering where they should** — `GE_D96` shows room 29's prop pass emits their leaf DLs, so the door geometry exists but its world transform puts it outside the doorway, leaving the wall hole unoccluded → sky. This is the D114 model-transform track, not visibility. No `bg.c` change. Probes reverted. **D114 (session M-6) — character inversion / mispositioned crate: OPEN, matrix chain verified sane, residual is a shared fast3d mirror (D75 class).** A subagent statically traced the whole joint-matrix chain (`chr.c:2564` → `subcalcmatrices` → `modelUpdateMatrices` → `process_02_position` `model.c:1662` → `modelBuildGroupMatrices` `model.c:1318` → `matrix_4x4_multiply_homogeneous` → `bondviewTransformManyPosToViewMatrix` → `matrix_4x4_f32_to_s32` `matrixmath.c:495` → `gfx_sp_matrix`) and an env-gated `GE_D114` probe (24 joint builds, 3+ BUNKER guards). Findings: `basemtx` from `camGetWorldToScreenMtxf()` is a proper orthonormal right-handed lookat (+Y up, `up ≈ (-0.09, 0.96, -0.26)`, `right = up × forward`), scaled by `bgGetLevelVisibilityScale()` = 0.1 (also applied to the room path — expected); joint `render_pos` are coherent and **upright** (head y ≈ 280 > pelvis ≈ 230 > limb ends ≈ 132); view-space z negative (in front, correct). Converter clean: `put_f32` is now the only float writer and is correct post-D112; `matrix_4x4_f32_to_s32` is byte-identical to `guMtxF2L`; `matrix_4x4_set_lookat` / `guPerspectiveF` / `matrix_4x4_multiply_homogeneous` match N64 and are native-LE. So the plan's hypotheses (converter axis/sign, lookat handedness, compose order, F2L) are all **disproven**. Leading residual: a horizontal (± vertical) mirror in the **shared fast3d viewport / MP-matrix path**, not the model path — consistent with the open D75 notes ("front-end text draws mirrored", "Nintendo logo mispositioned"): a flip is only visible on asymmetric content (humanoid, text, logo) and invisible on a boxy room. GE bakes a left-handed screen convention into its own transforms (`transform3Dto2DCoords`, `bondview.c:726`: `screenX = center − x·invz·scale`); if GE's RSP (`rsp/graphics/gmain.s`) applies an X flip that `port/fast3d` does not, every model and room is mirrored. Suspects: `gfx_calc_and_set_viewport` / `gfx_adjust_viewport_or_scissor` (`gfx_pc.cpp:1739/1771`), `MP_matrix` vertex transform (`gfx_pc.cpp:1122`). This is a fast3d-correctness gap → write-up, not a narrow ABI patch. Also: **rebuild with D112+D115 and eyeball a guard first** — the D115 `gunfire.c` fix stops a per-shot 64-byte scribble into the inline-Model / bondhead-matrix region that could itself have caused "inverted". The crate: static-prop placement comes via a per-prop `basemtx` from the Usetup `PropRecord` position/rotation — check `d88_emit.py` separately from the character question. Probe reverted; re-apply snippet in the M-6 agent report. **D115 (session M-6) — `struct player` / `struct hand` raw-offset audit + first fix.** Full survey in `docs/AUDIT-M6-player-offsets.md` (10 offset sites; 3 live HIGH, 1 dead const block, 2 MED, 4 already-correct). **Fixed:** `gunfire.c:4960-4962` `THROWMTX` / `THROWPOS(k)` / `THROWPREV(k)` were raw byte offsets — `(u8*)g_CurrentPlayer + handoffset + 0xAD8/0xB08/ 0xB48` with `handoffset = handnum * sizeof(struct hand)`. Both the stride (`sizeof(struct hand)` ≈ 0x968 PC vs 0x3B8 N64, D102) and the base offset are N64-sized, so on x86-64 the address lands inside the inline gait `Model` / bondhead-matrix region of `struct player`, and `matrix_4x4_copy(THROWMTX, …)` scribbles 64 bytes of live render state on **every shot from a casing-ejecting weapon** (`sub_GAME_7F068508`, solo only). The offsets are exactly `hands[handnum].throw_item_pos_related[_prev]` and its translation row, so the macros now use those field accessors under `#ifdef PORT` (N64 build keeps the byte-offset macros unchanged). **Still open (MED):** #5 — the D102 weapon-model `render_pos` is pointed at a `dynAllocate`'d transient arena that on N64 aliased the persistent `hand->mtxlist`; likely the "1P weapon model doesn't draw" cause, may fold into D114. #6 — the D56 watch-preview Model pool (`watchRwPool[0xC8]`) is N64-sized and the inline Model overruns live watch fields. See the audit doc for the full table and recommended fix order. **D116 (session M-7) — the HUD/menu text mirror (D75 class) is a per-glyph TEXTURE-space flip, NOT a screen/framebuffer/matrix mirror.** Rebuilt at D115 (`14b6b432`), build green, captured `-level_09` frames (`GE_PCDUMP="120-600:60"`, `ppm/frame_0003*.ppm`). Findings: - The "OBJECTIVE C: FAILED" flash (proportional font, `textrelated.c:textRenderGlyph` → per-glyph `gDPLoadTextureBlock` `G_IM_FMT_I/G_IM_SIZ_8b` + plain `gSPTextureRectangle`, dsdx `0x400`) renders with **string order preserved (O first, D last) but every glyph individually horizontally mirrored**. That signature is a texture-S reversal per glyph, not a block/screen flip. - The HUD ammo digits ("83", top-right, upright and correct) use a different font path and are **not** mirrored → the bug is specific to the proportional-font `textRenderGlyph` load/sample path, not global. - Static trace found nothing: `gfx_dp_texture_rectangle` / `gfx_draw_rectangle` (`gfx_pc.cpp:2161/2086`) assign `uls`→left corner, `lrs`→right corner with positive dsdx; `import_texture_i8` (`gfx_pc.cpp:810`) uploads linearly; viewport/scissor paths have no X negation; GE's camera (`fr.c:694` `guPerspectiveF` + `matrix_4x4_set_lookat` = standard gluLookAt) is conventional RH (confirms D114). - **Consequence for D114:** the unified "shared fast3d screen mirror" hypothesis is at least partly wrong — the text mirror is texture-space. The inverted-guard / mislocated-crate symptoms are either a *separate* defect or the same texture-S flip applied to model textures (an asymmetric guard skin flipped reads as "facing wrong way"). - **Next (needs a runtime probe, not static):** dump the glyph texrect's tile size / `line_size_bytes` / uploaded texture width vs `curchar->width` for one glyph, and the final S texcoords fast3d hands GL. Suspects: `gDPLoadTextureBlock` 8b `line`/pad (`(curchar->width+7)&0xF8` load width vs real `curchar->width` sample width) interacting with tile wrap; or the compiled-in font blob (`assets/obseg/text/LmiscE.h` / `chars[]` `pixeldata`) being 32-bit word-swapped by the asset step (would reverse 4-texel groups — check whether the mirror is clean or chunked at 4px). Probe artifacts: `ppm/frame_000300.ppm` (obj text), `ppm/frame_000480.ppm` (ammo). **D116 probe results (session M-7, overseer-run — supersedes the "specific to the proportional font" claim above).** Killed a subagent that was drifting toward a global `GE_D116FLIP` S-swap on every non-flip texrect (mirrors the whole HUD/3D to "fix" glyphs — wrong). Kept its `GE_D116`-gated `fprintf` probes in `textrelated.c` (both glyph paths) and `gfx_pc.cpp:gfx_dp_texture_rectangle`. Built green at D115, ran `-level_09` + `GE_PCDUMP="200-360:40"`. Data: - **The ammo digits ARE mirrored too.** `ppm/frame_000320.ppm` top-right: "83" renders with each digit individually X-flipped, digit order preserved; the clip-count glyph left of the mag icon likewise. So the prior session's "ammo digits render correct → bug is `textRenderGlyph`- specific" differentiator is **FALSE at D115**. The mirror is NOT path-specific. (`textRenderGlyphOutlined` is what the level text uses; `-level_09` direct-boot renders no "OBJECTIVE" flash / crosshair at all — only the ammo HUD — so the M-7 "OBJECTIVE C: FAILED" note came from a different scenario/run.) - **Rect screen position is correct; only the texture content is flipped.** Ammo HUD sits top-right and renders top-right. So this is a texture-U reversal per quad, not a screen/framebuffer/viewport flip and not a scissor issue. - **Every stage probed is clean, yet the output is mirrored:** - glyph bitmap in memory: correctly oriented — dumped `curchar->pixeldata` for 'D' (idx 68, w7/loadw8/h9) row-by-row; stroke on the left, bowl on the right, col 7 = wrap-pad of col 6. Not reversed. Rules out (c) a font-blob word-swap. - `gDPLoadTextureBlock` params: `loadw = (w+7)&0xF8` = 8 (or 16 for w9), `line_bytes` = 8, tile `siz=1 fmt=4 cms=2`. Consistent. No evidence the load-width vs sample-width interaction reverses anything — rules out (b) as the *primary* cause. - fast3d texrect: `ul.u = 0`, `lr.u = ` (e.g. 256 = 8 texels); `ul.x < lr.x`. Corner↔UV pairing is correct (`gfx_dp_texture_rectangle` 2187-2211, `gfx_draw_rectangle` 2113-2136: ul=(ulx,uls), lr=(lrx,lrs)). Rules out (a) at the texrect layer. - `import_texture_i8` (`gfx_pc.cpp:810`): strictly linear byte copy, no row/col reversal. - `gfx_opengl.cpp`: `vTexCoord = aTexCoord` pass-through; no U negation in the vertex/fragment shader; `cms=2`→`GL_CLAMP_TO_EDGE`. - **Conclusion:** the U-flip is downstream of everything fast3d computes — in the GL vertex-buffer assembly / draw, OR it is a shared transform on the rect quad's clip-space X that desyncs from U. This **re-opens D114's shared-mirror hypothesis** (relocated from screen-space to per-quad U/X space) and retires the D116 "proportional-font-specific, texture-S load path" framing. Same mechanism plausibly explains the "inverted" guards (mirrored skin) and mislocated door props (D113). - **Next:** shader/vertex-buffer-level probe — dump the actual per-vertex (x, u) pairs in the buffer handed to `glDrawArrays` for one glyph quad, and render a 1-texel asymmetric test texture on a known rect to see which axis inverts. Confidence the mirror is a real per-quad U/X flip (not a capture artifact): **high** — reproduced on two independent HUD text paths, rect positions provably correct. Confidence in the GL-layer-vs-shared-transform split: **low** — not yet isolated. - Probes left in tree (all `#ifdef PORT` + `getenv("GE_D116")`, zero-cost when unset): `textrelated.c` textRenderGlyph / textRenderGlyphOutlined; `gfx_pc.cpp` gfx_dp_texture_rectangle. **D116 code-trace follow-up (session M-7, overseer, static — no build).** Traced the rect quad end-to-end through fast3d for the I8 glyph case (`cms=2`=CLAMP, `tile.uls=0`, `dsdx=1024`, 8px glyph): - `gfx_dp_texture_rectangle` 2187-2211: `ul={x:ulxf, u:uls=0}`, `ur={x:lrxf, u:lrs=256}`, `ll={x:ulxf,u:0}`. `ulxf `gfx_sp_tri1` 1497-1572: for each vtx `u = v->u/32 - tile.uls/4`; the D74 wrap block is gated on `cms & G_TX_WRAP` and G_TX_WRAP==0 so it is a no-op for CLAMP glyphs; `is_rect` skips the persp/filter half-texel. Result: `buf_vbo.u` = `0/8 = 0.0` for left vtx, `256/32 / 8 = 1.0` for right vtx. - `gfx_adjust_x_for_aspect_ratio` 1092: `(aspect_ofs*w + x) * aspect_scale / aspect_ratio`. `aspect_scale` (1714) is always a positive aspect ratio; `aspect_ofs` is a monotonic shift. **Cannot invert X.** Ruled out. - `buf_vbo` X for a rect = `v->x` verbatim (clip space from `gfx_draw_rectangle` 2105-2111), Y optionally `invert_y`-negated (1504), X never negated. So the **entire fast3d 2D texrect -> vertex-buffer path emits a correct, non-mirrored quad** (x-left<->u=0, x-right<->u=1). Whatever flips U is DOWNSTREAM of `buf_vbo`: the GL vertex/fragment shader, the ortho/MVP the backend applies to direct (non-fb) draws, or the sampler. That also means it is testable without determinism (static per-quad property). - **Still owed (needs a build — currently serialized behind the determinism/framediff agent):** (1) a probe dumping `buf_vbo` (x,u) for one glyph tri right before `glDrawArrays`, to confirm the CPU-side buffer is non-mirrored as traced; (2) render a 1-texel asymmetric test texture on a known screen rect to see which axis GL inverts; (3) check whether 3D world geometry is ALSO X-mirrored (bunker is near-symmetric — need an asymmetric in-world texture or a guard-facing check) to tell a HUD-only (direct-draw) flip from a global one. **D116 runtime probe part 3 (session M-8, overseer). CONTRADICTION — every stage from font-bitmap to GL-draw verified non-mirrored at runtime, yet the on-screen glyphs are unambiguously X-flipped. Investigation budget-capped; deprioritised (cosmetic, not a playability blocker).** Confirmed symptom (zoomed `ppm/frame_000300.ppm`): "OBJECTIVE C: FAILED" (via `textRenderGlyphOutlined`) AND the ammo "83" + spare-clip digit (a *separate* HUD-number path — `textrelated.c` probes never fire for the digit indices) both render with word/column order preserved and every glyph individually horizontally mirrored; screen positions correct. Runtime probes added (`GE_D116`-gated, `#ifdef PORT`): - `[D116/vbo]` in `gfx_sp_tri1` after the per-vertex `buf_vbo` writes: for the OBJECTIVE glyph quads (left edge, `tile.uls=0 lrs=28`, `cms=2`=CLAMP) it prints `vtx0 x=-0.8125 u=0.0` and `vtx2 x=-0.7625 u=1.0`. **x-left <-> u=0, x-right <-> u=1. The CPU vertex buffer handed to GL is correct and non-mirrored.** - `[D116/i8up]` (since reverted) in `import_texture_i8` dumped the uploaded bytes for glyph 'D' — **byte-identical** to `curchar->pixeldata` in memory (`00 00 0f 37 34 0b 00 00` ...), and the bitmap is correctly oriented (stroke col 1, bowl col 6-7). No flip, no word-swap. - GL backend: vertex shader is `gl_Position = aVtxPos` (fast3d does all transform on the CPU — no GL matrix can flip anything); `vTexCoord = aTexCoord` pass-through fragment path; sampler `GL_CLAMP_TO_EDGE`/`GL_NEAREST`; `gfx_opengl_copy_framebuffer` has only a `flip_y`, no X flip; `G_TEXRECT` decode (`gfx_pc.cpp:2602`) extracts `ulxvbo math, the GL shaders/sampler, the framebuffer blit (each HIGH, runtime-verified). Where it IS: unknown (LOW). The contradiction means a stage is being mis-modelled — leading candidates now: (a) the render-*tile* setup (`gDPSetTile`/`gDPSetTileSize` S params — probes covered the *load* tile) vs fast3d's tile-window sampling; (b) a GL-driver-level surprise only a real API trace (RenderDoc/apitrace) would show; (c) the dedicated ammo-digit renderer (unidentified — not in `textrelated.c`) emitting mirrored S, with `textRenderGlyphOutlined` doing likewise via a shared lower-level helper. NEXT PERSON: capture one glyph texrect in RenderDoc, or find and read the HUD-number path, before touching fast3d again. Do not exceed ~30 min without one of those in hand. Probe left in tree: `[D116/vbo]` in `gfx_pc.cpp` (`GE_D116`, zero-cost). **D117 (session M-8) — frame-to-frame nondeterminism root-caused; `GE_DETERM` fixed-tick mode assessed NOT-narrow, deferred with a design. `tools_pc/framediff.py` added (structural/tolerant).** *Root cause — pure frame pacing (variable timestep), NOT PRNG / uninit state. Confidence: HIGH.* GE is a variable-timestep simulation. Per rendered frame it advances game logic by `deltaFrames` = *however many 60 Hz ticks of wall-clock elapsed since the previous frame*: - `src/game/frametiming.c:75` `waitForNextFrame()` — `nextFrameTime = (osGetCount() - copy_of_osgetcount_value_1 + 387937) / 775875` (NTSC: 775875 RSP-counter ticks per 1/60 s), loops until `>= frameDelay` (normally 1), then `updateFrameCounters(nextFrameTime)`. - `src/game/frametiming.c:46` `updateFrameCounters(deltaFrames)` sets `speedgraphframes = deltaFrames`; everything downstream (physics, AI, animation blends, `g_Vars.lvupdate*`) scales by it. - `src/boss.c:456-495` main loop: blocks on `gfxFrameMsgQ` for `OS_SC_RETRACE_MSG` (posted by the port pacemaker), and only builds a new frame once `mainTickElapsed = osGetCount() - copy_of_osgetcount_value_1 >= MAIN_LOOP_TICK_INTERVAL` (387937). A two-level gate, both levels keyed on `osGetCount()`. On the console `osGetCount()` is the CP0 Count register (fixed CPU rate); on PC `port/src/libultra.c:88-100` maps it to **real elapsed microseconds** scaled to 46.5525 ticks/µs (D52 — required so `waitForNextFrame` doesn't block ~388 ms/frame). So `deltaFrames` tracks real render time: a frame that took 28 ms advances logic 2 ticks, one that took 14 ms advances 1. Machine load, GL driver, vsync phase and the ~20 fps clean-run rate all jitter this → two runs of the same build take different numbers of logic steps to reach "frame N" and their sim trajectories diverge. Measured with `framediff.py --exact`: two `-level_09` runs differ **32 % (frame 440) – 69 % (frame 200)** of pixels. Ruled out as *primary*: - **PRNG seeding is correct.** `port/src/random.c:22` `g_randomSeed = 0xAB8D9F7781280783ULL` (the two `.word`s from `random.s`), `g_chrObjRandomSeed` likewise (`:93`); the xorshift is a line-by-line port. `randomSetSeed` matches the `.s` (`+1` before store). *However* — because AI/animation code calls `randomGetNext()` a `deltaFrames`-dependent number of times per frame, the PRNG *stream position* still diverges between runs. It is a victim of the pacing jitter, not an independent source. - **Uninitialised state:** not investigated exhaustively, but the `--exact` divergence grows smoothly from frame 200→445 rather than being present at frame 1, which is the signature of accumulated timestep drift, not a per-run uninitialised seed. *`GE_DETERM=1` fixed-tick mode — assessed NOT NARROW, deferred. Confidence that it's not narrow: MEDIUM-HIGH.* The obvious hook — make `osGetCount()` a virtual clock that advances exactly 775875 ticks per presented gfx frame (`videoEndFrame` in `osSpTaskStartGo`, `port/src/libultra.c:1187`) — deadlocks the `boss.c` main loop. That loop only presents a frame *after* the retrace gate `mainTickElapsed >= 387937` passes, and with a frame-coupled clock `mainTickElapsed` is 0 until a frame is presented → the first in-loop frame never renders. `boss.c:442` (`waitForNextFrame()` after `lvlStageLoad`, before any frame) hangs the same way. Breaking the seal requires the **VI retrace post itself** to drive the virtual clock (advance a fixed quantum per `portPostVIEvent`) *and* the pacemaker to be frame-gated so it can't enqueue >1 retrace per render (else `deltaFrames` jumps to the queue depth). That is a redesign of the port pacing model (`portTickThread` / `portPostVIEvent` / `osGetCount`), with real deadlock risk in loops that pump retraces without presenting — the `boss.c:448` `NOBLOCK` drain, multi-frame stage loads, the pause menu, `front.c` menu loops. It is contained in `port/` and env-gatable, but it is not "a few lines, obviously correct" — it changes retrace/tick semantics, so per AGENTS.md it is written up here rather than patched. **Recommended design (for a future dedicated pass):** 1. `GE_DETERM=1` → `portTickThread` stops pacing on wall clock. Instead: a global `g_determFrameReady` flag is set by `videoEndFrame`; the tick thread posts exactly one `OS_SC_RETRACE_MSG` and advances a virtual `g_determTicks += 775875` (931050 PAL) **only** when it sees a new presented frame (or when `g_viRetraceMQ->validCount == 0` and no frame is pending — to service pre-first-frame / load-screen waits, advancing by the same quantum so `waitForNextFrame` sees exactly 1). 2. `osGetCount()` returns `g_determTicks` verbatim in this mode (no sub-frame interpolation — `store_osgetcount`/profiling just see 0 deltas, which is harmless). 3. Seed `g_determTicks = 775875` at `portKernelInit` so the first `waitForNextFrame` computes `(775875 + 387937)/775875 == 1`. 4. Leave `osGetTime()` (µs wall clock) alone — audio mixing cadence and the heartbeat watchdog should stay real-time. 5. Prove with `framediff.py --exact`: two `GE_DETERM=1 GE_PCDUMP=... ` runs must produce ~0 % pixel diff (allow `--tol 2` for GL dithering). Then regenerate `tools_pc/golden/` with `--update` and switch CI to `--exact`. Risk to watch: any game loop that calls `waitForNextFrame()` in a context where no gfx task will be submitted (true loading spinners) — those need the step-1(b) "no frame pending" fallback or they hang. *`tools_pc/framediff.py` — DONE, committed. Confidence: HIGH (validated).* Structural/tolerant by default (no determinism to lean on): per-frame it computes (a) 16×12 grid-cell mean-RGB delta, (b) whole-frame non-clear (non-black, pixcount.py rule) pixel-% swing, (c) a 16×16 aHash Hamming distance; fails the frame if any exceeds its threshold. `--mask X0,Y0,X1,Y1` (repeatable) drops known-animating regions (HUD) from all three. `--exact` mode (per-pixel, `--tol`/`--tol-pct`) is there for a future deterministic build. `--update` refreshes the golden set. Reads `.ppm` (GE_PCDUMP) and `.png` (golden) on either side; PNG decoder is built in (stdlib `zlib`; falls back to PIL for odd formats). Validated: two nondeterministic re-runs of HEAD pass structural (worst cell dmean 15, phash ≤ 23, non-clear Δ ≈ 0) while `--exact` correctly reports 32–69 %; a deliberately wrong frame pair fails (18 cells, phash 105). Golden set at `tools_pc/golden/frame_0002{00,320,440}.png` is the D115 baseline — since it is a nondeterministic capture, only structural mode is meaningful against it today. **D118 (session M-9) — SDL input layer implemented (Phase 3).** `port/src/input.c` was an unimplemented stub; input only worked via `libultra.c`'s `contSnapshotFromKeyboard()` (Z/Space=A, X=B, LCtrl=Z, arrows=stick, WASD=D-pad — no C-buttons, no mouse, no gamepad). Implemented a focused `input.c` (NOT a full port of pd_port's 1551-line module — GE's menu/config code never calls that VK/bind-string API). Provides `inputInit/inputUpdate/inputDestroy/inputGetNumControllers` plus two helpers for `libultra.c`: `inputConnectedMask()` and `inputComputePad(idx, *sx, *sy) -> u16 button`. The N64 button bits are duplicated as `GE_CONT_*` in `input.c` rather than `#include ` (its `u8 errno;` field collides with ``'s macro; libultra.c only gets away with it via a `#pragma push_macro` dance). *Binding scheme.* Kbd/mouse (controller 0): WASD or arrows = analog stick (move/strafe); mouse motion = C-buttons (aim, see bridge below); LMB/LCtrl = Z (fire); RMB/LShift = R (aim mode); Space/Z/E = A; X/R/F = B; Q = L; Enter/Tab = Start. Gamepad (SDL_GameController; pad 0 merges into controller 0, pads 1-3 → controllers 1-3): left stick = stick, right stick = C-buttons (digital, 50 % threshold), RT = Z, LT = R, A/X = A, B/Y/RB = B, LB = L, D-pad = D-pad, Start = Start. *Mouse-look → C-button bridge (the subtle part).* GE aims with digital C-buttons and has no analog-aim hook reachable without editing `src/`. `inputUpdate()` integrates the relative-mouse delta (× `MouseAimSpeed`/ 100) into a per-axis signed accumulator clamped to ±8. Each controller poll `inputComputePad()` emits the matching C-button while |accum| ≥ 0.5 and drains one unit — so a flick holds the C-button for several frames (proportional dwell) instead of a single blip. Limitations: still digital (GE's own accel curve makes turn speed non-linear in mouse speed); fast flicks saturate at ~8 frames of turn; diagonals limited to the 8 C-button combos. A real analog path would need an `#ifdef PORT` hook in `bondview.c` — left as TODO. *Wiring.* `contSnapshotFromKeyboard()` in `libultra.c` (SI section) now just calls `inputUpdate()` then marshals `inputComputePad()` output into `g_contPad[]`/`g_contStatus[]` for all `MAXCONTROLLERS`, and sets `g_contConnected` from `inputConnectedMask()`. It is still driven by `osContStartReadData`/`osContStartQuery` (once per game logic tick) — no new frame hook in `video.c` was needed. `osContGetReadData()` now `memcpy`s the whole `g_contPad` array (N64 semantics: one pad per channel) instead of only controller 0 — joy.c passes a `MAXCONTROLLERS`-long array. `inputInit()` opens the gamepad subsystem + all connected controllers and enables relative mouse mode. *Config.* `ge007.ini` `[Input]` `MouseEnabled` (0/1), `MouseAimSpeed` (1..500, default 50), `MouseInvertY` (0/1), via `configRegisterInt` in a `PD_CONSTRUCTOR`. `config.c` has no float support so speed is an int percent. Key/button REBINDING is not wired (would need the pd_port bind-string system or a new mini-parser) — hardcoded scheme + TODO. *Status.* Build GREEN (`ntsc-final`). Boots `-level_09` crash-free for 35 s, 900+ frames, `input: ready (mask=0x1, 1 controller(s))` logged. `GE_INPUTLOG=1` gates a per-poll `sysLogPrintf` of the OSContPad when button/stick are nonzero (zero-cost when unset, kept). Live input UNTESTED (headless agent) — needs a human pass: see `docs/HANDOFF.md`. Confidence: build/wiring HIGH; in-game feel of the mouse→C-button bridge MEDIUM (aim speed tuning likely needed); gamepad UNVERIFIED (none present in build env). TODO: (1) key rebinding; (2) gamepad hotplug (opened only at init today); (3) optional analog-aim `#ifdef PORT` hook in bondview.c — only needed now for D118a (fully-analog hipfire pitch) and toggle-aim-scheme correctness. [M-24: mouse-look rework + real INI parser done — see "Mouse-look rework" below.] *Mouse-look rework — M-24 (`port/src/input.c`, port-layer only, no `src/` change).* Root reading of `bondviewProcessInput`/`MoveData`: GE's aim is **mode-dependent** — hipfire (`!insightaimmode`) yaw = analog stick-X, pitch = digital C-up/C-down (stick-Y = move, does not pitch); aim mode (R held) yaw+pitch = analog stick past ±60 → `(stick-60)/10`, and C-up/C-down there = crouch/lean/zoom (`bondview2.c:5340,5351`), *not* aim. Also GE's native pitch is inverted (`U_CBUTTONS → speedVertaDown`, i.e. C-up looks down, `:5272-5279`). New mapping in `inputComputePad`, keyed on our own RMB/LShift state as the hold-to-aim proxy: - **aim mode** → push `stick_x`/`stick_y` into the 61..80 band (proportional to per-poll mouse delta × `MouseAimSpeed`/100 × 4), **emit no C-buttons**. Yaw and pitch now identical analog feel. - **hipfire** → yaw `stick_x += dx × MouseTurnSpeed/100 × 6`; pitch = digital C-up/C-down on `|dy| ≥ 1.5` px/poll. - "mouse-down = look down" by default (accounts for GE's inversion); `MouseInvertY` flips. Per-poll deltas, no accumulator (rate device). `ge007.ini [Input]`: `MouseEnabled`, `MouseAimSpeed` (50), `MouseTurnSpeed` (100), `MouseInvertY` (0) — **now actually parsed** (`config.c` INI load/save implemented, M-24). - **D118b (FIXED, M-24)** — mouse-Y inversion resolved by the "mouse-down looks down" default above + `MouseInvertY` toggle. - **D118c (FIXED, M-24)** — aim + mouse-down → crouch. Fixed *without* a `src/` hook: in aim mode the mouse now drives the analog stick and emits no C-button, so the crouch/lean/zoom mappings are never triggered by look input. (Proxy caveat: a toggle-aim control scheme would need a read of `g_CurrentPlayer->insightaimmode` since RMB-held ≠ aim state there.) - **D118a (RESIDUAL, lower)** — in **hipfire only**, yaw (analog) vs pitch (digital C-button) still feel different. Aim mode is now fully analog and consistent. A fully-analog hipfire pitch would need the `#ifdef PORT` `bondview.c` hook (TODO 4). Minor — precise vertical aim in GE happens in aim mode, not hipfire. - **D137 (FIXED)** — right-mouse *crash* (not the crouch bug): `gunDrawSight` `s32 sp54` truncated a `Gfx*`; see §F D137. - Weapon switch on kbd/mouse = the A button (`Space`/`Z`/`E`), same as action/use (GE overloads it). No dedicated key; mouse-wheel cycle is a candidate quick-win. **`data/` deletion + recovery (session M-3).** `git worktree remove --force` on an agent worktree that had a directory *junction* `worktree/data → main/data` followed the junction and deleted the real `data/` contents (both `.z64` baseroms + `pccg-*`/`pcmodels-*` sidecars). `data/` is gitignored so nothing tracked was lost. Recovered: `cp baserom.u.z64 data/ge007.ntsc-final.z64` (sha1 `abe01e4a…` == the canonical `ge007.u.z64` build hash, so byte-identical to what was there), then regenerated all three sidecars — `python tools_pc/d43_emit.py ntsc-final` (pcmodels, 512), `d69_emit.py ntsc-final` (pccg bg/stan, 52), `d88_emit.py ntsc-final --regen` (Usetup, → 73 rows, `pccg.bin` 3605249 B) — all "ALL CHECKS PASSED". **Lesson: never junction `data/` into a throwaway worktree; copy it or point the generator's `ROM_PATH` at the repo-root baserom.** The extra `GoldenEye 007 (U) [!].z64` copy was not restored (unused — runtime and generators use `ge007.ntsc-final.z64`, falling back to root `baserom.u.z64`). **Docs-to-commit reminder (session L).** The D88.1–D88.3 work (`tools_pc/d88_emit.py`, `port/src/pccg.c`, `src/bondtypes.h`, `src/game/bondview2.c`, `src/game/bondview_r.c`) plus the `GE_D88` probes in `prop.c`/`stan.c` have been format-verified and pass `-level_09` up to the D88.4 crash, but **remain uncommitted** (carried through two interrupted sessions). Commit in sub-milestones per the usual pattern once D88.4 is understood: format spec → converter → port wiring → probes. **D69 status after D78-D88: the ORIGINAL blocker (`load_bg_file` faulting on first stage load) is fully resolved and verified** — a clean run loads BUNKER1's header/room/portal/envdata tables and its full ~1066-tile stan file correctly (spot-checked byte-for-byte against the N64 source via the `GE_D69STAN` probe: tile room/mid/tail/point values match). The game now progresses substantially further than before (through room-streaming setup, past the old D86 model-init crash and the D87 attract-mode crash) before hitting D88's separate, newly-exposed "stage setup" file format gap. D85 (room geometry renders wrong, not yet crash-free at the *visual* level) and D88 (stage-setup file format, unconverted) remain open follow-ups — **the "loads without fault" acceptance bar is not yet fully met** (the process still exits via crash, just much later in the load sequence), but the converter, port wiring, and every ABI fix identified so far are format-verified correct and committed. **Environment reminders.** MSYS2 tools in `/c/msys64/mingw64/bin/` (not on PATH — prefix `export PATH=…`). Build: `./build-pc.sh ntsc-final`. gdb **launch** mode is far too slow for timing-dependent crashes (a D56-class crash ~10 s in took >300 s under gdb to reach 2 frames — DBGHELP symbol loading + the D51 stall-heartbeat thread dumps; don't wait on it): prefer env-gated TEMP probes + the built-in crash log for reproducible faults. **Correction (D87 session):** gdb **attach** mode (`gdb -batch -x cmds -p `, where `` is the Windows PID from `ps`, 4th column — the game must already be running, e.g. launched with `nohup ... &`) works fine and is fast, since the process is already warmed up and running at full speed before you attach; a hardware watchpoint (`watch *(int*)0xADDR`) caught a global's write in well under a minute. Useful for "is this global legitimately written, or corrupted" questions on a long-running, non-crashing process — attach once the process has been running a while, `continue`, and it'll fire on the very next real write. Still avoid gdb for the crash itself if the crash is reproducible via the crash log; symbolicate offline with `addr2line -e build-pc/ge007.x86_64.exe -f -C <0x140000000+rel>`. Image base 0x140000000. `load_resource`/many init fns use a fake RBP — compute stack offsets from entry RSP. The D30 crash handler writes `ge007.crash.log` with a working Phase-2 backtrace (**D44** fixed) — first stop for any fault; frames past the true chain may be stale (a corrupted return address outside the module, as in D56, means unwind depth is limited — confirm callers by code- path analysis + behavior). Standalone probe compiles need `-std=c11` (without it `typedef s32 bool` in bondtypes.h breaks under gnu23) and pointer-difference arithmetic instead of offsetof (include/stddef.h is #if 0'd). --- **D121 (session M-12, WS1) — frictionless per-level boot.** A bare `./build-pc/ge007.x86_64.exe -level_XX` with no `-m*` args booted every level with the default memory pools and OOM-crashed early in the load sequence; you had to hand-copy the per-level `-ml -me -mgfx -mvtx -mt -ma` row from `memallocstringtable[]` (`boss.c:101`) onto the command line. *Root cause.* On N64 the per-stage auto-inject loop in `bossMainloop` (`boss.c` ~line 425: `tokenSetString(memallocstringtable[i].string)`) supplies that row, but it is gated on `g_DebugAndUpdateStageFlag`. With `-level_` present that flag is left to `rmonGetToken()` (`boss.c:178`), which the PC stub (`port/src/n64stubs.c`) returns 0 for, so the loop never runs. *Rejected fix.* Setting `g_DebugAndUpdateStageFlag = 1` when `-level_` is present (the "mirror `boss.c:199-202`" idea in the old HANDOFF) is **not behaviour-preserving on PC**: it makes boot render the full title-stage intro (logos → gun-barrel → cast) instead of loading the level directly. The flag does more than pick the token row. *Fix (`#ifdef PORT` in `bossInitMainthreadData`, after the flag's own `tokenSetString` block).* When `-level_` is present and no `-m` token was given, look up the stage's `memallocstringtable[]` row (same match the N64 loop does) and `tokenSetString` it — **prefixed with `-level_XX` (and `-hard N` if present)**, because `tokenSetString` (`src/token.c:41`) `strcpy`s over the whole token buffer; without the prefix `bossMainloop`'s `tokenFind(1, "-level_")` returns NULL and it boots the title stage. No flag change, no control-flow change. *Verified.* Bare `-level_09` now loads BUNKER1 directly (85–92 % non-clear frame content from frame 30, no intro), matching the old `-level_09 -ml0 -me0 -mgfx100 -mvtx50 -mt700 -ma150` repro. Residual BUNKER1 prop/`modelLoad` crash is intermittent (D117 nondeterminism + D88.4) and pre-existing — out of WS1 scope. HANDOFF repro line updated to drop the manual `-m*` list. **D122 (session M-12) — per-level prop/item model-load crash: propDef converter missing handlers for ObjectRecord-derived record types.** `-level_33` (Dam), `-level_34` (Facility), `-level_35` (Runway) crashed deterministically before frame 1: `modelLoad` (`loadobjectmodel.c:393`, `PitemZ_entries[modelid].header->RootNode` with an OOB `modelid` — `Rax` held ASCII) and `modelInitRwData` (`model.c:6249`). BUNKER1 (`-level_09`) and Silo (`-level_20`) were fine. *Root cause.* The D88.4 propDef stream converter (`tools_pc/d88_propdefs.py convert_record`) had per-type handlers for most record layouts but **four `inherits ObjectRecord` types fell through to the generic arm**: 47 TINTED_GLASS, 39 VEHICHLE, 40 AIRCRAFT, 45 TANK (plus 13 AUTOGUN and 20 AMMO/MultiAmmoCrate, exposed once the first four were fixed). The generic arm bswap32's N64 word 1 as a single 32-bit value, but for an ObjectRecord that word is `[s16 obj][s16 pad]` — it needs `_hh_word` (swap each half in place). Result: `obj` (the model id, read by `domakedefaultobj` at `prop.c:155` → `modelLoad`) landed in the wrong half → garbage/OOB index into `PitemZ_entries[]`. The generic arm also never widened the ObjectRecord's embedded pointer slots (prop*/model* 4→8 B), so every tail field was mis-offset too. BUNKER1/Silo use none of these six types (verified with a per-level `PROPDEF_N64_WORDS` type histogram), which is why they were unaffected — the stream *walk* was self-consistent for all 21 levels (`convert_stream` validates region length), only the *field contents* of these records were wrong. *Fix (converter only — `tools_pc/d88_propdefs.py`).* Added `OBJ_TAIL_DESC` = {type: (ptr_word_set, hh_word_set)} for types 47/39/40/45/13/20 and a handler that emits the real 144-byte PC ObjectRecord prefix (`_emit_object_prefix`, which does `obj` correctly) followed by the tail with pointer members widened 4→8 B and 8-aligned, `_hh_word` for u16-pair words (vehicle aioffset/aireturnlist; ammo-crate `slots[]`), `_bswap32` elsewhere. Updated `PROPDEF_PC_BYTES` (39: 176→208, 40: 180→208, 45: 224→248; 47/13/20 already correct) and the matching `sizepropdef()` `#ifdef PORT` arms in `loadobjectmodel.c` (VEHICHLE 44→52, AIRCRAFT 45→52, TANK 56→62 words; N64 values kept in the trailing comment / `#else` `#if 1` switch). No game-logic change. Regenerate: `python tools_pc/d88_emit.py ntsc-final --regen` (21/21, ALL CHECKS PASSED). *Verified.* `-level_33/34/35` all get past the model-load chain now (no `loadobjectmodel.c:393` / `model.c:6249` crash). BUNKER1 unregressed (83–92 % non-clear frame content, frames 80–200). Silo unregressed (exercises the new type-20 handler — no crash). *Residual (separate blockers, out of D122 scope — hand to the level sweep).* Dam + Runway now crash later in `chrIsNotDeadOrShot` (`chraction.c:4483`, `self` = an image rodata address — guard/chr setup, not propDef-related). Facility crashes in `import_texture_i8` (`gfx_pc.cpp:821`, bad texture pointer — fast3d). Neither is touched by this fix. *Tail-layout confidence: medium* for 39/40/45 (VehichleRecord/ AircraftRecord/TankRecord field offsets have "locs need confirming" notes in `bondtypes.h`; the pointer-word sets and total sizes are derived from the struct as written and may need a nudge if a level actually drives tank/vehicle motion). **High** for 47/13/20 (offsets are known) and for the crash fix itself (the `obj`-field half-swap is unambiguous). --- **D123 (session M-13) — crash class C1: `chrIsNotDeadOrShot` NULL deref on 6 levels (Dam 33, Runway 35, Frigate 26, Statue 22, Streets 29, Cradle 41). Converter zeroed the vehicle/aircraft `ailist` id. FIXED (`tools_pc/d88_propdefs.py` only).** *Symptom.* Bare `-level_33` etc. crashed before frame 1 in `chrIsNotDeadOrShot` (`chraction.c:4483`, `self->actiontype`), fault addr 0x8 → `self ≈ NULL`. (The log's frame #1 `0x1401296a0` / `chraidata.c:61` was a stale stack-scan hit, not a real frame — misled the original triage.) *Real backtrace (gdb):* `objTick` (`propobj.c:5504`) → `ai((PropDefHeaderRecord *)poTruck, PROP_TYPE_OBJ)` → `ai()` sees `Entityp->type == 39` (PROPDEF_VEHICHLE) so sets `VehichleEntityp`, `AiListp = VehichleEntityp->ailist`, `ChrEntityp = NULL`. `AiListp` resolved to `m_AimAtBond` (`GAILIST_AIM_AT_BOND` == id 0). Its first opcode is `AI_TRYFireOrAimAtTarget` → `actor_aim_at_actor(ChrEntityp=NULL,…)` → `chrIsNotDeadOrShot(NULL)`. *Root cause.* `prop.c:1764` does `pdef_veh->ailist = ailistFindById(pdef_veh->ailist)` — it reads the **pre-populated integer AI-list id** out of the ROM record (VehichleRecord.ailist @ N64 0x80; Dam's is `0x040a` = 1034, a per-level list). But D122's `OBJ_TAIL_DESC` put word 32 in `ptr_word_set`, whose handler emits **8 zero bytes** for the widened 4→8B slot (correct for `path*`/`Sound*`, which `prop.c` overwrites with 0 — but wrong for `ailist`, read before overwrite). Result: `ailistFindById(0)` → `isGlobalAIListID(0)` true → global list id 0 = `GAILIST_AIM_AT_BOND` → a vehicle runs a CHR-only aim list with a NULL chr. `AircraftRecord.ailist` (`prop.c:1786`) is identical. BUNKER1/Silo emit no vehicle/aircraft propDef → unaffected. All 6 crashing levels have a vehicle or aircraft prop. *Fix (converter only).* New `OBJ_ID_WORDS = {39:{32}, 40:{32}}` in `tools_pc/d88_propdefs.py`; the `OBJ_TAIL_DESC` walk now emits `_bswap32(w)` into the low 4 bytes of the 8-aligned slot for id words (high 4 stay zero), so the u16/s32 id survives into the widened pointer field. Words 32 removed from the 39/40 `ptr_word_set`. Struct size unchanged → no `sizepropdef()` edit. TANK (45, `collision*`) and AUTOGUN (13, `unkC4/unkC8/beam`) left as ptr/zero — verified genuinely runtime-populated (`prop.c:1712` / `setupAutogun` `prop.c:694`). Regen: `python tools_pc/d88_emit.py ntsc-final --regen` (21/21 ALL CHECKS PASSED). *Verified.* `chrIsNotDeadOrShot` crash gone on all 6. Dam 83%, Statue 80%, Frigate 68–90%, Cradle 77% non-clear frames, no crash log. BUNKER1 (91.7%) + Silo (91.7%) unregressed. Runway and Streets now crash **later** in fast3d (`palette_to_rgba32`/`import_texture_i4` `gfx_pc.cpp:851`; `gfx_sp_matrix` `gfx_pc.cpp:1046`) — class C2/other track, not C1. Frigate/Statue/Cradle/Dam reach a frame cleanly. *Confidence: high* — root cause reproduced in gdb, fix is a 1-field converter change matching the exact `prop.c` read pattern, and the id value (0x040a) matches the N64 `UsetupdamZ.c` Vehicle record tail. **D124 (session M-13) — crash class C2: fast3d handed a garbage texture pointer on Facility (`-level_34`) and Jungle (`-level_37`).** Symptom: early deterministic crash — Facility `import_texture_i8` `gfx_pc.cpp:821` fault `0x72181ee8`; Jungle `gfx_tex_normalize_source` `gfx_pc.cpp:644` fault `0xabcd0824` (= `IMAGESEG(0x824)`). Two *different* root causes: *Jungle — FIXED.* `gimgSyncCompiledGlobalDLs()` (`port/src/gimgfixup.c`, D68) is meant to copy the texture pointers that `texLoadFromDisplayList()` patched into the ROM copy of the Globalimagetable back into the compiled `globalDL_0xNNN` shadow arrays (`assets/oddtextures.c`) that `explosion.c` executes via `g_ExplosionDisplayLists[]`. Its slot-detect test was `p[6]==0xCD && p[7]==0xAB` — the *post-fixup marker* — but by the time it runs `texLoad()` has **already replaced every IMAGESEG w1 in the ROM copy with a real pointer**, so that test never matches and the sync copied *nothing*. The compiled arrays kept their link-time `IMAGESEG(id) = 0xABCD0000|id` words; the first explosion/smoke/particle DL that Facility/Jungle render feeds `0xABCDxxxx` straight into fast3d `seg_addr()` (falls through to `return (void*)w1`) → `import_texture` deref. The 7 "working" levels are only working because nothing triggered an explosion-DL draw inside the ~22 s capture — **the bug was latent in every level.** *Fix:* detect the slot from the **compiled** array instead (`dst[j]` is a `G_SETTIMG` whose w1 is still `0xABCDxxxx`), then copy the ROM copy's resolved `*(u32*)(p+4)`. `port/src/gimgfixup.c` only; no converter/sidecar change. Verified: Jungle now renders ~300 frames (91.7% non-clear) before a *separate* downstream crash (`gfx_sp_matrix` `gfx_pc.cpp:1046`, bad G_MTX seg addr in the same explosion-DL stream — those DLs also carry unconverted `G_MTX` words; follow-up, D75/matrix family). BUNKER1 (83%) + Silo (91.7%) unregressed. *Facility — NOT fixed (separate class).* Fault `0x72181ee8` is a bogus `G_SETTIMG` w1 (`fmt=4 siz=2 w=0`, prev cmd an unconsumed `0xba` GE tex-macro opcode) in a DL living in V1 DRAM — a **model/prop GDL from the runtime `texLoadFromGdl()` relocation path** in `sub_GAME_7F0762E0` (`objecthandler_2.c:82`), not a room GDL and not the global bank. `GE_C2` probe of `texLoadFromGdl` shows its model-GDL calls (second batch, `src≈0x706a****`) write to **non-16-aligned `dst`** (e.g. `0x701eac01`, `0x701eb7d5`) — the `replacementgdl`/`name`(=srcsize) offset arithmetic in `objecthandler_2.c` still mixes N64 8-byte and PC 16-byte `Gfx` strides, so converted commands land mid-slot and a `G_SETTIMG` w1 reads as garbage. This is the open D80/D82/D83 "model/room GDL runtime conversion not verified" area — shared infra touched by every level; out of C2 budget, left for a dedicated pass. BUNKER1/Silo/Jungle don't hit it because their visible prop models happen to convert cleanly; Facility's do not. *Probes:* none left in tree — `GE_C2` scratch prints removed after root-cause. *Files touched:* `port/src/gimgfixup.c` (fix), `docs/PORT-LEARNINGS.md`, `docs/PCPortResearch.md`, `docs/LEVEL-STATUS.md`. *Confidence:* Jungle fix **high** (mechanism proven with a `[C2sync]` trace showing 32 slots going `abcd08xx -> 700e****`, verified render). Facility diagnosis **medium** (probe evidence strong; exact off-by in `objecthandler_2.c` not yet pinned). **D125 (session M-14) — crash classes C3 + C6: the converted `propDefs` blob in RAM does not match `tools_pc/d88_propdefs.py`'s offline output; the `sizepropdef()` walk drifts, so every `pdefIndex`-keyed lookup (`setupDoor` → `linkedDoor`, `weaponAssignToHome`, `modelLoad(modelid)`) gets the wrong record.** NOT root-caused; investigation cut for a usage reset. Affects Aztec (`-level_28`, `propobj.c:13523/:13601`), Bunker2 (`-level_27`, same `door7F054FB4`), Surface2 (`-level_43`, `loadobjectmodel.c:393` `PitemZ_entries[modelid].header`). *Evidence.* Instrumented the `proplvreset2` propDef walk (`prop.c:1865`, `#if defined(PORT)` `getenv("GE_C3")` print of `pdefIndex / phead->type / sizepropdef(phead) / phead / g_CurrentSetup.propDefs`) and the `setupDoor` `linkedDoor` resolve (`prop.c:1206`). On `-level_27` (`UsetupsevbZ`, `setup_text_pointers[27]`): - `g_CurrentSetup.propDefs = 0x701ce814`. - Runtime walk: idx0 `type=35` (WATCH_MENU, correct), then idx1–4 `type=0`, idx6 `type=112`, idx15 `type=26`, … — i.e. after the first record the blob is **zeros / garbage**, but `sizepropdef` still strides the phantom `type=35` at idx0/5/10 forward 16 B each. - By the first real DOOR the walk has counted **147** records where the offline converter (`convert_stream(UsetupsevbZ)`) puts it at **46** (verified: offline output = 346 records, DOOR at idx 46, self- consistent with `PROPDEF_PC_BYTES` == `sizepropdef()` PORT strides — every type cross-checked against `d88_layoutprobe` sizes, all match). - `setupDoor` then gets `arg2=147` for a stream-index-46 door; `linkedDoorOffset(-1) + 147 = 146` → `setupGetPtrToCommandByIndex(146)` returns a `type=0` record → `door->linkedDoor` garbage → fault walking the linked-door list in `door7F054FB4`. Surface2's C6 is the same mechanism one step earlier: a drifted `pdefIndex`/`modelid`. *What is NOT the bug (ruled out).* (a) `d88_propdefs.py` `convert_stream` itself — self-test passes, offline walk of all 3 crashing levels' converted output is clean, DOOR/linkedDoorOffset values correct, stream tiles `[propDefs, intro)` exactly (`end=OK` for all 21). (b) `sizepropdef()` PORT strides — every `case` equals `PROPDEF_PC_BYTES[type]/4` and equals the compiler `sizeof` of the widened struct (checked via `d88_layoutprobe`). (c) DOOR converter layout — `linkedDoorOffset@144`, `linkedDoor@216`, `unkcc@224`, size 296 all match `d88_layoutprobe`; tail pointer-widening walk is correct end-to-end. (d) The per-type histogram diff (brief's suggested approach): the 3 crashing levels emit no propDef type absent from the 12 passing levels — TINTED_GLASS and AUTOGUN appear in passing Caverns/Control too. **This is not a forgotten-type converter gap like D122/D123.** *Best hypothesis (M-14) — DISPROVEN (session M-15).* M-14 guessed the converted `propdefs_pc` bytes were not landing in the sidecar at the relocated `propDefs` offset (a `d88_emit.py` region-tiling / delta-reloc bug, lines ~308-338/471-476). The M-13 overseer note refined this to a pass-1 delta / region-`end` mismatch (`pd_end != pd_start + _n64len`). **Both are wrong.** New diagnostic `tools_pc/d125_check.py` decompresses the *emitted* `data/pccg-ntsc-final/pccg.bin` entry for every Usetup*Z, reads the relocated `propDefs` header field, and byte-compares that slice to a fresh `convert_stream()`: - **All 21 levels: `MATCH`** (Aztec/Bunker2/Surface2 included). The sidecar's propDefs blob, post-RZ-roundtrip, is byte-identical to the converter output, placed exactly where the relocated header field points. - Instrumented `d88_emit.py` (D125_DEBUG, since reverted): for all 21, `tiled_pd_end == H[intro]` exactly, `_n64len == pd_end - pd_start` exactly, the region after `propdefs` in the sorted list is always `intro`, and the growth `pclen - n64len` fed to `cum` is correct. There is **no** region-end/delta bug at `d88_emit.py:308-343`. - Full re-cross-check of `sizepropdef()` PORT strides vs `PROPDEF_PC_BYTES/4`: all 28 types match (as M-14 already found). **Therefore the offline pipeline (converter → emit → RZ compress) is correct end-to-end. The "idx0 correct, then zeros" RAM signature M-14 observed must originate at or after runtime load** — candidates, in rough order: (a) `decompressdata()` truncation — `port/src/rzdecomp.c` inflates with `Z_FINISH` and a 4 MiB `avail_out`, loops while `Z_OK`; if the deflate stream for these larger converted files hits `Z_BUF_ERROR` or `Z_STREAM_END` mid-output the `while (ret == Z_OK)` exits early and the tail of `dst` stays zero (needs a probe: log `ret` + `produced` vs expected decompressed size for `UsetupsevbZ`); (b) the STAGE bank alloc / `mempAllocBytesInBank` giving a buffer that is fine for the N64-size file but the converted file is ~15-25% larger — check `mempGetBankSizeLeft` at the `_fileNameLoadToBank(strResource, …, 256, MEMPOOL_STAGE)` call (`prop.c:1271`) vs the decompressed size; (c) the `prop.c:1280-1308` rebase loop or `langLoadToAddr` (`prop.c:1274`, runs right after the load, same bank) overwriting the tail of the setup file. M-14's own runtime dump had `g_CurrentSetup.propDefs = 0x701ce814` (`&15 == 4`). *Next step.* Probe `decompressdata()` for `UsetupsevbZ`: print `ret`, `produced`, and the first 64 bytes at `dst + 16` (offline record 1 start). If `produced` < the offline decompressed file size → truncation (fix the inflate loop / `avail_in`). If `produced` is correct but the bytes are still zero → something overwrites it post-load (bisect `langLoadToAddr` / the rebase loop). `tools_pc/d125_check.py` stays in tree as the offline-side regression guard. *Probes:* **none left in tree** — the two `#if defined(PORT)`/`GE_C3` scratch prints in `src/game/prop.c` (lines ~1206 and ~1865) and the temp scan scripts `tools_pc/c3_scan.py` / `tools_pc/c3_doors.py` were all reverted/deleted. *Files touched:* only `docs/PCPortResearch.md` (this entry) + `docs/LEVEL-STATUS.md` (one line). No code, no converter, no sidecar change — **sidecars do NOT need regen**, tree builds clean at `f2beae4b` + M-13 uncommitted set. **D125 addendum (session M-16) — ROOT CAUSE FOUND: `d88_emit.py:374` 4-byte literal into an 8-byte slice; per-record buffer shrink; out-of-range leaf writes insert at the current end → boundpad name blob drift.** *Crash mechanism (Aztec, `-level_28`), fully traced.* Door idx 226 (`objid=307`, `pad=33`) is left with `model=NULL`/`prop=NULL` by `setupDoor()`: its boundpad's plink name in the sidecar is `"8d2"` (original ROM: `"p138d2"`) → `stanPackId()` rejects it (valid = `p`/`q` + decimal ≤32767 + letter + optional digit 0-7; probe line `D88 stanMatchTileName id=8d2 hi=ffff lo=ff` is the failure sentinel) → `init_pathtable_something()` returns 0 with `*tile_stack=NULL` → `getposstan()` nonzero → `setupDoor` sets `door->prop=NULL`, skips `doorInit()` → later tick `door7F054FB4()` (`propobj.c:13601`) derefs `door->model->obj`; `Model.obj` sits at PC offset 0x10 (widened `chr` ptr) → fault addr 0x10, Rcx=0. Linked-door pair: idx225 (`pad=32`, valid) has `linkedDoorOffset=1` → idx226. All 178 regular pads resolve fine; only boundpad names are corrupt. *The bug.* `tools_pc/d88_emit.py` `emit_pad()` line 374: `out[dst_o + 0x30:dst_o + 0x38] = b"\x00\x00\x00\x00"` — the stan zero-fill assigns a **4-byte** literal to an **8-byte** slice. Python silently allows in-range short-RHS slice assignment, so each of Aztec's 179 pads + 148 boundpads shrinks the output `bytearray` by exactly 4 B (measured: after header 0x14d1c → after pads 0x14a50 → after boundpads 0x14800). Later verbatim leaf writes (the plink string blob, relocated to ~0x147EC+) then target offsets at/past the shrunken tail; for those, Python clamps the slice to an empty range **at the current end** and inserts the data there — each string lands at the buffer tail instead of its relocated offset, drifting earlier with every write. A simulation of exactly this semantics reproduces the observed sidecar names byte-for-byte: pad30 `p139d2` (intact), pad31 `139d2`, pad32 `37d1`, **pad33 `8d2`**, pad34 `2`, pad35 `1`, pad36 `` (empty). *Why it was missed.* No error is raised; the final file is only `(n_pads+n_boundpads)*4 − re-extension` bytes short of `total_new` (Aztec: 85276 vs 85145) because later leaf writes re-extend the buffer; `d125_check.py` verified only the propDefs slice. M-14's "propDefs zeros in RAM" signature was a misread — M-15 already proved sidecar propdefs byte-correct, and M-16's post-load RAM dump matched the sidecar byte-for- byte (85,145 B), so runtime load is clean too. *Fix (pending).* Line 374 → `b"\x00\x00\x00\x00\x00\x00\x00\x00"` (8 NULs). Audit of every other `out[` write in the file: all remaining slice/RHS pairs are width-matched — this is the only one. **All 21 Usetup*Z sidecars are affected to varying degrees** (shrink = 4×(pads+boundpads) per level; which names drift depends on where the blob lands vs the shrunken tail) — regen all: `python tools_pc/d88_emit.py ntsc-final --regen`, then re-run `tools_pc/level_sweep.sh` (expect C3 Aztec+Bunker2 to clear; currently- passing levels can only improve, but the sweep is the proof). *Probes in tree (temporary — remove after fix verified):* `GE_D125` env-gated prints in `port/src/rzdecomp.c` (decompress ret/produced), `src/game/prop.c` (setup header + post-load 0x15000 dump to `d125_setupdump.bin` + post-`setupDoor()` door state), `src/game/propobj.c` (`door7F054FB4` NULL-model detect+skip, `objInit` failure print, plus temp `#ifdef PORT` stdio/stdlib includes). Scratch: `tools_pc/d125_convtest.py` (instrumented converter copy — source of the shrink evidence), `tools_pc/d125_check.py` (**keep** — offline regression guard), `tools_pc/d125_inflate_test.c`, `d125_offs_tmp.c`, `d125_t.c`, `d125_run.log`, `d125_setupdump.bin`, `build-pc/d125conv/`. **D125 addendum (session M-16b) — FIX LANDED.** `d88_emit.py:374` changed to 8 NUL bytes (+ a warning comment). Offline proof (Aztec, throwaway `d125_proof.py`): the fixed converter's boundpad plink names 30–36 read `p139d2 p139d2 p837d1 p138d2 p85a2 p1377b1 p504d2` (= original ROM), 147 boundpad records recovered (M-14's "46" was the corrupt read — no second bug). `d43/d69/d88 --regen` → 21/21; `d125_check.py` → 21/21 propDefs still MATCH. Direct re-test: **Aztec `-level_28` PASSES** (91.0%, 900+ frames, was C3 CRASH at `propobj.c:13601`); Bunker1/Silo unregressed. **Bunker2 `-level_27` still crashes** — moved to `door7F054FB4` `propobj.c:13536` (`var_s1->openPosition`, `var_s1` = `0xffff…` from `linkedDoor`). Separate bug: the DOOR-tail converter in `d88_propdefs.py` (`DOOR_TAIL_PTR_WORDS` / `linkedDoorOffset` word 32) vs the compiled PC `DoorRecord` layout — `linkedDoor` is resolved from `linkedDoorOffset` (`prop.c:1204`), a read-before-write int id like D123's `ailist`; Bunker2 has linked double-doors, Aztec's are singletons so it dodged it. Folded into the converter write-audit (C3 residual). Sweep: 12 → 13/21. All M-16 probes reverted, scratch files deleted, `d125_check.py` kept. **D126 (session M-17) — objective-subrecord `->next` pointer growth desyncs the propdef walk (C3r Bunker2 + C4 Depot + C6 Surface2).** The Bunker2 `door7F054FB4` crash was NOT a DOOR-tail layout bug (the M-16b hypothesis). Instrumenting the `proplvreset2` first-loop walk showed `pdefIndex` drifting ~100 ahead of the offline `convert_stream` record index; a byte dump proved the RAM propDefs matched the sidecar exactly *at load* but had record N+1's header zeroed *by the time the walk reached it*. Cause: four objective sub-record types — `criteria_picture` (30), `criteria_roomentered` (32), `criteria_deposit` (33), `setup_objective_text` (35) — are `{ s32 x N; T *next; }`. `set_parent_cur_obj_photograph/enter_room/ deposited_in_room` and `setup_briefing_text_entry_parent` (`objective.c`) write `arg0->next` unconditionally while the setup walk visits each record, building a runtime linked list. On N64 `next` is a 4-byte word at the end of a 16/20-byte record — no spill. On x86-64 the pointer widens to 8B and the compiler 8-aligns it at offset 16, so the struct is 24B; the 8-byte `->next` store lands at record+16..24, clobbering the *following* record's `PropDefHeaderRecord` (type byte at +0x13). Walk desyncs → command indices drift → `setupDoor`'s `setupGetPtrToCommandByIndex(linkedDoorOffset + arg2)` returns the wrong record → `door->linkedDoor` chain walks into non-door memory → crash. Depot (`prop.c:902` tile-walk room) and Surface2 (`PitemZ_entries[modelid]`, the C6 "D122 continuation") were the same desync landing on different downstream derefs. Fix: `tools_pc/d88_propdefs.py` `PROPDEF_PC_BYTES[30/32/33/35] = 24` + a typed handler emitting the leading s32 words then an 8-byte zero `next`; `src/game/loadobjectmodel.c` `sizepropdef` PORT returns 6 for those types (N64 `#else` kept). Regen'd all 21 sidecars, `d125_check.py` 21/21 MATCH. **Bunker2 `-level_27`, Depot `-level_30`, Surface2 `-level_43` now PASS** (91.5% / 79.8% / 70.4%); Bunker1/Silo/Aztec/Archives/Egypt/Train unregressed. Sweep 13 → 16/21. Generalisable quirk appended to `PORT-LEARNINGS.md` §A. Remaining crashes: C2 Runway/Facility (model-GDL align), C2m Jungle (`G_MTX`), C5 Control (BG portal), C7 Surface1 (`sndSetupSound`). **D133 (session M-20) — intro "mostly black" is the D75/D76 steady state, not a regression.** The M-18/M-19 handoff "Next task" listed "intro renders mostly black" as a regression against the M-17 handoff line "the entire intro renders — logos → gun barrel → cast". Method: `git worktree add ../ge007-m17 9ec6121e` (COPY `data/`, never junction — see the data-dir-junction-hazard memory), `./build-pc.sh ntsc-final`, capture `GE_PCDUMP="20-900:20"`, `tools_pc/pixcount.py` per frame. Compared against the integrator's HEAD (`0b5f5d1a`) capture. | frames | M-17 `9ec6121e` | HEAD `0b5f5d1a` | |---|---|---| | 20–100 (legal screen) | non-clear **6677 (2.17%)**, bbox (50,265)-(589,446) | non-clear **6677 (2.17%)**, same bbox | | 120–360 | 0–16 non-clear px (near-black) | 0–16 non-clear px | | 380–400 (centred logo) | 20.5k–22.1k (6.7–7.2%), bbox ~(253,142)-(394,336) | 18k–24k (6–8%), bbox ~(250,138)-(390,340) | The legal-screen frame is a static 2D framebuffer; a **pixel-identical** non-clear count on two independently-built binaries is proof the render path is unchanged. 2D/text/texture layers draw on both; the animated character-model layers (Nintendo-logo transform, gun-barrel Bond figure, cast models) never appear on either — this is **D75** verbatim ("skeletal/animated character models never appear; non-animated 3D appears with a bad transform"). The M-17 "entire intro renders" line was aspirational, not a measured coverage state. Bisect not needed (state identical at the older commit). No cheap `#ifdef PORT` regression exists; the fix is the parked D75 3D-pipeline work (`docs/GRAPHICS-BACKLOG.md`). `-level_09` re-verified PASS (690+ frames, 91.7%) so the capture harness is sound. Worktree removed (`git worktree remove --force ../ge007-m17` — safe because `data/` was copied, not junctioned). Tree restored to `master`. Confidence: **high** (pixel-identical legal-screen coverage; symptom is a verbatim match to the open D75 description). Secondary (M-20, not investigated per scope): Facility `-level_34` boot-crashes `frames=0` PC `0x1400c3b77` (addr2line unresolved); Jungle `-level_37` renders frames 1–2 then kernel-heartbeat hangs (`frames=2`). Both claimed PASS in M-18/M-19 (D130/D131). Machine was only lightly loaded. Needs a clean-machine re-verify (`docs/LEVEL-STATUS.md`) before calling it a regression. **D131 (session M-19) — Jungle `-level_37` C2m crash: `osVirtualToPhysical()` truncates a compiled-symbol pointer inside a GBI matrix command.** Jungle renders ~300 frames then AVs in `gfx_sp_matrix` (`gfx_pc.cpp:1046`, `int32_t int_part = addr[…]`) with `addr = 0x401c68e0`. Caller is the `G_MTX` case of `gfx_run_dl` → `seg_addr(cmd->words.w1)`. Probe (`GE_MTXPROBE`, reverted) dumped the faulting DL — a well-formed model render sequence in a per-frame DRAM GDL: `gSPMatrix(PROJECTION,LOAD)` w1 = `0x700d8070` (valid), `gSPMatrix(MODELVIEW,LOAD)` w1 = `0x401acbe0` (bad), `gSPMatrix(MODELVIEW,MUL)` w1 = `0x401c68e0` (bad, the fault), `gSPVertex` w1 = `0x700da350` (valid). All lens `0x40`, opcodes sane — the walk is *not* desynced, only the modelview-matrix pointers are wrong. `0x401c68e0` == `(u32)(0x1_401c68e0)` — a compiled-module address with the `0x1_00000000` high word dropped (module fixed-based at `0x140000000`, cf. D94 `chraction.c:1243` comment). Source: `explosionRenderPropSmoke` (`explosion.c:1501`) does `gSPMatrix(gdl++, osVirtualToPhysical((void*)&dword_CODE_bss_8007A100), G_MTX_NOPUSH|G_MTX_MUL|G_MTX_MODELVIEW)` — `&dword_CODE_bss_8007A100` is a compiled `.bss` matrix; `osVirtualToPhysical()` is `(u32)(uintptr_t)va` (`libultra.c:1207`), so the store truncates. The `[-1]` LOAD matrix is the room matrix from `applyRoomMatrixToDisplayList` (same truncation path). Why the projection matrix is fine: `get_BONDdata_field_10E0()` returns a runtime `0x70xxxxxx` pointer that survives the `u32` cast. Why `gSPDisplayList(&globalDL_0xNNN)` is fine: the port's `Gwords.w1` is a 64-bit `uintptr_t` (`include/PR/gbi.h:1730`) and `gDma1p` stores the full pointer — only `osVirtualToPhysical` truncates. ~30 sites pass a compiled matrix/vtx symbol through `osVirtualToPhysical` (`explosion.c`, `glass.c`/`glass2.c`, `blood_animation.c`, `bondview2.c`) — each latent until that effect first draws, which is why only Jungle (an early scripted explosion) tripped it in the no-input sweep. **Fix** (`port/fast3d/gfx_pc.cpp` `seg_addr`): at the final fallthrough, if `w1 ∈ [0x40000000, 0x70000000)` restore the module high word (`(mod_hi | w1)`, `mod_hi` read from this TU's own load address). Everything legit that reaches the fallthrough is either DRAM V1 (`≥0x70000000`), KSEG0 (`≥0x80000000`), an LSB-set / low-nibble segmented address, or a sub-`0x800000` physical offset — all handled in earlier branches — so the range is unambiguous. Narrow, ABI/pointer-width class (same as D94/D3x). Verified: Jungle renders to logic-frame 2400+ crash-free at 91.7% non-clear; `-level_20` (Silo) + `-level_24` (Archives) unregressed. **Sweep 18→19/21.** Not fixed this session: **`-level_09` (BUNKER1) now crashes at boot** (`frames=0`, PC `0x1400066fc`, fault `0x00a2fc68` — a stack pointer `0x0fa2fc68` with the top nibble masked, i.e. another 28/24-bit segment-mask applied to a real pointer). Reproduced on a **clean `git stash` checkout of master `0a4b3bae`** → pre-existing regression, not caused by D131 (a fast3d-only change cannot affect a pre-first-frame boot crash). Contradicts the M-18 handoff's "`-level_09` unregressed" claim; likely fallout from a D126/D128 propdef/portal change or an environmental sidecar/ROM mismatch. Next session: `git bisect` D125→HEAD against `-level_09`. Silo also shows an intermittent hang in the intro fly-down-to-Bond cinematic (user report; not always reproducible — ran 300 frames clean here). --- **D130 (session M-18) — Facility + Runway C2 crash was `romdataFixupFont`, NOT the model-GDL relocation.** `-level_34` (and `-level_35`) fault in `import_texture_i8` (`gfx_pc.cpp:821`) on `loaded_texture.addr = 0x72181ee8`, an un-mapped wild pointer. gdb showed the fault DL is a 2D `G_TEXRECT` block (`gfx_dp_texture_rectangle` → `gfx_sp_tri1(is_rect)`), with the wild pointer baked into a preceding `G_SETTIMG` w1. Traced back through `GE_C2GDL` probes: - `texLoadFromGdl` **never copies a `G_SETTIMG` (0xFD)** on these levels, and `texWriteLoadToTmemAddr/Zero` are **never called** — so the M-14 addendum's "model-GDL relocation writes non-16-aligned dst" hypothesis is wrong. A probe in `sub_GAME_7F0762E0` also confirmed `gdl` from `modelIterateDisplayLists` is still a **segmented `0x05xxxxxx` value** (`modelPromoteNodeOffsetsToPointers` does NOT promote DL Primary/Secondary), so the `Switches + (x & 0x00ffffff)` idiom is correct as written. - The wild `G_SETTIMG` is emitted by `gDPLoadTextureBlock(gdl, curchar->pixeldata, G_IM_FMT_I, G_IM_SIZ_8b, …)` in `textRenderGlyphOutlined` (`textrelated.c`) while rendering the level-title string "Chemical Warfare Facility #2" — specifically glyph `#` (and `"`). - `curchar->pixeldata` for BankGothic/ZurichBold glyph indices **0, 1, 2** is corrupt after `load_font_tables`: glyph 1 `.width = 0x01000000`, glyph 2 `.pixeldata = 0x020002e8` (→ `+= font_base` → wild). Root cause: `romdataFixupFont` (`port/src/romdata.c`) re-lays-out the N64 24B `fontchar` array into the PC 32B array **in place** — backward over glyphs, forward over the 5 leading `u32` fields per glyph. PC−N64 stride = 8, so `d = s + 8*i`; for `i ∈ {0,1,2}` `d` overlaps the 24-byte source read span and an early `*(u32*)(d+4k) = bswap(*(u32*)(s+4k))` write clobbers a later `k`'s source. Glyph 1 (`Δ=12`): k=3/k=4 read bytes k=0/k=1 already overwrote → `width = bswap(bswap(index)) = index = 0x01000000`. Glyph 2 (`Δ=20`): k=0 writes `s+20`; line 507 then reads `s+20` for the pixeldata offset → `o = index = 0x02000000` → `o >= pixStart` → remapped to `pcPixOff + (0x02000000 − pixStart) ≈ 0x020002e8`. Confirmed exactly against a raw-bytes dump. On N64 the array is read-only rodata — never re-laid-out — so no bug there; this is purely a PC-relayout aliasing bug. Fix: stage all six N64 fields into a local `u32 f[6]` before writing any field of `d`. (A previous iteration's write can never touch `s(i)` — for `j > i`, `d(j)` starts past `s(i)`'s end — so a full pre-read is safe.) `GLYPH_IDX` clamp in `textrelated.c` was tried and reverted: the crashing string has no control byte, and a negative-index guard is a separate latent concern (logged in PORT-LEARNINGS §A). **Verification:** `-level_34` 0 crashes / ~14 runs (was ~50–100%); `-level_35` 0 crashes / 4 runs (was ~3/4). `-level_09`/`-level_20`/`-level_24` unregressed (91.3% / 91.7% / 90.8% non-clear). `-level_37` (Jungle) unchanged — still the separate C2m `gfx_sp_matrix` explosion-DL `G_MTX` crash at ~frame 300 (D75 family). Sweep **16 → 18/21**. All probes reverted; only `port/src/romdata.c` (11 lines) changed. Confidence: **high** (exact byte-level match, deterministic value, 2 levels fixed, no regression). **D124-Facility addendum (session M-14 — partial, NOT fixed, out of time).** *(Superseded by D130 — the model-GDL hypothesis below was wrong.)* Re-instrumented with a `GE_C2GDL` probe in `sub_GAME_7F0762E0` (`objecthandler_2.c`) + a bad-`G_SETTIMG` catcher in fast3d (`gfx_pc.cpp` G_SETTIMG case). Both probes **reverted** — tree is clean. Findings: 1. *Exact crash cmd.* `-level_34` faults in `import_texture_ia16` (`gfx_pc.cpp:757`, via `import_texture` :955) — NOT `import_texture_i8` as the old note said. The offending command in the relocated model GDL (dList base `0x7007b870`, cmd at `0x70080490`) is: `w0=0xfd900000` (`G_SETTIMG`, fmt=7/rgba? siz per bits, w=0), `w1=0x72181ee8`. `seg_addr(0x72181ee8)` returns it unchanged (top byte 0x72 is not a segment) → deref of unmapped DRAM → AV. Preceding slot is `0xba 00 0e 02` (GE tex-load macro, w1=0) then a `00000000` slot. 2. *`Gfx` stride confirmed.* `Gwords{ uintptr_t w0; uintptr_t w1; }` → on x86-64 `w0`@+0 (8B), `w1`@**+8** (8B), `sizeof(Gfx)==16`. The probe dump (8-byte granular) shows w0 at slot+0 and w1 at slot+8, i.e. the command is genuinely `{0xfd900000, 0x72181ee8}` — the converter wrote a real 16-byte slot, it is not a half-slot artefact. 3. *`w1` is a bad `tex->data`.* This `G_SETTIMG` is emitted by `gDPSetTextureImage(gdl++, .., tex->data)` inside `texWriteLoadToTmemAddr` / `texWriteLoadToTmemZero` (`tex.c`). So the **texture-pool** entry `tex` returned by `texFindInPool()` has a garbage `.data` (should be a `0x05xxxxxx` seg-5 ref, resolved later by fast3d `segmentPointers[5]`). i.e. the fault is (at least partly) in the `texLoadFromModelFileHeader` → `texLoad` texture-pool path, driven off `objheader->Textures`, **not** solely the `texLoadFromGdl` command-stream copy. This partially contradicts the original D124-Facility hypothesis. 4. *Alignment smoking gun.* `GE_C2GDL` probe shows **every** model file's `objheader->Switches` base has `((uintptr_t)Switches & 15) == 1` (odd!). `mempAllocBytesInBank` (`memp.c`) does **zero** alignment — `allocation = pool->pos; pool->pos += bytes;` — so a single earlier odd-sized bank alloc leaves the STAGE pool cursor permanently odd and every subsequent model file loads at an odd address. `delta` (`romremaining - pcremaining`, the scratch-relocation offset) is also not 16-aligned (`&15` = 3, 7, 11 across files). `sub_GAME_7F0762E0` then forms `dst = Switches + (replacementgdl & 0x00ffffff)` and `src = Switches + gdloff + delta`; observed `dst&15` = 5, 7, 9, 13. The N64 code assumes the file base is Gfx-aligned (8 on N64). The converted model GDLs the sidecar carries also have per-DL offsets that are only 4/8-aligned (`gdloff0 & 15` = 4, 8, 12), so even a 16-aligned base would not make every DL 16-aligned. - *Note:* an odd/misaligned base is self-consistent between the converter writer (`Switches+off`) and the fast3d reader (seg-5 = `Switches`, `+off`), so it does **not by itself** corrupt command *content* on x86 (unaligned `uintptr_t` loads are tolerated). The bad `tex->data` (#3) must come from an *offset* miscalc, likely the model texture-blob offset assuming N64-sized (8B-Gfx) GDL extent while the PC sidecar's GDLs are pre-expanded to 16B — the texture data then sits at a stale/too-large offset (`0x72181ee8` is ~0x1.9M past the bank end `~0x706a8000`), OR `texLoad` reads a `textures[i]` descriptor at a wrong stride off the odd `objheader->Textures` base (`(u8*)filedata + sizeof(ModelNode*)*numSwitches`, D43/D45 PORT line). - *Next step:* probe `texLoad` (`image.c`) — dump `textures[i]` `TextureID` / offset / resulting `tex->data` for `-level_34`, and dump `objheader->Textures`, `numtextures`, `numSwitches`, and the sidecar's texture-section layout. Compare against `d43_emit.py` / `pcmodels` sidecar builder: does it emit model texture blob offsets in N64 (8B-Gfx) or PC (16B-Gfx) GDL-extent terms? That converter is the likely fix site (offline sidecar per the D43/D69/D88 rule), with a fallback `#ifdef PORT` 16-align of the STAGE pool cursor / the model file base in `mempAllocBytesInBank` or `load_object_fill_header`. 5. *Files touched this session:* none committed; both probe edits (`src/game/objecthandler_2.c`, `port/fast3d/gfx_pc.cpp`) reverted via `git checkout`. Docs only: this entry + `docs/LEVEL-STATUS.md`. *Confidence:* crash cmd + `Gfx` stride **high**; `tex->data`-is-the-bad- value **high**; root cause of *why* `tex->data` is bad **low–medium** (two candidate mechanisms, neither proven). --- ## D132 — D88 propDefs layout audit (M-20) Static analysis only (no build, no game run). Goal: prove `tools_pc/d88_propdefs.py`'s per-type cursor matches the real compiled PC struct layout (8-byte pointers, 8-byte alignment) for every `PROPDEF_*` record type any of the 21 solo `Usetup*Z` levels actually emits, and that the handler set is total. ### Handler totality Authoritative per-level type histogram: walk each level's `propDefs` region with `d88_propdefs.PROPDEF_N64_WORDS` as the stride (the same table `convert_stream` uses; it walks all 21 to an exact region-end match, and `d125_check.py` confirms offline emit == converter for all 21 — so the stride table and the encountered type set are sound). Union of all `type` bytes emitted across the 21 levels: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 17 18 19 20 21 22 23 24 25 26 27 28 30 32 33 34 35 36 37 38 39 40 42 43 44 45 46 47 48 Never emitted (in the enum, absent from every shipped solo level): 15 (DEBRIS), 16, 29 (OBJ_DEPOSIT), 31 (OBJ_NULL), 41. `tools_pc/d88_propdef_scan.py` is **stale / unreliable** — its private `WC` + `SIZEOF_N64` guess table desyncs mid-walk and reports spurious `unmapped type NOTHING`. `d88_propdefs.convert_stream` is the ground-truth walker. (Scan tool left as-is; a follow-up could point it at `PROPDEF_N64_WORDS`.) Every emitted type has a non-generic handler **or** is provably safe under the generic arm (header `_hdr_word` + `_bswap32` of the remaining words, no pointer widening, no sub-word packed field): - Generic-safe: 2 (DOOR_SCALE, `s32 Scale`), 23 (OBJECTIVE_START — see below), 24 (OBJECTIVE_END, header only), 25/26/27/28 (the meaningful field is the `s32 ObjRefID` at 0x4 that `get_status_of_objective` reads via the `MissionObjectiveRecord` cast — the `u16 unk4` in the stub structs is not how it is accessed, so `_bswap32` is correct), 34 (OBJ_COPY_ITEM, 3 scalar words), 46 (CAMERAPOS / `CutsceneRecord` — `coord3d pos; f32 theta; f32 verta; s32 pad`, **no pointer**, 7 words, `PROPDEF_PC_BYTES[46]=28` correct). ### Per-type layout comparison (audited types) Legend: N64 word -> PC byte offset; check = converter cursor lands on the same PC offset the compiler would. **ObjectRecord prefix** (shared by 3/5/12/17/36/42/43 and the head of 1/4/6/7/8/10/11/13/20/21/39/40/45/47). N64 0x80 / 32 w -> PC **144 B**. Field-by-field: header@0; obj/pad `_hh_word`@4; flags@8; flags2@12; prop*@16(+8); model*@24(+8); Mtxf mtx@32..96; runtime_pos@96..108; runtime_bitflags@108; collisiondata*@112(+8); projectile*@120(+8); maxdamage@128; damage@132; shadecol@136 (verbatim); nextcol@140; sizeof **144**. Converter `_emit_object_prefix` cursor lands on every one of these. MATCH. **DOOR (1)** — `DoorRecord`, tail N64 w32..w63. The C3r Bunker2 suspect. PC struct = **296 B** (`PROPDEF_PC_BYTES[1]=296`, `sizepropdef` PORT `return 74`). | field | N64 off | PC off (compiler) | converter | ok | |---|---|---|---|---| | linkedDoorOffset s32 (read-b4-write id) | 0x80 | 0x90 | 0x90 `_bswap32` | yes | | maxFrac..maxSpeed 5xf32 | 0x84..0x98 | 0x94..0xa8 | same | yes | | doorFlags u16 / doorType u16 | 0x98 | 0xa8 (`_hh_word`) | 0xa8 | yes | | keyflags..doorOpenSound 3xu32 | 0x9c..0xa4 | 0xac..0xb4 | same | yes | | frac..speed 5xf32 | 0xa8..0xb8 | 0xb8..0xc8 | same | yes | | openstate s8/unkbd s8/calcopacity s16 | 0xbc | 0xcc (byte-pattern) | 0xcc | yes | | TintDist s32 | 0xc0 | 0xd0 | 0xd0 | yes | | CullDist s16/soundType s8/fadeTime60 s8 | 0xc4 | 0xd4 (byte-pattern) | 0xd4 | yes | | linkedDoor* | 0xc8 | 0xd8 (8-al) +8 | 0xd8 +8 | yes | | unkcc* (Vertex*) | 0xcc | 0xe0 +8 | 0xe0 +8 | yes | | bbox (u32 + `struct bbox` 24B = 28B, 7 w) | 0xd0 | 0xe8..0x104 | 0xe8..0x104 | yes | | openedTime u32 / portalNumber s32 | 0xec / 0xf0 | 0x104 / 0x108 | same | yes | | openSoundState* | 0xf4 | 0x110 (8-al, 4B pad) +8 | 0x110 +8 | yes | | closeSoundState* | 0xf8 | 0x118 +8 | 0x118 +8 | yes | | lastcalc60 union s32/f32 | 0xfc | 0x120 | 0x120 `_bswap32` | yes | | **sizeof** | 0x100 | **0x124 -> pad 296** | 296 | yes | **Conclusion: the DOOR converter cursor matches the real PC layout exactly.** `linkedDoorOffset` lands at PC 0x90 and is emitted as an int id (not zeroed). The C3r Bunker2 residual was fixed by **D126** (objective sub-record `->next` growth desyncing the walk so `linkedDoorOffset+arg2` resolved to the wrong record) — not a DOOR-tail cursor bug. Bunker2 currently PASSES, consistent with this. **VEHICHLE (39)** — `VehichleRecord`, PC **208 B** (`sizepropdef` PORT `return 52`). ailist@w32 = int id (`OBJ_ID_WORDS`, D123); aioffset|aireturnlist@w33 = `_hh_word`; path@w41, Sound@w43 = ptr. Cursor: id 144->152, hh 152->156, 7xf32 156->184, path 184(8-al)->192, nextstep 192->196, Sound 200(8-al, 4 pad)->208. MATCH (208). **AIRCRAFT (40)** — `AircraftRecord`, PC **208 B**. Same shape; path@w43, Sound@w44. Cursor lands path 192(8-al)->200, Sound 200->208. MATCH. **AUTOGUN (13)** — `AutogunRecord`, PC **248 B** (`sizepropdef` PORT `return 62`). 17 scalar tail words 144->212, then unkC4*/unkC8*/beam* (w49/50/51) 216(8-al, 4 pad)->224->232->240, is_active 240->244, unkD4 244->248. MATCH (`PROPDEF_PC_BYTES[13]=248`). **AMMO / MultiAmmoCrate (20)** — `MultiAmmoCrateRecord`, PC **200 B**. Tail = `slots[13]` of `{u16 modelnum; u16 quantity}` -> 13 `_hh_word` writes 144->196, pad->200. `AMMOTYPE_GLOBAL_MAX == 13` matches `PROPDEF_N64_WORDS[20]=45` (32+13). MATCH (`sizepropdef` PORT `return 50`). **TANK (45)** — `TankRecord`, converter **248 B** (`_emit_object_prefix` + collision*@w32 + 23 `_bswap32` words). The `TankRecord` struct as declared in `bondtypes.h` is **53 words**, but `PROPDEF_N64_WORDS[45]=56` (getools/ROM) — the struct's `//s32 unk88..` comments imply a 3-word gap the C declaration omits, so the *field offsets* past `rect` are unconfirmed. However: the only pointer is `collision`@w32 (correctly widened + kept in slot), the stride is self-consistent with `sizepropdef` PORT (`return 62` = 248), and TANK appears only twice (Depot x1, Runway x1). Walk integrity is intact; only tank-field *semantics* (non-crash) could be off. **Confidence medium**; acceptable until a tank level is played. **TINTED_GLASS (47)** — `TintedGlassRecord`, PC **168 B**. 5 `s32` tail words 144->164, pad->168. No pointers. MATCH (`sizepropdef` PORT `return 42`). **Objective sub-records 30/32/33/35** (D126) — re-confirmed: each is `{header; s32 payload x N; T *next}`; converter emits header + N `_bswap32` words + 8 zero bytes for `next` at offset 16, total 24 B. `criteria_deposit` (33) has N=3 (5 N64 words); the rest N=2 (4 words). MATCH (`sizepropdef` PORT `return 6`). **OBJECTIVE_START (23)** — `MissionObjectiveRecord` ends in `WatchMenuObjectiveTextRecord *nextentry` @0x10, so PC `sizeof` is 24, but `PROPDEF_N64_WORDS[23]=4` (nextentry is not serialized) and `sizepropdef` PORT `return 4` (16 B stride). This is **safe**: nothing writes `MissionObjectiveRecord.nextentry` — the briefing-text linked list (`ptr_last_briefing_setup_entry_type23`, `objective.c:54`, `objective_status.c:88`) is a chain of `struct watchMenuObjectiveText` (type-35 records) via *their* `nextentry`, despite the misleading global name. The type-23 record is only ever *read* (`get_status_of_objective` walks it as `ObjRefID`@4 / `TextID`@8 / `MinDificulty`@0xc). Uniform 16-byte stride, no pointer store -> no D126-class overflow. ### Divergence found — types 14 / 19 / 38 / 44 (union-with-pointer index slots) `LinkRecord` (14), `SwitchRecord`=`LinkRecord` (19), `LockDoorRecord` (38), `SafeObjectRecord` (44) each declare their index fields as a **union with a pointer**: typedef struct LinkRecord { inherits PropDefHeaderRecord; // 0, 4 B union { struct PropRecord *first; s32 Index1; }; // PC: off 8 (8-al, 8 B) union { struct PropRecord *second; s32 Index2; }; // PC: off 16 struct LinkRecord *next; // PC: off 24 } LinkRecord; // PC sizeof = 32 On PC the union is 8 bytes / 8-aligned, so `Index1` sits at **byte 8** (4 B pad at byte 4), `Index2` at **byte 16**, `next` at **byte 24**. `LockDoorRecord` is the same shape (sizeof 32). `SafeObjectRecord` has three such unions (`item`/`safe`/`door`) + `next` -> `Index1`@8, `Index2`@16, `Index3`@24, `next`@32, sizeof **40**. The converter's handler for `(14, 19, 38, 44)` does: out[0:4] = _hdr_word(src[so:so + 4]) for i in range(1, n64w): out[4 * i:4 * i + 4] = _bswap32(src[so + 4 * i: so + 4 * i + 4]) i.e. it lays the N64 index words at PC bytes **4, 8, 12** — the N64 tight-4-byte packing. Result on PC: | read | converter put | compiler expects | effect | |---|---|---|---| | `pdef->Index1` (byte 8) | N64 word 2 = **Index2** | Index1 | wrong id | | `pdef->Index2` (byte 16) | (never written) = **0** | Index2 | always 0 | `PROPDEF_PC_BYTES` also under-sizes 14 & 19 & 44 (24 vs real 32 / 32 / 40; 38 is coincidentally 32). **Runtime effect (`prop.c` `proplvreset2` walk):** `PROPDEF_SWITCH` reads `index1 = pdef_switch->Index1` / `index2 = pdef_switch->Index2`, resolves `doorA`/`doorB`, and only if `doorA && doorA->prop && doorB && doorB->type==PROPDEF_DOOR && doorB->prop` writes `pdef_switch->first/second` + calls `initSetLevelLoadPropSwitch` (which does `arg0->next = ...`). Because the indices read wrong, that guard **fails**, so the pointer stores (and the would-be `->next` overflow past the under-sized record) never happen. Same for `PROPDEF_LOCK_DOOR` (`pdef_lock_door->door/lock` + `->next`), `PROPDEF_SAFE_ITEM` (`pdef_safe->item/safe/door`), and `PROPDEF_LINK` (`propweaponSetDual`, also `guna && gunb` guarded). **Net: non-crashing but silently broken** — switch-activated doors, dual (left+right) weapon pickups, padlocked doors, and safe/safe-item links do not initialise on PC. This explains why Streets (`UsetuptraZ`, 20x LOCK_DOOR) and Aztec/Dam (SWITCH) still PASS the load+no-crash sweep. Below crash work in priority, but a WS6 objective-playthrough blocker. Affected levels: LINK — Caverns, sevb. SWITCH — Archives, Aztec, Dam. LOCK_DOOR — Dam, sevx, sevxb, **Streets x20**. SAFE_ITEM — Archives, Depot, sevb, sevx. **Not previously caught:** D125's stride re-check only asserted `sizepropdef == PROPDEF_PC_BYTES/4` (internal consistency), never `PROPDEF_PC_BYTES == real compiler sizeof`. D122's totality note flagged `[u16|u16]` half-swap but not the `union{ptr; s32}` index-slot case. ### APPLIED (M-20, commit pending) — ABI/layout only, `#ifdef PORT` Fix below applied verbatim. `tools_pc/d88_propdefs.py`: `PROPDEF_PC_BYTES` `14/19 -> 32`, `44 -> 40`; the `(14,19,38,44)` handler now emits each `Index{k}` into the low 4 B of the 8-aligned slot at PC `8 + 8*k` (`nidx = {14:2,19:2,38:2,44:3}`). `loadobjectmodel.c sizepropdef()` PORT switch: `LINK/SWITCH/LOCK_DOOR -> return 8`, `SAFE_ITEM -> return 10`, `TAG` stays `6`. Regen chain run. Verified: `-level_09` framediff 3/3 PASS; `-level_20` behaviour byte-identical to a freshly-built pre-D132 baseline (the `frame_000320` phash delta is pre-existing stale-golden / Silo slowdown, NOT a regression — confirmed by building the baseline); Archives (`-level_25`, SWITCH+SAFE_ITEM), Streets (`-level_29`, 20x LOCK_DOOR), Dam (`-level_33`, SWITCH+LOCK_DOOR) all load + render + no-crash. Sweep runs this pass were heavily flaky (level_09 itself 0-framed on a loaded machine) — pure D117/watchdog noise, no crashes. ### Original proposed fix (as written pre-apply) `tools_pc/d88_propdefs.py` — `PROPDEF_PC_BYTES`: `14: 24 -> 32`, `19: 24 -> 32`, `44: 24 -> 40` (38 stays 32). Replace the `(14, 19, 38, 44)` handler: if type_byte in (14, 19, 38, 44): # LINK / SWITCH / LOCK_DOOR / SAFE_ITEM # D132: each Index{1,2,3} field shares a union with a pointer, so on # PC it lives in the LOW 4 bytes of an 8B/8-aligned slot at # PC offset 8 + 8*(N-1); the record ends in a *next the setup walk # writes. The N64 image packs the indices as tight 4-byte words; # emitting them there put Index1 where the compiler reads Index2. nidx = {14: 2, 19: 2, 38: 2, 44: 3}[type_byte] out[0:4] = _hdr_word(src[so:so + 4]) for k in range(nidx): w = src[so + 4 * (k + 1): so + 4 * (k + 1) + 4] out[8 + 8 * k: 12 + 8 * k] = _bswap32(w) # low 4B, LE; high 4B = 0 return bytes(out) `src/game/loadobjectmodel.c` `sizepropdef()` `#ifdef PORT` switch: move `PROPDEF_LINK` / `PROPDEF_SWITCH` out of the `return 6` group and give `LINK`/`SWITCH`/`LOCK_DOOR` -> `return 8` (32 B) and `SAFE_ITEM` -> `return 10` (40 B). `PROPDEF_TAG` stays `return 6` (its `ID`/`OffsetToObj` are plain `u16`/`s16` at 0x4, not in a pointer union; handler already correct). After applying: `d88_emit.py --regen` (all 21 sidecars), then the full `d43 && d69 && d88 --regen` chain, then re-verify `-level_09` / `-level_20` golden + a Streets/Dam/Archives load. ### Confidence per type | type(s) | verdict | confidence | |---|---|---| | ObjectRecord prefix (3/5/12/17/36/42/43) | matches | high | | 1 DOOR | matches (incl. linkedDoorOffset @0x90) | high | | 39 VEHICHLE / 40 AIRCRAFT | matches | high | | 13 AUTOGUN | matches | high | | 20 AMMO/MultiAmmoCrate | matches | high | | 47 TINTED_GLASS | matches | high | | 30/32/33/35 objective sub-records | matches (D126) | high | | 23 OBJECTIVE_START | 16B stride safe (nextentry never written) | high | | 2/24/25/26/27/28/34/46 (generic) | safe under generic arm | high | | 4 KEY / 7 MAGAZINE / 21 ARMOUR / 8 COLLECTABLE | prefix + plain tail; widely used in passing levels | medium-high | | 45 TANK | struct offsets unconfirmed (53 vs 56 w); stride self-consistent, walk intact, non-crash | medium | | **14 LINK / 19 SWITCH / 38 LOCK_DOOR / 44 SAFE_ITEM** | **DIVERGENT** — union index slots at wrong PC offset; silently non-functional (non-crash) | fix high confidence | ### Files touched Docs only: this subsection + the §F index rows (D88.4 status, new D132 row). No code changed; the proposed diff above is not applied. No temp probe scripts left.