GE's amMain feedback loop (src/audi.c:531) has a ~3 ms setpoint that PC
scheduling jitter clears, so the SDL queue starves and the device pads
playback with silence -> audio runs permanently ~2 % below real time.
- F5 (the fix): audioGetAiLengthBytes() subtracts a 1024-frame (~46 ms)
cushion before reporting, so the loop tops up while slack remains;
audi.c's control law untouched (rule #2 clean).
- F1: osAiGetLength reports AI_LEN_REG single-buffer semantics (bytes left
in the buffer currently playing), not full SDL queue depth.
- F3: Audio.QueueLimit 8192->2880 + non-silent drops on overflow.
- F4: oversize-block invariant guard.
- GE_D204=1 cheap audio-health monitor (rt/q/drop/max), safe to leave on;
GE_D204_OLD restores pre-fix behaviour for A/B measurement.
Measured A/B in one binary: rt 0.980 -> 1.000, queue min 0 -> 368,
drop 0. Findings: docs/dev/findings.md D204.
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.
Adds an env-gated (GE_DETERM=1, default OFF), test-only deterministic-clock
mode to port/src/libultra.c per the design in docs/dev/findings.md section D117:
osGetCount() returns a virtual clock advanced synchronously in osRecvMesg()
when __scMain (the sole consumer of the scheduler's retrace queue) asks for
the next message, instead of tracking real elapsed time. Goal: two runs of
the same build take the same simulation path, for exact frame-diff
regression testing and reproducible crash repro.
Four passes this session, each is a real, verified result, not a guess:
1. Fixed the predicted pre-first-frame deadlock (a poll-driven fallback
caused it in the first cut).
2. Replaced the poll-driven fallback with synchronous per-ask generation
in osRecvMesg -- fixed the deadlock cleanly, but two-run divergence was
still 33-38%.
3. Capped the pre-first-task tick backlog to a fixed constant -- divergence
dropped to 17-20%, still not the ~0% target.
4. Added GE_DETERM_TRACE=1 instrumented tracing and diffed two runs' clock
event logs directly. Proved the tick mechanism is now fully
deterministic (byte-identical clock values through the first real task).
Root-caused the actual remaining divergence: every synthesized retrace
increments the scheduler's frameCount, which gates a half-rate client
(the audio client) by parity -- since the pre-task spin count is still
real-time-scheduling-dependent, that parity isn't guaranteed to match
between runs.
GE_DETERM is NOT achieving its determinism goal yet and is not meant to be
used for anything today -- it ships here purely as a well-documented,
zero-risk-when-off checkpoint (verify.sh bunker1 PASS, worst_cell unchanged,
confirmed after every change this session) so the next attempt starts from
proof instead of re-deriving four sessions of dead ends. Full trace numbers,
exact line citations, and the mechanism: docs/dev/findings.md section D117
(four M-52 addenda).
Claude-Session: https://claude.ai/code/session_01P3WrUHAFQZUuhrHhiJgTUr
gbi.h's gDP* DL macros use _SHIFTL/_SHIFTR without defining them; game TUs
pull them in via <ultra64.h>. This is the first port/src TU to build a
display list, and on GCC/ld the missing macro became an undefined symbol
(MinGW's include chain happens to provide it). Define the two pure macros
locally, verbatim from PR/mbi.h -- including mbi.h itself drags in N64 OS
headers that shadow libc.
- Fix invisible text: drawText passed the measured text w/h into textRender's
width/height args, which are the on-screen CLIP rect (not the text size), so
every glyph failed the clip test. Pass the full 2D viewport like bondview2.c's
debug-text path.
- Mouse UI: hover-to-highlight; left-click in the value column toggles/cycles;
drag sliders; red X close box (clicking a row's label only focuses it, so
selecting can't flip a toggle). inputSuspendForOverlay() forces the OS cursor
free + visible while the overlay owns the mouse.
- New rows: Fullscreen (live) and Resolution (windowed presets filtered to the
desktop, live resize + recenter). SDL window ops are posted from the
scheduler thread and drained host-side in videoPumpEvents.
- Layout recomputed for the wide BankGothic font: right-aligned values, wider
row pitch, all 13 rows fit the ~240-unit 2D space; nothing clips.
Port-layer only. Links clean; runtime-verified on a real display (front end +
-level_09): layout, hover/click/drag, live VSync/filter/fullscreen/resolution.
sync_framerate_with_timer() runs inline on the scheduler thread
(osSpTaskStartGo -> gfx_run -> swap_buffers_begin), so a low frame cap
blocks VI-retrace delivery and drags every game thread down to the cap
rate rather than just dropping presented frames. There is no N64 analogue
(RSP/RDP/VI are separate silicon; the CPU never waits on them).
Clamp 0 < FpsCap < 30 to 0 (uncapped) with a warning, at both:
- videoInit, so a bad ge007.ini value (e.g. dinged to 10 via the F10
options overlay) is normalised and persists sane on next configSave();
- gfx_sdl_set_target_fps, the fast3d chokepoint, which also covers the
overlay's live set path.
Caps >= 30 are unchanged. Default (FpsCap = 0) is byte-identical to
golden (-level_09 framediff 3/3). The real fix — moving frame pacing off
the scheduler thread — is documented as owed in findings.md D186.
portHeartbeatCheck fired on any >3s frame gap and re-fired every 2s,
spamming ge007.crash.log with thread dumps during gdb pauses, level
loads, and SIGTERM teardown on a slow/software-GL setup. Widen to 8s and
stop after 3 reports. A genuine hang is permanent so it still gets caught.
- gfx_opengl_start_draw_to_framebuffer: fb_id 0 now binds GL framebuffer 0
(the window) instead of framebuffers[0].fbo, a generated attachment-less
FBO. Harmless on Windows (verified: -level_09 renders, mean RGB ~80);
strict GL (Mesa) treats the incomplete FBO as an error and drops draws.
- GE_PCDUMP / F12 screenshots move to a pre-swap hook (gfx_pre_swap_hook,
called from gfx_sdl_swap_buffers_begin before SDL_GL_SwapWindow). Reading
the back buffer AFTER the swap is undefined on buffer-exchange drivers
(Mesa/WSLg) -> black capture. Also: read GL_BACK explicitly, glFinish
first, GL_PACK_ALIGNMENT=1 (639*3 is not 4-aligned -> row shear), and log
any glReadPixels error.
Windows -level_09 verified: renders, no crash, capture non-black.
The N64 <stdlib.h> stub declares neither. MinGW's other CRT headers leak
them; a strict host GCC (Linux/macOS, -std=gnu11) does not, so getenv()'s
64-bit char* return is assumed int and truncated -> configGetFrameDump
(GE_PCDUMP path) dereferenced a low-32 pointer and segfaulted. Add K&R
decls: pc_protos.h (D38 mechanism -> game TUs via <ultra64.h>) and the
port/shim/stdlib.h non-Windows branch (-> port TUs that #include
<stdlib.h>). K&R form stays compatible with the real prototypes; malloc/
calloc/free are already covered elsewhere so not repeated.
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.
crashDumpThreads was defined only in the PLATFORM_WINDOWS block; the
kernel-heartbeat watchdog in port/src/libultra.c references it
unconditionally -> undefined reference at link on Linux. Add a
PLATFORM_LINUX implementation: log the live thread roster + the watchdog
thread's own backtrace (a full cross-thread stackwalk needs signal-based
peer unwinding -- deferred). Linux-only block; Windows unaffected.
The decomp's src/sched.h (N64 OSSc* scheduler) is on the include path and
shadows the system header. libstdc++'s <thread>/<mutex> gthr headers in the
fast3d C++ TUs #include <sched.h> and need the real one (cpu_set_t,
sched_yield) -- with -std=gnu++20 libstdc++ defines _GNU_SOURCE so
<pthread.h> also references cpu_set_t. New shim: C++ -> generated
hostsched.h (absolute path, hoststring pattern); C -> #include_next so
src/sched.h still wins as before. Windows build verified green (240/241).
- CMakeLists: CMAKE_C_EXTENSIONS/CXX_EXTENSIONS ON. Strict -std=c11/c++20
define __STRICT_ANSI__, which disables glibc _DEFAULT_SOURCE and hides
clock_gettime(CLOCK_REALTIME) etc. that port/src/libultra.c needs.
-std=gnu11/gnu++20 is GCC's default and what MinGW already used.
- port/src/{pccg,pcmodels,romdata}.c: local 'extern int snprintf(...,
unsigned long long maxsize, ...)' decls conflict with glibc's size_t one
(same bug already fixed in pc_protos.h in M-39); spell it size_t.
- libultra.c: drop the _GNU_SOURCE define (pulls cpu_set_t refs into
<pthread.h> without <sched.h>); the D60 POSIX stack-bounds diagnostic
now just prints n/a rather than needing pthread_getattr_np.
Windows: full rebuild 240/241, -level_09 crash-free past frame 1740.
- pc_protos.h: the vestigial 'long long time()' prototype conflicts with
glibc's time_t time(time_t*); no game code calls time(), guard it to
_WIN32 (MinGW shadows <time.h> with the N64 stub; POSIX gets the real one).
- n64stubs.c: don't redefine bcopy/bzero on POSIX -- glibc exports them
(via <strings.h>, size_t sizes); the local int-signature definitions
conflict. _WIN32-only now.
- libultra.c: #define _GNU_SOURCE on POSIX for clock_gettime(CLOCK_*) and
pthread_getattr_np; guard the D60 VirtualQuery committed-memory probe and
GetCurrentThreadStackLimits behind PLATFORM_WINDOWS with a
pthread_getattr_np-based stack-bounds fallback for the diagnostic path.
Windows build verified green.
SDL2's headers (SDL_stdinc.h, SDL_hidapi.h) and glibc's <bits/wchar2.h>
reference wchar_t. In C it is a library typedef owned by the compiler's
shadowed <stddef.h>, which our shim replaces with builtin-derived types
on Linux/macOS. Add wchar_t from __WCHAR_TYPE__, guarded by _WCHAR_T so a
real <stddef.h> reached another way stays a no-op. C-on-Linux/macOS
branch only; MinGW and C++ paths untouched.
Next GCC-on-Linux breaks after the stddef fix (linux-build CI):
- port/shim/stdarg.h routed to the decomp include/stdarg.h, which defines
va_list from __builtin_va_list but NOT the __gnuc_va_list alias that
glibc's <stdio.h> references — 'unknown type name __gnuc_va_list' across
every TU that pulls <stdio.h>. Add a Linux/macOS branch that provides
va_list / __gnuc_va_list / va_* straight from compiler builtins and does
not drag <ultra64.h> into system-header parsing. Windows branch unchanged.
- pc_protos.h declared snprintf's size argument as 'unsigned long long' —
correct on MinGW (LLP64) but not glibc, where size_t is 'unsigned long':
'conflicting types for snprintf'. Spell it size_t (now visible via the
stddef fix); identical to ull on MinGW.
Windows: both changes are no-ops on MinGW (stdarg Windows branch byte-identical;
size_t == unsigned long long).
The first cut routed C TUs to hoststddef.h unconditionally. That regresses
MinGW: its <stddef.h> defines `errno (*_errno())`, which collides with
PR/os.h's `u8 errno;` field. And hoststddef.h's bin/../include/stddef.h path
does not exist on Linux anyway.
Split three ways: C++ -> hoststddef.h (unchanged); C on Windows -> the empty
N64 stub (unchanged, pre-existing behaviour); C on Linux/macOS -> size_t /
ptrdiff_t / NULL / offsetof from __SIZE_TYPE__ / __PTRDIFF_TYPE__ /
__builtin_offsetof (always predefined, no header, no collision).
Verified: Windows ntsc-final builds 240/240, -level_09 boots crash-free.
Two GCC-on-Linux build breaks that MinGW masked (surfaced by the new
linux-build CI job):
- port/shim/stddef.h routed C TUs to the empty N64 include/stddef.h stub.
MinGW's CRT headers leak size_t/ptrdiff_t transitively; glibc/GCC define
them only in the compiler's own <stddef.h>, so include/PR/ultratypes.h's
non-N64 path ("typedef ptrdiff_t ssize_t") failed with "unknown type name".
Route C TUs to hoststddef.h too (C++ already was). Additive — the active
N64 stub typedefs nothing.
- CMakeLists: dram_syms.s absolute symbols at 0x70000000 need a non-PIE image
on Linux exactly as they need --disable-dynamicbase on Windows. Add an
elseif(UNIX AND NOT APPLE) branch: -fno-pie / -no-pie.
Windows unaffected (host <stddef.h> is strictly more correct than the stub;
the MINGW linker branch is unchanged).
The cart-base map at 0x10000000 (so absolute ROM-asset symbols are live host
pointers) was Windows-only; the #else branch was a stub that logged
"not implemented" and fell to the degraded heap-copy path.
- Factor the post-map work (memcpy + pcmodels/pccg sidecar load + D55 RLE
header fixup) into romdataFinishCartMap(), shared by both paths.
- POSIX #else: anonymous mmap at CART_BASE with MAP_FIXED_NOREPLACE where
available; on any failure munmap + fall through to the identical heap copy.
- romdataDestroy() munmaps on POSIX (length tracked in mappedLen).
Verified on Windows: builds 242/242, -level_09 boots crash-free (identical
behaviour with/without the refactor). Linux compile + runtime verification
owed — see docs/dev/LINUX-PORT.md. No behaviour change on Windows.
New port/src/optionsoverlay.c + port/include/optionsoverlay.h: an
immediate-mode options overlay drawn as its own fast3d 2D display list,
appended after the game DL. Implements docs/dev/OPTIONS-MENU-PLAN.md §2/§3.
- config.c/h: configForEachOption() + configSetOptionMeta() label/step/enum
side table; CONFIG_OPT_* enum. config.c stays free of specific-key knowledge.
- gfx_pc.cpp gfx_run(): one-line hook runs optionsOverlayEmit() after the game
DL, before gfx_flush. Returns NULL when closed -> zero bytes appended, golden
dumps byte-identical.
- video.c: F10 toggles (videoPumpEvents, next to F12); ESC closes; wheel
scrolls; videoRequestLiveConfig()/liveCfgDirty re-applies VSync/FpsCap/
TextureFilter at frame start.
- input.c: while open, controller 0 returns a neutral pad and nav
(arrows/enter/wheel/mouse) routes to optionsOverlayHandleInput() — mirrors
the D180 WI-1 swallow pattern.
- v1 rows: VSync, FpsCap, MSAA(restart), TextureFilter, MouseAimSpeed,
MouseTurnSpeed, MouseInvertY, MouseCaptureMode, ScreenShakeIntensity.
Live-apply where possible; configSave() on close.
- GE_OPTIONSOVERLAY=1 auto-opens at boot (env-gated diagnostic, left in).
Port-layer only, no src/ edits, no #ifdef PORT in game code. Build links
241/241. Runtime framediff/soak/eyeball owed on an interactive machine
(this headless env kills the GUI process early and writes 0-byte PPMs).
Family A of the M-36 triage (Surface "grey static" D176b, file-select
interlacing D182-2). The assigned hypothesis — import_texture_* shear when
line_size_bytes != full_image_line_size_bytes — is DISPROVEN for the Surface
repro:
- Only gfx_dp_load_tile produces a strided load; GE loads every texture via
gDPLoadBlock (tex.c texWriteLoadToTmem*), which sets line == full. Measured:
0/64 GE_DTEX + 0/166 GE_TEXDUMP imports strided on -level_36; 0 in the
front end either.
- Independent offline pitch scan (new GE_TEXRAW dump) shows the importers
pick exactly the pitch the source is stored at. The Surface cliff texture
is a 32x32 IA8 whose ROM bytes are genuinely a noise field — decoded
faithfully, tiled ~1 texel/px. D176(b) is likely a tiling-density / UV /
shift-LOD issue (RC3/D167 family) or a wrong-texture bind, not a decode
bug. Full evidence: findings.md D183. D176(b)/D182(2) stay OPEN.
- Re-confirmed D159's texSwapAltRowBytes no-op is correct (re-applying it
makes the dumps worse).
Shipped anyway (golden-safe, reversible):
- import_texture() compacts strided rows into a contiguous buffer before the
format dispatch, so every importer sees line == full — the behaviour the
seven SUPPORT_CHECK(full == line) asserts only assert (and assert compiles
out under NDEBUG). Provably inert when full == line (all current loads).
GE_TEXPITCH=0 restores the old flat read.
- GE_DTEX gains line=/full=/STRIDED markers; GE_TEXRAW=1 dumps raw
importer-input bytes for offline decode-vs-source analysis.
Verified: build 240/240; -level_09 framediff 3/3; GE_STARTMENU=10 briefing
crash-free, renders. Investigation: subagent (worktree).
Claude-Session: https://claude.ai/code/session_01GULf6knbsqfsnW39tMr9WT
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
Follow-on to ad748654 (D180). Playtested this session: ESC-to-free now
works; Dam -> Surface seamless on Agent.
- Dual SDL event pump race: port/src/video.c (host thread) and
port/fast3d/gfx_sdl2.cpp (render thread) both drain the same SDL queue,
so either loop can dequeue a given event. ESC-to-free / click-to-lock /
mouse wheel / focus events were being eaten by the render-thread pump
and lost. Now handled in BOTH pumps (inputReleaseCapture /
inputNotifyClick / inputPostWheel / inputSetMouseGrab).
- Menu pointer rewritten for capture mode. front.c's cursor integrator
caps at ~5.75 virtual px/poll, so the D165/D169 P-controller fed from
an absolute mouse position was always laggy/floaty and could not reach
the bottom of the grid. With MouseCaptureMode=1 the front end has a
real free OS cursor: write cursor_h_pos/v_pos directly from the
absolute mouse position and emit a zero stick. Legacy relative path
(MouseCaptureMode=0) unchanged, and its estimate clamp now tracks the
live front-end rect (was pinned to 320x240 -> right/bottom unreachable).
- OS cursor hidden while the window is focused (applyCursorVisibility);
shown again on focus loss.
- Window.Width/Height <= 0 now auto-fits a 4:3 window to ~85% of the
desktop (gfx_sdl2 gfx_sdl_init) instead of 640x480 native. video.c
passes 0 through; [Window] persistence still pins whatever you leave.
Verified: build-pc.sh ntsc-final green 240/240; -level_09 framediff
3/3 within threshold (nonclear d <= 0.01pp, phash = documented D117).
Claude-Session: https://claude.ai/code/session_01GULf6knbsqfsnW39tMr9WT
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
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
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
- 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.
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
- 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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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).