From 5f985e9756eac191ddca47bf413ac560b347f7cd Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:37:11 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20M-20=20=E2=80=94=20D132=20propDefs=20un?= =?UTF-8?q?ion-index=20layout=20divergence=20+=20D133=20intro-render=20tri?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D132 (propDef converter layout audit, static): converter cursor confirmed correct vs real PC struct layout for every propDef type the 21 solo levels emit (DOOR/ObjectRecord-prefix/VEHICHLE/AIRCRAFT/TANK/AUTOGUN/AMMO/ TINTED_GLASS/objective sub-records all MATCH; handler set is total). Real divergence found for types 14/19/38/44 (LINK/SWITCH/LOCK_DOOR/SAFE_ITEM): s32 IndexN fields share a union with a pointer -> 8B/8-aligned slot on PC, converter emits at N64 tight 4B offsets -> indices misread -> switch-doors / dual-weapons / locked-doors / safes silently fail to link (non-crash, WS6 blocker). Proposed fix in D132, NOT applied - needs regen + game verify. D133 (intro render triage): intro 'mostly black' is NOT a regression - it is the D75/D76 parked-cosmetic steady state. Legal-screen coverage pixel- identical (6677 non-clear px) between M-17 7159fff5 (worktree build) and HEAD. Parked. Facility -level_34 + Jungle -level_37 re-verify failed this pass -> flagged RE-VERIFY in LEVEL-STATUS, not investigated. --- docs/PCPortResearch.md | 302 ++++++++++++++++++++++++++++++++++++++++- docs/PORT-LEARNINGS.md | 27 ++++ 2 files changed, 328 insertions(+), 1 deletion(-) diff --git a/docs/PCPortResearch.md b/docs/PCPortResearch.md index 8f193399..36515ee6 100644 --- a/docs/PCPortResearch.md +++ b/docs/PCPortResearch.md @@ -806,7 +806,8 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D85 | room primary/secondary DL → `texLoadFromGdl` garbage | OPEN (safety-netted; widen/pool fixes landed, geometry now renders) | | D86 · D87 | modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure` | resolved | | D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved | -| D88.4 | `propDefs` polymorphic record stream not byteswapped → `setupDoor` crash | **OPEN — cross-level blocker** | +| D88.4 | `propDefs` polymorphic record stream not byteswapped → `setupDoor` crash | resolved (`d88_propdefs.py`); layout audit M-20 → **D132** | +| D132 | D88 propDefs layout audit (M-20): converter cursor confirmed vs real PC struct layout for all types the 21 levels emit. DOOR/OBJECT-prefix/VEHICHLE/AIRCRAFT/TANK/AUTOGUN/AMMO/TINTED_GLASS/objective sub-records all MATCH. **Divergence found:** types 14 LINK / 19 SWITCH / 38 LOCK_DOOR / 44 SAFE_ITEM — each has `s32 IndexN` fields sharing a union with a pointer (`LinkRecord.first`/`Index1`), so on PC the field sits in an 8-byte, 8-aligned union slot, but the converter emits it at N64 tight-4-byte-word offsets → `pdef->Index1` 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 | | 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 | @@ -822,6 +823,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | 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`) | | 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. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS @@ -4391,6 +4393,46 @@ unregressed. Sweep 13 → 16/21. Generalisable quirk appended to 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`, @@ -4569,3 +4611,261 @@ Findings: *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. + +### Proposed fix (NOT applied — ABI/layout only, `#ifdef PORT`) + +`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. diff --git a/docs/PORT-LEARNINGS.md b/docs/PORT-LEARNINGS.md index 4619e0a0..93432d84 100644 --- a/docs/PORT-LEARNINGS.md +++ b/docs/PORT-LEARNINGS.md @@ -40,6 +40,22 @@ state. 22 (`TagObjectRecord.NextTag`, already handled). Check every record type the walk can see for a `set_parent_*` / `*_entry_parent` writer. +- **D132 corollary — a `union { T *ptr; s32 IndexN; }` field is 8B/8-aligned + on PC even when the ROM image only ever stores the `s32`.** `LinkRecord` + (`first`/`Index1`), `LockDoorRecord`, `SafeObjectRecord` etc. pack their + index words tight (4B) in the N64 setup stream, but the compiler places + each `IndexN` in the LOW 4 bytes of an 8-byte-aligned pointer slot + (`Index1`@8 not @4, `Index2`@16 not @8, then a trailing `*next`). A + converter that lays them at N64 4-byte offsets makes `pdef->Index1` read + `Index2`'s value and `Index2` read 0 → the record's validity guard fails + and switch-doors / dual weapons / locked doors / safes silently never + initialise (usually non-crash, because the failed guard also skips the + pointer stores). Fix: emit each `IndexN` at PC offset `8 + 8*(N-1)` and + size the record with the widened unions + trailing `*next` + (`d88_propdefs.py` types 14/19/38/44 — proposed in §F D132, not yet + applied). Grep every ROM-serialized record for `union {` with a pointer + arm. NB `[u16 ID][s16 x]` NOT in a union (e.g. `TagObjectRecord`) stays + at offset 4 — only the pointer union forces the 8-align. - **D130 corollary — in-place N64→PC struct-array re-layout aliases when the stride grew less than the read span.** A ROM struct array widened on PC (`fontchar` 24→32B, `romdataFixupFont`) is often re-laid-out *in place*: @@ -237,6 +253,17 @@ deprioritised below level-progression / crash work. See (especially Phase 3 audio underruns), reach first for host thread priorities (`SetThreadPriority`: scheduler thread time-critical, tick normal) and the deferred `GE_DETERM` mode (D117) — not for kernel changes. +- **"Regression vs. steady state" is decided by a build-and-compare, not by + reading an old handoff line** (D133). Handoff prose like "the entire intro + renders" is often aspirational — the author's intent, not a measured coverage + number. Before bisecting a suspected render regression: `git worktree add` the + commit whose handoff made the claim (COPY `data/`, never junction — see the + data-dir-junction-hazard memory), build, capture the *same* `GE_PCDUMP` + window, `pixcount.py`. A **pixel-identical** non-clear count on a static 2D + screen (e.g. the legal/disclaimer framebuffer, 6677 px here) across two + independently-built binaries proves the path is unchanged → not a regression, + don't bisect. Clean up the worktree with `git worktree remove --force` (safe + only because `data/` was copied). - **Watch item: `osYieldThread` = `Sleep(0)`.** The one place we fake cooperative behavior. If a level-sweep hot loop misbehaves under host load, this shim is the first thing to inspect. Keep as-is until then.