Port-layer only (no game-code edits):
- main.c seeds debug_enable_all_levels_flag + debug_007_unlock_flag
after configLoad() when Game.AllUnlocked is set (same class as the
existing GE_UNLOCK_ALL getenv hook).
- libultra.c eep shim patches the save block at read time (the exact
fileValidateSaves pattern: block 4, 5x96 bytes): sets all 20
progression-gated cheat-unlock bits per slot and fills every empty
stage/difficulty completion time with the max (0x3FF), preserving
existing player times, then recomputes each slot's CRC with the
game's own fileGenerateCRC so validation passes. The completion-time
half is required: with any SP cheat active, g_AppendCheatSinglePlayer
makes mission select accept only STAGESTATUS_COMPLETED levels, so an
unlock-only save locks every level once a cheat is turned on.
- optionsoverlay.c: 'All unlocked' toggle row (default ON for this cut).
Verified: fresh-eep GE_SAVELOG shows all 5 slots stored==calc; user
playtest confirms all levels at each difficulty, 007 mode, populated
cheat menu, cheats working, mission select intact with all cheats on.
The two user-facing config keys were registered but only reachable by
hand-editing ge007.ini. Add ROW_TOGGLE rows (below Screen shake):
- No hit flash (D232, community "no damage flash"; suppresses the
non-black fade paths in bondview2)
- Skip intro (D216; read at title-stage load, so it takes effect the
next time the title screen is reached)
Video.FixMipTextures / Video.WrapFix deliberately left out: opt-in
fidelity bug-fixes, not preferences.
Verified live (isolated CWD): both rows render in the F10 panel and
toggle; clean exit persists Game.NoHitFlash=1 + Game.SkipIntro=1 to
ini; relaunch with SkipIntro=1 logs "Game.SkipIntro: booting to
file-select" and skips straight to mission select. NoHitFlash's
in-game suppression rests on its prior D232 verification (needs
combat, not automatable).
STEAMOS detection in main() seeds video config defaults before configLoad;
first-launch-only by construction (an existing ge007.ini always wins). The
150% DrawDistance/LodDistance addresses authored N64 fade distances reading
short on the close-up Deck panel (e.g. the Dam lock fading in within sight)
while desktops keep the faithful 100% default.
romdataInit finds the ROM in CWD data/, exe-dir data/, or ./, but the
POSIX $S branch only ever produced a CWD-relative path, so any launch
from another directory ran fine while every EEPROM and ini write failed
silently (no ./data/ to fopen into) — saves never persisted and F10
settings reset on every launch. Mirror the Windows branch via
readlink(/proc/self/exe) and mkdir the target dir best-effort. Verified
on WSL with exe+data in /tmp and CWD=/: config, eeprom, and ROM all
land in <exedir>/data/.
The POSIX handler re-entered on SIGABRT (sysFatalError -> abort) and
truncated ge007.crash.log, so field crashes came back as bare abort
records with no faulting PC or registers. Guard against re-entry,
terminate directly with _exit(), and dump si_addr + the general register
set from the ucontext — a Deck crash now names the bad pointer on its
face.
Also documents D255 (new SIGSEGV in vtxstore's active-object walk during
a Facility firefight on the Deck; disassembly shows the fault is a
32-bit-zero-extended read of ObjectRecord.model, misnamed ChrRecord.
chrflags by the decompiler) and updates the release notes' Deck status.
Controller-only machines (Steam Deck) had no way to open or navigate the
F10 options overlay -- it was keyboard+mouse only. Now:
- Select (BACK) rising edge toggles the overlay, tracked across both the
open and closed poll paths (padSelectPrev) so a held button cannot
re-toggle on the transition. BACK is read nowhere else in the port.
- While open, D-pad or left stick up/down moves the selection, A/X step
forward (the Enter equivalent), B/Y step back, Start closes. The gamepad
bits are OR-ed into the existing keyboard edge logic, so repeat/clamp/
scroll behaviour is identical; input.c already swallows pad 0 while the
overlay is open, so nothing reaches the game.
- inputPadButton/inputPadAxis accessors expose raw SDL pad state to the
overlay (unaffected by our logic-layer swallow).
Smoke-tested headless with GE_OPTIONSOVERLAY=1 and no pad attached.
Two defects found in the local review of the unpushed v0.2.0-pre work:
1. input.c: padShoulderPrev was only updated outside menu mode, so a
shoulder held across the menu->game transition fired a spurious
weapon cycle on the first gameplay poll. Edge state is now tracked
every poll; emission stays suppressed in menus where A means select.
2. ci.yml release job: the notes template substitutes <version> with the
raw tag (v0.2.0-pre), but bundle-{linux,win}.sh strip the leading v
when building asset names -- the published notes would have linked
goldeneye-pc-port-v0.2.0-pre-* assets that don't exist. Substitute
the stripped form (NOTES_VERSION) instead.
Launch with -fresh (or --fresh) for a clean-slate run: removes
$S/ge007.eep (the file-backed EEPROM save, all five slots) and
$S/ge007.ini before configLoad / the eeprom's lazy load touch them.
The ini is re-written with defaults on exit as usual; the .eep only
reappears if a save is actually written. Verified in a scratch data
dir: both files removed at launch, ini regenerated as defaults.
Steam Deck target. LS move/strafe + RS look + RT fire + LT aim were
already in place (D194/D238 naturalPitchMode); this completes the
scheme with shoulder weapon cycling per the console re-releases:
RB rising edge = next weapon, LB rising edge = previous weapon.
Cycling rides the game's own A-line formulas (bondview2.c
weaponForwardOffset/weaponBackOffset): a fresh A edge cycles forward;
A+Z presented on the same tick cycles backward -- the identical trick
the mouse-wheel path uses (D223), which also guarantees no stray shot
(triggerOn is gated off while invButtons is held). Edges are emitted
for exactly one poll so holding a shoulder cannot latch A and block
firing; both are suppressed in menu mode where A means select.
A/X = action/use, B/Y = crouch/cancel (unchanged); LB no longer maps
to lean. README controls table + release notes updated.
Per the M-82/M-106 user decision: click-to-lock is now the only cursor
capture mode. Port-only deletion, no game-code touch:
port/src/input.c drop mouseCaptureMode + Input.MouseCaptureMode key;
simplify reconcileGrab/notifyClick/releaseCapture/
aimGepdCompute guards; delete the !haveAbs relative
P-controller menu path and its dead state (menuEst/Tgt,
menuCursorStep, MENU_P_GAIN/EST_DELTA/MOUSE_TO_PX);
drop the unused inputMouseCaptureActive() export
port/include/input.h update WI-1 comment, remove the dead declaration
port/src/optionsoverlay.c remove the 'Mouse capture' toggle row + labels
Existing ini files that set Input.MouseCaptureMode are ignored (config layer
skips unknown keys). Closes D192 (outer grid cells unreachable under mode 0)
and D239 (always-grab parity) by removing the bugged path rather than
patching it.
Known issue (logged in D251): intermittent ghost repeat of a top row at the
panel bottom in true 4K fullscreen; local repro clean, MSAA-off bisection
pending with the user.
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.
The cursor-anchored chase model swung the aim out to where the cursor
sat, which is not how GEPD feels. Replace it with the model the PD port
itself uses (mouseDX/DY + sensitivity): while RMB is held and the cursor
is free, per-poll cursor DELTA rotates the view by the same screen angle
the cursor swept (viewport width <-> full hFOV, height <-> fovy). The
crosshair stays locked at centre; stop moving and the game's own speed
decay coasts the view to rest where it is. Scope zooms compose for free
(angle-per-pixel scales with the live fovy). Dithered quantization keeps
fine sub-level movements smooth; no target, no chase, no snap-back.
Both axis signs were wrong (verified against bondview2.c plant code +
live GE_INPUTLOG trace): a right turn INCREASES vv_theta and looking
down DECREASES vv_verta. The anchor placed the frozen target at the
mirror image of the cursor point, so the controller steered through
the real point and ran away to the screen corner.
- anchor: tgtTheta = curT + offYawR, tgtVerta = curV - offPitchD
- stick map: errT>0 -> +stick (turn right), errV<0 -> +stick (look down)
- per-poll absaim log now includes cursor/ndc/fov for tuning
Injection test (RMB hold + 15 mouse steps): target tracks cursor,
error converges to deadband (0.7deg), stick->0, view holds.
Holding RMB (or the Aim bind, default Left Shift) in a stage now releases
the click-to-lock grab and drives the aim stick from a closed position
loop on the live camera (vv_theta/vv_verta read via player.h): the
crosshair chases the point under the free OS cursor and HOLDS it --
stationary cursor = stationary aim, even while walking. Release re-grabs
with no snap.
- Target derived from cursor NDC against the real render viewport rect
(viGetView*), portScaleFovY so scope zooms compose for free; re-anchors
on every cursor move or FOV change.
- Coast prediction (game's 0.05*mult/tick speed decay) prevents overshoot;
first-order dithering maps continuous demand onto the game's 10 discrete
stick levels for precision work (shoot-the-lock etc.).
- LMB fires while the cursor is out; ESC still releases capture; menus and
MouseCaptureMode=0 unchanged. New knob: Input.AimAbsolute (default 1).
Also: -I include shadows <math.h> with the game's constants-only header,
which left fabs/pow/tan in input.c on implicit declarations -- new
port/include/port_math.h pulls the real system math.h via #include_next.
* 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.
* 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.
D194(a): bondview2.c's aim-mode turn speed formula, (stick-60)/10 clamped
to 1.0, saturates at stick=70 -- the port's [61, 60+AimBand] mapping (up
to stick=80) was treating an effectively-capped range as proportional,
so a few px/poll already maxed out perceived sensitivity ("bang-bang").
Curve-map px/poll onto the game's real [61,70] band with a configurable
gamma (Input.MouseAimCurve, default 160) for a wider low-speed zone.
D238: added Input.MouseSensitivity as a master multiplier over both
MouseAimSpeed and MouseTurnSpeed/HipfirePitchSpeed, so one knob scales
overall feel; legacy per-axis knobs remain an independent trim.
D194/D238 vertical-vs-horizontal asymmetry: GE's default 1.1/HONEY
scheme only exposes hipfire pitch as a digital C-button, so the port
duty-cycled it (D166) to approximate continuous motion -- structurally
incapable of matching yaw's genuinely continuous analog mapping. Fix:
force GE's own 1.2/SOLITARE control style (cur_player_set_control_type,
an original player-selectable scheme, not new game logic) every poll,
which gives pitch the same continuous analog treatment as yaw
(canNaturalPitch/canNaturalTurn in bondviewProcessInput). Trade-off:
SOLITARE moves movement from the analog stick to digital step buttons,
so WASD forward/back now fires C-up/C-down instead of the stick (strafe
was already C-button-driven in both schemes, unchanged). Gamepad stick
roles swapped to match (left = digital-step movement, right =
continuous look) so the global scheme switch doesn't break pad play.
Escape hatch: Input.NaturalPitch=0 reverts to 1.1/HONEY + the D166
digital-pulse hack, same pattern as the existing MouseDtDecouple knob.
Verified: build green, tools_pc/verify.sh bunker1 PASS (worst_cell=0).
Needs a human A/B playtest for feel -- headlessly unverifiable.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
GE_TEXDUMP on Facility (-level_34) found ~14% of LOD-textured CI uploads
whose palette is IA16 (intensity-alpha, not RGBA16) rendering solid
near-white. A GE_D228 correlation probe ruled out the texpool/RC2-clip
hypothesis this row started with -- decoded size exactly matched the base
tile every time, so the data reaches import_texture fine.
The real bug: palette_to_rgba32's G_TT_IA16 branch read
intensity = palentry & 0xff (low byte), alpha = palentry >> 8 (high byte)
-- backwards relative to the already-correct direct-IA16 decode
(import_texture_ia16) a few lines above it in the same file, which reads
intensity from the first byte (high, after gfx_dp_load_tlut's PD_BE16
swap) and alpha from the second. A dim, fully-opaque palette entry (e.g.
intensity=0x58, alpha=0xff) decoded backwards as bright and
mostly-transparent -- the white-patch symptom.
Fix: swap the two lines to match the working convention.
User-verified live: AK47 and other metallic guns render correctly.
Headless -level_34 run crash-free pre-verification; a full sweep is
still owed (the user's own game instance was running concurrently this
session, so verify.sh/taskkill were avoided).
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
GE uses real N64 RSP environment-mapping (G_TEXTURE_GEN) for every
shiny/reflective material: gold/silver weapon skins, chrome trim, and
Control's reflective console glass. An earlier session (D72.1) removed
the PD-inherited envmap UV-generation block wholesale from gfx_sp_vertex
to fix the Rareware logo, reasoning "GE never generates texture
coordinates from vertex normals" -- true for the logo (lit, but doesn't
set G_TEXTURE_GEN) but false in general. Every envmap draw was left
sampling whatever raw tc[] happened to be authored (near 0), landing on
a fixed corner texel of the reflection texture regardless of view angle
-- reading as solid black for a typically dark-edged reflection map.
Restored the envmap UV generation (ported from the reference Perfect
Dark PC port's already-correct implementation), gated specifically on
rsp.geometry_mode & G_TEXTURE_GEN so D72.1's actual fix is untouched
(the logo never sets this bit) while every other envmap draw gets real
reflection coordinates back.
User-verified live: console glass and gold/silver guns are now visibly
reflective instead of flat black. The remaining "not translucent"
complaint on the glass is confirmed NOT a bug -- it was never see-through
on the original N64 either (reflective by design, not transparent).
Full 21-level sweep crash-free post-fix.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
texSelect loads the water image as ZLIB+CI8 (raw 8-bit palette indices);
GE's custom RSP ucode expands them to RGBA16 in TMEM at load time, so a
later RGBA/16b tile re-declaration over the same slot samples real blue
pixels. fast3d has no such expansion and read the raw index byte-pairs as
16-bit texels (g dominates -> green mottle); the "pulsing" was always the
pre-existing PRIM_LOD_FRAC sinf() cross-fade between two mis-decoded tiles.
Track each TMEM load's source G_IM_FMT in LoadedTexture.src_fmt; in
import_texture, an RGBA/16b tile over a CI-sourced slot now routes through
the CI8 palette import path instead. Fires only on the genuine format
disagreement, so real RGBA16 textures are untouched.
Verified live on Frigate (-level_26): water renders blue, user confirms
"looks better". Sweep + Surface 2 by-eye still owed.
Also files D245 (water seam), D246 (edge strips), D247 (black bars) from
the same playtest session.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
Live-tested against 4 real user playtests (not scripted) tonight, each
confirming purple/blue explosions still reproduce on current main:
1. G_LIGHTING hypothesis (previous session's lead) RULED OUT: extended the
D172 probe to flag lit particle draws uncapped (the original 40-hit cap,
sized for a short scripted repro, silently went dark after ~40 ordinary
bullet-impact particles well before any real explosion -- masking the
result, not confirming it). Fixed with an always-on periodic counter.
Result: 5000+ particle draws logged across a real playtest with
explosions triggered, 100% LIGHTING=off, 0 suspect hits.
2. Texture-cache-collision hypothesis RULED OUT: extended the same probe to
log the actual bound TEXEL1 address/GL-texture-id/byte-count. 7 distinct,
internally-consistent 448-byte (16x14 RGBA16) textures observed -- no
aliasing, no stale-slot signature.
3. ROOT CAUSE: used the existing GE_TEXDUMP feature (extended to exempt
16x14 RGBA images from its own 400-image cap, same masking problem as
above) to dump all 15 IMAGE_FIRE_0..14 particle textures as PPM during a
live playtest with confirmed-purple explosions. Every single one decodes
blue/purple-dominant; swapping the decoded R and B channels turns all 15
into a plausible warm fire palette, consistently. This is NOT a bug in
the shared RGBA16 decoder (port/fast3d/gfx_pc.cpp import_texture_rgba16)
-- that function is proven correct by every other RGBA16 texture in the
game rendering fine, and the general C-array byte-order normalization
already runs upstream of it. The wrongness is in the decoded bytes
themselves: either the Globalimagetable entries for these 15 image ids
resolve to the wrong ROM offset, or these assets use a sub-format the
shared decoder doesn't special-case. Do NOT blanket-swap R/B in the
shared decoder -- it would break every correctly-rendering RGBA16
texture in the game.
Also fixes a mistake made mid-session: a follow-up raw-byte-dump probe
(GE_TEXRAW) initially keyed its own cap-exemption on the wrong field
(gsDPSetTileSize's logical 56x56 wrap size instead of the real 16x14 pixel
content), so it never matched and burned a live-playtest cycle for nothing.
Fixed to key on the real signature: a 448-byte load (16*14*2, RGBA16).
Diagnostic infra left in tree, env-gated and inert by default:
GE_D172=1 (uncapped lit-case + periodic counter in gfx_sp_tri1),
GE_TEXDUMP=1 (16x14-exempt-from-cap fire dumper in gfx_opengl_upload_texture),
GE_TEXRAW=1 (fixed 448-byte-exempt raw dump in the texture import path).
* docs+probe(D219): new lead -- explosion.c never clears G_LIGHTING before particle draws
explosion.c's 15 particle DLs (assets/oddtextures.c globalDL_0x078..0x9a8,
all G_CC_INTERFERENCE+G_CC_MODULATEIA2) never get G_LIGHTING cleared by any
of explosion.c's own gSPClearGeometryMode calls. If lighting is left on from
a prior draw (e.g. a nearby lit character model), the port's vertex path
(gfx_pc.cpp:1301-1346) reinterprets the particle's authored RGBA tint as a
normal vector, turning SHADE into a scene-light color instead of red/orange
-- state-dependent, would explain "game-wide, live-play-only" exactly as
reported, and why the M-90 scripted repro (no nearby lit models) came back
clean.
Extends the existing D172 multitex-tri probe (GE_D172=1, already proven
reliable and uniquely keyed to this particle family) to also flag
geometry_mode & G_LIGHTING. Sanity-checked against the known-clean M-90
repro: still fires 40/40, all LIGHTING=off, matching the prior clean result
-- confirms the probe works without false-positiving.
Not a fix -- needs one live GE_D172=1 capture during a real explosion to
confirm or rule out before touching any rendering logic.
* fix: regenerate findings-index.csv via tools_pc/gen_findings_index.py
Hand-edited the CSV directly in the previous commit instead of running the
generator -- CI's "validate" job caught the drift.
gfx_sp_tri1's CPU-side trivial-reject AND-test trusted per-vertex outcodes
(x<-w / x>w / y<-w / y>w / z>w) that are only valid half-space tests when
w>0. A vertex that has crossed behind the camera plane (w<0 -- common when
the camera is close to a large room-structure polygon, e.g. walls/ceiling
trusses near a doorway) flips the comparison sense, so its outcode can come
out wrong. When that spurious bit happens to match the other two genuinely
correct vertices' bits, the AND-reduction fired and dropped the whole
triangle before it ever reached OpenGL -- even though GL's own homogeneous
clipper (which handles w<0 correctly) would have rendered the visible part
of it.
This reads exactly like D233 (indoor geometry intermittently vanishing near
doors while props/furniture keep drawing): large near-camera polygons are
far more likely to have a vertex cross behind the eye than small
self-contained prop meshes, and the backface-cull code six lines below
already special-cases this same w-sign hazard for its own cross-product
sign -- the trivial-reject block above it never got the same treatment.
Fix: skip the trivial-reject AND-test whenever any of the triangle's three
vertices has w<0, deferring to GL's clipper instead. This can only ever
send MORE triangles to the GPU than before, never fewer, so it cannot make
anything newly invisible.
Also investigated D234 (Dam guard towers, only the first renders) under
the same M-106 hypothesis that it shares a root cause with D233/D222 --
did not find fast3d-side evidence for it; left open in findings.md with
the ruled-out mechanisms and a next-step recommendation. D222 (FovScale
breaks culling) is a separate, already-characterised game-logic bug in
src/game/fr.c/bondview2.c, out of this fast3d-only fix's scope.
Build verified green (MSYS2 mingw64 cmake+ninja, full link succeeds).
Could not visually verify -- this worktree has no ROM/data and no
interactive display; see docs/dev/findings.md D233 entry for the exact
human verification steps (GE_PCDUMP on a doorway-heavy interior).
docs/dev/findings.md: D233 (FIXED, high confidence on mechanism/unverified
visually) + D234 (OPEN, not root-caused) entries added.
docs/porting-notes.md: generalised the w<0 trivial-reject hazard as a
recurring class for any future CPU-side "skip this triangle" heuristic.
docs/dev/findings-index.csv regenerated.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
Part (b) of D194 ("sensitivity correlates with movement/frame rate"):
input.c converted raw accumulated mouse px directly to stick counts,
treating "one inputComputePad(0) drain" as a fixed-size time unit even
though the real interval between drains drifts with render/scene load.
Same physical hand motion -> different emitted turn rate depending on
how ragged the poll cadence gets.
Fix: measure real elapsed time between drains with a monotonic clock
(SDL_GetPerformanceCounter) and rescale the accumulated px by
(1/60s / dtActual), clamped [0.25, 4.0], before MOUSE_TURN_GAIN/AIM_GAIN
apply -- so "px this poll" always means "px for a nominal 1/60s tick"
regardless of actual poll-to-poll timing. 1/60s matches D193's own
measured steady-state sim tick rate. Applied only to the aim-mode and
hipfire gameplay-look branches; menu-cursor paths (D165/D169's
poll-count-based P-controller) are untouched.
New Input.MouseDtDecouple config knob (default 1, escape hatch to the
prior frame-coupled behaviour) + a GE_INPUTLOG lookdt trace line.
Part (a) (aim-mode response curve) and the CONTROLLER_CONFIG_SOLITARE
controldef switch are deliberately NOT touched here -- both are design
decisions (D238 overlaps significantly with part (a)) that should get
the user's own interactive feel-testing rather than be baked in blind.
Build green (241/241), verify.sh bunker1 PASS (crash-free). Cannot be
feel-verified headlessly -- needs a human playtest (A/B against
Input.MouseDtDecouple=0).
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
* 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.
* fix(D223): directional mouse-wheel weapon cycling
Wheel-up keeps the existing 2-poll A pulse (weaponForwardOffset: fresh
A edge, Z released). Wheel-down now runs a 2-poll backward sequence:
A-only poll, then A+Z_TRIG -- the fresh-Z-edge-while-A-held combo that
weaponBackOffset requires (bondview2.c:5337-5340); ordering matters
because the edge is computed against the game's own previous poll.
moveData.triggerOn (bondview2.c:5419) requires A to be released, so
the synthesized Z edge can never fire a shot.
Roles are stable: the port forces CONTROLLER_CONFIG_HONEY
(bondview.c:1484), whose scheme gives invButtons=A_BUTTON /
shootButtons=Z_TRIG -- the same roles the pre-fix forward pulse
already relied on. While RMB aim is held the game flips controldef to
KISSY, under which neither direction cycles -- identical to the old
forward-only behavior in that state (pre-existing, not a regression).
Port-only; no src/ changes.
* docs(D223/D196): mark D223 FIXED (M-105); correct stale D196 status
D223 row now records the implemented fix and its known aim-mode
limitation. D196 was already fixed and merged (a17d5466, the
prescribed 1-line applyCursorVisibility() after reconcileGrab()) but
its status cell still led OPEN -- same stale-claim class the roadmap
flags; corrected. findings-index.csv regenerated.
* D223 follow-up: fix wheel-down backstep racing the real game tick
User playtest (M-105 cont.) found the backward-cycle combo unreliable:
PP7->AK47->PP7->wheel-down sometimes correctly landed on melee, but
sometimes landed back on AK47, and separately AK47->wheel-down sometimes
skipped past PP7 straight to melee.
Root cause: the wheel-down pulse staggered the synthetic 'hold A, tap Z'
combo across two polls (A alone, then A+Z one poll later). Whenever those
landed in two SEPARATE game ticks -- which happens unpredictably depending
on real-time poll/tick alignment (same D117-class nondeterminism as
everywhere else in the port) -- the 'A alone' tick was itself a fresh A
edge with no Z held, which bondview2.c's weaponForwardOffset formula (both
control-scheme sites) reads as a genuine cycle-FORWARD request, firing a
real, visible weapon change *before* the correcting backward tick ran.
Confirmed with an instrumented trace build: every wheel-down after the
very first one in a session mis-fired as a forward step.
Fix (port/src/input.c only, no game-logic touched): present A and Z
together from the first poll instead of staggering them, so whichever
single tick samples the 0->(A|Z) transition sees them rising
simultaneously. weaponForwardOffset requires Z NOT held, so that's
unambiguous -- only the backward path can fire, regardless of tick
timing. moveData.triggerOn (actual fire) is separately gated off while
A/invButtons is held, so this doesn't introduce an accidental shot.
User-verified: repeated up/down cycling through PP7/AK47/melee/Skorpion
in both directions with no more mismatches.
GE assembles weapon and character model display lists in scratch arena RAM
and reissues gDPLoadTLUT from a repeated source address with different
palette *content* between materials and frames. The CI TextureCacheKey
keyed only on {texel addr, palette_addrs[0/1], palette_index, size_bytes}
with no content check, so gfx_texture_cache_lookup() (a plain map.find)
could return a stale HIT whose GL texture was decoded against a different
palette. Proven with an env-gated probe on -level_34: the same
{addr, paladdr} pair re-imported many times per frame with materially
different rdp.palette content, all taking HITs.
Fix: FNV-1a hash of the 512-byte rdp.palette, recomputed only in
gfx_dp_load_tlut() (rare vs draws -> no per-texel or per-draw cost), added
as a trailing field of the CI cache key. Non-CI keys carry 0. Cannot
produce a worse decode than the stale HIT it replaces; worst case is a few
extra texture uploads per frame (~2 measured on -level_34).
Partial for D217: this removes a real stale-palette-HIT class (the D217
finding's own "test a palette-content hash" next-step) but does NOT resolve
the visible green PP7 grip. That has an upstream cause - the grip's count=1
model TLUT is fed different source bytes per level (Bunker 0x0001 -> black,
Facility 0x1A00 -> green) for the same tile; fast3d faithfully stores what
it is given. See scratchpad D217-report.md. Full 21-level world-CI
regression sweep still owed before merge.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
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
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.
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.
port/src/input.c: 12 gameplay actions (Forward/Back/StrafeLeft/StrafeRight/
TurnLeft/TurnRight/Fire/Aim/Action/Cancel/LeanLeft/Start), each bound to a
comma-separated list of up to 4 SDL scancode names via Input.Bind.* keys.
inputRebuildBinds() parses them in inputInit() after configLoad();
SDL_GetScancodeFromName resolves names, unknowns warn + skip. The hardcoded
keyDown(ks, SDL_SCANCODE_*) block in inputComputePad is replaced with
actHeld(ks, IA_*). Mouse buttons (fire/aim) stay hardwired.
File-driven, no UI -- matches the PD port. A fresh ge007.ini gets the full
[Bind] block with defaults visible.
Defaults reproduce the previous FPS layout exactly: all 20 default key names
round-trip to the identical SDL_Scancode the old code used (0 mismatches in a
SDL_GetScancodeFromName test). Build clean; verify.sh dam/facility/bunker1
PASS crash-free.
findings.md D214 + index; QOL-INVENTORY row marked done.
Three port-only knobs, each defaulting to byte-identical behaviour:
- Video.FovScale (D211): multiplicative half-angle scale on every perspective
projection matrix as it is loaded (gfx_apply_fov_scale, gfx_pc.cpp). Columns
0+1 scaled together so H/V FOV widen uniformly and aspect is preserved; ortho
(HUD/menu) matrices untouched. Composes with the game's per-frame aim-zoom
rewrites for free. Percent of vertical FOV, 50-150, default 100 => guarded
early-return (zero FP ops, golden dumps byte-identical). WIDESCREEN-FOV-PLAN
Phase 4, landed standalone.
- Video.Anisotropy (D212): expose fast3d's already-implemented GL anisotropy
hook as config (1-16). New gfx_set_anisotropy_level() extern-C wrapper clamps
to the GL max. Default 4 = the value fast3d already hardcoded => no delta.
- Video.DisplayFPS (D213): PD-parity top-right FPS readout, sampled once per
emitted frame (optionsoverlay.c fpsTick). Config-only; default 0 => emit
still returns NULL with the panel closed.
F10 overlay gains 'Anisotropic' and 'FOV scale %' rows (DisplayFPS is file-only
- the panel is at its 13-row layout limit, matching PD).
Build clean (ntsc-final); bunker1 framediff worst_cell 12.219 vs 12.221
baseline = render noise, no-op confirmed. Interactive eyeballs owed (see
docs/HANDOFF.md M-83).
findings.md: D211-D213 rows + index regen. WIDESCREEN-FOV-PLAN Phase 4 and
QOL-INVENTORY rows marked done.
ROOT CAUSE FOUND (GE_D172 probe, user Silo firefight capture): particle
draws (G_CC_INTERFERENCE = TEXEL0*TEXEL1) configure tile 0 = IA8 smoke @
tmem 0 and tile 1 = RGBA16 fire @ tmem 0x188, but gfx_lod_tile_offset()
returns 0 unconditionally on the !gfx_detail_textures_enabled path (a D107
blurry-ceiling fix), so texunit 1 samples the smoke texture too. smoke *
smoke -> the magenta/cyan blood/spark colour.
Probe evidence, 40/40 particle tris:
cfg1[tile=1 tmem=392 fmt=RGBA siz=16b] SAMPLED1=tile0(tmem=0 fmt=IA)
Fix: only fold texunit i to the base tile when LOD is actually active
(rdp.tex_lod). D107's mip case (tex_lod=1) is unchanged; genuine non-LOD
multitexture now gets tile i. bunker1 verify PASS (worst_cell 11.3, the
D107 repro level, no regression).
Needs an in-game eyeball: blood/sparks should read dark red, not magenta.
The 16-line budget filled during the Silo intro with LOD-bilerp tris
(TEXEL1 = mip level, T0==T1 is correct there). New probe: only fires when
the DL configured tile first+1 at a DISTINCT tmem (genuine 2nd material),
logs tex_lod + which tile each texunit actually SAMPLES vs what was
configured, flags TEXEL1==TEXEL0. Budget 40.
The old 'cyc2 combine at cycletype=1CYC' probe over-fired on trivial
passthrough cyc2 terms. Replace with one that fires only when a 2-cycle
triangle actually consumes TEXEL1 (the G_CC_INTERFERENCE particle case) and
dumps both texunits' tile/tmem/fmt/siz. One Silo capture then shows whether
TEXEL1 resolves to the fire tile (tmem 392, RGBA16) or something wrong.
Build clean; bunker1 verify PASS.
Real particle records (assets/oddtextures.c globalDL_0x078..) DO set
gsDPSetCycleType(G_CYC_2CYCLE); GE_D172=1 probe confirms fast3d applies it
(particle combine fc26a004 logged at cycletype=2CYC). Both the 0xB9-abort
and the cycle-type hypotheses are now dead. Bug is the G_CC_INTERFERENCE
two-tile combine / TEXEL1 path (IA8 smoke x RGBA16 fire @ tmem 0x188).
Probes (#ifdef PORT, GE_D172=1, inert otherwise):
- gfx_pc.cpp G_SETCOMBINE: distinct combine + active cycletype
- gfx_pc.cpp gfx_sp_tri1: tris with cyc2 combine drawn at cycletype!=2CYC
Needs a display capture to go further (headless can't see particle colour).
inputSuspendForOverlay() forces the OS cursor visible while the overlay is
open; on close nothing re-hid it unless reconcileGrab() happened to re-grab
(only true if the user had already clicked-to-lock in a stage). Opening the
overlay from a menu, or from a stage not yet click-locked, left the cursor
stuck visible over the game's own crosshair.
Fix: re-assert applyCursorVisibility() once per controller-0 poll, right
after reconcileGrab(). Unreachable while the overlay is open (early return
above), so it only fires once the overlay has closed and self-heals every
poll thereafter. Port-only, no render change; verify.sh bunker1 PASS.
Owed: 5-second visual check - open F10 from a menu, close it, cursor gone.
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.