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.
The $ORIGIN verification compared ldd's absolute resolved path against the
relative $OUT, so it always failed even though the bundled libSDL2 was the
copy being resolved (CI 34778363397 showed exactly that). Match both forms;
the remaining-deps listing now filters by bundle dir name for the same
reason.
User playtest report: bullet-impact sparks and lingering smoke/explosion
residue drift through the full hue wheel over time instead of holding their
intended grey/orange palette. Logged in findings §F (D252) with this
session's mechanism work: particle colour comes entirely from the 15
distinct IMAGE_SMOKE_N x IMAGE_FIRE_N (RGBA16) pairs the particles age
through (assets/oddtextures.c records, white default vertex); the M-82/D172
tile-offset fix and the 5551 decode are both verified correct for this path;
prime suspect is import_texture_rgba16 deriving width/height from the tile
line size (32x7 instead of 16x14 for the 16px-wide fire tiles), which would
scramble each aging stage into a different wrong hue -- exactly the observed
slow rainbow sweep. Per user instruction: documented only, ships as a known
issue (README + release notes + GRAPHICS-BACKLOG); index csv regenerated.
The in-game GEPD-style aim mode (D194/M-122) is modelled on the Mouse
Injector plugin for 1964 -- credit its author Carnivorous and the 1964
developers Rice & schibo in the README Credits section. Note the port's
implementation is an independent reimplementation of the behaviour, not
derived code.
bundle-linux.sh now copies the exact libSDL2 the exe was linked against next
to it and sets the rpath to $ORIGIN via patchelf, with an ldd assertion that
the bundled copy is the one resolved. zlib/libGL remain system deps
(preinstalled on desktop distros and SteamOS). CI installs patchelf; the
generated in-bundle README gains a Steam Deck sideload note and the SDL2
zlib-license copyright file is carried when the distro ships one.
Also refreshes the release-facing docs that ship with / describe the cut:
- .github/release-notes.md: v0.1.0-alpha body -> <version> pre-release body
(what's new + known issues incl. D243/D230/D245/D246/D197/D75, the D251
4K ghost row, and 'not yet verified on real Deck hardware')
- tools_pc/dist/README.md.in: alpha claims (no audio / black sky / US-only)
-> pre-release status; ROM table now lists all three regions (the asset
step auto-detects by SHA-1)
- Status: 'alpha' -> 'v0.2.0 (pre-release)'; working list now reflects the
60 fps fix (D248), click-to-lock mouse + GEPD aim mode (D194 closed), sky
(D227) and water green-pulse (D229) fixes, F10 overlay, mute/screenshot QoL;
known issues updated (cutscenes D243/D160/D173, music D230, front-end models
D75, water seam D245, edge strips D246).
- Download: leads with the two v0.2.0-pre bundles (names match bundle-*.sh
output, which strips the leading v), notes SDL2 is bundled in the Linux
tarball so it runs as-is on any distro or a sideloaded Steam Deck.
- New 'Steam Deck' subsection: sideload steps, CPU-bound performance note,
D203 status (suspect fixed, unverified on real hardware).
- Quick start + comparison table + Linux build section updated to match.
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.
RMB aim mode rewritten as a direct mirror of GEPD's mouse aiming:
crosshair-position writes with the game's own damping left running,
gun pose derived from crosshair position (no stick synthesis), camera
edge-scroll at screen edges. vv_theta [0,360) wrap dropped for GEPD
parity. User playtest: spazz accepted (95%), sensitivity calibrated
(hipfire matched to aim mode; MouseSensitivity master now scales both).
Defaults ~25% above the user-approved match point. Input.GepdSens
renamed Input.AimModeSens (old key kept as deprecated alias).
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.
Build flags confirmed as -O2 RelWithDebInfo by default, not the cause.
GE_D193 idle-load probe on bunker1 and streets both hold a healthy
55-60fps in this dev box, well under the 16.7ms/60fps budget -- does
not reproduce the user's reported ~45fps at 720p on a high-end PC, so
the real cost is likely gameplay-load-dependent (AI/combat/particles)
or resolution/GPU-fill-rate-bound in a way idle testing doesn't
exercise. This session had no interactive input tooling to drive real
gameplay headlessly.
Ruled out in passing: the D51 vi-post debug log is throttled to
~once/sec, not a per-frame cost.
Next step needs a human: capture GE_D193=1 output during a live
session at the moment fps visibly tanks, to tell sim-side cost from
render/GL-side cost.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
User A/B playtest of PR #62 (fix/d194-mouse-dt-decouple, rebased onto
main this session) confirms hipfire/primary mouse-look feels right now
(dt-decouple + SOLITAIRE natural-pitch), but RMB/Shift aim-mode is
still too sensitive and not usable for precision aiming.
Static re-read of the aimHeld branch in port/src/input.c finds a likely
cause the M-107 curve fix didn't reach: AIM_STICK_MIN=61 is a hard
floor the stick snaps to on any motion past a 0.3px/poll threshold --
the aimCurveGamma response curve only shapes the range above that
floor, so tiny mouse nudges still jump straight to 10% of max aim-turn
speed instead of ramping from zero, unlike the (working) hipfire path.
Not build/runtime-verified -- a hypothesis, not a confirmed fix.
PR #62 stays held, unmerged, pending another aim-mode iteration.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
M-120: dispatched a local-Qwen triage pass on D230 (music quality) and a
Claude subagent on D243 (Dam cutscene race), per the roadmap triage.
D230: the dispatched hypothesis (afFixupBook's BE->LE book-coefficient
conversion in port/src/romdata.c is backwards) does not survive review —
it's the same conversion pattern used for every other field in the same
function, and if it were wrong every ADPCM sound would be corrupted, not
one bass instrument on three tracks. Ruled out; the standing M-108 leads
(predictor-count truncation / order mismatch in port/src/mixer.c) remain
the live hypothesis.
D243: the subagent wrote the planned GE_D243 env-gated MENU_* transition
trace (src/game/front.c only, statically reviewed sound) but could not
build-verify it — hit a reproducible CMake/ninja-vs-bare-gcc environment
failure ("Cannot create temporary file in C:\Windows\"). Independently
reproduced the same failure in a fresh worktree this session. Diagnostic
is pushed on investigate/d243-cutscene-race (2a47f110), not merged —
needs a session with a working toolchain to build, run the -level_33
repro, and correlate against the D146 burst.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
Findings-index regen (tools_pc/gen_findings_index.py) — only D194's row
had actually drifted (fix direction implemented since, on PR #62, not
"not implemented" as the cell said). GRAPHICS-BACKLOG.md's D233/D234
rows were also stale ("not investigated") despite both being fixed and
verified since M-107/M-108 per findings.md.
New findings from user QA (M-119):
- D249: LOD/culling breaks on far geometry on big open levels (Streets,
Egyptian) at default FOV; distinct from D222/D218. Also a QoL ask for
a togglable LOD improvement, N64-accurate LOD kept selectable.
- D250: FPS now drops to 20-40 intermittently on many levels, a likely
direct consequence of D248 (M-118) exposing real per-frame cost that
was invisible while the port silently ran at half rate.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
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
osScAddClient() stashes a per-client "every retrace vs every other
retrace" flag in the next array slot's .next field (c[1].next = next);
__scHandleRetrace read it back via a hardcoded 8-byte offset that only
lands on the right memory when OSScClient is 8 bytes (32-bit
pointers). On this 64-bit port the struct is 16 bytes, so the read
landed inside the gfx client's own msgQ pointer (always non-NULL)
instead, silently collapsing the gfx client onto the audio client's
30Hz path even though the VI-retrace pacemaker itself was always
correctly ticking at 60Hz.
Fix indexes the struct (client[1].next) instead of hand-rolling the
byte offset, so it scales with pointer width on either platform --
same class as D122/D126/D132/D209, just in the scheduler instead of a
ROM-serialized struct. Verified via the Video.DisplayFPS overlay:
30 FPS pre-fix, 60 FPS post-fix, same build/scene (-level_09).
-level_09/-level_20 both crash-free post-fix.
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
User report on Frigate post-fix: water still glitches between two patterns
and still changes with view angle, unlike the sky (which uses the same
skyPortBeginFan/skyPortEmitTileShift mechanism post-D227 and no longer has
this problem). The mechanistic writeup and GE_D227V trace from the same
session looked correct but were evidently not sufficient evidence -- a
single static frame/angle capture never exercised whatever actually causes
the live symptom. Reopening honestly rather than leaving a stale FIXED
label; the superseded analysis is kept in the row for reference but flagged
as unconfirmed. No code change in this commit (docs only).
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
Display pipeline pass on the two remaining M-116 QA reports.
D247 (black bars top/bottom): traced to bondviewGetCurrentPlayerViewportHeight()
returning VIEWPORT_HEIGHT_DEFAULT (220 of 240 NTSC scanlines, fr.h) for the
decomp's own default SCREEN_SIZE_FULLSCREEN option -- unmodified ground-truth
code, no #ifdef PORT anywhere near it. Verified against both the existing
save and a fresh EEPROM (rules out a stale Screen=Wide/Cinema setting from
earlier sessions) -- same inset either way. This is GoldenEye's real
TV-safe-area letterbox, present on original hardware. No code change.
D246 (left/right edge strips): GE_PCDUMP captures of the native render show
clean edge columns, ruling out an RDP/game-DL scissor bug and the D227/D245
tc-overflow class -- the defect must be introduced later, in the SDL window
presentation path GE_PCDUMP bypasses (MSAA resolve blit or a live-window
scale remainder). Still needs the user's window size/MSAA setting to chase
further; documented as a narrowed OPEN with concrete next steps.
New porting-notes.md entry: check the decomp's own constants before
assuming a rendering "defect" is a port bug.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
The water quad's fan was the one skyPortBeginFan() caller passing
allowShift=FALSE, on the theory that a matched tc-shift on both texunits
would flatten the sinf()-driven cross-fade the effect depends on. That
theory was wrong: skyPortEmitTileShift() replays both tiles' SETTILE
commands with the identical k, rescaling both texunits' decoded S/T by
the same factor before each tile's own (unshifted) uls/ult offset is
applied by the RDP -- the offset between tile 0 and tile 1's sample
points is preserved, not flattened.
Confirmed with the GE_D227V probe on Frigate (-level_26): a water fan
vertex spans S=-166..299317, T=-6451..288410 in S10.5 units (same order
of magnitude as D227's pre-fix sky overflow), now correctly getting
kS=4 kT=4 instead of being forced to 0.
verify.sh frigate and "surface 2" (the two IsWater levels) both PASS
crash-free. Live playtest of the mouse-tracking seam itself still owed.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
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
M-115: re-apply M-113's store-time bswap scoped to the 16-bit GBI-size
family only (PORT_PIXEL16 = bswap16 under #ifdef PORT; PORT_PIXEL32 stays
identity). The fast3d importers disagree on pool byte order:
import_texture_rgba16/_ia16 read manually big-endian bytes, while
import_texture_rgba32 does PD_BE32() on a native load -- M-113's blanket
swap hit both and regressed muzzle flash/ammo HUD (M-114).
Format is the structural discriminator, not compmethod (fire frames span
LOOKUP/RLE/HUFFMANBLUR/RLELOOKUP) or texnum range. Offline census of all
2698 ROM images (scratch/d219_headers.py / d219_census.txt): 94 non-zlib
wide-pixel; the 16-bit family is IMAGE_FIRE_0..14 + texnum 1198-1201/2430/
2510-2523, every ammo/flare/crosshair/muzzle-flash image is RGBA32.
Runtime-verified on Dam (scripted fire): all 15 frames decode format=1,
pool dumps warm R-dominant (mean opaque RGB 255,238,139). RGBA32 pool
bytes bit-identical to the M-114-revert build. User playtest confirmed;
D219 closed. GE_D219RAW probes generalized (no texnum filter), inert.
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).
Previously: self-hosted for push/workflow_dispatch, windows-latest for every
pull_request (including the user's own same-repo branches). Per standing
preference (2026-09-12): route all Windows builds to the self-hosted box for
now, except when cutting a release (tag push) -- that stays on a clean
GitHub-hosted runner for the artifact that actually ships. Fork PRs also
stay on windows-latest regardless (untrusted code must never reach a box
that will later hold a ROM, docs/dev/notes/SELFHOSTED-RUNNER-PLAN.md §G2).
Adds a `windows_selfhosted` output on the `changes` job (computed once) and
threads it through windows-build's runs-on, the msys2/setup-msys2
release/update/install flags, and the self-hosted dependency check --
replacing every prior `github.event_name == 'pull_request'` check.
* 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.
Those two PRs edited the CSV by hand instead of running
tools_pc/gen_findings_index.py. Both were docs-only, so CI's "validate" job
(which only runs when non-doc files also change) never caught the drift.
D219 (purple/blue explosions): user confirmed 2026-09-12 this is not
Dam-exclusive -- game-wide -- and wants an active fix, not further triage.
Static/scripted analysis (M-89/M-90) is exhausted; next step is a live
GE_PCDUMP capture during a real explosion.
D234 (Dam guard towers): already marked VISUALLY VERIFIED FIXED in both the
findings.md row and findings-index.csv -- no change needed, just confirming
consistency per the user's note that this is resolved.
D230 (bass-instrument audio corruption): narrowed suspect to port/src/mixer.c's
hardcoded order=2/npredictors<=8 ADPCM decoder assumptions; not yet confirmed
against real ROM book data.
D235 (wrong textures after death/restart): the leading "CI-cache survives
restart" theory is likely refuted -- lvlStageLoad (the only call site for both
set_mt_tex_alloc and proplvreset2) runs on every stage transition including
restarts, so the cache reset isn't actually skipped. Real mechanism still
unknown.
D243 (Dam end-cutscene D146 corruption burst): corrects the prior write-up's
timing claim by re-reading the same session's raw logs at the frame-counter
level -- the corruption fires ~1600-3900 frames after the camera-cut, not
during it, in both runs (not just one). This also weakens the D134/pendingGfx
race citation, since osSpTaskStartGo runs gfx_run() synchronously. New lead:
the mission-complete/briefing screen chain, not the cutscene render itself.
Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3
Live GE_D160=1 traces on Dam plus a fresh-save playthrough during this
session's interactive playtest:
- D160: corrects its old "cutscene never plays" premise (refuted by a
live trace showing the AI trigger fires and all 3 camera_switch calls
execute). Real defect split out to new D243: a stale/freed
display-list-memory read (D146 guard burst, freed-memory byte pattern)
during the cutscene's camera-angle cuts, evidenced by non-deterministic
call ordering between two runs. User confirmed the glitch reproduces
on real fresh-save play, not just the debug-load harness.
- D233: visually confirmed fixed (Frigate -level_26, corrected level id).
- D234: visually confirmed fixed; mechanism still unexplained (the M-107
investigation argued D233's fix shouldn't cover this symptom).
- D219: REOPENED. Live Dam PPK firefight still shows purple/blue
explosions, contradicting M-90's clean scripted-repro verification —
the scripted GE_INPUTSCRIPT repro likely didn't exercise the same
explosion DL live play hits. User's impression is this is game-wide,
not Dam-specific; needs a second-level confirmation.
No src/ or port/ changes. findings-index.csv regenerated via
tools_pc/gen_findings_index.py.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
* ci: route windows-build to self-hosted runner (G2); land ROM-gated sweep workflow (G1)
G2: windows-build now runs on the self-hosted box (corpyvt6agent01) for
push/workflow_dispatch, using local ccache instead of the actions/cache
round-trip and skipping msys2/setup-msys2's install once packages are
already present. pull_request always stays on windows-latest — fork PRs
must never reach a box that will hold a ROM.
G1: adds .github/workflows/selfhosted.yml, a dispatch-only workflow that
builds + runs the 21-level headless sweep (tools_pc/verify.sh sweep --json)
against a runner-cached ROM. Inert until manually dispatched.
Still pending (manual, physical steps on the box — not done by this
commit, see docs/dev/notes/SELFHOSTED-RUNNER-PLAN.md rollout table):
- register corpyvt6agent01 as a runner against this repo
- confirm MSYS2 at C:\msys64 with the required mingw64 package set
- one-time ROM seed into {runner_home}/_rom-seed/
- first workflow_dispatch sweep + parity check against a local run
- flip G1 from workflow_dispatch to push:[main] once green
Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU
* fix(selfhosted.yml): remove invalid concurrency.max-parallel key
Found by testing: dispatching the workflow failed with a hard parse
error (HTTP 422, "Unexpected value 'max-parallel'"). GitHub Actions'
workflow-level `concurrency` block only supports `group` and
`cancel-in-progress` — `max-parallel` is a job `strategy:` key for
matrix jobs, not a concurrency-block key. Serialization was already
achieved via group + cancel-in-progress:false; the stray key just
blocked every dispatch.
Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU
* fix(selfhosted.yml): register the msys2 shell wrapper (setup-msys2 step)
Found by testing: the first fixed dispatch failed every msys2-shell step
with "msys2: command not found". Unlike ci.yml's windows-build, this
workflow used `shell: msys2 {0}` without ever running msys2/setup-msys2,
which is what registers that shell alias for the runner — MSYS2 being
pre-installed on the box doesn't help if Actions doesn't know the shell
exists. Adds the same setup-msys2 step ci.yml uses, with update:false
since the packages are expected already present (fast no-op per plan).
Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU
* ci: stop reinstalling MSYS2 per run on the self-hosted box
The self-hosted paths (selfhosted.yml; ci.yml windows-build's push/
dispatch route) no longer call msys2/setup-msys2 or pacman -S at all.
MSYS2 + the mingw64 package set are now assumed hand-maintained on
corpyvt6agent01 (docs/dev/notes/SELFHOSTED-RUNNER-PLAN.md), invoked by
calling its bash directly (`C:\msys64\usr\bin\bash.exe -leo pipefail
"{0}"`, the same invocation setup-msys2's generated wrapper uses) with
MSYSTEM=MINGW64 so /etc/profile still puts mingw64/bin on PATH.
Adds tools_pc/check-selfhosted-deps.sh: a fast, install-free check that
exits 0 immediately when the required packages (toolchain, cmake,
SDL2, zlib, python, ccache, zip, unzip) are present, or fails with the
exact `pacman -S --needed ...` line to run otherwise — so a missing
package on the box surfaces immediately with a clear fix, instead of
as a confusing failure deeper in the build.
ci.yml's windows-latest (PR) path is unchanged: ephemeral runners still
get a full msys2/setup-msys2 install every time, since there's nothing
to persist there.
Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU
* fix: use msys2/setup-msys2 release:false instead of a raw bash-path shell
The previous attempt (invoking C:\msys64\usr\bin\bash.exe directly as a
custom `shell:` command) failed on the actual runner with "Second path
fragment must not be a drive or UNC name" — a real limitation, not a
typo; it failed identically in both files, including the one with a
static (non-templated) shell string, so it wasn't the ${{ }} expression
either.
Root-caused by reading msys2/setup-msys2's actual source: `release:
false` makes it reuse the existing C:\msys64 as-is (hardcoded path in
that branch, confirmed in main.js) instead of downloading+installing a
fresh copy, and — verified in the same source — omitting `update` and
`install` skips every pacman call entirely (just one `uname -a` + a
pacman.conf sed tweak, no network). This is the officially documented
mechanism for self-hosted runners with a pre-existing MSYS2 install
(see the action's README, "release" section).
ci.yml's windows-build now runs setup-msys2 unconditionally but with
release/update/install all keyed off github.event_name: full install on
windows-latest (PR, ephemeral), reuse-only on the self-hosted box
(push/dispatch). selfhosted.yml always uses release:false. Both then
run tools_pc/check-selfhosted-deps.sh to verify the box's hand-
maintained packages are actually present.
Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU
* fix(selfhosted.yml): cp -r for the ROM sidecar directories
Found by testing with the ROM seed actually in place: pcmodels-ntsc-final
and pccg-ntsc-final are directories (not single files, unlike
ge007.ntsc-final.z64), so a plain `cp` failed with "omitting directory"
and left the seed step failing on every run even with the seed populated.
Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU
* ci: drive MSYS2 MINGW64 directly via ci-shell.cmd, drop msys2 shell shim
The self-hosted runs were dying with 'msys2: command not found' because the
msys2/setup-msys2 action's shell shim was never registered on the box. Replace
the dependency on that shim with a small committed launcher (tools_pc/ci-shell.cmd)
that headlessly reproduces the local 'MSYS2 MINGW64' terminal:
- MSYSTEM=MINGW64 + /mingw64/bin on PATH
- NON-login bash, so it keeps the workspace cwd (a login shell cd's to $HOME
and would break every relative path in the workflow)
- -e -o pipefail for GitHub-style error propagation
Both workflows now set defaults.run.shell to C:\msys64\ci-shell.cmd {0}; a
bootstrap step (shell: cmd) refreshes the launcher from the repo into C:\msys64
each run. selfhosted.yml drops setup-msys2 entirely; ci.yml keeps it only for
the ephemeral windows-latest PR path (which installs MSYS2 at C:\msys64).
.gitattributes: force CRLF for *.cmd so cmd.exe parses the launcher correctly.
* Revert "ci: drive MSYS2 MINGW64 directly via ci-shell.cmd, drop msys2 shell shim"
This reverts commit a976b2589c.
* docs(D244): log self-hosted-runner link failure — undefined reference to objectiveGetStatus_WEAK
First real build attempt on the reinstalled corpyvt6agent01 box (run
34707798707) fails at link with an unresolved #pragma weak alias. A local
repro of the identical alias pattern links fine on current MSYS2
(gcc 16.2.0/binutils 2.47); the box reports gcc 16.1.0 — likely a stale
toolchain, consistent with rollout step 2's earlier package-hygiene gap.
Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3
* fix(verify.sh): surface run.log tail on NO-FRAMES instead of silently discarding it
sweep mode's per-level run.log (the game process's own stdout/stderr, where
an SDL/GL init failure would show up) was captured into a mktemp capdir but
unconditionally rm -rf'd with no diagnostic ever printed for the NO-FRAMES
(no-crash) case -- every level failed this way on the self-hosted runner's
first real sweep attempt with zero clue why.
Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3
* fix(selfhosted.yml): stop deleting the ROM seed dir -- cache save silently fails on this box
actions/cache/save has been failing to persist the ROM cache entry on
corpyvt6agent01 (WARNING annotation, tar.exe exit code 2) but doesn't fail
the step -- by design, a cache-save failure is non-fatal, so the workflow
carried on and the now-unconditional "Delete one-time seed dir" step wiped
the only copy of the ROM every single run. Every dispatch since has failed
at "Seed ROM cache" with an empty seed dir and an empty cache -- exactly
the user-reported "it wipes out the ROM data each run".
Fix: don't delete the seed dir. It's cheap disk (three files, a few hundred
MB) and the existing restore-then-seed-if-missing logic already treats it as
the fallback source whenever the cache comes back empty, so leaving it in
place is safe and makes the workflow tolerant of the cache-save bug until
that's root-caused separately.
Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3
* fix(verify.sh): sweep --json never actually produced a verdict array; fix NO-FRAMES diagnostic clipping it further
Two bugs, found together while chasing the self-hosted runner's first real
sweep attempt:
1. sweep mode's `out=$(verify_level ...); echo "$out" | head -1` always
discarded emit_verdict's second (--json) output line -- RESULTS_JSON was
declared for assembling a verdict array but never populated or printed,
so `sweep --json` has never produced anything but `[]` for the
workflow's "Extract JSON verdict" step. Now builds and prints a real
`[{...}, {...}]` array before the RC exit.
2. My own D244-session addition (printing a NO-FRAMES level's run.log tail)
wrote to stdout, which the same `out=$(...)/head -1` capture turned into
the *new* first line, clipping the real verdict summary. Moved to stderr,
which bypasses that capture.
Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3
* docs: regenerate findings-index.csv for D244
Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3
Investigation pass on the cutscene-reliability cluster (D148/D160/D173),
part of the 1.0 acceptance-bar #4 push. No build/runtime toolchain was
available in this worktree (no gcc/cmake on PATH, MSYS2 MINGW64 not
active), so this is a static-analysis-only pass; no code changes.
D160 (Dam end-cutscene / D148 label): independently re-derived (not
just re-cited) that hypothesis (a), a propDef sizepropdef() stride
desync, is ruled out — CutsceneRecord (propDef type 46) is all-scalar,
28B, zero pointer/union fields, so the D122/D126/D132/D209 pointer-
width-growth class cannot apply. Confirms the existing M-31 audit
recorded in porting-notes.md. Hypotheses (b)/(c) (AI trigger flow /
invisible puppet render) remain unconfirmed — still need the shipped
GE_D160=1 trace driven live.
D173 (third-person Bond puppet floats / spins in cutscenes): checked
the two most D209-shaped candidates for a puppet Y-position aliasing
bug and ruled out both. (1) PadRecord.stan is a ROM-serialized pointer
field zeroed by tools_pc/d88_emit.py's emit_pad() with a "dead-on-load"
comment; verified against the N64 source asset tables (UsetupdamZ.c)
that every PadRecord literal initializes stan to 0 on N64 too, so the
PC zeroing is byte-identical to ground truth, not a converter bug.
(2) The anim-root-motion Y-accumulation chain was already exhaustively
audited during D193/D209 (M-80/M-81) and the padding[]-union sweep
that found D209/D210 explicitly cleared act_anim/act_dead as
layout-stable. Root cause not found; the D209-style struct-aliasing
pattern does not appear to be the shared explanation here, unlike the
hope stated in the dispatch brief. Flagged next steps: a live Cradle
capture, and locating the actual two-actor Cuba end-credits code path
(not found this session).
Added a generalizable porting-notes.md quirk: before assuming a zeroed
ROM-serialized pointer-width tail slot is a converter miss, check the
N64 source literal — some fields are legitimately always NULL on both
platforms.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
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
* docs(M-106): log user QA report as D233-D239 (delegate triage)
7 new open findings from local-delegate triage of a fresh user QA pass:
- D233: indoor geometry vanishes near doors while world objects still draw
(portal/frustum-cull suspect; screenshots committed, D106/D222 neighbours)
- D234: Dam guard towers invisible except the first (instance suspect)
- D235: Facility wrong textures, reported as a regression (bisect vs
pre-D217-fix build first)
- D236: Surface 1 billboard trees render as a texture-sheet wall, not
discrete camera-facing sprites
- D237: F10 overlay QoL — wheel scroll, more exposed settings incl.
Game.NoHitFlash, category grouping
- D238: mouse sensitivity rework — separate RMB aim sensitivity, single
linked master by default, fix up/down vs left/right asymmetry
- D239: click-to-lock is already default; drop always-grab entirely if
parity isn't cheap (re-confirms M-82, closes D192 by deletion)
findings-index.csv regenerated via tools_pc/gen_findings_index.py.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
* docs(M-106): file D230 — in-level music quality (bass instrument corrupted)
Repro obtained from the user, deterministic (not intermittent): Control
(elevator + regular track, throughout), Cavern (elevator, throughout),
Runway (a bass instrument enters ~5-7s in and stays bad). User confirmed
against original N64 audio that a bass instrument belongs on Runway --
instrument selection is correct, only its sound is wrong on PC. Determinism
scopes this to a static decode/synthesis bug (csplayer.c/synthesizer.c or
bank loading), not the D147/D152/D202 live voice-stealing family.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
* docs(M-106): dedupe D230 -- fold M-106 repro into the original M-105 filing
The M-105 session already filed D230; the M-106 delegate triage didn't
know that (branched off a stale local main before pulling) and filed a
second D230 row with the refined repro. Merged into one entry -- kept
the M-105 filing as the base, folded in the M-106 deterministic repro
(Control/Cavern/Runway) and the bass-instrument-is-correct clarification.
findings-index.csv regenerated via tools_pc/gen_findings_index.py.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
* docs(D235): clarify trigger is death/restart, not a Facility-only regression
User clarification: the wrong-texture symptom isn't specific to Facility
and isn't a general build regression -- it's triggered by dying/
restarting a level (seen on other levels too). Reframes the leading
hypothesis from "bisect against #47/#49" to a texpool/CI-cache reset gap
on level re-entry (D217/D228 neighbourhood). Updated GRAPHICS-BACKLOG.md
+ findings.md; findings-index.csv regenerated.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG