261 Commits
Author SHA1 Message Date
James Dansereau ad74865404 port: click-to-lock mouse capture + absolute menu cursor + aim retune (D180)
WI-1: Input.MouseCaptureMode (0 = legacy always-grab, default; 1 =
Quake/Nightdive-style click-to-lock). In mode 1 the OS cursor is free
until a click lands in the game window; ESC, focus loss, or entering
any front-end menu frees it again, and re-entering a stage while armed
re-locks so unpausing needs no click. Mouse buttons are withheld from
the game while the cursor is free in-stage (no phantom fire). Controller
input paths are untouched. reconcileGrab() runs once per controller-0
poll off the game's current_menu global (UI context only).

WI-2: when capture mode is on and the cursor is free in a menu, the
D165/D169 pointer P-controller takes its target from the real cursor's
absolute window position mapped onto the live virtual front-end rect
(getPlayer_c_screen*), for true 1:1 tracking. The relative-delta path
(legacy / grabbed) is unchanged.

B3: Input.MouseAimSpeed default 25 -> 16 (aim mode still overshot at 25).

Build 241/241; -level_09 framediff 3/3 within threshold (unregressed);
GE_STARTMENU menu boot crash-free. Feel-checks owed (headless can't
drive the mouse).

Claude-Session: https://claude.ai/code/session_01GULf6knbsqfsnW39tMr9WT
2026-09-02 01:26:33 -04:00
James Dansereau 91b7c5aa4e port: config system — add Float/UInt option types with clamps
Modelled on the PD port's configRegister{Float,UInt}. Needed for the
in-game video/options menu work (WI-3): sliders that store fractional
or unsigned values (sensitivity, screen-shake, fov). configSave() now
flattens all four option kinds into one section-grouped pass so mixed
int/float keys in the same [Section] stay together, and the
newly-registered-key migration check covers them.

Claude-Session: https://claude.ai/code/session_01GULf6knbsqfsnW39tMr9WT
2026-09-02 01:26:24 -04:00
James Dansereau 231a8a91bf D169: level-select mouse pointer reaches the whole grid
The front-end pointer P-controller clamped its target/estimate to a
320x240 field (MENU_CURSOR_HI_H/_V 300/220); GE's front end runs 440x330,
so the pointer saturated before the outer mission-select grid columns and
bottom row -- only an inner ~3x3 was selectable.

port/ only, no game-logic change: the menuMode branch now derives its
clamp bounds from the live virtual screen (getPlayer_c_screenwidth/height
/left/top, src/game/bondview.c) -- [screenleft+20 .. screenleft+w-20] x
[screentop+20 .. screentop+h-20], matching front.c
frontUpdateControlStickPosition -- and seeds the estimate from the real
cursor_h_pos/cursor_v_pos instead of a 320x240 centre guess. Falls back
to the old constants when the front-end screen isn't set.

Builds + links clean (engine symbols resolve). GE_STARTMENU=7 boots
crash-free; -level_09 framediff 3/3 within threshold. Interactive
feel-check still owed (headless can't drive the mouse).

Claude-Session: https://claude.ai/code/session_01UpTM1AcBRJuTetoZNcCD5V
2026-09-01 20:22:23 -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 203549f57e D168: fix vertically-flipped GE_PCDUMP captures; close D114/D116
gfx_opengl_dump_bound_fbo wrote glReadPixels output (GL bottom-left
origin) straight into a top-row-first P6 PPM, so every GE_PCDUMP / F12
capture was upside-down. This is the entire source of the D114/D116
"HUD/text X-mirror" - four sessions chasing a fast3d mirror that never
existed, misreading inverted screenshots of asymmetric content. The
developer confirms the game renders correctly on real hardware.

- writer now emits rows top-to-bottom
- tools_pc/golden/*.png flipped in place to match + golden/README.md
- D114/D116 reclassified as capture-orientation artifacts (not deleted);
  D75 "logo mispositioned" / RC1 "photo 180 deg" flagged for re-judging
- annotations in findings.md, GRAPHICS-BACKLOG.md,
  TEXTURE-GLITCH-ANALYSIS.md, porting-notes.md D2, GE-ENV-PROBES.md

Not runtime-verified (migrated tree has no ROM/toolchain) - needs a
fresh capture to confirm text reads normally.

Claude-Session: https://claude.ai/code/session_01UpTM1AcBRJuTetoZNcCD5V
2026-09-01 19:44:39 -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 40f177a982 RC3/D167: non-power-of-two texture wrap period (Video.WrapFix, default off)
fast3d never stored the N64 tile mask and wrapped textures purely at the
uploaded image size; the RDP wraps a WRAP render tile at 1<<mask, which GE
sets to ceil(log2(dim)) - so non-power-of-two surfaces (Depot room
textures) repeat at the next power of two on console and one image-width
too soon in the port ('textures repeat oddly').

- gfx_dp_set_tile: store masks/maskt on the tile.
- gfx_sp_tri1 hoisted pre-wrap block (the reworked D74 block, indexed by
  texunit): when WRAP and 1<<mask != tex_width, fold UV at 1<<mask and
  clamp the [dim,1<<mask) overflow band (no real texels) to the edge.
- GE_WRAPFIX env override (wins over the ini) for testing.

Behind the existing Video.WrapFix knob, default OFF -> byte-identical to
golden. Per-level captures (-level_09/-30/-34/-20, off vs on): no crashes,
no structural regression; Depot shows small localized ceiling/wall texel
changes. Kept default off pending a human visual check on Depot.

docs: PCPortResearch.md RC3/D167, PORT-LEARNINGS.md D, TEXTURE-GLITCH-ANALYSIS.md 6d.
2026-08-31 12:49:39 -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 99c5926924 D165/D166: front-end 1:1 pointer P-controller + speed-proportional hipfire pitch pulses
port/src/input.c only (port-layer, no game-code change):

D165 - the M-30 menu 'pointer mode' fed mouse velocity into the stick, but
front.c frontUpdateControlStickPosition integrates the stick as a velocity
into a screen-pixel cursor -> velocity^2 feel. Replace with a P-controller:
maintain menuEst{H,V} (our estimate of the game cursor, stepped each poll with
front.c's exact recurrence via menuCursorStep), accumulate menuTgt{H,V} from
raw mouse motion, emit stick = clamp(6.0*(target-est), +/-70). Gain 6.0 =>
per-poll error decay 0.45, no overshoot, ~8-poll settle, parks under the
game deadzone with no drift. Input.MenuPointerMode (default 1) keeps the old
velocity mode as a fallback; Input.MenuPointerSpeed scales the mapping;
GE_INPUTLOG prints an est/tgt/eff/stick trace. No mouse motion => emitted
stick unchanged => prior behaviour exactly.

D166 - hipfire mouse pitch was a fixed digital threshold while yaw is analog.
Emit the C-button as a duty-cycled pulse whose frequency scales with mouse-Y
speed (fast = solid hold as before, slow = sparse taps). Input.HipfirePitchSpeed
tunes it. Aim-mode (RMB, already analog) untouched.

Build green; -level_09 (~360 frames) + GE_STARTMENU=6 smoke crash-free.
Feel-check owed (headless can't drive the mouse).
2026-08-31 12:18:15 -04:00
James Dansereau d23802ff9b D161: Depot ceiling blue-speckle — CI tile with G_TT_NONE decoded as palette
The Depot roof (CI8 tile drawn with gsDPSetTextureLUT(G_TT_NONE)) was
decoded against a stale rdp.palette, producing blue/magenta noise that
aliased into radial rays on the receding ceiling plane. A CI-siz tile
with the TLUT disabled must be treated as raw intensity (I). Route
CI4/CI8 -> I4/I8 in import_texture() when palette_fmt == G_TT_NONE.

Adds GE_TEXDUMP (env-gated PPM + param dump) as an inert diagnostic.
-level_09/-30/-34 sweep PASS, unregressed.
2026-08-31 12:16:19 -04:00
James Dansereau 3e5720c01b D74: rework sub-tile UV pre-wrap as opt-in Video.WrapFix (was dead + OOB)
The D74 block in gfx_sp_tri was inert (guard `cms & G_TX_WRAP` == `& 0`,
always false) AND OOB (`tex_width2[i]` indexed by the vertex i=0..2, but
the array is per-texunit [2]). SMALL-FIXES B1: the naive in-place
correction boot-crashed -level_09.

Rework per the B1 plan: precompute per-texunit `wrap_s/t[t]`,
`wrap_tw/th[t]`, `wrap_uls/ult[t]` once in the texunit setup loop (needs
the original cms/cmt before the CLAMP-clear), keyed on 'clamp bit not
set' + window smaller than the uploaded image; apply per vertex indexed
by `t`. Gated behind `Video.WrapFix` (default 0 -- new/never-run path).
Verified: WrapFix=0 unchanged; WrapFix=1 -level_09 renders clean through
frame 375, no boot crash, no visible corruption. Visual fix on wrapped
triangle textures (room panels) needs a level with them + an N64
reference -- Depot per B1.
2026-08-31 11:40:42 -04:00
James Dansereau ddef038238 fast3d RC2: clip mip-contaminated texture uploads to base height (+ GE_DTEX probe)
Reported: menu / room textures look 'interlaced' (alternating garbage
rows). Root cause (docs/TEXTURE-GLITCH-ANALYSIS.md RC2): GE's
texGetDepthAndSize() packs base level + every LOD mip into one
gDPLoadBlock, so fast3d's upload height (size_bytes / row) runs ~1.3x
taller than the base image and the mip bytes render as rows below it.
GE_DTEX=1 confirmed it: 64x64 I4 uploaded 64x87, 65x65 -> 65x95, etc.

Fix in import_texture(): when LOD is active (rdp.tex_lod) and the load
is a plain full-width block (not a windowed gfx_dp_load_tile - the D74
sub-tile / Rare-logo case, which is lod=0), clip size_bytes to the
SETTILESIZE base-tile height. GL then builds correct mips from a correct
base. New knob Video.FixMipTextures (default 1; =0 restores old upload,
verified byte-identical to golden -level_09 3/3). BUNKER1 with the fix
on: side-wall textures visibly cleaner, geometry/HUD unchanged; Silo
unregressed. -level_09/-level_20 framediff WILL flag with the fix on
(textures changed for the better) - refresh golden after the user
confirms the menu + Depot look right.

New GE_DTEX=1 env probe logs load params for the first 64 textures.
2026-08-31 08:25:34 -04:00
James Dansereau 8dc952774b input: fix inverted Y in front-end pointer mode (mouse-down moved cursor up) 2026-08-31 08:10:34 -04:00
James Dansereau cbc83c24ab input: front-end pointer mode - mouse drives the menu cursor on both axes
Reported: on SELECT FILE the mouse only moved side-to-side; up/down
needed W/S. The front-end cursor is stick-driven (frontUpdateControl
StickPosition reads joyGetStickX/Y); the port only fed mouse-X to the
stick in menus (mouse-Y went to C-buttons, which the menus ignore).

New: when current_menu != MENU_RUN_STAGE (and != MENU_INVALID, so bare
-level_XX in-game aim is untouched), mouse velocity drives stick X+Y as
a pointer, no C-buttons/aim-band, LMB=A (select) RMB=B (back). Reads the
game global current_menu directly for UI context only - no logic change.
New knob Input.MenuPointerSpeed (%, default 100). -level_09 3/3
framediff; FILE_SELECT boots clean 900 frames.
2026-08-31 08:07:23 -04:00
James Dansereau 7d31654f2e D2/D146: strip unconditional D63 debug trail from gfx_pc.cpp
The D63 branch-trail dump fired ~10 sysLogPrintf lines + a full trail
walk on EVERY unknown GBI opcode, unconditionally. On the front-end
menu (D149 corrupt model DL) this ran per-frame and collapsed the menu
to ~5fps with input starved. Removed the trail statics, the per-G_DL
recording, and the default-case dump; the rate-limited D146 'ending DL'
log stays. -level_09 3/3 framediff, -level_20 91.6% unregressed.
2026-08-31 07:56:35 -04:00
James Dansereau 944b28bd0d M-29: wire up opt-in N64 3-point texture filtering (BACKLOG B1 groundwork)
The sm64ex 3-point filter shader path already existed in gfx_opengl.cpp but
was never reachable from config, and its min-filter row was all GL_NEAREST
(no mip sampling) so it would shimmer badly.

- gfx_opengl.cpp: 3-point min-filter row -> GL_LINEAR_MIPMAP_{NEAREST,LINEAR}
  so minification uses the mips it already force-generates at upload;
  anisotropy default 0 -> 4 (0 was invalid for GL_TEXTURE_MAX_ANISOTROPY).
- video.c: Video.TextureFilter now accepts 2 = 3-point + trilinear.
  DEFAULT STAYS 1 (bilinear) -- 3-point is more console-authentic but
  softens textures at normal viewing distance, and (tested) did NOT fix
  the Depot roof garbage. That is a separate texture-data/UV/effect bug,
  not filtering -- BRIEF-B2 updated, next step is a GE_DTEX probe.

Verified: default (bilinear) -level_09 frame 320 unregressed vs golden
(nonclear delta 0.00pp, phash 11); build green; 1200-frame -level_30 run
crash-free with 3-point active.
2026-08-30 21:09:53 -04:00
James Dansereau d0d18b2472 M-29: file-backed EEPROM saves (BACKLOG B5 / Phase 4)
port/src/libultra.c: osEeprom{Probe,Read,Write,LongRead,LongWrite} were
no-op stubs, so campaign progress (unlocked missions, times, difficulty)
was lost on every quit. Implement a real 16Kbit (2048-byte) EEPROM backed
by $S/ge007.eep: lazy-load on first access, write-through on every write,
bounds-checked against the 256-block device. Pattern mirrors the PD port
(pd_port/port/src/libultra.c). Probe now reports EEPROM_TYPE_16K so the
decomp save logic (src/game/file2.c, already compiled) runs unchanged.

Verified: build green (ntsc-final); -level_09 boots, the game writes
data/ge007.eep (2048 B, checksum @block 0 + save_data slots @block 4),
600+ frames crash-free; -level_09 unregressed. File persists across runs.
2026-08-30 20:52:47 -04:00
James Dansereau 609bb732b9 M-29: aim sensitivity fix (BACKLOG B3 / SMALL-FIXES B4)
port/src/input.c: RMB manual-aim mouse sensitivity overshot badly at the
old MouseAimSpeed=50 default (25 px/poll saturated the aim band, so a
flick ran GE's (stick-60)/10 curve at full rate for many polls). Lower
the default to 25 and expose the band itself as Input.AimBand (int 5..40,
default 20 -> unchanged behaviour at the new speed). Port-only, config-
backed, fully reversible via ge007.ini.

port/fast3d/gfx_pc.cpp: annotate the dead D74 texture-wrap block
(SMALL-FIXES B1). The in-place correction (G_TX_CLAMP test + per-texunit
index) activates never-run code and crashes -level_09 at boot; it needs
the hoist-out-of-vertex-loop rework + per-level visual verification, not
a one-liner. Left inert with a NOTE; no behaviour change.

Verified: build green (ntsc-final); -level_09 + -level_20 crash-free for
a full ~75s capture window on a settled machine (D153: earlier back-to-
back runs crashed under build/run thrash, not a regression).
2026-08-30 20:49:50 -04:00
James Dansereau 57e24a374b M-28: watch-menu text (D150/D151) + audio-lock deadlock mitigation (D152)
D150 (src/str.c) — NULL-tolerant strcpy/strncpy/strcat under #ifdef PORT.
The watch BRIEF/OBJECTIVES pages build text with strcat(buf, langGet(id));
langGet() returns NULL on PC for a string bank the in-level flow never
loaded, and str.c's primitives are __nonnull__ builtins so a plain param
NULL check is optimised away — the guard launders the ptr through an empty
asm (GE_IS_NULL). NULL src -> empty string instead of an AV crash.

D151 (src/bondtypes.h) — watch data (objectives / mission background / M /
Q / Moneypenny) all rendered blank. propDef types 35 (WatchMenuObjectiveText)
and 23 (ObjectiveStart) store a plain s32 lang slot id in their 3rd word;
the decomp structs decode it as [u16 reserved][u16 text] reading text at
offset 0xA, which only works because the id is in the low 16 bits of a
big-endian word. d88_propdefs.py bswap32's the whole word -> text @0xA
reads 0 -> langGet(0) -> NULL. Fix: text is a full u32 at the word offset
under #ifdef PORT (N64 layout kept under #else). No converter change, no
sidecar regen. This is the D143/D150 "briefing text renders blank" root cause.

D152 (port/src/libultra.c) — mission-failed / objective-failed permanent
black screen. The D147 recursive-mutex behind osSetIntMask can be leaked
(libaudio has unbalanced / early-return mask calls; a transient thread can
acquire OS_IM_NONE and exit) -> mainThread + amMain both block forever in
alEvtq* -> gfx pipeline stalls. Live gdb.txt from the user's session
confirmed the deadlock (mainThread in the mission-fail audio fade-out
sndSetScalerApplyVolumeAllSfxSlot -> alEvtqPostEvent). Mitigation: model
the mask as a self-healing logical lock (owner/depth under s_imMx + condvar)
— normal microsecond critical sections behave as before, but a waiter
blocked > 2s concludes the holder leaked it, logs the stale owner + the
stealing caller's return address, and steals the section. Worst case is a
~2s audio hiccup + a log line naming the leak, instead of a hung game.
Proper narrow fix (dedicated ALEventQueue lock) still owed.

D153 investigated and dismissed — the "-level_09 crashes at frame ~900"
seen while regression-testing was D117/D134 host-scheduling flakiness under
heavy concurrent build+run load; on a quiet machine -level_09 runs past
frame 2400 clean with all three changes applied.

Build: green (ntsc-final). Golden framediff not run clean (same machine-load
flakiness); one quiet -level_09 run = 2400+ frames, 0 heartbeats.
2026-08-30 20:25:02 -04:00
James Dansereau 48bac33068 M-27: D147 - osSetIntMask is a real recursive lock (audio-queue race -> hang)
The hang seen near the end of Dam (and latent everywhere a sound effect
plays): a door finishes opening, objTick -> doorPlayCloseSound0 ->
sndPlaySfx -> alEvtqPostEvent walks g_sndPlayer's ALEventQueue insert list
on the main thread while the audio-manager thread (amMain -> alAudioFrame ->
sndPlayerVoiceHandler -> sndRemoveEvents) mutates the same list. Both guard
with osSetIntMask(OS_IM_NONE) -- which the port stubbed as a no-op -- so on
PC there is no mutual exclusion: the list corrupts and alEvtqPostEvent's
for-loop spins forever (thread-dump confirmed both threads inside the evtq
under a bogus/circular link).

osSetIntMask now maps to one process-wide recursive mutex: OS_IM_NONE
acquires, the OS_IM_ALL token it returns releases on the paired restore,
other masks (OS_IM_VI in sched.c) pass through. The decomp never blocks
while holding OS_IM_NONE (N64 contract), so it cannot deadlock. Covers
every cross-thread critical section in libaudio + sched, not just this one.

Verified: -level_09 1200 frames, 0 heartbeats, 91.66% coverage (golden).
Thread dump captured via CTRL_BREAK to the batch gdb + fresh attach.
2026-08-30 15:40:59 -04:00
James Dansereau a82e90d457 M-27: D146 hardening - guard gfx_sp_vertex/movemem/set_vertex_colors ptrs
A desynced/corrupt front-end display list (D75 family - the compiled
wallet/dossier/logo sub-DLs at seg5+offset) feeds wild pointers to the RSP
command handlers before the walk reaches an unknown opcode. Added a shared
fast3d_ptr_ok() mapped-range check; gfx_sp_vertex, gfx_sp_movemem and
gfx_sp_set_vertex_colors now bail on a wild pointer instead of faulting.
Pairs with the D146 unknown-opcode -> end-DL change.

Verified: -level_09 unregressed.
2026-08-30 15:30:16 -04:00
James Dansereau cea9ce0df9 M-27: D146 unknown-GBI-opcode -> end DL instead of abort(); tools_pc/debug.ps1
D146 (port/fast3d/gfx_pc.cpp): an unrecognised opcode in the DL walk called
sysFatalError() -> abort() -> whole process dies with no ge007.crash.log
(the crash handler does not cover abort). Seen on the real post-Dam
MISSION COMPLETE screen: "[FATAL] Unknown GBI opcode 0x40c03 at
0x70161a8c" - a front-end 3D model (dossier, D75/D144 family) whose display
list was never built, sitting at a valid DRAM address full of junk. D144
guarded the bad matrix pointer; the walk then hit more garbage. Now: log
loudly (rate-limited x20) and `return` to end the (sub-)DL so the frame
finishes. A real level-render desync is still visible in the log.

tools_pc/debug.ps1: launch the port under gdb from the repo root so a
playtest crash always leaves a backtrace (abort/fatal or silent exit).
-Menu <id> sets GE_STARTMENU; passes through -level_XX etc. Prints the
FATAL line + backtrace + crash log when the game exits.

Verified: GE_STARTMENU=13 mission-complete -> Next chain renders, no abort,
no crash (D75 model cosmetics aside).
2026-08-30 15:27:23 -04:00
James Dansereau 36acf324fa M-27: D145 - ESC no longer hard-quits (it is the menu back key)
port/src/video.c, port/fast3d/gfx_sdl2.cpp, port/src/input.c: bare ESC was
wired to exit(0) in both event handlers. On the front-end / debrief / mission-
complete screens ESC is the natural "back / previous page" key, so a player
paging through the post-mission report with ESC quit the whole game -- a
clean exit(0), no crash log, looked like a crash. ESC now feeds the N64 B
button (back / cancel) through input.c alongside X/R/F; quitting is
window-close (X) or Alt+F4 only.

Verified: -level_09 boots crash-free, 8 frames rendered.
2026-08-30 15:05:12 -04:00
James Dansereau 70793eba4f M-27: D143 null-string text guard + D144 bad-matrix-ptr fast3d guard
D143 (src/game/textrelated.c): langGet() returns NULL for a string slot the
current flow has not loaded (non-DEBUG builds; DEBUG substitutes a
placeholder). The N64 retail menu flow guarantees every front-end string is
resident before it is drawn; the PC flow does not yet, so a missing
briefing/objective/mode-select string reached textRender/textMeasure/
textWrap as NULL and faulted in the `*text` scan. Added #ifdef PORT NULL
guards at all four entry points -> degrade to "draw nothing" (blank text)
instead of crashing. User verified: front-end now navigable folder -> mode
-> difficulty -> level -> in-game.

D144 (port/fast3d/gfx_pc.cpp gfx_sp_matrix): a front-end 3D model
(MISSION COMPLETE dossier / mode-select wallets - D75 family) emits a
gSPMatrix whose pointer field was never resolved; w1 arrives as
0xFFFFFFFFFFFFFFFF, seg_addr() passes it through, and gfx_sp_matrix faults
reading addr[]. Guard: an unmapped/non-canonical addr -> substitute identity
(model draws with wrong transform, already a parked D75 cosmetic) so the
screen and its "Next" -> menu transition survive.

Verified: -level_09 unregressed (framediff 2/2 ok w/ HUD mask, no crash).
2026-08-30 14:47:37 -04:00
James Dansereau 94f677584a M-27: D142 file-select hang fix + GE_INPUTSCRIPT test harness
D142 (src/bondconstants.h): GCC/mingw gives an all-non-negative enum an
UNSIGNED underlying type, so a descending loop that relies on the counter
going negative to terminate spins forever. LEVEL_SOLO_SEQUENCE hit this in
fileGetHighestStageDifficultyCompletedForFolder (file2.c:859/887), called
every frame by the SELECT FILE screen -> whole game froze (no crash, just a
kernel-heartbeat stall). Fix: #ifdef PORT negative sentinel forces the enum
signed; first real enumerator stays 0, sizeof stays 4, no stored value
changes. Same trick DIFFICULTY already uses (DIFFICULTY_MULTI = -1).
Verified: front-end now boots -> intro -> SELECT FILE and stays alive 3000+
frames; menu chain navigable (user playtest).

port/src/input.c: GE_INPUTSCRIPT env var -- headless scripted controller-0
input for front-end/menu walkthroughs. "<frame>:<tok>[,<tok>]" entries;
buttons (A B Z START L R UP DOWN LEFT RIGHT CUP CDOWN CLEFT CRIGHT) pulse,
stick tokens (SUP SDOWN SLEFT SRIGHT / SNONE) sustain. When set it is the
sole controller-0 source (real kbd/mouse ignored) so a no-focus relative-
mouse window can't inject phantom deltas. Unset => zero effect.
2026-08-30 14:34:59 -04:00
James Dansereau 326564736d M-26: [D] [Debug] ini flags mirroring GE_PCDUMP / GE_INPUTLOG
- config.c: Debug.FrameDump (string) + Debug.InputLog (int).
  configGetFrameDump()/configGetInputLog() check the env var first
  (tools_pc/*.sh unaffected), fall back to the ini.
- video.c / input.c consumers switched to the accessors.
- GE_DETERM is design-only (D117), not mirrored.
- Verified: ini-only FrameDump=40-120:20 -> exactly 5 frames; GE_PCDUMP
  still overrides; -level_09 crash-free.
2026-08-30 13:00:52 -04:00
James Dansereau 710d02f802 M-26: [B/C] level_sweep STALLED verdict, raw mouse input opt-in
- level_sweep.sh: frames rendered then frozen with process alive ->
  STALLED (froze at N/M frames) instead of a false PASS (the Silo
  -level_20 fly-down / D117-D134 load case). Retried once like NO-FRAMES.
- input.c: Input.MouseRawInput=1 (default 0) sets SDL relative
  system-scale + warp hints off in inputInit -> no OS pointer accel on aim.
- docs: M-26-QOL-REVIEW.md + HANDOFF section.
2026-08-30 12:55:12 -04:00
James Dansereau db1834ca9b M-26: [A/E] atexit config+window persistence, --help/--version
- main.c: atexit(portAtExit) = videoSaveWindowState() + configSave(),
  fires on clean exit(0) (SDL_QUIT/ESC/close); abort() paths skip it.
- video.c: new [Window] ini section (Width/Height/X/Y/Maximized);
  sentinels (0 / -1) reproduce native-size + centered. videoSaveWindowState()
  snapshots live geometry via the SDL2 wapi backend. Video.Fullscreen
  now round-trips.
- main.c: --version / --help (-h) before crashInit — build id, usage,
  21-level -level_XX table.
- Verified: -level_09/-level_20 crash-free 6/6 frames, ge007.ini gains
  populated [Window] block on exit.
2026-08-30 12:55:06 -04:00
James Dansereau 39dded2a17 M-25: [Input] ge007.ini knobs — mouse Y scale/smoothing, pad deadzone/trigger/invert-Y 2026-08-30 12:35:17 -04:00
James Dansereau f6006bcdb4 M-25: [Video] ge007.ini knobs (vsync/fps cap/MSAA/filter/fullscreen) + config auto-migration 2026-08-30 12:30:00 -04:00
James Dansereau 9d136bc657 M-24: more playtest QoL — gamepad hotplug, wheel weapon-cycle, title FPS
port/src/input.c:
- inputRescanPads(): close + re-open all pads on hotplug. Game latches the
  connected mask at osContInit so a late pad merges into controller 0.
- inputPostWheel(): a wheel notch queues a 2-poll A-button press. GE's
  default scheme cycles weapon forward on a bare A edge (invButtons=A,
  bondview2.c:5162/5326). Both directions cycle forward.

port/src/video.c: pump SDL_MOUSEWHEEL -> inputPostWheel,
SDL_CONTROLLERDEVICEADDED/REMOVED -> inputRescanPads, and refresh the
window title with vidAvgFPS ~1 Hz via wmAPI->set_window_title.

-level_09 2/2 crash-free to frame 900, unregressed (91.6%). Build green.
2026-08-30 12:18:55 -04:00
James Dansereau 46fa579e54 M-24: playtest QoL — F12 screenshot + mouse frees on focus loss
port/src/video.c: F12 sets a request flag consumed in videoEndFrame (render
thread, GL context current) -> ppm/shot_NNN.ppm via the existing
gfx_opengl_dump_bound_fbo. SDL_WINDOWEVENT_FOCUS_LOST/GAINED now call
inputSetMouseGrab(0/1) so alt-tabbing away releases the trapped cursor.

port/src/input.c: add inputSetMouseGrab() — toggles SDL relative-mouse
mode (respecting MouseEnabled), zeroes the aim delta, and gates inputUpdate
mouse reads on a mouseGrabbed flag.

-level_09 3/3 runs crash-free to frame 900, unregressed (91.6%).
2026-08-30 12:08:22 -04:00
James Dansereau 546d803870 M-24: mouse-look rework (D118b/c) + real ge007.ini parser
port/src/input.c: mode-aware mouse->pad map matching bondviewProcessInput.
Aim mode (RMB held) drives the analog stick past +/-60 for proportional
yaw+pitch and emits no C-buttons; hipfire keeps digital C-up/C-down pitch.
Fixes D118b (mouse-Y inverted) and D118c (aim + mouse-down -> crouch, from
C-down being overloaded to crouch/lean/zoom in insightaimmode). D118a
(hipfire digital pitch vs analog yaw) residual, minor. Port-layer only.

port/src/config.c: implement configLoad/configSave (were TODO stubs).
Parses $S/ge007.ini ([Section] blocks, Key = value, #/; comments), clamps
ints to registered bounds, writes defaults on first run. New knob
Input.MouseTurnSpeed (hipfire yaw %) split from MouseAimSpeed.

Verified: -level_09 crash-free 900+ frames @ 91.65% (unregressed),
-level_20 crash-free, ini round-trips with no unknown-key warnings.
2026-08-30 11:59:52 -04:00
James Dansereau 46d4f03e5a D134: task-done SP/DP events must not be dropped into a full sched interruptQ
The frame-2 boot hang blamed on D117 flakiness since M-13 is a real bug:
osSpTaskStartGo posted OS_EVENT_SP/DP with OS_MESG_NOBLOCK into the 8-slot
scheduler interruptQ that the 60 Hz VI pacemaker also fills. A gfx task runs
the whole frame synchronously on the sched thread, so a slow early frame lets
retraces fill the queue and the done event is silently discarded -> curRSPTask
never clears -> pendingGfx never clears -> permanent stall.

portPostEventForce() drops the oldest (stale retrace) message and retries;
portPostVIEvent reserves 2 slots. -level_09: 6/6 boots to frame 600, 0
heartbeats (pre-fix 1/3).

Also hardens tools_pc/level_sweep.sh: 45s watchdog, lockdir serialization,
stray-process kill, one retry on NO-FRAMES.
2026-08-29 23:32:17 -04:00
James Dansereau ef1ec52366 @
Remove closed D63 debug scaffolding (libultra.c + game-file probes)

D63 is a closed finding. Strips the dead TEMP D63 probe code:
- port/src/libultra.c: watchdog thread, activity ring, gun-barrel slot
  watchers, and their call sites in osSendMesg/osRecvMesg/osSpTaskStartGo.
- #ifdef PORT probe blocks in blood_animation.c, dyn.c, front.c, rsp.c,
  title.c (all env-gated / no side effects).

port/fast3d/gfx_pc.cpp still carries a D63 branch trail -- left for a
later pass. Build links clean; no behaviour change.
@
2026-08-29 22:13:14 -04:00
James Dansereau bb14baf3bf @
D131: restore truncated module ptr in seg_addr (Jungle -level_37)

explosionRenderPropSmoke passes a compiled .bss matrix symbol
(&dword_CODE_bss_8007A100) through osVirtualToPhysical() -- a u32-returning
shim -- into gSPMatrix, truncating the 0x1_00000000 module high word. w1
lands as 0x40xxxxxx and seg_addr() returns it raw -> wild deref in
gfx_sp_matrix when the first explosion draws (~frame 300).

Fix: seg_addr() restores the module high word for any fallthrough w1 in
[0x40000000, 0x70000000) (module fixed-based at 0x140000000, no ASLR;
DRAM/KSEG0/segmented/phys are all handled in earlier branches). Covers the
~30 latent osVirtualToPhysical(<compiled matrix/vtx symbol>) sites in
explosion/glass/blood/bondview2.

Verified: Jungle renders to frame 1500-2400+ crash-free at 91.7% content;
-level_20/-level_24 unregressed. Sweep 18->19/21 (note: -level_09 has a
separate pre-existing boot crash regressed since M-18 -- see HANDOFF).
@
2026-08-29 22:13:08 -04:00
James Dansereau fda4c19159 D130: stage fontchar fields before in-place 24->32B relayout (C2 Facility+Runway)
romdataFixupFont re-lays out the N64 24B fontchar array into the PC 32B
array in place; for glyphs 0/1/2 dst overlaps src by less than the 24B
read span, so a forward field write clobbered a later field's source
mid-loop (glyph 1 width = bswap(index), glyph 2 pixeldata -> wild ptr).
Facility's level title 'Chemical Warfare Facility #2' rendered '#' with
the wild pixeldata -> gDPLoadTextureBlock -> fast3d AV in import_texture_i8.

Fix: stage all six N64 fields in a local f[6] before writing any.
Not the model-GDL relocation the D124-Facility addendum suspected
(disproved; D124 entry + BRIEF-C2gdl-model-reloc premise corrected).

Verified: -level_34 0/~14 runs, -level_35 0/4 (was ~3/4);
-level_09/20/24 unregressed. Sweep 18 -> 20/21 PASS; only Jungle C2m
(gfx_sp_matrix explosion-DL G_MTX) remains.

Docs: D130 in PCPortResearch §F/§H, LEVEL-STATUS 20/21, HANDOFF M-18,
PORT-LEARNINGS §A (in-place relayout aliasing + negative glyph index).
2026-08-29 21:33:56 -04:00
James Dansereau 9d118e0b13 M-13: 21-level sweep matrix + D123 (C1 fix) + D124 (C2 Jungle fix)
WS4 level sweep driven to completion: 12/21 solo levels PASS
(load + render + no-crash). Full matrix in docs/LEVEL-STATUS.md,
reusable runner tools_pc/level_sweep.sh, WS6 checklist docs/LEVEL-PLAYTEST.md.

Fixes:
- D123 (tools_pc/d88_propdefs.py): crash class C1 on 6 levels
  (Dam/Runway/Frigate/Statue/Streets/Cradle). D122 fallout - the widened
  Vehichle/AircraftRecord.ailist slot was zeroed instead of carrying its
  read-before-write integer AI-list id, so prop.c -> ailistFindById(0) ->
  GAILIST_AIM_AT_BOND -> ai() ran a CHR aim list with a NULL chr ->
  chrIsNotDeadOrShot(NULL). New OBJ_ID_WORDS arm. 4 levels -> PASS,
  Runway falls through to C2. BUNKER1/Silo unregressed.
- D124 (port/src/gimgfixup.c): crash class C2 on Jungle. Compiled
  explosion-DL pointer-sync keyed on an IMAGESEG marker that texLoad()
  had already erased -> compiled globalDL_0xNNN arrays kept link-time
  IMAGESEG words -> 0xABCDxxxx into fast3d on first explosion draw.
  Latent on every level. Detect the slot from the compiled array instead.

Diagnosed, NOT fixed (write-ups in PCPortResearch.md SF):
- C2-GDL (D124-Facility addendum): Facility/Runway - model texture-pool
  path yields a garbage tex->data; every model file loads at an odd
  bank base (mempAllocBytesInBank does no alignment). Suspect
  d43_emit.py/pcmodels sidecar texture-blob offsets (N64 8B vs PC 16B Gfx).
- C3/C6 (D125): Aztec/Bunker2/Surface2 - converted propDefs blob in RAM
  doesn't match d88_propdefs.py offline output (garbage after record 0);
  runtime sizepropdef() walk drifts ~100 records -> wrong door/modelid.
  d88_propdefs.py convert_stream + sizepropdef() PORT strides verified
  correct; suspect a dest-offset/pd_end bug in d88_emit.py propdefs emit.

Remaining: 9 crashes / 6 classes (C2-GDL, C3/C6, C2m Jungle G_MTX,
C5 Control BG portal, C4 Depot BG tile/room, C7 Surface1 sndSetupSound).
Priority order in docs/LEVEL-STATUS.md.

Also folds in the in-progress docs restructure (HANDOFF/AGENTS/CLAUDE,
PCPortResearch D121/D122 SF/SH entries, PLAN-linear-level-sweep,
HANDOFF-ARCHIVE, BACKLOG).
2026-08-29 16:36:18 -04:00
James Dansereau da379d9644 M-10: D119 guard-attack crash fixed + D120 blood-stain hang guarded + input playtest fixes
- D119: ~28 weapons_held[]->chr puns in chraction.c read .act_*.attack_item,
  aliasing WeaponObjRecord.weaponnum at 0x80 on N64 (act union @0x2C+84).
  Pointer widening moves the union to ~0x38 on PC -> garbage negative item
  id -> OOB g_ItemStats -> segfault the instant a guard fires. PUN_ATTACK_ITEM
  macro reads weaponnum directly; #else branch identical -> N64 unchanged.
- D120: chrCreateBloodStain PointUsage[] chain walk cycles forever on PC
  (d43_emit opcode-0x18 collision-record conversion incomplete). #ifdef PORT
  caps + bounds-checks both walk loops so the game survives; converter fix
  deferred to its own session.
- input.c: mouse-look per-poll delta (no stuck accumulator); mouse X = turn
  (stick-X), A/D = strafe (C-buttons), mouse Y = look. D118a/D118b minor
  bugs documented in HANDOFF.

-level_09 now survives the BUNKER1 guard firefight (45s+ crash-free, was
hard segfault ~frame 1200 every run).
2026-08-29 13:48:05 -04:00
James Dansereau 8337a87fb2 D118: implement SDL input layer (kbd/mouse/gamepad -> N64 pad)
port/src/input.c was a stub; input only worked via libultra.c's minimal
contSnapshotFromKeyboard (no C-buttons, mouse, or gamepad).

- input.c: keyboard+mouse + SDL_GameController -> OSContPad. Analog stick
  = move, C-buttons = aim. Mouse-look bridged to digital C-buttons via a
  clamped per-axis accumulator (proportional press dwell). RMB/LT = aim
  mode, LMB/RT = fire. Config: ge007.ini [Input] MouseEnabled /
  MouseAimSpeed / MouseInvertY.
- input.h: add inputConnectedMask() + inputComputePad().
- libultra.c SI section: contSnapshotFromKeyboard() now delegates to the
  input module (single source of controller state); osContGetReadData()
  fills all MAXCONTROLLERS channels, not just controller 0.
- GE_INPUTLOG=1 gates a per-poll OSContPad trace (zero-cost when unset).

Build green (ntsc-final); boots -level_09 crash-free 35s. Live input
untested from headless agent - human playtest owed.
2026-08-29 11:11:08 -04:00
James Dansereau 93b31c8c1d D116 part 3: buf_vbo + texture-upload runtime-verified non-mirrored; contradiction documented, deprioritised (cosmetic). GE_D116/vbo probe kept 2026-08-29 11:03:13 -04:00
James Dansereau 8fdf473dc8 D116: probe finds HUD mirror is per-quad texture-U flip, not proportional-font-specific; ammo digits also mirrored; re-opens D114 2026-08-29 10:31:07 -04:00
James Dansereau 1fe85339f2 D107: fast3d sampled unloaded mip tile for GE's LOD textures (blurry ceilings)
GE's room GDLs emit G_TL_LOD + G_TD_DETAIL for mip-mapped textures, but
gfx_detail_textures_enabled is false for this port. gfx_lod_tile_offset's
'rdp.tex_lod ? rdp.tex_detail : i' then returned tex_detail (1) for every
texel, so fast3d sampled GE's first mip (render tile 1). GE loads the
whole mip chain with one G_LOADBLOCK to TMEM 0 and just points tiles 1..5
at offsets inside it; fast3d keys loaded_texture[] by TMEM addr and has
no entry for tile 1's offset, so import_texture fabricated a 16x16 from
the top-left 256 bytes of the base image and magnified it across the
polygon -- the blurry brown/grey ceilings and wall panels in BUNKER1.

Fix: when detail textures are disabled, gfx_lod_tile_offset always
returns 0 (the base render tile, the only correctly-loaded level). Sharp
near surfaces, matching what the N64 shows at close range.

Verified -level_09: BUNKER1 ventilation room ceilings/walls/light panels
now render crisp instead of brown mush. Single-level textures (racks,
room 1) were already fine and are unaffected.
2026-08-28 22:02:25 -04:00
James Dansereau 82f71855ca D103: native-res height from yScale, not hard-coded 480 (viewport was half-height)
osViSetMode hard-coded the fast3d native resolution to the visible
scanline count (pal ? 400 : 480) instead of the 320x240 CFB space GE
authors its GBI in. SCREEN_WIDTH/SCREEN_HEIGHT alias the native viewport;
RATIO_X came out 2.0 but RATIO_Y 1.0, so every gSPViewport/G_SETSCISSOR
was a 220px band (~46% of frame) shown in the lower half after invert_y.
gfx_current_native_aspect was wrong too (320/480).

Recover CFB height from vm->fldRegs[0].yScale (bufy*0x800/480, fr.c:417)
with a pal ? 272 : 240 fallback. comRegs.width already carried bufx.

Verified -level_09: GE_PCDUMP frames 46%/lower-band -> 91.7%/bbox
(0,20)-(639,459), correct letterbox + HUD placement. Reveals next layer:
room geometry (D85) and weapon model still don't draw (flat blue + HUD).
2026-08-28 20:38:07 -04:00
James Dansereau d13105147d D95: raise the PC mempool ceiling to reclaim ~4 MB of unused DRAM
The N64-fidelity mempool end (0x702F4400, tlbmanageGetTlbAllocatedBlock)
leaves ~5 MB of live, mapped, unused DRAM below the 8 MB top (only
animations_frame_buffer at 0x707FFD30 lives up there). PC structs are
larger everywhere, so the per-level -m* budget no longer fits and
MEMPOOL_STAGE OOMs (zbufAllocate spins in mempAllocBytesInBank). Raise
the ceiling to 0x70700000; the ~4 MB gain goes to MEMPOOL_STAGE.

With this + the reapplied 2x g_GfxBuffers (128f7644), -level_09 renders
~5 frames (previously: OOM hang, or non-deterministic overrun within
seconds). It then still faults in bgScissorCurrentPlayerView writing at
0x70800000 -- but deterministically at ~frame 5 and regardless of -mgfx
size (tested up to 1.6 MB/half), so that is a RUNAWAY GDL append in the
prop-render path (chrpropsRenderPass/chrpropRender), not a buffer-size
problem. Next blocker.
2026-08-28 16:11:49 -04:00
James Dansereau 03dcda7bc3 port: feed host argv into the game token string
osPiReadIo was stubbed to return 0, so token.c's cartridge-token read
always produced an empty string and N64 debug switches (-level_09,
-hardN, -m*) were silently ignored -- the game could only ever reach a
level via attract-mode demo playback. Synthesise the token string from
argv[1..] (sysGetTokenString) and serve its bytes from the 0xFFB000
range in osPiReadIo, so -level_09 now boots straight into BUNKER1.

Empty when no args are passed, so no-arg (attract) runs are unchanged.
2026-08-28 09:58:02 -04:00
James Dansereau c09bf37c66 D63: gate dram-branch G_DL trace behind GE_D63 (was flooding -level_09) 2026-08-28 09:04:46 -04:00
James Dansereau 4e1b891fc6 D88.1/D88.3: SetupIntroCamera narrow-pointer ABI + pccg wiring
- bondtypes.h: SetupIntroCamera lang1c/lang20/prev kept u32 under
  #ifdef PORT so sizeof stays 40 and the fixed-stride intro-record walk
  in bondview_r.c/bondview2.c still matches 40-byte file records. Fields
  are write-before-read (bondview_r.c:276-300 writes all three before any
  read via langGet()/list-link), verified. Same class as D79/D53.1.
- bondview2.c/bondview_r.c: cast (char *)(uintptr_t) at each use site.
- pccg.c: PCCG_MAX_FILES 128 -> 256 to also hold the Usetup*Z rows.
2026-08-28 08:30:56 -04:00
James DansereauandClaude Sonnet 5 81d5614fbd D69.3: bg/stan sidecar port-layer wiring (port/src/pccg.c)
Serves the offline-converted sidecars from tools_pc/d69_emit.py, cloned
from port/src/pcmodels.c (D50 Plan-B pattern): pccgReserveSize/
pccgLoadSidecars/pccgPatchTable/pccgTotalSize/pccgSidecarBase.

Wiring: romdata.c extends the CART_BASE VirtualAlloc reservation to
include pccgReserveSize() right after the pcmodels extension and loads the
sidecar image there; romdataCartAddrValid's bounds check and libultra.c's
D60 DMA-source diagnostic both extended to cover the pccg byte range.
One-shot pccgPatchTable() call added next to pcmodelsPatchTable() in
load_object_fill_header (objecthandler_2.c) -- reused purely as a
"definitely after obInit()" hook, unrelated to model loading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3xib6jjMV8o2iE4JfixAo
2026-08-27 21:02:12 -04:00
James Dansereau 978306d14f D73+D74: intro logo fixed (sinf/cosf endianness + texture import/sub-tile wrap)
D73 (root cause of old D72.3): GE's sinf/cosf build their double
constants through the du bit-union with big-endian word pairs {hi,lo};
on LE PC the .d read has the words swapped (rpi.d ~ 2^733), range
reduction overflows, and FTOFIX32 saturates to -32768 for every
sin/cos entry of guMtxF2L — so the Rareware logo's rotation matrix was
garbage and all its triangles projected off-screen. Fix: DVAL() macro
in src/libultra/gu/guint.h (PORT-only; #else branch is the original
expression) re-packing hi/lo into the correct LE double; sinf.c/cosf.c
.d reads replaced. N64 build untouched.

D74 (texture side, three port-layer bugs in fast3d):
- import_texture's fallback condition (rdp.tex_lod && tile >=
  first+detail) overwrote valid gDPLoadBlock tmem data with
  line*tile.height: letter mip chains 2744B -> 2048B (mips 1-5 dropped),
  D_02005FF0 32x32 -> 32x3 (stale tile.height from gsDPSetTileSize).
  Now falls back only when the tmem slot was never written.
- TextureCacheKey gains size_bytes so a truncated first import cannot
  poison later full imports of the same address.
- VBO UV path now wraps by TILE size for WRAP sub-tiles (N64 semantics);
  the logo's 20x3 tile at offset (11.5,29) was sampling row 0 instead
  of rows 29-31. Per-vertex pre-wrap; known limits: half-texel edge
  bleed, mirror sub-tiles unhandled.

Verified: clean runs reach frame ~2100 then hit the D69 load_bg_file
fault (unchanged — still the milestone blocker); PPM frame ~555 shows
the four gold RARE letters on the dark-blue plate (final pixel-perfect
check vs N64 footage left as a low-priority task). All env-gated probes
from this session (GE_DBGUV/DBGTRI/DBGTALL/DBGMAT/D74IMP/D74DUMP/
GE_DBGLOAD) stripped; previously committed TEMP D63 blocks + GE_D71LOG
remain on the HANDOFF strip list.

Docs: PCPortResearch.md §F D73/D74 entries, §G/§H status updated;
HANDOFF.md rewritten for Session I (D69 primary); AGENTS.md phase line.
2026-08-24 22:32:27 -04:00