Commit Graph
166 Commits
Author SHA1 Message Date
James Dansereau 1db6992e5e Fix D203/D253: Facility SIGSEGV on Linux — callback prototype ABI mismatch
stanCheckLinkedSpecialTile was decompiled with an all-s32 middle-arg
prototype while its callers pass it through the standTileLocusCallback_B_t
typedef (f32 f32 f32). On x86-64 SysV the int/float class split puts the
6th arg (outFlags/record) in %rdx per the typedef but %r9 per the
definition, so the FORCECROUCH/LADDER store hit a stale scratch register.
MIPS o32 passes both through GPRs a0-a5 identically, which is why the
mismatch was invisible on N64 and in the decompilation.

ABI-class edit (AGENTS.md #2 narrow exception): body never uses the three
middle params; N64 codegen unaffected. Verified under gdb on WSL:
pre-fix SIGSEGV at stan.c:2441 with r9=0x3 and a valid record in the
caller frame; post-fix the same walk route completes clean.
2026-09-13 18:55:26 -04:00
James Dansereau 790fb622c5 D194: GEPD-mirror aim -- direct crosshair/gun/camera writes, game damping left running
Mirrors MouseInjectorPlugin (games/goldeneye.c) in model: mouse motion
drives a crosshair POSITION accumulator (clamped +/-5.16), written
straight into crosshair_x/y_pos + gun_azimuth_angle/turning via GEPD's
formulas; the view only scrolls past 72% toward the screen edge
((ratio-0.72)*475*dt*fov/basefov); no look stick is emitted while
aiming; on release the game's own damping eases everything to centre.

Key correction vs the earlier attempt: GEPD patches NOTHING in
bondview2's damped crosshair update (caclulate_gun_crosshair_position_
rotation) -- the per-frame overwrite simply wins over the damping, and
that function is what derives crosshair_angle (HUD crosshair + aim ray)
and field_FFC (arm/gun offset in gunUpdateAndFire). The temporary
portAimOverrideActive() guard is removed; bondview2.c carries only an
explanatory comment. No game-code behaviour change.

Knobs: Input.AimAbsolute (1=GEPD mirror, 0=legacy velocity stick),
Input.GepdSens (default 20). Field mapping vs GEPD's N64 RAM offsets
verified with offsetof (constant 0xBE0 delta = 32->64-bit pointer
expansion).

Open: crosshair/arm 'spazz' glitch, aim-mode FPS drop (perf.ps1),
sensitivity tuning -- see docs/HANDOFF.md M-122.
2026-09-13 13:53:05 -04:00
James Dansereau 7c48f664ca D222/D218/D249: fix high-FovScale culling, draw distance, LOD scaling (#76)
* D222/D218: fix high-FovScale culling + add Video.DrawDistance

D222 (high FovScale breaks screen-edge culling): currentPlayerSetCameraScale()
derives both the frustum-cull plane normals and the fog/LOD
distance-visibility-fade scale (c_scalelod/c_lodscalez) from c_perspfovy,
which every viSetFovY/viSetAspect/viSetFov/viSetZRange call in fr.c was
setting from the nominal fovy -- never the portFovScale-widened value D211
already computes for the actual render projection two lines away. Factored
the widen-FOV math into a shared port/src/video.c helper (portScaleFovY,
same logic D211 already had inline) and fed it into all 4 currentPlayerSetPerspective
call sites. Identity at FovScale=100 by construction. Audited: the affected
fields are consumed only by render-side fog-visibility-fade and screen-space
transform code (propobj.c/bondview.c), not NPC AI visibility/behavior.

D218 (wide FOV exposes the fog/far-clip boundary): implements the
previously-parked plan. Video.DrawDistance (percent, 100-400, explicit
override) and Video.DrawDistanceAutoFov (couples the multiplier to FovScale
by default) applied in fogLoadCurrentEnvironment (bgfog.c) to both the
far-clip plane and the fog-blend distance read, so they stay consistent.
Clamped to [1.0, 2.5] per the plan's own far-field z-fighting caution.
Both new knobs added to the F10 overlay alongside FovScale.

Rebuilt clean, crash-free at FovScale=150/DrawDistanceAutoFov=1 on Dam.
bunker1 golden framediff was inconclusive in this dev box -- confirmed via
an A/B stash/rebuild that the unmodified baseline also fails it by a
similar/larger margin (pre-existing frame nondeterminism, not a regression
from this change).

D249 (default-FOV culling, sometimes) is unrelated/still open -- different
mechanism, not touched here.

Needs a human's eyes: does high-FOV screen-edge culling and the wide-FOV
fog/draw-distance artifacting actually look fixed now.

Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog

* D249 follow-up: add Video.LodDistance geometry-LOD scaling

Found a real, distinct geometry-LOD system in src/game/model.c:
MODELNODE_OPCODE_LOD nodes whose children are shown/hidden per-frame by
modelUpdateDistanceRelations() testing a view-space distance (scaled by
getPlayer_c_lodscalez(), now FOV-consistent per this branch's D222 fix)
against each node's MinDistance/MaxDistance -- separate from the
fog-visibility-fade D218 already covers. It has an existing ground-truth
multiplier hook, g_ModelDistanceScale, already used transiently by the
game itself (chr.c's 0.3125f close-up override, front.c/title.c's menu
modelSetDistanceDisabled calls).

Rather than write to that shared global (would fight those existing
overrides), compose a second multiplier at the same call site:
portLodDistanceMultiplier() (port/src/video.c), driven by new
Video.LodDistance (percent, 25-400, default 100) and
Video.LodDistanceAutoFov (default OFF, unlike DrawDistanceAutoFov --
this is meant as a standalone perf lever to pull down, not something
that silently costs more as FovScale widens). Inverse-scaled: 200%
keeps full detail twice as far (more cost), 50% drops to the lower LOD
twice as soon (less cost). Both new keys in the F10 overlay.

Identity at LodDistance=100. Rebuilt clean, crash-free at LodDistance=40
(aggressive) on Dam and at defaults on bunker1.

Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog

* D222/D218 follow-up: fix guard fade-early regression, retune draw-distance coupling

Live playtest on Dam found two issues in the D222/D218/D249 work:

1. Regression: D222's c_lodscalez widening (correct for the screen-edge
   cull planes) is also read directly by propobj.c's
   chrobjFogVisRangeRelated()/sub_GAME_7F054C58() -- the character/prop
   fog-visibility-fade cutoff. Uncompensated, this made guards fade
   sooner at wide FOV than before D222. Fixed by dividing that same
   c_lodscalez reading by portDrawDistanceMultiplier() at both call
   sites, so the "how far can you see" question stays answered
   consistently across both consumers.

2. Tuning: a straight 1:1 FovScale auto-coupling on
   portDrawDistanceMultiplier() wasn't aggressive enough -- live-tested
   up twice (1x -> 2x -> 4x FovScale, clamp ceiling raised 2.5x -> 4.0x
   -> 6.0x each time) until guards held up and the Dam tunnel's blue fog
   artifact receded. Re-verified the fog ramp itself (not just the
   far-clip cutoff) scales with the multiplier end-to-end.

User confirms this is good for now.
2026-09-13 10:13:04 -04:00
James Dansereau 70bc9d65b5 D250: getenv() caching fix -- root cause of the Dam fps drop (plus sysSleep unit fix, GE_D250 probe, perf.ps1) (#75)
* D250: fix sysSleep unit-conversion bug in frame-pacing; add GE_D250 lvlRender probe

port/fast3d/gfx_sdl2.cpp sync_framerate_with_timer() computed the
remaining frame budget in 100ns units (qpc_to_100ns) and passed it
straight into sysSleep(), which takes microseconds -- a real 10x
oversleep whenever that branch fires. Genuine bug, but only active
when target_fps is nonzero (tied to Input/Video FpsCap); confirmed via
live testing that it is NOT the active cause of the user's reported
~45-50fps with FpsCap=0/VSync=0, where this pacing path never runs.

Added GE_D250 (src/boss.c, #ifdef PORT) to isolate lvlRender()'s
DL-building cost (culling/animation/scene traversal) from gfx_run's
already-measured software-RSP interpretation cost. Live test on Dam
shows lvlRender averaging ~0.5-1.5ms -- also not the bottleneck.

Documented in docs/dev/GE-ENV-PROBES.md (GE_D250 row). findings.md
D250 update to follow after rebase.

Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog

* docs(D250): live A/B session -- MSAA/aniso/FOV/resolution/vsync all ruled out

Live session with the user driving the game directly, confirmed the
sim-healthy/render-sagging pattern from earlier idle testing reproduces
under real play on Dam. One-at-a-time A/B via the F10 overlay ruled out
MSAA, anisotropy, FOV, resolution (1280x960 fullscreen vs 640x480
windowed), and VSync (render never exceeds ~60 even uncapped) as the
cause -- the cost is resolution/GPU-setting-independent.

gfx_run (~6-10ms) + lvlRender (~0.5-1.5ms, via the new GE_D250 probe)
together don't account for the real ~20-22ms/frame. Leading remaining
suspect: the emulated N64 thread-handoff/scheduler round-trip
(gesched.c/sched.c) -- real OS context-switch/wake latency standing in
for what was free hardware handoff on N64. Needs a real profiler
(Visual Studio / WPR) attached during a live bad-fps moment to confirm;
ad-hoc per-call timers have now ruled out the two most obvious CPU call
sites without finding the cost.

Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog

* D250: fix uncached getenv() in fast3d hot paths -- root cause of the fps drop

A live WPR/xperf CPU trace (tools_pc/perf.ps1, new sister script to
debug.ps1) with Microsoft public symbols resolved found the dominant
render thread spending only 5.2% of its CPU time in the game's own
code -- 43% was in msvcrt.dll, and getenv()/_getenv_helper_nolock
alone accounted for ~50% of the thread's total CPU time.

Root cause: port/fast3d/gfx_pc.cpp's import_texture() (runs on every
texture bind) and several per-triangle/per-G_SETCOMBINE debug-probe
checks (GE_DTEX, GE_TEXDUMP, GE_TEXRAW, GE_D229 x2, GE_D172 x2, plus
GE_TEXDUMP/GE_PCDUMP in gfx_opengl.cpp) called getenv() uncached on
every call, unlike the safe static-cached pattern already used
elsewhere in this codebase. On a texture-heavy scene that's tens of
thousands of locked, locale-aware Windows CRT environment lookups per
second -- invisible to both of the timers added earlier this session
(gfx_run's own cost measurement and the new GE_D250 lvlRender probe),
since neither isolates CRT/library time from the game's own code.

Fix: cache every getenv() site in gfx_pc.cpp/gfx_opengl.cpp with the
established static-int pattern. Narrow, mechanical, no logic change.

Verified: the exact idle-Dam test that was pinned at 45-50fps before
the fix now holds a rock-steady 60-61fps, zero dips. Also explains why
the earlier MSAA/aniso/FOV/resolution/vsync A/B pass all showed "no
change" -- the getenv() cost was a large per-call constant that
swamped any real, smaller resolution-dependent GPU cost at every
setting tried.

Residual, separate issue found once this was fixed: still runs badly
at 1440p+ (640x480 now holds a clean 60fps) -- likely the real GPU
fill-rate cost the getenv() bug was masking, now exposed on its own.
Not yet investigated.

docs/dev/findings.md D250 updated with the full root-cause writeup.
docs/porting-notes.md gets a new lesson under section E (cache
getenv() in any hot-path probe). tools_pc/perf.ps1 automates the
trace-and-analyze workflow for next time.

Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog

* docs(D250): correct the 1440p+ follow-up -- it was a stale-build false alarm

User re-tested 1440p/4K on the actual getenv-fix build and confirmed
it holds up fine too. The "still runs badly at 1440p+" report was
from before the fix was live, same stale-build class as the DLL-load
false alarm earlier in the session. D250 is fully resolved across all
resolutions tested, not just 640x480.

Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog

* docs: add missing GE_D219RAW row to GE-ENV-PROBES.md (CI validate fix)

Pre-existing gap unrelated to this branch's own changes -- gen_env_probes.py
--check caught it now since it runs on every PR. GE_D219RAW has had live
getenv() sites in src/game/image.c since the D219 investigation but was
never added to the probe doc.
2026-09-13 09:33:36 -04:00
James Dansereau 52477b44af D248: fix port-wide 30fps halving -- pointer-width scheduler ABI bug
osScAddClient() stashes a per-client "every retrace vs every other
retrace" flag in the next array slot's .next field (c[1].next = next);
__scHandleRetrace read it back via a hardcoded 8-byte offset that only
lands on the right memory when OSScClient is 8 bytes (32-bit
pointers). On this 64-bit port the struct is 16 bytes, so the read
landed inside the gfx client's own msgQ pointer (always non-NULL)
instead, silently collapsing the gfx client onto the audio client's
30Hz path even though the VI-retrace pacemaker itself was always
correctly ticking at 60Hz.

Fix indexes the struct (client[1].next) instead of hand-rolling the
byte offset, so it scales with pointer width on either platform --
same class as D122/D126/D132/D209, just in the scheduler instead of a
ROM-serialized struct. Verified via the Video.DisplayFPS overlay:
30 FPS pre-fix, 60 FPS post-fix, same build/scene (-level_09).
-level_09/-level_20 both crash-free post-fix.
2026-09-12 21:36:21 -04:00
James Dansereau 4806799867 D245: fix IsWater view-tracking seam -- allow the D227 tc-shift safety valve on the water quad
The water quad's fan was the one skyPortBeginFan() caller passing
allowShift=FALSE, on the theory that a matched tc-shift on both texunits
would flatten the sinf()-driven cross-fade the effect depends on. That
theory was wrong: skyPortEmitTileShift() replays both tiles' SETTILE
commands with the identical k, rescaling both texunits' decoded S/T by
the same factor before each tile's own (unshifted) uls/ult offset is
applied by the RDP -- the offset between tile 0 and tile 1's sample
points is preserved, not flattened.

Confirmed with the GE_D227V probe on Frigate (-level_26): a water fan
vertex spans S=-166..299317, T=-6451..288410 in S10.5 units (same order
of magnitude as D227's pre-fix sky overflow), now correctly getting
kS=4 kT=4 instead of being forced to 0.

verify.sh frigate and "surface 2" (the two IsWater levels) both PASS
crash-free. Live playtest of the mouse-tracking seam itself still owed.

Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
2026-09-12 20:28:49 -04:00
James Dansereau b1cf0eac4c D219: scope the fire-particle byte-order fix by pixel format; resolved
M-115: re-apply M-113's store-time bswap scoped to the 16-bit GBI-size
family only (PORT_PIXEL16 = bswap16 under #ifdef PORT; PORT_PIXEL32 stays
identity). The fast3d importers disagree on pool byte order:
import_texture_rgba16/_ia16 read manually big-endian bytes, while
import_texture_rgba32 does PD_BE32() on a native load -- M-113's blanket
swap hit both and regressed muzzle flash/ammo HUD (M-114).

Format is the structural discriminator, not compmethod (fire frames span
LOOKUP/RLE/HUFFMANBLUR/RLELOOKUP) or texnum range. Offline census of all
2698 ROM images (scratch/d219_headers.py / d219_census.txt): 94 non-zlib
wide-pixel; the 16-bit family is IMAGE_FIRE_0..14 + texnum 1198-1201/2430/
2510-2523, every ammo/flare/crosshair/muzzle-flash image is RGBA32.

Runtime-verified on Dam (scripted fire): all 15 frames decode format=1,
pool dumps warm R-dominant (mean opaque RGB 255,238,139). RGBA32 pool
bytes bit-identical to the M-114-revert build. User playtest confirmed;
D219 closed. GE_D219RAW probes generalized (no texnum filter), inert.
2026-09-12 19:05:50 -04:00
James Dansereau 8f0abae18f QoL: Game.NoHitFlash toggle (D232) (#58)
* QoL: Game.NoHitFlash toggle (D232, route-(b) in currentPlayerSetFadeColour)

The community 'no damage flash' option. Every fade routes through
currentPlayerSetFadeColour; the only non-black ones are the two damage
paths (white g_DamageTypes envelope + red 150,0,0 death overlay), so
zeroing the RGB there kills exactly the hit flash and leaves blackouts,
the colour screen and the death fade untouched. Default 0 = original.

Frame A/B verification in progress; finding entry to follow.

* D232: add GE_NOHITFLASH_TRACE diagnostic to currentPlayerSetFadeColour

Env-gated osSyncPrintf (pattern per GE_D160) logging r/g/b/frac/caller
whenever a non-black fade reaches the gate. Used to resolve the frame-A/B
anomaly from the previous session: zero trace fires during a headless
no-input BUNKER1 run confirms the gate is inert absent real damage, and a
controlled ini-toggled A/B (framediff.py, exact + tolerant modes) shows
the gate's visual effect (99/100 frames match) is far smaller than the
port's own run-to-run noise floor (0/100 match between two runs of the
identical build) — the anomaly was D117 nondeterminism, not a real bug.
Full writeup in docs/HANDOFF.md (local, gitignored).

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* D232 fix: zero frac too, not just RGB, in the NoHitFlash gate

User playtest found the white hit-flash was gone but a black fade-to-black
still played on every hit -- the gate zeroed r/g/b but left frac (the
overlay's alpha/opacity) untouched, so a fully-opaque BLACK rectangle drew
at the same envelope the white one would have. Same visual intrusion,
different colour, not what the toggle promises.

Fix: zero frac too when the gate fires, so the overlay doesn't draw at
all. This also drops the GE_NOHITFLASH_TRACE diagnostic added earlier
this session to hunt the original (unrelated, since-resolved) headless
frame-diff anomaly -- no longer needed now that the real bug is fixed and
user-verified in a live playtest.

User-verified: hit flash fully gone, no residual black flash, blackouts/
colour-screen/death-fade untouched.
2026-09-12 09:20:13 -04:00
James Dansereau 741dfe78b9 fix(D227/D176a): sky texcoords are S10.5, not texels — fixes seam, over-tiling and scroll speed (#43)
* fix(D176a): sky scroll/tiling too fast -- perspective-correct tc interpolation

skyPortRenderPoly (Path B, D176(a) black-sky fix) placed sky quad verts
under a plain w=1 ortho projection, so fast3d's GPU pipeline interpolated
texture coords linearly in screen space across the primitive. The source
S/T data (unk20/unk24) is computed upstream from a world-space projection
without a perspective divide -- exactly like the real N64 RDP's
G_TRI_SHADE_TXTR coefficient block, which relies on real perspective-
correct interpolation to divide it back out per pixel.

A single sky quad's near (top) and horizon-grazing (bottom) vertices can
differ up to ~38x in camera-space w. Under linear interpolation that
smears the entire S/T delta evenly across every pixel instead of
concentrating it near the horizon, producing a uniformly dense aliased
'hatching' over the whole sky -- reported as 'scrolls too fast / tiles
too visibly'.

Fix: build a custom projection matrix that gives each vertex its real
camera-space w (already computed upstream, SkyRelated38.unk0c) while
keeping the same screen-space x,y, so the GPU's normal perspective divide
recovers the intended position but interpolates tc (and colour) with
real 1/w weighting -- the same pipeline every other textured draw in the
game already depends on. No port/fast3d changes; #ifdef PORT only, N64
path untouched.

Verified: tools_pc/verify.sh bunker1 PASS (no regression on a non-sky
level); -level_22 (Statue) and -level_41 (Cradle) GE_PCDUMP captures show
the expected before/after change (uniform parallel aliasing -> a proper
perspective-converging fan), and Cradle's sky is no longer black at
default FOV. Defect 2 (horizon-band tessellation) and the FOV-coupled
black-sky-past-frustum symptom are unchanged / still open.

docs/dev/findings.md: new D176(a) subsection + summary-row update.
docs/porting-notes.md: generalized the screen-space-overlay quirk to
call out the w=1-vs-real-w interpolation pitfall.

* chore(D176a): regen findings index

* docs(D176a/D227): M-93 - human playtest of PR #43 confirms Cradle black-sky fix + partial Statue scroll improvement; new split-sky residual filed as D227



* docs(D227): M-94 - visual evidence + a concrete mechanistic hypothesis for the sky seam (per-primitive wScale quantization), not yet fixed

Headless -level_22 capture (this shell can do offscreen GE_PCDUMP capture
via the game binary directly, run from the repo root with the MSYS2
mingw64 PATH set -- the earlier GUI-launch flakiness in this session was
specific to a live window, not this path) shows a real, static-frame
crease in the sky's cloud texture. Screenshot committed:
docs/img/bugs/d227-sky-seam-statue.png.

Hypothesis: skyPortRenderPoly (D176(a)'s fix) computes wScale per call,
and skyRenderTri/skyRenderFull issue up to 5 separate calls per frame
(one per triangle in the sky fan). The position math is exact for any
wScale (re-verified algebraically), but Vtx.ob[] is s16, so two
triangles sharing an edge can quantize a logically-shared vertex to two
slightly different values depending on which triangle's independently-
chosen wScale computed it -- crack at the shared edge, reads as "two
regions with a different pattern."

Not yet confirmed (didn't isolate camera motion from sky-scroll motion
to test the "moves at a different rate" half of the report) and not
fixed -- documented the fix direction (one wScale shared across the
whole fan, not recomputed per triangle) for whoever picks this up next.
Cleaned up capture scratch files after pulling the one evidence shot.

* docs(D227): M-94 addendum - static-camera test confirms the sky genuinely animates over time, independent of camera motion

* fix(D227): M-95 - one shared wScale across the whole sky fan instead of one per triangle, closes the visible seam

skyRenderTri/skyRenderFull draw up to 5 triangles per frame from a
shared pool of up to 5 vertices (built once, then fan-triangulated
across 1-3 calls). D176(a)'s fix had each call compute its own wScale
from only its own 2-4 verts; two triangles sharing a vertex could each
pick a different wScale, and since Vtx.ob[] is s16, quantize that
logically-shared vertex to two different values depending on which
triangle's call computed it -- a crack at the shared edge (D227, the
"two sky halves" report).

Fix: skyPortBeginFan()/skyPortEndFan() (new, #ifdef PORT) compute one
wScale across the whole fan and skyPortRenderPoly uses it when set,
falling back to the old per-call computation otherwise. N64 path
unchanged (these are PORT-only additions around existing #ifdef PORT
code, no #else branch touched).

Verified:
- Clean build, no new/duplicate symbols.
- Full verify.sh sweep: 21/21 PASS, both with and without this change.
- Same-frame before/after screenshots at -level_22 frame 345
  (docs/img/bugs/d227-sky-seam-statue{,-fixed}.png): visible kink in
  the cloud streaks in the before shot, gone in the after shot, same
  exact frame.
- bunker1's nonzero framediff worst_cell is unaffected by this change
  (reproduced identically pre-fix) -- confirmed pre-existing stale
  golden-baseline issue, unrelated to sky.c.
- Cradle's black-rectangle artifact is also confirmed pre-existing
  (reproduced identically pre-fix) -- the separate, already-documented
  FOV-coupled black-sky-past-frustum-edge symptom (D218/D222 family),
  not touched by this fix.

Not re-verified: whether the "moves at a different rate" half of the
original report is also resolved, and the aspect-ratio-artifact
alternate theory was never independently ruled out. Human eyeball
still owed before merge, same as every other rendering fix landed this
session without interactive display access.

Also generalized the quirk (per-call vs per-shared-vertex-group scale
quantization) to docs/porting-notes.md alongside the existing D176(a)
entry.

* docs(D227): M-96 - human playtest on Statue: seam/crack STILL PRESENT despite M-95 fix

The M-95 wScale-sharing fix only verified the static per-primitive
quantization theory (single-frame screenshot diff); it explicitly left
the 'two halves move at different rates' temporal theory from the
original report unverified. This playtest is that missing
verification, and it says the seam is still visible in live play.

D227 NOT resolved. PR #43 stays in draft pending investigation of the
temporal/rate theory -- do not merge on the M-95 fix alone.

* docs(D227): M-96 addendum - N64 sky should be near-static, this build scrolls extremely fast

New data point from the same Statue playtest session, separate from the
seam/crack: the user reports the intended N64 scroll rate is near-static,
stronger than M-93's 'still reads too fast'. Bears on D176(a)'s fix
magnitude itself, not just the D227 seam -- flagged as a possible shared
root cause (wrong per-frame tc delta could drive both symptoms), not yet
investigated.

* fix(D227): M-98 - share sky S/T fold across whole fan, not per-triangle

skyPortBeginFan() (M-95) shared wScale across a whole sky fan so shared
vertices quantize position consistently, but skyPortRenderPoly's S/T
fold (foldS/foldT -- floorf(minS/64)*64, keeps texel coords in s16
range) was still computed per-call, from only that triangle's own 2-4
verts. Same bug class as the wScale fix, one field over, never fixed --
which is why M-96's human playtest still saw the seam after M-95.

Two triangles in one fan can floor a shared vertex's S/T to different
multiples of 64 texels; nominally invisible (GL_REPEAT period 64), but
each triangle bakes (S-fold)*32 into its own s16 Vtx buffer, so two
folds 64 texels apart produce tc values 2048 apart -- a real crack at
the shared edge.

Fix: extend skyPortBeginFan()/skyPortEndFan() to also compute one
shared fold across the whole fan, threaded into skyPortRenderPoly the
same way as wScale. #ifdef PORT only, no game-logic change.

Also measured (per M-96/M-97's ask): cross-correlated 11 consecutive
frames at a static camera -> ~7 px/tick horizontal scroll at 640px
capture width, a full screen-width sweep every ~1.5s. Confirms
"extremely fast" quantitatively; root cause of that magnitude is not
yet found (accumulator + vertex geometry are unmodified N64 logic, so
if it's genuinely wrong the bug is in how the Path B substitution maps
texel-ticks to screen pixels, not the tick rate itself -- next step
spec'd in findings.md).

Verified: build clean; -level_22 frame 345 (same frame M-95's
before/after used) shows no visible crease vs. the pre-M-95 reference
screenshot; verify.sh bunker1 PASS, worst_cell unchanged (known stale
golden baseline, not a regression). Human eyeball still owed on both
the seam-close and the scroll-speed question before PR #43 leaves
draft.

* docs(D176a): M-98 - Cradle side of PR #43 checked; tower starburst is Defect 2

Checked the Cradle side of the D227 fold-sharing fix (M-97 left this
outstanding). No regression -- verify.sh cradle PASS -- but found a
much more visible pre-existing artifact while there: looking up the
suspension tower near spawn, roughly half the sky renders as a sharp
radiating starburst instead of clouds.

Confirmed pre-existing (pixel-identical with sky.c reverted to
pre-M-98) and confirmed static, not the D227 "moves at different
rates" symptom (pixel-identical 30 ticks apart at a fixed camera).
This is the already-known, already-open D176(a) Defect 2
(horizon-band tessellation) -- the code's own fold-logic comment
already predicts exactly this failure shape for a triangle spanning
>1000 texels near the horizon, which looking straight up the tower
produces. Not fixed (tessellation is separate, bigger work); logged
with a concrete repro + screenshot so it's no longer "inconclusive."

* docs(D227): M-99 - human playtest negative (seam persists, view-angle-dependent); headless repro blocked

Human playtest after M-98's fold-sharing fix: the seam is still present
on Statue and now confirmed to move/change with mouse/view position --
also reported still bad on Cradle and present on Surface. M-98's fix
was real but is not the dominant mechanism live play is hitting, same
pattern as M-95 before it.

Ruled out this pass: the cross-quad water/cloud texture-scale mismatch
theory, on the two IsWater==0 levels actually tested (Statue, Cradle)
-- untested on any IsWater==1 level, which is now the top ranked
candidate for next session.

Blocked on: headless camera-pan repro via GE_INPUTSCRIPT does not
work -- confirmed neither analog-stick nor C-button tokens rotate the
camera, so this needs either a human playtest or a debug hook into the
real mouselook path before it can be chased further headlessly. Full
ranked candidate list + environment notes for next session in
findings.md and (session-local) docs/HANDOFF.md.

* docs(D227): M-100 - root cause PROVEN: s16 texture-coordinate overflow

The sky seam is not a crack, not a quantization mismatch, and not
view-dependence. One sky quad spans ~30,000 texels in S and T, but
Vtx.tc is S10.5 in an s16 = +/-1024 texels, so 7 of its 8 texture
coordinates overflow and wrap to arbitrary values. The fold cannot help
against a span 29x the representable range.

Unblocked by a user-supplied emulator-vs-ours screenshot pair on Surface:
the real symptom is a radial starburst, not a crack, which is a different
mechanism from what M-94..M-98 were chasing. M-95 (shared wScale) and M-98
(shared S/T fold) each fixed a real second-order bug; neither could touch
this one. Both stay - per-fan consistency is exactly what will make
subdivision seamless.

Measured with a new env-gated GE_D227V probe (GE_D176 style) on the Cradle
frame-350 camera. All four w are positive, so no projective pole. The quad
is split gSP2Triangles(0,1,3, 0,3,2) on the 0-3 diagonal, and vertices 0
and 3 are exactly the two that keep one coordinate in range - hence "two
halves with different patterns, split by a line", the original D227 report
verbatim. Apparent view-dependence needs no view-dependent mechanism: S/T
depend on view, so the wrap point moves.

Confirmed by a diagnostic build: baking tc at 1-texel instead of
1/32-texel granularity (32x range, deliberately wrong scale) makes the
starburst and the split completely disappear. No second defect behind it.

D227 == D176(a) "Defect 2" == M-98's "Cradle tower starburst". M-98
misfiled that capture as unrelated, which is what made D227 look
headlessly-unreproducible in M-99 - it reproduces statically.

Fix not implemented: subdivide until span < ~960 texels/primitive. The
subdivision math and cut placement are worked out in the finding (S is
linear in w, so cut uniformly in w), but the vertex budget is an open
decision - dynAllocateVertices is an unchecked 50 KB/frame pool and pure
tessellation wants ~40% of it.

* docs(D227): M-100 addendum - RDP tile shift (option D) dominates; fast3d already implements it

shifts/shiftt is the RDP's native precision-vs-range knob and gfx_pc.cpp
implements it fully including the left-shift case. Emitting tc at 32/2^k
units with shifts = 16-k buys 2^k range with no tessellation, no fast3d
change and no vertex-budget cost. k=5 covers the measured 29,830-texel
span outright.

* fix(D227): M-100b - sky texcoords are S10.5, not texels; drop M-82's stray *32

Fixes the sky seam/starburst, the over-tiling, and the "scrolls too fast"
complaint - all three were the same bug.

M-82 ("D176(a) Defect 1") read unk20/unk24 as texel counts and baked tc as
(S - fold) * 32 to convert texels into tc's S10.5 units. They are not texel
counts. skyRender sets them as worldpos * 0.1f and the N64 path hands them
to the RDP coefficient block unscaled (skyRenderTri: S * unk34 * sp368), so
they are already in the RDP's native S10.5 1/32-texel units.

That stray *32 made every sky texture coordinate 32x too large, producing
three symptoms previously tracked as separate bugs:

  1. D227 - spans of ~30,000 texels against an s16 tc holding +/-1024, so 7
     of a quad's 8 coordinates overflowed and wrapped to arbitrary values.
     The quad splits on the 0-3 diagonal and verts 0 and 3 are exactly the
     two that keep one coordinate in range, hence "two halves with different
     patterns" - the original D227 report verbatim.
  2. Over-tiling - ~466 repeats of the 64-texel cloud per quad, not ~15.
  3. M-96's "the N64 sky is nearly static, ours races", measured at ~7
     px/tick in M-98. g_SkyCloudOffset advances 1 unit/tick = 1/32 texel,
     not 1 texel. This is why M-98's re-derivation of the tick path kept
     coming back clean: the accumulator was never wrong, its unit was.

Fix: tc = (S - fold) 1:1, with the fold rebased to a whole tile period
(SKY_TC_WRAP = 64 texels * 32 = 2048 units). The ~30,000-unit span then fits
an s16 unaided - which is also why the original N64 code needs no
tessellation here: it never had a range problem to solve.

Kept: M-95's shared wScale and M-98's shared fold (both real bugs; per-fan
consistency keeps a fan's shared vertices bit-identical). Also kept as a
safety valve, since only ~4% of s16 headroom remains on the observed span:
skyPortPickShift/skyPortCaptureTile/skyPortEmitTileShift, which trade tc
precision for range via the RDP's own tile shifts/shiftt (port/fast3d
already implements it, so no fast3d change). skyPortCaptureTile replays the
exact G_SETTILE texSelect emitted with only the shift fields patched, so it
cannot drift from texSelect's format/line/mask derivation.

Also adds the env-gated GE_D227V per-vertex probe (GE_D176 style) that
measured all of this.

Verified: -level_41 Cradle frame 350 (the M-98 starburst repro) now shows
soft cloud bands at the correct scale with no starburst, diagonal split or
moire; GE_D227V confirms kS=kT=0 and max tc 31,513 of 32,767. -level_22
Statue frame 345 clean. verify.sh bunker1 PASS (worst_cell 10.32, the known
stale-golden-baseline number). Human play-test still owed.

* D229: file green/pulsating water; decouple D227's tc safety valve from it

New finding D229: on IsWater levels (Frigate, Surface 2) the water renders
green and pulses blue<->green on an interval.

Pre-existing, proven by A/B: built plain main with none of the D227 work and
captured -level_26 frame 340 - same green, only the tiling density differs.
D229 is independent of D227.

The gun half of the same report was NOT a bug: the branch was 2 commits
behind main and those 2 commits were exactly the D217 fixes (#47 texpool
alignment, #49 CI cache palette-content key). After merging main the user
confirms the gun is good on Frigate.

Mechanism so far: the water quad is sky.c's IsWater block1 (Frigate confirmed
IsWater=1 via GE_D176, which also settles M-99's ranked item 1).
sub_GAME_7F09343C binds tile 0 and tile 1 to the SAME tmem, offsets tile 1 via
gDPSetTileSize(1, 90, 150, 0, 0), and cross-fades TEXEL0/TEXEL1 with a
PRIM_LOD_FRAC driven by sinf(). That sine is the reported pulsing, so one
texunit resolves blue and the other green. Ruled out the shade path by
measurement (it spans dark blue to pale yellow, never green; sampled pixels
have R and B crushed). Not yet root-caused.

Code change here is only the decoupling: skyPortBeginFan takes allowShift and
the water quad passes FALSE, leaving that path byte-identical to its pre-D227
behaviour. The water quad is a two-texunit draw sharing one set of vertex tc,
so a tc rescale needs a matching shift on both tiles - and shifting both makes
them sample identically, which flattens the cross-fade. An intermediate build
that did so turned the water from pulsating into constantly green, observed
live. Also fixes skyPortCaptureTile to capture after all tile setup (not just
after texSelect) and to handle both tiles, so a future shift cannot clobber a
later re-declaration.

Cradle sky re-verified unaffected (frame 350 still correct). bunker1 golden
framediff reports REGRESSION at 55.02, but plain main scores WORSE at 73.24 -
the baseline is stale relative to main's D217 texture fixes, the re-baseline
owed since M-83/M-84. Not caused by this branch.

* docs(D227/D229): regenerate findings index; mark D227 FIXED

The generated findings-index.csv was hand-edited during the main merge and
when D229 was filed, so CI's 'Check generated dev indexes are current' failed
on PR #43. Regenerated with tools_pc/gen_findings_index.py.

Also updates the D227 row's status to lead with FIXED (M-100b) rather than
the M-94 'not yet fixed' text, so the index reflects the user-confirmed fix.

* docs: register GE_D227V in the env-probe index

CI's 'Check generated dev indexes are current' also runs
gen_env_probes.py --check, which flags any GE_* probe with live sites that
has no row in docs/dev/GE-ENV-PROBES.md. GE_D227V (added in M-100 to measure
the sky texcoord overflow) had none.
2026-09-11 20:45:08 -04:00
James Dansereau 50d73c519c D217 (M-90): align texpool arena bases to 8 bytes on PC — fixes solid wrong-colour weapon/model CI tiles (#47)
* D217 (M-90): align texpool arena bases to 8 bytes on PC — fixes solid wrong-colour weapon/model CI tiles

Root cause: texAlignIndices pads CI index rows to ABSOLUTE 8-byte
boundaries, and model-DL G_LOADTLUT palette offsets are a formula that
only matches the real layout when the texpool base is 8-aligned. On N64
the memp bump allocations feeding these pools happened to be 8-multiples;
PC pointer bloat (D36/D37-class struct growth) made some STAGE-bank sizes
not, so weapon-pool bases land %8==4 (e.g. FACILITY). Every palette in a
misaligned pool then sits 4 bytes early inside its allocation while the
draw reads the N64 offset: the PP7 grip's count=1 TLUT lands on the next
tenant's 8-byte header slot (texnum=26 -> LE s16 -> 0x1A00 = the green),
and larger loads read a 2-entry zero pad then the real palette (the M-86
'leading zero pad' tell). BUNKER1 was clean only because its bases
happened to be aligned.

Fix: texInitPool rounds the arena base up to 8 under #ifdef PORT; end
unchanged (pool loses <=7 bytes). No-op on N64 where bases were already
aligned. ABI/layout-class edit per AGENTS.md rule 2 narrow exception —
no game logic touched.

Verified: FACILITY 188/188 decodes %8==0, draw reads pal0=0001 (was
1a00), 0 green pixels in frames 250-450 (pre-fix ~1300); BUNKER1 + DEPOT
still clean; full verify.sh sweep 21/21 PASS. Pending: human playtest
eyeball of the M-87 weapon matrix (closes defect 1). Defect 2 (CI cache
content-hash key, fix/d217-model-texture-palette) remains separate/parked.

findings.md: new section H 'D217 - M-90' + D217 row update; GRAPHICS-BACKLOG
D217 status.

* docs(D217): M-92 - human playtest of PR #47 confirms PP7/PPK/watch fixed; AK47 + NPC-held world-model weapons are a distinct, still-open white-texture residual

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* docs(D217/D228): M-97 human playtest - green FIXED, AK47 white patches split out as D228

Second independent playtest (after M-92) of PR #47 on FACILITY, this
time with the branch merged up to date with main. Confirms the same
split the M-90 write-up predicted: PP7/PPK grip+hammer, watch face and
world-model green are all fixed by the texpool alignment; NPC-held
AK47 white missing-texture patches are untouched.

White is a different signature from wrong-colour -- a wrong palette
binds a real TLUT with wrong contents, white means the texture was
never decoded/uploaded at all -- so this is a separate mechanism, not
an incomplete fix. Split out as D228 so D217 can close on the green
mechanism it actually describes instead of carrying an unrelated open
failure mode indefinitely.
2026-09-11 18:06:03 -04:00
James Dansereau 257e9c3fc1 front/D221: pass explicit {min,max} coord2d pairs to projectRectCornersTo2D in file select
interface_menu05_fileselect passed four separate f32 locals (&xmin, &ymin)
to a function that takes {min,max} coord2d pairs, relying on the N64
compiler laying each max adjacent to its min. The PC toolchain lays the
locals out in declaration order, so arg1->f[1] read ymax (as an X coord)
and arg2->f[1] read an unrelated stack float (sometimes NaN) -- the file-
select hit band collapsed to a ~14px strip at each wallet's bottom edge:
'click below the file to select it' (D221, M-87 user QA).

Pass explicit pairs under #ifdef PORT; identical values to what the N64
binary used, N64 call untouched. ABI/layout exception per AGENTS.md rule 2.

Verified headless: same cursor position + A press -- pre-fix never
selected folder 0, post-fix selects and advances (findings.md §H D221).
2026-09-11 01:36:12 -04:00
James Dansereau 14bf7e59f2 TEMP probe(D217): log decode-time palette[0] keyed by texnum (GE_D217TEX) 2026-09-11 00:00:43 -04:00
James Dansereau 199182d1d9 fix(D77): in-level music silent — missing return in sub_GAME_7F0C0BF0 (D6-class UB)
Root cause: src/game/mp_music.c's sub_GAME_7F0C0BF0() is a one-line wrapper
around get_mTrack2Vol() with no return statement. On N64/IDO the value
happened to already sit in $v0; at the PC release build's -O2 the return
register is genuinely undefined (docs/porting-notes.md D6 class, same as
D187). Every in-level music trigger goes through set_missionstate()'s
MISSION_STATE_1/4 cases (the only path that starts a level's background
track), which feeds this garbage/zero value straight into
musicTrack1ApplySeqpVol()/musicTrack3ApplySeqpVol() right before
musicTrack1Play()/musicTrack3Play() — silencing the whole track for its
entire duration while note-on processing continues normally. Front-end/menu
music bypasses set_missionstate() entirely (front.c calls musicTrack1Play()
directly), which is why it was never affected.

Fix: return the tail call under #ifdef AVOID_UB, #else keeps the N64 body
verbatim. One line, no game-logic change.

Diagnostic: added a temporary, cheap (~1/s, gated on GE_AUDIOTRACE)
[AUDIOLVL] peak/RMS probe of the exact buffer audioSetNextBuffer() hands to
SDL (port/src/audio.c), interleaved into the same audiotrace.log as the
existing csplayer.c [MUSICNOTE] probe via the shared geTracePrintf lock.
Before the fix: 173 [MUSICNOTE] events over a 28s BUNKER1 capture, but every
single [AUDIOLVL] reading (29 samples spanning the full run) read
peak=0 rms=0.0. After the fix: same note count, every [AUDIOLVL] reading
sustained non-zero (peak ~2700-10300, rms ~800-2900). tools_pc/verify.sh
bunker1: PASS, no visual regression.

Docs: docs/dev/findings.md D77 updated from stale "nothing reaches PC
speakers" framing to the actual root cause + verification; new D6 instance
logged in docs/porting-notes.md.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 23:34:40 -04:00
James Dansereau 208fcfe27d Merge main into feat/d216-skip-intro (M-84 + D215 findings rows)
# Conflicts:
#	docs/dev/QOL-INVENTORY.md
#	docs/dev/findings-index.csv
#	docs/dev/findings.md
#	port/src/video.c
2026-09-09 20:54:05 -04:00
James Dansereau 6e5a4e4e7f Merge main into fix/d215-viewmodel-renderdata-flags (M-84 findings rows)
# Conflicts:
#	docs/dev/findings-index.csv
#	docs/dev/findings.md
2026-09-09 20:37:22 -04:00
James Dansereau b3bf7786ff M-84: fold D211 FOV refix + user-test findings into the QoL wave
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.
2026-09-09 20:30:21 -04:00
James Dansereau da25c4c507 feat(D216): Game.SkipIntro — boot straight to the file-select menu
New s32 portSkipIntro (port/src/video.c, configRegisterInt Game.SkipIntro),
read once in src/game/lv.c at LEVELID_TITLE stage load, in the else branch of
the existing GE_STARTMENU #ifdef PORT block. When set it performs the same
state change every intro stage's own !is_first_time_on_main_menu branch does
(front.c): is_first_time_on_main_menu=FALSE; prev_keypresses=TRUE;
maybe_is_in_menu=TRUE; menu_update=MENU_FILE_SELECT. So it reuses the game's
own post-intro route rather than inventing one; the legal screen + logo
attract loop is skipped. GE_STARTMENU still wins when both are set.

Default 0 => unchanged. Verified headless: default boot frame 90 = the
TWYCROSS classification screen; SkipIntro=1 frame 90 = the SELECT FILE menu
(folders/photos/cursor render, no crash across repeated boots). Screenshots
eyeballed.

findings.md D216 + index; QOL-INVENTORY row marked done.
2026-09-09 01:07:05 -04:00
James Dansereau f602b1738f fix(D215): 1P weapon viewmodel never drew in-level — renderdata.flags read 0
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.
2026-09-09 00:56:19 -04:00
James Dansereau b2efc0af9d fix(D176a): phase-fold sky texcoords to kill the horizon streak (Defect 1)
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).
2026-09-08 22:29:28 -04:00
James Dansereau 9a6017a784 D176(a): emit screen-space sky geometry on PC (Path B)
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.
2026-09-08 22:29:28 -04:00
James Dansereau b47eea65e3 fix(D210): chrToPatrol leaves act_patrol.lastvisible60 uninitialised on PC
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.
2026-09-08 20:51:18 -04:00
James Dansereau a63efe184b fix(D209): AI locomotion locked to walk anim — union byte alias shifts at 64-bit
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.
2026-09-08 19:50:45 -04:00
James Dansereau 25b46e020a diag(D193): instrument the locomotion / anim-root-motion path
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
2026-09-08 09:50:06 -04:00
James Dansereau c723207d3a diag(D193): measure the timing keystone — "sim sums low" hypothesis contradicted
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
2026-09-08 09:30:49 -04:00
James Dansereau 1ad03b3d53 fix(model): D191 — Bunker ii / Statue crash: bondviewSelectCuff Switches stride
bondviewSelectCuff byte-indexed header->Switches (a ModelNode* array,
8-byte stride on PC per D43/D45) with `offset = switchindex << 2`, the
N64 4-byte-pointer stride. Every base[N] deref then landed mid-slot and
produced a half-word-swapped pointer (0x70267a90 -> 0x70267a9000000000),
non-NULL by luck, handed straight into modelGetNodeRwData -> SIGSEGV on
the frame the player fires (gunUpdateAndFire -> bondviewSelectCuff,
switchindex=29). Confirmed by a gdb backtrace from the shipped v0.1.0
bundle on the Bunker ii first-guard-kill repro.

Identical to D141, which fixed the same Switches byte-index bug in
gunfire.c; this call site was missed. Fix is PORT-only:
`offset = switchindex * sizeof(ModelNode *)`. The downstream
`base = (u8*)switches + offset` math and the #else N64 line are unchanged.
ABI / pointer-width only (D3x class) — no game-logic change.

Verified: build clean; -level_09 golden framediff 3/3; -level_27 +
scripted-fire crash-free; user playtest of Bunker ii + Statue both
crash-free -> 21/21 solo levels crash-free (v0.2.0 gate).
2026-09-08 09:12:12 -04:00
James Dansereau 76910cbf19 fix(audio): D208 — player fire sound goes permanently silent (field_A48 ptr width)
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
2026-09-08 08:06:28 -04:00
James Dansereau 2f503f007d diag(audio): D207 — re-arm [D207-DROP] counter; close D205
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
2026-09-08 07:44:29 -04:00
James Dansereau 0845d70953 port(audio): D207 diag probe — GE_FORCEALARM
Temporary env-gated probe in handle_alarm_gas_timer_calldamage to force
and trace the alarm klaxon while investigating D207 (user report: alarm
SFX overrides other audio). #ifdef PORT + getenv, inert unless set.
Remove once D207 is resolved (see findings.md D207 / HANDOFF).
2026-09-07 22:23:01 -04:00
James Dansereau 9bf0c49151 fix(audio): D206 — every SFX resolved one bank slot too high
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.
2026-09-07 22:22:55 -04:00
James Dansereau c0047f76f7 docs(dev): D202 M-71 by-ear PASS + close-ready; new D205 wrong/extra SFX indices (bank exonerated); [VOL]/[DISTVOL] probes 2026-09-07 14:18:39 -04:00
James Dansereau 4ceb917ad8 port(audio): D202 M-67-M-69 runtime probes — GE_VOICEDUMP, [VOICEMAP], timestamped [WIRE]/[EVT]/[STOP-EVT]
Env-gated diagnostics for the D202 per-voice sample-correctness work
(M-67/M-68/M-69), all inert unless the env var is set:

- GE_VOICEDUMP (mixer.c aEnvMixerImpl): per-voice resampled mono stream
  to voicedump.raw, records [stateAddr][nSamples][us][s16 x n]. NB the
  us stamp is mix time — records render ahead, samples END at the stamp.
- [VOICEMAP] (synthesizer.c, under GE_AUDIOTRACE): full PVoice ->
  decoder/resampler/env-state address map at synth init, tying voicedump
  slot addresses to WIRE filter addresses.
- [WIRE] (load.c) now carries a µs timestamp; sndPlaySfx trace gains
  t=µs + wavetable pointer.
- [EVT]/[STOP-EVT] (snd.c): every ALSndpEvent the player processes, plus
  the release ramp a STOP/DEACTIVATE computes (deltaUs).
- audiodebug.ps1: -VoiceDump switch.

With scratchpad/exact_match.py these prove every allocated voice plays
the exact ROM sample at the exact ROM pitch (286/320 CC >= 0.85 on the
M-69 full-corpus run; remaining failures are dropped allocations + wire
attribution, not corruption). Remove once D202 closes.
2026-09-06 08:21:14 -04:00
James Dansereau 54c5095c23 port(audio): D202 env-gated diagnostics probes (removable on close)
Env-gated trace probes added while root-causing D202 (stuck door loop +
voice leak). All off by default; catalogue: docs/dev/GE-ENV-PROBES.md.
Remove this commit's additions when D202 closes after the user by-ear pass:

- GE_AUDIOTRACE: [VOICE+]/[VOICE-] voice acquire/release pairing,
  [EXPIRE] (permanent — part of the M-66b fix), sndPlaySfx resolution
  lines (src/snd.c)
- [DOORSND] + [SLOTWRITE] door sound state/slot ownership (propobj.c)
- [WAVELOOP] per-sound ALADPCMloop presence (libultra/audio/load.c)
- GE_PULLTRACE mixer pull diagnostics (port/src/mixer.c)
- GE_BANKDUMP / GE_KEYMAPDUMP ROM audio-table dumps (port/src/romdata.c)
- GE_MIXERTRACE [DMAHIT]/[DMAMISS] AI DMA callback trace (src/audi.c,
  __x86_64__-only)
2026-09-05 19:07:38 -04:00
James Dansereau 388d932df7 port(audio): D202 sndp fixes — ownerless-loop guard (M-65), sndCreatePostEvent unstub, option-C loop expiration (M-66b)
Three #ifdef PORT changes in src/snd.c (N64 build zero-diff), all
documented in findings.md D202:

1. M-65 voice-leak guard: the preemption scan reclaims provably-ownerless
   SOUND_FLAG_LOOPED SFX voices only, and only under pool pressure.
   Sound 203 (METAL_SLIDE_CLOSE_SFX) is played fire-and-forget by
   doorPlayCloseSound0/1 with a NULL owner; stock GE never preempts
   flag-0x12 voices, so leaked loops accumulate until new SFX are dropped.

2. sndCreatePostEvent un-stub (D138 stub removed): the stub had deleted ALL
   distance attenuation from SFX volume/pan. Verified rt=0.996, drop 0.

3. M-66b option-C expiration (user-chosen disposition for D202): new PC-only
   AL_SNDP_PORT_EXPIRE_EVT (reuses the unused 1<<13 slot in snd.h). After a
   voice starts, if it is provably ownerless (LOOPED + FINAL_IN_SEQUENCE +
   not RETRIGGER + state->state == NULL) and its wave carries an ADPCM loop
   with count == -1, post the expire at D202_EXPIRE_DELAY_US (2 s); the
   handler re-validates the predicate, fades volume to 0 over
   D202_EXPIRE_FADE_US (500 ms), and posts END_EVT. Not a reused STOP_EVT:
   its ramp uses the envelope releaseTime (~2.7 ms for sound 203) and would
   hard-cut/click. Stale-event safe because sndDisposeSound removes ALL
   pending events for a state; owned loops and retrigger sounds untouched.
   Placed in sndp (not port/src/mixer.c) because ownership is only visible
   there. Measured: pre-fix capture had the 256.7 Hz loop line flat at max
   t=39->361 s; post-fix run shows transient bursts with full silence
   between and ends allocated=0/8.
2026-09-05 19:07:32 -04:00
James Dansereau a9cc629de6 port(audio): Phase-3 software mixer + K0_TO_PHYS/bnkf pointer fixes (D198-201)
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.
2026-09-05 07:22:21 -04:00
James Dansereau 86440e3b58 fix(linux): survive the decomp's latent stack-buffer overruns (D189) (#24)
* 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
2026-09-03 20:18:25 -04:00
James Dansereau 1261389a5c fix(linux): thread a va_list* through _Printf (x86-64 SysV ABI) (#22)
* 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
2026-09-03 20:12:56 -04:00
James Dansereau a9d8e4d44c fix(hud): restore missing return in set_rgba_redirect_generate_microcode
The HUD weapon/ammo-type icon (bottom-right, and bottom-left when
dual-wielding) stopped drawing ~2026-09-02; the ammo *numbers* still
render. Root cause: set_rgba_redirect_generate_microcode (gunfire.c, the
sole icon emitter) is non-void but has no return statement (the decomp
banner flags it). At -Og the tail call's result stayed in $v0 and it
worked; commit 7549570b restored -O2 for playtest builds, where the
undefined return register makes callers resume the DL from the icon's
start offset and overwrite the icon commands with the following
microcode_constructor / gunDrawHudInteger output.

Fix: add the return under AVOID_UB (already -D'd in the PC build;
CMakeLists.txt:207), N64 byte-match path unchanged under #else.
Codegen-correctness only, same class as the existing AVOID_UB edits.

Owed: build (-O2) + -level_09 capture diff vs docs/img/bunker1-2.png.
A/B: Debug build (-Og) shows the icon, -O2 build without this fix does not.
2026-09-03 13:11:42 -04:00
James Dansereau fe3d543647 linux-runtime: BUNKER1 boots + renders on Linux
First runtime bring-up on Linux (Ubuntu, gcc-14). -level_09 now renders
past frame 1200 crash-free with a clean shutdown, matching Windows.

- port/src/romdata.c: no-PIE loads the image at 0x400000, so the D34
  'clear low 32 bits of a symbol address' gives 0 -> false 'image base not
  4GiB-aligned' abort. The ANIM_DATA_* lvalues are only ever address-taken
  (never dereferenced), so fall back to a fixed 4GiB-aligned sentinel
  (0x100000000) for g_pc_animdata_base.
- port/src/main.c: the 'image must load at 0x140000000' assertion is
  PE-specific (.refptr base relocations); ELF no-PIE resolves dram_syms.s
  absolute symbols literally regardless of load address. Guard it to
  PLATFORM_WINDOWS.
- port/src/libultra.c: mmap game-thread stacks with MAP_32BIT so they sit
  in the low 2GiB. The decomp truncates stack-buffer pointers to u32 (e.g.
   in texLoad); glibc pthread stacks live above 4GiB and
  those truncations corrupt. One fix covers the whole idiom class. Windows
  pthread stacks are already low.
- src/game/image.c: belt-and-braces #ifdef PORT -- align compbuffer at
  uintptr_t width (identical result on N64).
- CMakeLists.txt: drop the -Ttext-segment experiment (needs -mcmodel=large;
  breaks R_X86_64_32 relocs) -- comment only.

Windows: build 240/241, -level_09 crash-free past frame 1440.
2026-09-02 22:50:56 -04:00
James Dansereau 97b0539ef7 linux-build: cast chrflags->Model in vtxstore modelGetNodeRwData call
GCC 14 hard-errors enum(CHRFLAG)->pointer as 'incompatible type for
argument' with no controlling warning flag (not downgraded by
-fpermissive / -Wno-error=int-conversion). Line 157 of the same WIP-decomp
function already casts the identical (Model*)var_v0->chrflags expression;
match it on line 162. Codegen-identical.
2026-09-02 21:52:57 -04:00
James Dansereau 9baf2c67f5 linux-build: PORT-guarded append_text_picked_up signature for GCC 14
The vestigial u8* param2/param3 receive an AMMOTYPE enum and a u32 at
both callsites. GCC 14 treats enum->pointer as a hard 'incompatible type
for argument' error that -fpermissive does not downgrade (unlike the
int<->pointer permerrors). Guard the definition with #ifdef PORT to match
the callsites' actual types; params stay unused so codegen is unchanged
and the N64 source path is literally untouched.
2026-09-02 21:47:11 -04:00
James Dansereau 293e7436d3 port: D177 — ladders non-functional (progression blocker), pointer-width ABI
MoveBond gates the entire ladder-collision path on
stanGetLocusCount(&curLocus), which was pinned at 0 on PC. Three compounding
pointer-width bugs in the stan navigation layer (same class as D79/D90),
no game logic touched:

1. stanCheckLinkedSpecialTile receives the caller's
   struct StandTileLocusCallbackRecord typed as `s32 *outFlags` and writes
   the LADDER signal with raw `outFlags[1] = 1`. The record's first member
   is `s32 *rooms` — 8 bytes on PC — so `[1]` is its high half and `count`
   (moved +4 -> +8) is never written. stanGetLocusCount() reads ->count,
   stays 0, ladder path never runs. (FORCECROUCH survived by luck: LE low
   half of rooms is at +0.) Fix: #ifdef PORT cast to the real struct, write
   ->rooms / ->count by name.

2. curLocus was declared `struct move_bond_temp_struct` — an 8-byte
   "placeholder while matching" that equals the N64 record size but is too
   small for the widened PC record, so stan's field writes overflowed the
   local. Fix: #ifdef PORT declare it as the real
   struct StandTileLocusCallbackRecord.

3. stanGetMoveBondCollisionTiles would AV on the first climb:
   `(coord3d *)((s32)coords + off)` truncates the 64-bit &bondCollision
   stack pointer. Fix: PORT_PTRADD (uintptr_t arithmetic) added to stan.c.

All #ifdef PORT; N64 path kept verbatim under #else. Candidate root-cause
from an M-36 subagent (parts 1/3); part 2 found on review.

Verified: build links clean; -level_09 framediff 3/3 (frame_200 run
variance only); -level_36 (Surface, mandatory ladders) boots crash-free;
interactive climb confirmed working in-game by the owner.

findings.md D177 (§F + §H), porting-notes.md §A.
2026-09-02 18:26:38 -04:00
James Dansereau 809e7378e1 port: D176(a) Surface black-sky investigation + GE_D176 probe (M-36, partial)
Not root-caused; narrowed substantially. Headless -level_36 + GE_D176 probe
(bgfog.c fogLoadLevelEnvironment, sky.c skyRender):

- Environment data is CORRECT: fog_tables[] matches Id=36, Sky.Clouds=1,
  warm CloudRGB=240,120,30, SkyImageId=0. Not a serialized-struct
  byte-order bug (fog_tables is a compiled C initializer).
- skyRender runs the cloud path; corner classification is correct
  (switch case 12, "sky is top half").
- But skyRenderTri/skyRenderFull emit nothing - not even the env->RGB
  fill shows. Sky polys degenerate after projection, culled, or the
  skywaterimages[0] bind is failing.

Next (findings.md "D176(a)"): probe sub_GAME_7F097388 projected coords;
verify skywaterimages resolves on PC (image_bank.c GIMG offset 0xFB4);
fast3d sky-tri trace; cross-check Dam/Statue exteriors.

GE_D176=1 probe left in tree (#ifdef PORT, env-gated, inert). Build
240/240 links clean; -level_09 framediff 3/3.
2026-09-02 18:04:36 -04:00
James Dansereau cd1ed57449 port: D178 — byte-swap the raw briefing segment (blank objectives / briefing text)
Family B of the M-36 triage. The Ubrief*Z segment (assets/obseg/brief/) is
a raw 48-byte ROM image of struct BriefStruct { u16 brief[4]; struct { u16
textid; u16 enabled_difficulty; } objective[10]; }, loaded by front.c
load_briefing_text_for_stage() via _fileNameLoadToAddr() with no converter
and no BE->LE fixup. On the LE host all 24 u16 read byte-swapped:

- textid 0x2C04 (getStringID(LDAM,4)) -> 0x042C -> langGet() resolves an
  unloaded bank -> NULL -> blank objective/briefing text. This is the whole
  of the D143 "briefing text blank" residual (the bank loaded fine; the ids
  into it were swapped).
- enabled_difficulty 0x0001 -> 0x0100 = 256, so `selected_difficulty >=
  enabled_difficulty` is always false -> difficulty-gated objectives vanish
  (on Agent only the one AGENT-difficulty line survived -> lone "a." bullet).

Fix: romdataFixupBriefing() in port/src/romdata.c, called from a #ifdef PORT
block in load_briefing_text_for_stage() right after the load — same shape as
langFixupLoadedBank(). BE-serialized-struct reconciliation (AGENTS.md #2),
semantics-preserving, N64 build untouched. GE_D178=1 dumps raw + fixed words.

Verified: Dam briefing renders all four objectives at 00 Agent with correct
difficulty gating and "007: James Bond" header; -level_09 framediff 3/3;
GE_STARTMENU=7/=13 crash-free; build links clean 240/240.

Investigation + patch: subagent (worktree). Suspect 2 (Gfx-width temp_s0
arithmetic) was ruled out — benign 512-byte gap, langLoadToBank records the
bank base itself. Follow-ups noted in findings.md: annotate D143 as
superseded; latent 64->32 pointer truncation at front.c:6569 / :2202.

Claude-Session: https://claude.ai/code/session_01GULf6knbsqfsnW39tMr9WT
2026-09-02 10:56:03 -04:00
James Dansereau 849ee512cd port: Game.ScreenShakeIntensity route-(b) hook in viShake (D181)
First behavioral src/ hook under the AGENTS.md #2 documented-exception
path. src/fr.c viShake() gets one #ifdef PORT line multiplying the
incoming amplitude by portScreenShakeScale (extern f32, registered
0.0-10.0 in port/src/video.c, committed with WI-1). Default 1.0f is an
exact no-op so headless golden dumps are byte-unaffected; 0.0 disables
explosion/effect screen shake. N64 build keeps the original line under
#else. findings D180/D181 + F-index.

Claude-Session: https://claude.ai/code/session_01GULf6knbsqfsnW39tMr9WT
2026-09-02 01:26:41 -04:00
James Dansereau fa54f8fb90 docs: add operating manual, fix cross-references after doc restructure
- Commit CLAUDE.md + AGENTS.md so a fresh clone has the operating manual and
  the ABI/no-game-logic rules (trimmed of stale phase status; the personal
  pd_port path removed; points at the split docs).
- Sweep every stale doc reference across the tree after the restructure:
  PCPortResearch.md -> internals.md / dev/findings.md,
  PORT-LEARNINGS.md -> porting-notes.md,
  AGENT-WORKFLOW.md -> dev-process.md,
  docs/{GRAPHICS-BACKLOG,HANDOFF-ARCHIVE,GE-ENV-PROBES,LEVEL-*,TEXTURE-GLITCH-ANALYSIS,AUDIT-M6-*,AUDIO-PLAN}.md -> docs/dev/.
- Drop the superseded docs/PLAN-linear-level-sweep.md.
- docs/HANDOFF.md + docs/BACKLOG.md stay local-only (rolling working files);
  CLAUDE.md now handles their absence in a fresh clone.
2026-09-01 20:01:09 -04:00
James Dansereau 52539d10de Prepare for public release
- README, LICENSE (MIT for the port layer), NOTICE, CONTRIBUTING.
- Restructure docs: internals.md, porting-notes.md, dev-process.md +
  dev/agentic-development.md (the research write-up), dev/ (raw record).
- Replace decomp status-badge CI with a PC-port validate workflow.
- Remove session/agent scaffolding and stale planning docs from the tree.
- Add .gitattributes (normalize line endings to LF); add screenshots.

The pre-2026-08 history is the GoldenEye 007 decompilation by Larry Ficken
(KholdFuzion) and contributors, preserved unchanged. The PC-port work from
2026-08-17 on is mine.
2026-09-01 19:28:59 -04:00
James Dansereau 652663aac6 D75 Bug 2: runtime probe + triage update
Adds env-gated GE_D75 probe in title.c sub_GAME_7F007F30 (capped 8 lines).
Booted the bare front end through the gun-barrel sequence:

- Bond chr model + gun model entirely absent; both Model instances are
  valid (non-NULL obj, numMatrices 21/1) -> 'model not loaded' ruled out.
- chrModelInstance->render_pos is valid and freshly written each frame
  (== that frame's dynAllocate mtxlist base, clean double-buffer swap,
  o2p returns it unchanged) -> the 'render_pos = stale dynAllocate arena'
  hypothesis (D115 #5) is RULED OUT; do NOT land a persistent render_pos
  buffer.
- Zero fast3d DL warnings during the sequence -> not the D144/D146
  corrupt-sub-DL family.

Failure is downstream in drawjointlist/dotube vtx/node-DL resolution or an
off-screen basemtx. Next step: a drawjointlist-level probe. Full write-up
in PCPortResearch.md 'D75 Bug 2 - RUNTIME PROBE'.

-level_09 nonclear stable (91.66->91.62, D117 pan noise); build green.
2026-08-31 15:53:05 -04:00
James Dansereau cde5505840 strip closed-Dxx debug scaffolding (D54/D63/D90) from compiled src/
Removes inert env-gated (GE_D54/GE_D63/GE_D90) probe blocks left in game
and libaudio TUs after those findings closed. All were #ifdef PORT +
getenv-guarded with no side effects; bg.c also drops a bare non-PORT
static (d63bgprimarycount) + entry log. No behaviour change.

Build green (ntsc-final, 10/10 link); -level_09 + -level_20 render
crash-free (19 frames each, Silo frame 300 @ 88.4% non-clear).
2026-08-31 15:41:03 -04:00
James Dansereau dc5739bc93 D167: D152 addendum -- audio osSetIntMask audit + narrow lock-leak fixes
Static audit of every osSetIntMask(OS_IM_NONE) in the compiled audio code
(event.c, csplayer.c, synaddplayer.c, snd.c): all balanced on every exit
path, including alEvtqPostEvent's freeList-empty early return. No unbalanced
same-thread leak exists -- the D152 s_imLock wedge is not a missing restore.

Two real defects fixed (#ifdef PORT, N64 verbatim under #else):

1. snd.c sndSetSfxSlotVolume walked the live ALSoundState list and posted to
   the shared ALEventQueue without holding OS_IM_NONE, unlike its twin
   sndDeactivateAllSfxByFlag. On PC: unguarded walk racing amMain + one lock
   acquire/release per matching sound. During the mission-failed fade-out
   (sndSetScalerApplyVolumeAllSfxSlot -> sndApplyVolumeAllSfxSlot -> here,
   per frame) that is a per-frame lock-acquire storm. Now holds the mask once
   across the walk; sndApplyVolumeAllSfxSlot batches its slot loop under one
   recursive hold.

2. port/src/libultra.c: portThreadWrapper now calls imThreadExitRelease() when
   a host thread returns from its entry -- if it still owns s_imHeld, release
   the orphan (+ LOG_ERROR). Kills the 'transient thread acquired OS_IM_NONE
   and exited' leak (the §F dump's own alternate hypothesis) and the re-wedge
   when a new thread reuses the dead pthread id and imAcquire mis-detects
   recursion. Steal-lock kept as backstop.

Build 240/240 green; -level_09 frame 300 renders 91.7% non-clear crash-free;
-level_20 boots crash-free. Fade-out repro is playtest-gated (not headless-
reproducible) -- user to confirm no black screen + no D152: log lines.
2026-08-31 12:49:06 -04:00
James Dansereau 75641fb0b7 M-30: D154 re-audit — fix vtxoff, port bgTestBulletHitBackground G_SETTILE sibling, add GE_D154 diagnostic
Re-audited the M-28 bgTestRayIntersectionInRoom GBI-parser port (bg.c):

- G_TRI1 (w1>>16/8/0 & 0xff /10) and all 12 G_TRI4 nibble recoveries
  cross-checked twice against the N64 #else path and the raw BE-word bit
  layout -- both CORRECT, no change.
- BUG: vtxoff read gdl->dma.par & 0xf. The PC Gdma_le shim maps .par to
  bits 0-23 of word0 (packed length, 16-aligned) not the N64 byte-1
  params field at bits 16-23, so vtxoff was silently 0 -> wrong
  vtxbase[idx] for any G_VTX batch with v0 != 0. Now
  ((u32)gdl->words.w0 >> 16) & 0xf.
- Sibling ported: bgTestBulletHitBackground tail (post-hit G_SETTILE
  back-scan for tileformat/tilesize) was itself an unported raw
  ((u8*)gdl)[0]/[1] + 64-bit words.w0<<11>>30 parser over the same
  D85-widened 16-byte room DL. #ifdef PORT: opcode from bits 24-31,
  byte1 from bits 16-23, (u32)-forced 32-bit width for the tilesize
  shift. N64 path kept under #else.
- Diagnostic: GE_D154=1 logs the parsed cmd / vtx-index stream (call
  header + per-TRI1/TRI4 idx) for the first 64 invocations so the
  integrator/user can eyeball the parse. Verification is playtest-gated
  (shoot a wall/floor on an idle machine) -- the no-input level sweep
  never enters this function.

Build green (ntsc-final, 242/242). ABI/GBI-parse only, no game-logic
change. docs/PCPortResearch.md D154 + PORT-LEARNINGS §B updated.
2026-08-31 12:48:16 -04:00