docs: M-20 — D132 propDefs union-index layout divergence + D133 intro-render triage

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.
This commit is contained in:
James Dansereau committed 2026-08-29 22:37:11 -04:00
1 parent 71c205092a
commit 5f985e9756
2 files changed
+328 -1

No files matched your search

+301 -1
View File
@@ -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(<compiled matrix/vtx symbol>)` 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.
+27
View File
@@ -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.