D211 didn't affect the in-level view (user test): the fast3d
gfx_apply_fov_scale hook only recognised *pure* perspective matrix loads,
but GE draws the world through g_CurrentPlayer->field_10E0 -- a
projection x view matrix pre-combined on the CPU (bondview2.c:8224) --
which failed the guard. Only the pause/watch model + sky FOV changed.
Fix: scale fovy game-side at the guPerspectiveF chokepoint
(src/fr.c, #ifdef PORT, frFovY *= portFovScale, clamp 160deg), which
lands before the CPU combine. Removed the fast3d hook + gfx_api.h decl;
port/src/video.c now sets f32 portFovScale. Gated to
lvlGetCurrentStageToLoad() != LEVELID_TITLE so the front end (which
renders through the same path) keeps its authored FOV.
User-verified: menu unaffected, in-level view widens (Dam tunnel).
Caveat: currentPlayerSetCameraScale keeps nominal FOV for frustum-cull
/ screen<->world math (edge geometry culls slightly early at high scale).
Adds src/fr.c as a #ifdef PORT game-code touch (D181 policy class).
Docs (this test pass):
- D214: config.c sections by the last dot, so binds live in [Input.Bind]
not [Bind]. Doc-only fix (finding, index, QOL-INVENTORY); code unchanged.
- D217 (OPEN): viewmodel + 3rd-person Bond model textures render garbled
-- stale-palette CI-texture bug (D161 family), surfaced by D215.
- D218 (LOGGED, parked): wide FOV exposes the per-level FarFog
draw-distance boundary; Video.DrawDistance fix proposed.
gunRenderFirstPersonGunModels built its ModelRenderData with
renderdata = *(ModelRenderData *)&D_80035CC0;
a reinterpret that reads the lone `u32 D_80035CC0 = 0` and continues into the
next declared global `u32 D_80035CC4[] = {1, 3, 0, ...}` (gun.c). On N64 that
yields { basemtx=0, zbufferenabled=1, flags=3, rest=0 } because the linker
lays the two globals out contiguously in declaration order and pointers are
4 bytes. On x86-64 it breaks twice: ModelRenderData's pointer members widen
4->8 B (so `flags` is no longer at struct offset 8) and C guarantees nothing
about the two globals' placement. Measured (GE_DVM=1, BUNKER1): flags == 0,
which gates every weapon-model DL node off in modelRenderNodeGundl
((renderdata->flags & 1)) -> subdraw emits 1 gfx command instead of ~150 ->
no gun visible while playing.
All three VIEWMODEL-RESEARCH.md ranked suspects were ruled out first by the
same probe: field_87F==1, RootNode valid, numRecords 22 < pool 192, node walk
visits 24+ valid DL/SWITCH/BSP nodes.
Fix: #ifdef PORT -> set the fields explicitly (zbufferenabled=TRUE; flags=3);
N64 line verbatim under #else. ABI/layout only (D3x class), no logic change.
Verified (headless): GE_DVM=1 post-fix shows flags=0x3 and the weapon subdraw
now emits 153 gfx commands (was 1); bunker1 framediff worst_cell 12.2 -> 28.5
(gun renders in-frame); Dam/Facility/Bunker1/Archives/Cradle/Aztec sweep
crash-free. Owed: one BUNKER1 eyeball; the bunker1 golden needs re-baselining.
GE_DVM gate+render probes left in, env-gated (cf. GE_D193*).
findings.md D215 + index; GRAPHICS-BACKLOG + VIEWMODEL-RESEARCH marked resolved.
port/src/input.c: 12 gameplay actions (Forward/Back/StrafeLeft/StrafeRight/
TurnLeft/TurnRight/Fire/Aim/Action/Cancel/LeanLeft/Start), each bound to a
comma-separated list of up to 4 SDL scancode names via Input.Bind.* keys.
inputRebuildBinds() parses them in inputInit() after configLoad();
SDL_GetScancodeFromName resolves names, unknowns warn + skip. The hardcoded
keyDown(ks, SDL_SCANCODE_*) block in inputComputePad is replaced with
actHeld(ks, IA_*). Mouse buttons (fire/aim) stay hardwired.
File-driven, no UI -- matches the PD port. A fresh ge007.ini gets the full
[Bind] block with defaults visible.
Defaults reproduce the previous FPS layout exactly: all 20 default key names
round-trip to the identical SDL_Scancode the old code used (0 mismatches in a
SDL_GetScancodeFromName test). Build clean; verify.sh dam/facility/bunker1
PASS crash-free.
findings.md D214 + index; QOL-INVENTORY row marked done.
Three port-only knobs, each defaulting to byte-identical behaviour:
- Video.FovScale (D211): multiplicative half-angle scale on every perspective
projection matrix as it is loaded (gfx_apply_fov_scale, gfx_pc.cpp). Columns
0+1 scaled together so H/V FOV widen uniformly and aspect is preserved; ortho
(HUD/menu) matrices untouched. Composes with the game's per-frame aim-zoom
rewrites for free. Percent of vertical FOV, 50-150, default 100 => guarded
early-return (zero FP ops, golden dumps byte-identical). WIDESCREEN-FOV-PLAN
Phase 4, landed standalone.
- Video.Anisotropy (D212): expose fast3d's already-implemented GL anisotropy
hook as config (1-16). New gfx_set_anisotropy_level() extern-C wrapper clamps
to the GL max. Default 4 = the value fast3d already hardcoded => no delta.
- Video.DisplayFPS (D213): PD-parity top-right FPS readout, sampled once per
emitted frame (optionsoverlay.c fpsTick). Config-only; default 0 => emit
still returns NULL with the panel closed.
F10 overlay gains 'Anisotropic' and 'FOV scale %' rows (DisplayFPS is file-only
- the panel is at its 13-row layout limit, matching PD).
Build clean (ntsc-final); bunker1 framediff worst_cell 12.219 vs 12.221
baseline = render noise, no-op confirmed. Interactive eyeballs owed (see
docs/HANDOFF.md M-83).
findings.md: D211-D213 rows + index regen. WIDESCREEN-FOV-PLAN Phase 4 and
QOL-INVENTORY rows marked done.
The old 'cyc2 combine at cycletype=1CYC' probe over-fired on trivial
passthrough cyc2 terms. Replace with one that fires only when a 2-cycle
triangle actually consumes TEXEL1 (the G_CC_INTERFERENCE particle case) and
dumps both texunits' tile/tmem/fmt/siz. One Silo capture then shows whether
TEXEL1 resolves to the fire tile (tmem 392, RGBA16) or something wrong.
Build clean; bunker1 verify PASS.
Real particle records (assets/oddtextures.c globalDL_0x078..) DO set
gsDPSetCycleType(G_CYC_2CYCLE); GE_D172=1 probe confirms fast3d applies it
(particle combine fc26a004 logged at cycletype=2CYC). Both the 0xB9-abort
and the cycle-type hypotheses are now dead. Bug is the G_CC_INTERFERENCE
two-tile combine / TEXEL1 path (IA8 smoke x RGBA16 fire @ tmem 0x188).
Probes (#ifdef PORT, GE_D172=1, inert otherwise):
- gfx_pc.cpp G_SETCOMBINE: distinct combine + active cycletype
- gfx_pc.cpp gfx_sp_tri1: tris with cyc2 combine drawn at cycletype!=2CYC
Needs a display capture to go further (headless can't see particle colour).
0xB9 == G_SETOTHERMODE_L in this non-F3DEX2 build (G_IMMFIRST-6); fast3d
handles it as gsDPSetRenderMode. -level_09 sustained-fire run: zero D146
aborts, zero warnings. Re-scoped to the 2-cycle/cycletype hypothesis
(records never set G_MDSFT_CYCLETYPE -> use_2cyc==0 on PC -> TEXEL1
garbage -> magenta/cyan).
Cloud tile is s_skywaterimages[0] = 64x64 IA8, G_TX_WRAP -> it tiles.
GE's screen-space textured-quad idiom is tc = width<<5 (glass2.c:678), so
skyPortRenderPoly's *32.0f scale was correct; the bug was (s16) overflow on
the +/-20k-texel horizon S/T (unk20/unk24 are texel coords, not fractions).
Fix: fold every vertex of one primitive by the same multiple of the 64-texel
tile period before scaling. Inter-vertex deltas (hence the interpolated
cloud texture) are preserved; magnitudes stay in s16 range. Exact for any
triangle whose S/T span is < ~1000 texels -- everything except tris
straddling the horizon line, which are Defect 2 (adaptive tessellation).
sky.c #ifdef PORT only; N64 path unchanged. Builds clean; bunker1 + statue
verify PASS, no crash. UNVERIFIED VISUALLY -- owed: one -level_22 screenshot
(D176a-SKY-NOTES.md 'Verification ask').
Also: rebased onto main (porting-notes.md conflict resolved).
skyRenderTri/skyRenderFull emit the sky as a verbatim N64 RDP triangle
command chopped into G_RDPHALF_* immediates, which fast3d deliberately
no-ops -> Surface/Statue/Streets skies render solid black.
Behind #ifdef PORT, substitute a screen-space gSPVertex + gSP1Triangle/
gSP2Triangles emit built from the already-projected SkyRelated38 verts
(new helper skyPortRenderPoly, ortho matrices via dynAllocate*). N64 path
unchanged under #else. Pure RSP-idiom substitution, no behavioural change.
Links 243/243. Render check owed on a display: -level_22 frame ~360.
findings.md D176(a) M-46; porting-notes.mdD.
Sibling of D209 (A1 raw-byte union-alias class). chrToPatrol writes
act_init.padding[0x13] (union byte 0x4c) = -1, which is act_patrol.lastvisible60
on N64. act_patrol starts with a patrol_path* that widens 4->8 on x86-64, so
0x4c now lands in waydata and lastvisible60 (@0x50) stays uninitialised. The
patrol 'haven't seen the player recently' gate (chraction.c:~9347) then reads
garbage on the first ticks after entering PATROL.
Fix: write the named field under #ifdef PORT; N64 line verbatim under #else.
ABI/pointer-width only, no game-logic change.
Found by the post-D209 A1 sweep; the only new instance (act_anim/act_dead
padding aliases are pointer-free -> layout-stable).
Build clean; bunker1 + facility verify PASS (both patrol-heavy), no crash.
findings D210 + porting-notes A1 + findings-index regen.
Root cause of D193 ("NPC AI locomotion is too slow", the user's biggest
playtest complaint; broke Cradle completability).
get_sound_at_range() (chraction.c:3573) is the locomotion-animation
selector: it picks ANIM_DATA_sprinting/running/walking from an arg1 speed
tier. Its only caller passed that tier as self->act_ubytes.padding[45] — a
raw byte alias into the ChrRecord action union rather than the named field.
On N64 offset 45 is exactly act_gopos.unk59:
act_gopos = {coord3d targetpos@0, StandTile *target@12,
waypoint *target_path@16, waypoint *waypoints[6]@20,
u8 curindex@44, u8 unk59@45}
At 64-bit the three pointer members widen 4->8B (target@16, target_path@24,
waypoints@32), pushing unk59 to 81 while the literal 45 lands inside
waypoints[1] (40..47) — byte 5 of a heap pointer. The arena is low-4GB
(0x00000000_70xxxxxx), so that byte is always 0x00: arg1 reads 0 forever
and every AI chr in the game was bound to ANIM_DATA_walking /
ANIM_DATA_walking_unarmed regardless of what the ailist commanded. GE
character travel is animation-root-motion driven, so all AI moved at walk
pace on every level. Bond is unaffected (explicit physics, no root motion),
which is the reported "player fine / all AI slow" split.
Fix: PORT-only read of the named field; N64 line kept verbatim under #else.
ABI/pointer-width only, no game-logic change (rule-2 narrow exception, D3x).
Both callers reach the site with actiontype == ACT_GOPOS, so it is the same
byte N64 reads.
Evidence (Cradle -level_41, GE_D193A extended with tier/aidx): all six chrs
report ACT_GOPOS tier=1 SPEED_RUN on 420/440 samples, while the live
model->anim reverse-resolves to animation_table_ptrs1 index 40
(PTR_ANIM_walking) for Trevelyan and 107 (PTR_ANIM_walking_unarmed) for the
guards on every sample; 42 (running) and 41 (sprinting) never appear.
Travel sat in a flat 140-155 u/s band for all of them.
User playtest-verified on the fix build: Trevelyan and the guards run.
Also:
- GE_D193A extended with tier / gspd / typ / tick / move / frz / hid / aidx
(aidx reverse-resolves model->anim against animation_table_ptrs1).
- bondtypes.h: unk59 was commented "guess: room" — it is the SPEED tier.
- porting-notes.md A1: new bug class — raw-byte aliases into a union read a
plausible constant rather than garbage, so they misbehave silently.
- findings.md: D209 added; D193 closed against it.
Static review of the full anim-root-motion path finds no port-side bug:
frame clock ruled out (measured), all model/chr scalars nominal,
ModelSkeleton ruled out (static C not serialized), and the D33
romdataFixupAnimationData byte-swap covers every field the locomotion
decode reads -- walk/run anim records ARE in animation_table_ptrs1,
_ptrs2 is 3 non-overlapping anims (no double-swap), no fixup errors,
sizeof(ModelAnimation)=64 and sizeof(ModelAnimBitField)=6 both platforms,
bitstream walked byte-MSB-first (endianness neutral, matches PD).
A magnitude error, if present, is only visible against an N64 reference.
Two paths documented (both need a fresh session; no MIPS toolchain /
emulator in this env): (A) build ROM with matching logging + diff,
(B) frame-count N64 reference footage of a measured guard traversal.
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
Two #ifdef PORT env-gated probes (default-inert, no golden regression):
- GE_D193A (chr.c chrTick): per-wall-second per-chr travel speed +
the root-motion scalars (scale / anim_translation_scale / animrate /
playspeed / model->speed / g_GlobalTimerDelta). GE_D193A_CHR=<n>
narrows to one chrnum.
- GE_D193B (model.c sub_GAME_7F06D3F4): rate-limited raw anim-bitstream
root-motion decode (joint / mtx base channel / decoded s16 triple /
angle / the D32 bitDescriptors+bitStream addrs).
Silo + Facility captures: every port-side input to the locomotion math
reads nominal -- g_GlobalTimerDelta=2.0, playspeed=1.0, scale=0.1,
anim_translation_scale=1.0, and the C in this path is identical to N64
(only the non-firing D156 NaN guards are #ifdef PORT). animrate=0.0 is
the default (only set on blended transitions). Patrolling guards travel
~60-99 u/s. GE_D193B shows base=0 / angle=0 on every call.
So the divergence is either subtle wrong magnitudes in the decoded
bitstream values, or in the pos34 accumulation -- neither visible
without an N64-decomp reference trace. Findings D193 + GE-ENV-PROBES
updated with the numbers and the next step (build n64decomp/007 with
matching logging and diff, or frame-count reference footage). One
explicit structural check flagged: ModelSkeleton.Joints (ModelJoint*,
4->8 B) reached via model->obj->Skeleton from D43/D45-converted data.
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
User ran Cradle (-level_41; the "-level_51" in the docs was wrong -> that
id is LEVELID_EAR, an unused slot that SIGSEGVs in stanBuildRoomData)
with GE_D193=1 and drove the Trevelyan sequence while Trevelyan walked
visibly slow. Probe held render=30 / sim=60 / clamped=0 / maxraw=2 dead
steady, no [D156] fires. So under the exact failing scene the sim clock
is perfect -> D193 is in the character locomotion/animation path.
Best hypothesis: GE character travel is anim-root-motion driven
(modelTickAnim -> modelSetAnimFrame2WithChrStuff, translation scaled by
model->scale * model->anim_translation_scale); the player uses explicit
physics instead, which explains "player fine / all AI slow". Next:
instrument sub_GAME_7F06D3F4 pos magnitude + scale + playspeed/speed per
chrTick for one walking NPC. Findings D193 + HANDOFF updated; Cradle
level-id corrected to 41.
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
Add GE_D193=1 per-wall-second probe in waitForNextFrame (#ifdef PORT,
env-gated, default path byte-identical): render frames, sim ticks
delivered (Σ deltaFrames == Σ g_ClockTimer), catch-up clamp hits, max
raw pre-clamp delta.
Measured -level_09/_20/_27/_34 idle: rock-steady render=30 fps /
sim=60 ticks/s / clamped=0 / maxraw=2 on every level, VSync on or off.
The per-second sim sum is exactly the N64's ~60 and FRAMETIMING_PORT_
MAX_CATCHUP never bites in normal play, so B1's premise
(g_GlobalTimerDelta / g_ClockTimer sum low over a real second) is wrong
for the steady-state case. Findings D193 updated with the measurement,
the residual quantization risk (no remainder carry in the per-frame
round; only matters if a real AI scene drops into the 20-30 fps band),
and the revised next step (instrument under real AI load, then pivot to
the locomotion/anim path). GE_D193 catalogued in GE-ENV-PROBES.md.
No game-logic change; no golden regression (level_09 f203 91.52% vs
~91.6% ref).
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
Root cause of the "doesn't recover after the alarm stops" half of the user's
D207 report (distinct bug from D207's voice-pool question).
struct field bondview.h:200 `s32 field_A48` is used as a SECOND
ALSoundState* sound-handle slot for the player's double-buffered per-hand
fire sound (gunfire.c:3186-3200). sndPlaySfx stores the new state through
`&field_A48` as an 8-byte `pendingState->link.next = nextState`; as s32 that
write tears into the adjacent field_A4C (the auto-fire cadence timer, rewritten
every frame). After enough double-buffered fire cycles field_A48 holds a value
that is neither 0 nor a valid pointer, so the `else if (field_A48 == 0)`
re-arm gate never fires again and no further player gunshot is issued.
Evidence: user Bunker audiotrace.log — silenced-PP7 idx-46 sndPlaySfx requests
stop dead at log line 18930/25086 while all other SFX continue; the player-gun
slot 0x70076630 is then caught holding a torn value 0x000001db00000000
(field_A48=0 + field_A4C=0x1db read as one word).
Fix (D3x ABI/layout, #ifdef PORT): declare field_A48 as ALSoundState* under
PORT, s32 verbatim under #else. hands[] is runtime-only state (g_CurrentPlayer),
never ROM-serialized, and all downstream fields are accessed by member name —
widening 4 bytes is layout-safe. The (struct ALSoundState *) casts at the use
sites become no-ops and stay for a minimal diff.
Build clean (gunfire.c + full link). Runtime verification owed: replay the
Bunker firefight, confirm idx-46 requests continue for its full duration.
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
User repro: player gunshots specifically go permanently muted mid-firefight
and stay muted after the alarm ends; all other SFX fine. Not generic pool
starvation — a stuck-state bug in the gunfire.c:3178 two-slot double buffer
(audioHandle / field_A48). Leading mechanism: asynchronous sndDeactivate
posts to the 64-slot SFX event queue; under firefight+alarm load the queue
saturates, alEvtqPostEvent drops the deactivate, the state never disposes,
the slot never clears — once both slots stick, no player gunshot can play.
All three pool sizes are byte-match stock GE => port timing bug, not a value
to bump. Next: read the user's audiotrace.log to confirm evtq saturation +
uncleared slots.
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
D205: CLOSED (M-78). User re-tested explosion->scream on Bunker after the
PR #28 merge — the explosion SFX is correct by ear now. All four D205
symptoms were D206 (the +1 bank-slot alias); the "scream" was a coincident
guard death-yelp (faithful N64 behaviour). M-74's pan audit already ruled
out a presentation-layer bug.
D207: re-add ONLY the [D207-DROP] trace line (not the reverted last-resort
LOOPED-preemption pass) at both sndDisposeSound no-voice sites in snd.c,
gated on GE_AUDIOTRACE, logging state/prio/flags/count=N/max — so a user
capture on the real failing level is decisive between (a) heavy-level 8/8
starvation, (b) a leak on an alarm-stop path, (c) perceptual. Static
re-check noted in findings D207: ALARM3 slot 162 sampleVolume=80 (below
gunfire), keyMax=8 => not RETRIGGER => continuous-loop by design on N64 too.
findings-index.csv regenerated.
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
User by-ear A/B vs N64 confirms D206: armour pickup, unarmed melee whiff
and silenced PP7 all correct. D202's silenced-PPK 'slap' was D206 (SFX
bank index +1) — CLOSED; M-65/M-66b ownerless-loop mitigations stay.
D205 narrows to symptom (2) explosion->scream only. D207 (alarm klaxon)
acceptable in play per user; voice-starvation hypothesis didn't hold up
(M-77b), no fix shipped, GE_FORCEALARM probe kept for follow-up.
Regenerated findings-index.csv.
GE's sndPlaySfx reads the on-disk sound bank through the cast alias
struct ALInstrumentAlt_s { s32 unk0, unk4, unk8; ALSound *soundArray[1]; }.
On N64 (4-byte pointers) soundArray sits at struct offset 12 and
deliberately aliases the on-disk ALInstrument's bendRange/soundCount
words, so soundArray[N] == on-disk table entry [N-1] — GE's SFX_IDs are
1-based into the table by design (ID 0 = NOTHING_SFX, short-circuited).
On PC the 8-byte pointer + 8-byte alignment push soundArray to offset 16,
and the converted bank (romdata.c afFixupInst) is packed to match, so
every SFX_ID resolved one bank slot too high.
Root cause of D202's silenced-PPK 'slap' (id 46 -> PUNCH1 instead of
GUN_SILPPK_A) and D205 (3) armour + (4) melee->Klobb. M-61's rejected
'index 45, +0.999' was correct all along.
Fix: src/snd.c:1001, #ifndef PORT / #else -> soundArray[soundIndex - 1],
N64 line kept verbatim. ABI/layout only (D3x class), no game-logic change.
Also serves the retrigger chain (same 1-based space).
Verified: build clean; BUNKER1 GE_AUDIOTRACE 60s crash-free, all ~90
distinct requested indices resolve to rom_sfx_decode.py slot N-1 (0/90 at
N); -level_09 golden framediff 3/3. User playtest: 'most audio is good now.'
findings.md D206 + D205 (M-73/M-74) + D207; porting-notes.md §E.
- findings.md: D202 §M-67 (static analysis exhausted, GE_VOICEDUMP probe
motivation), §M-68 (decisive runtime A/B — all three user complaint
classes play the exact ROM samples at the exact ROM pitches) and new
§M-69 (320-request full-corpus exact match: 286/320 CC >= 0.85; the
initial 44 'BAD' were tooling artifacts — voicedump stamps are block-END
times, coarse-slide missed sharp onsets, refs unclipped at voice death;
remaining failures = dropped allocations / shared-wavetable wire
attribution, not corruption) + index row.
- GE-ENV-PROBES.md: GE_VOICEDUMP entry (incl. block-END-stamp gotcha);
[WIRE]/[VOICEMAP]/[EVT]/[STOP-EVT] documented under GE_AUDIOTRACE.
- AUDIO-PLAN.md: status banner — Phases 0-2 done (D199/D200/D201/D204),
Phase 3 verification is the active track.
All referenced from HANDOFF.md / GRAPHICS-BACKLOG.md but untracked:
- D172-PARTICLE-COLOR-RESEARCH.md: magenta/cyan blood root-cause candidate
(unhandled GBI opcode 0xB9 in ROM particle records + D146 guard)
- D176a-SKY-NOTES.md: RDPHALF sky-tri wire format decoded; Path B draft state
- VIEWMODEL-RESEARCH.md: 1P weapon model static audit, three ranked suspects
- QOL-INVENTORY.md / WIDESCREEN-FOV-PLAN.md / UNLOCKED-FPS-PLAN.md: parked
Phase-4 proposals
Software RSP-audio-ucode mixer (macro-swap aXxx -> Impl functions, PD-port
pattern) makes libaudio output audible on PC. Alongside it, two pointer-width
bugs surfaced by exercising the audio path for the first time:
- D198: alAdpcmPull/_decodeChunk used K0_TO_PHYS (N64 kseg0 mask) on 64-bit
heap pointers; switched to osVirtualToPhysical like every sibling call site.
- D201: bnkf.c bank/inst/sound/wavetable relocation offsets were s32,
truncating the real 64-bit base pointer for ~50% of allocations; widened
to uintptr_t.
- D200 (already committed-pending from a prior session): reverb's ALDelay
u32 negative indices zero-extended into +4GB offsets on 64-bit; (s32)/(s64)
casts match PD ground truth.
- D199: documents the mixer's aSetBuffer persistent-DMEM-context design,
reconstructed from call-site pairs (no RSP disassembly to check against)
since there's no N64 audio ucode source to reference.
- D195/D197 (playtest reports referenced by an earlier HANDOFF note but never
written into findings.md): backfilled as OPEN with next steps.
Verified: ./build-pc.sh ntsc-final links clean; -level_09 runs 600+ frames
crash-free with music + SFX audible.
The D117 findings.md addenda in the prior commit changed D117's status
text without regenerating findings-index.csv, and the new GE_DETERM_TRACE
env var wasn't yet mentioned in GE-ENV-PROBES.md -- both fail their
--check gate in CI's validate job. Caught by a real CI run, not predicted.
Claude-Session: https://claude.ai/code/session_01P3WrUHAFQZUuhrHhiJgTUr
Adds an env-gated (GE_DETERM=1, default OFF), test-only deterministic-clock
mode to port/src/libultra.c per the design in docs/dev/findings.md section D117:
osGetCount() returns a virtual clock advanced synchronously in osRecvMesg()
when __scMain (the sole consumer of the scheduler's retrace queue) asks for
the next message, instead of tracking real elapsed time. Goal: two runs of
the same build take the same simulation path, for exact frame-diff
regression testing and reproducible crash repro.
Four passes this session, each is a real, verified result, not a guess:
1. Fixed the predicted pre-first-frame deadlock (a poll-driven fallback
caused it in the first cut).
2. Replaced the poll-driven fallback with synchronous per-ask generation
in osRecvMesg -- fixed the deadlock cleanly, but two-run divergence was
still 33-38%.
3. Capped the pre-first-task tick backlog to a fixed constant -- divergence
dropped to 17-20%, still not the ~0% target.
4. Added GE_DETERM_TRACE=1 instrumented tracing and diffed two runs' clock
event logs directly. Proved the tick mechanism is now fully
deterministic (byte-identical clock values through the first real task).
Root-caused the actual remaining divergence: every synthesized retrace
increments the scheduler's frameCount, which gates a half-rate client
(the audio client) by parity -- since the pre-task spin count is still
real-time-scheduling-dependent, that parity isn't guaranteed to match
between runs.
GE_DETERM is NOT achieving its determinism goal yet and is not meant to be
used for anything today -- it ships here purely as a well-documented,
zero-risk-when-off checkpoint (verify.sh bunker1 PASS, worst_cell unchanged,
confirmed after every change this session) so the next attempt starts from
proof instead of re-deriving four sessions of dead ends. Full trace numbers,
exact line citations, and the mechanism: docs/dev/findings.md section D117
(four M-52 addenda).
Claude-Session: https://claude.ai/code/session_01P3WrUHAFQZUuhrHhiJgTUr
Static/tooling slice of docs/dev/notes/AGENTIC-SPEEDUPS-PLAN.md — no build or
runtime environment needed to verify any of it.
- Step 6 / N7: add a VERIFY: line to the investigation-brief template
(docs/dev-process.md) and the CLAUDE.md dispatch-preflight checklist — the
brief must now name the exact probe/verify invocation that proves the task
done, or state that runtime verification is impossible here.
- Step 4 / N2: tools_pc/README.md — indexes the ~60 scripts with a
living vs artifact marker so closed-finding artifacts can be archived.
- Step 4 / R8: tools_pc/gen_findings_index.py -> docs/dev/findings-index.csv
(label, bucket, one_liner, status_raw) — grep-before-you-read aid for the
~200 KB finding log. Heuristic parser of the §F/§H tables.
- Step 4 / R9: tools_pc/gen_env_probes.py — re-greps live getenv("GE_*")
sites and reports NEW/GONE drift vs docs/dev/GE-ENV-PROBES.md (keeps the
curated prose, only checks the site map). Doc reconciled: 7 new probes
added, GE_D54 kept as a tombstone.
- CI: validate job now runs both generators with --check.
Step 1 / N1 (HANDOFF preflight) applied in the local-only docs/HANDOFF.md.
Step 2 (verify.sh) and the HANDOFF/HANDOFF-ARCHIVE trim are left for a
session with a build environment.
Update the public-facing docs to the M-49 state (v0.1.0 alpha, first
full-campaign playtest: 19/21 missions completable, D191 crashes Bunker ii
+ Statue, D193 AI pacing is the top flaw).
- README: lead with what it is + status, research angle second; new
Quick start section; Status/Download rewritten for the alpha + dual
platform; "By the numbers" refreshed (~3 weeks, ~320 commits, ~190
findings, ~49 sessions, 69 PORT files).
- .github/release-notes.md: v0.1.0 title + known-issues rewrite.
- .github/social-preview.html: status/stat lines (regen + re-upload the
PNG separately).
- docs/index.md, docs/dev/LEVEL-STATUS.md: M-49 status.
- docs/dev/findings.md, docs/dev/GRAPHICS-BACKLOG.md: D191-D197 +
D148/D170/D173 M-49 escalations.
Hygiene:
- Fix the `&` literal in internals.md H1.
- Repoint two ../README.md links that 404 on the Pages site.
- Standardise British -> American spelling across the public docs
(behaviour->behavior, colour->color, catalogue->catalog,
centre->center, etc.). Deep internal logs left untouched.
- .gitignore: document the local-notes convention (docs/HANDOFF.md,
docs/BACKLOG.md, and a new docs/dev/notes/ drop for roadmaps and
session plans) so it survives a fresh clone.
* fix(linux): survive the decomp's latent stack-buffer overruns (D189)
The decompiled game code has a class of fixed-size stack-buffer over/under-
runs that write a few words into adjacent stack scratch:
* stan.c sub_GAME_7F0B1DDC — `StandTile *tileStack[39]` with a bail check
of `if (cat >= 41)` that only runs once per outer iteration, so `cat`
reaches 40 + pointCount and the append writes several slots past the end.
BUNKER1 (-level_09) prop room-list setup legitimately visits ~40 tiles.
* bondview2.c bondviewCalcIntroSwirlCamera — `f32 pointbuf[10]` indexed
`&pointbuf[i*3]` for i in [-1..2] then written [0..5], i.e. [-3 .. 11].
On the N64 and on the MinGW/Windows build (neither uses a stack protector)
these are harmless in practice — the Windows build is stable over thousands
of frames. Only Ubuntu's gcc default (`-fstack-protector-strong`) turns them
into a fatal `__stack_chk_fail`, at BUNKER1 stage load and again at the intro
swirl camera (~frame 5).
Two-part fix:
* `-fno-stack-protector` globally (CMakeLists) — matches the N64 and MinGW
builds so the same code runs the same way. A hardened Linux build would
need the whole overrun class swept first.
* `StandTile *tileStack[64]` under `#ifdef PORT` (stan.c) — the one case
that is cleanly understood and bounded (`pointCount` is 4-bit, so 40+15
is the true worst case). Pure local scratch, never escapes, no behaviour
change. N64 keeps `[39]` under `#else`.
* stan.c:2225 link-resolve also switched to the D177 PORT_PTRADD macro for
idiom consistency (the blob maps below 4 GiB so the bare cast is
currently lossless; #else is byte-identical).
Stacked on the D188 xprintf fix. Verified: Windows -level_09 framediff 3/3
within golden threshold (all changes are #ifdef PORT or no-op on MinGW);
Linux -O2 -level_09 now runs to frame 2100+ with no crash.
* docs: D189 update (stack-protector / latent overrun class) + porting-notes D8
* docs: D190 — Linux -level_45 objective.c load crash (open); D189 sweep 5/6
* fix(linux): thread a va_list* through _Printf (x86-64 SysV ABI)
On the x86-64 System V ABI `va_list` is an array type, so a by-value
`va_list` parameter decays to a pointer. `_Printf()` was passing `&args`
(the address of that local pointer, not the argument list) to `_Putfld()`,
which does `va_arg(*args, ...)` — so `_Putfld` walked garbage and
segfaulted. Latent at `-Og`, fatal at `-O2` (PR #9), which is why the
Linux release build crashed at boot:
bossInitMainthreadData (boss.c:247, the D121 per-level -m token sprintf)
-> sprintf("-level_%c%c %s", ...) -> _Putfld -> SIGSEGV @ xprintf.c
Same UB class as D187. Windows x64's `va_list` is a scalar (`char*`), so
`&args` happened to work there — the Windows build is unaffected.
Fix: under `#ifdef PORT`, `_Printf` takes `va_list *` and passes it
straight to `_Putfld` (which already expects `va_list *`); `src/sprintf.c`
(the only PC caller — `rmon.c` is excluded from the build) passes `&args`
of a real `va_list` object. N64 build untouched (`#ifdef PORT`, and it
byte-matches the ROM).
Verified:
- Windows: build links, `-level_09` framediff 3/3 within golden threshold.
- Linux -O2: boot crash gone, now advances past bossInitMainthreadData
into stage load (uncovers a separate pre-existing stan.c stack overflow
during BUNKER1 prop setup — tracked separately).
* docs: D188 (xprintf va_list ABI) + D189 (stan.c stack-smash) findings + porting-notes D7