Virtual seats, per-seat camera and input routing, aim modes, hot-plugged
controllers reopening via a coalesced rescan (D450, dolent's PR #122,
merged as the original commit). Rule-2
sign-off for D417.
Refs: #99 D416 D417 D418 D419 D422 D428 D448 D450 #122
Root cause H2 confirmed: the gun-barrel / file-select vertical gradient is
built from 0.75-native-pixel-tall gSPTextureRectangle row strips (title2.c
(<<2)-1 bottom idiom). On the N64 RDP the scan converter floors the U10.2
edges to whole scanlines, so each strip rasterizes to exactly one scanline;
gfx_draw_rectangle passed the fractional edges straight to the GPU's
continuous rasterizer, aliasing the 0.75px pitch into a periodic 'comb'
(fixed ~3-native-row period; window period = 3*scale — confirmed by a
3-scale 640/1024/1280 GE_PCDUMP test).
Fix (port-layer, surgical): after the sub-pixel offset, floor the top edge to
its scanline and the bottom edge to the last covered scanline (the RDP
scanline range [top_scan, bot_scan+1]) before NDC conversion. No-op for
whole-pixel rects (edges already 4k/4k-1), so only sub-pixel strips change
and normal fills are untouched.
Also: remove the temporary GE_D397SH / D397SHM env-gated shader probes
(gfx_opengl.cpp, gfx_pc.cpp) now that the root cause is settled.
Docs: findings.md D397 -> RESOLVED (+3-scale confirmation + fix record),
findings-index.csv regenerated, porting-notes.md section D15 marked resolved
with the landed fix.
Verified by playtest: gun-barrel + file-select gradient render clean, comb
gone.
The file-select Options label opens a panel drawn in the same hook:
sections as tabs, rows from the F10 overlay's table via an exported row
API, the game's crosshair as pointer (input.c passes the stick, hands the
buttons to the panel). Quit to desktop now goes through videoRequestQuit.
Also repairs two escaped-newline typos in the D343 docs.
One Rule-2 #ifdef PORT draw hook in constructor_menu05_fileselect (user
sign-off 2026-09-27). The label's hit test and open request live in
optionsoverlay.c; the open is applied on the input thread. While the
overlay is open on file select, g_MenuTimer is held at 0 so the screen no
longer drops to the legal screen after 30 s.
The PC wallet model (0x1664C B, 0x17000 reservation) loads at the start of
ptr_logo_and_walletbond_DL, but the file-select scratch DL and the
briefing text still used the N64 offset +0xA000 -- inside the model.
After main menu -> back the model is not reloaded, so the overwritten
folder bodies/photos disappeared. Both now use +0x17000 (same 0x6e000
budget). User-verified; golden gate 3/3. D341 also user-verified.
Two fast3d defects. (1) The 1-cycle blood texture rectangle sampled a
stale tile-1 CI8 declaration: the title leaves G_TL_LOD set and the D236
detail-base rule redirected texunit 0 to tile 1. LOD tile selection is
2-cycle-only on the RDP, so texrects outside 2-cycle now sample their own
tile. (2) The drip image is regenerated in place in the dyn pool (reached
via the KSEG0 mirror); the address-only cache key froze it on its first
image. Dyn-pool textures now key on a content hash. Headless-verified,
golden 3/3. A/B: GE_DYNTEXHASH_OFF.
The IsWater 32x32 CI8 image (drawn through an RGBA16 tile, D229) was
imported with its mip chain as 32x43 and normalised as 16 wide, so the
pattern ran at 2x and wrapped every 43 rows instead of the N64's 32:
fold steps made it jump. Import the base level only and use CI8
geometry in the tri path. Sky/water fans now go through a port-only
G_FLOATVTX_EXT (float clip positions + S/T) instead of s16 Vtx, removing
the tc shift and wScale quantisation jitter. User-verified vs 1964;
golden 3/3; Win + Linux build. A/B: GE_D245_OLDVTX / GE_D245_OLDTEX.
The op-22 DisplayListPrimaryRecord count is the number of star-flash
arms; dorottex (and PD's modelRenderNodeStarGunfire) read count*4
vertices. d43_emit/d43_convert sized the array as count vertices, so
3/4 of every muzzle star came from neighbouring sidecar bytes and drew
as long spikes (M16 "shooting up"). Size it 16*4*count. Emitter
round-trip checks all pass; M16 renders a clean star headless.
Also TEMP GE_STARTWEAPON (bondview_r.c) for weapon repros. Re-run
tools_pc/d43_emit.py to regenerate data/pcmodels-*.
CCTVRecord's own s32 `pad` (the look-at pad) shadows the inherited
ObjectRecord `pad` (mount pad) under IDO. The PC struct renamed it
`lookpad` to satisfy GCC, which silently re-pointed setupCctv's reads at
the mount pad, so every camera's look vector was its lens offset and the
lens faced the wall. Read `lookpad` on PC (#ifdef PORT, A1 class);
matches PD's setupCreateCctv (lookatpadnum). User-verified vs 1964,
golden gate 3/3. Porting-notes gains the duplicate-member rename trap.
D291 (user-captured) is D306: Dam intro camera 5, truck over the walkway
camera; reproduces at FovScale 100. All PC-specific leads refuted with
evidence: FOV, depth precision/DrawDistance, depth state (every truck
triangle is opaque z-tested), near-plane handling, wheel transforms. The
image is a depth-correct view of the authored model from under the truck.
GE_ZF's earlier "real fighting" was a +-255 z quantisation artefact.
TEMP tools: GE_INTROCAM=<n> pins the level intro's first camera
(bondview_r.c, #ifdef PORT, PRNG draw still consumed); GE_D306C depth-state
census in gfx_pc.cpp. Golden gate 3/3.
gimgSyncCompiledGlobalDLs (D68 shim) only copied texLoad's resolved
texture pointers into the compiled globalDL shadows while a slot still
held the 0xABCD IMAGESEG marker, i.e. only for the first stage (title).
Every later stage kept pointers into the title's freed texture pool, so
explosion smoke/fire/debris rendered as rainbow garbage once the stage
overwrote that memory (Facility: past the lockers). Learn the slots once,
re-sync them every stage. User-verified; golden gate 3/3.
Also: TEMP GE_D252POOL texpool/sync probe; GE_D157 F12 burst and caps
(uncapped colour match made live play 4-6 fps). D314 F10 flicker
user-confirmed fixed. Findings M-201, porting-notes C2.
- front.c: constructor_menu07_missionsel / constructor_menu0D_missioncomplete
fall off the end while the caller uses the Gfx* result; return DL under
AVOID_UB (porting-notes D6). Old/new objects disassemble identically at -O2,
so no behaviour change on this compiler; clang/arm64 no longer rely on luck.
- libultra/audio/event.c: include <stdlib.h> under PORT so getenv() is not an
implicit int-returning declaration (D324 class).
- dram.c: DRAM_SIZE >> 32 on a 32-bit unsigned long (Win64) was UB.
Golden gate 3/3.
Four simultaneous drain monitors on the same USB interface: WASAPI (512 and
2048 samples) fell to 81-88% drain for ~30-50 s every ~317 s and lost 1.4% of
playback, while DirectSound stayed at exact real time. Each WASAPI shortfall
overflowed the audio queue and dropped blocks (the periodic garbled bursts).
audioInit() now hints directsound,wasapi; SDL_AUDIODRIVER still overrides.
Golden gate 3/3; 60 s GE_D204 run clean after load.
A no-game SDL drain monitor (new tools_pc/sdl_drain_monitor.c, opens SDL
exactly like port/src/audio.c) shows the maintainer's Focusrite USB interface
draining at 88-95% for ~15 s every ~312 s, matching the in-game overflow
bursts' period. Level loads, combat, the SDL pull pattern and a
pacemaker-vs-vsync beat were ruled out. Remaining D322 items: the 8-voice SFX
cap (D207/D241) and #87's progressive decay. porting-notes: measure the host
device before chasing periodic audio glitches.
watchRenderController built its ModelRenderData from a raw 64-byte read at
D_80035D04+0x3c, which on N64 is the {0,1,3,...} template spanning into
watchControllerButtonBases. On PC the struct is wider and the globals aren't
at those offsets, so flags read 0 and subdraw() drew nothing. Use the
explicit template, as D264 did for the item preview. Also fix the three
pointer-width/raw-Gfx sites on the same path that now execute: two u32
render_pos casts (fatal under Linux PIE), a (s32) pointer arg, and the
dual-controller branch's hand-encoded G_MTX word (now gSPMatrix).
Verified on native Windows (headless watch walk + capture); bunker1 golden
3/3; Windows and Linux builds clean. D291: headless check noted (needs play).
Auto-Aim / Look Ahead "wouldn't stay on": in the watch, current_menu is
still RUN_STAGE and hipDirectCompute declines on its !watch gate, so mouse
motion fell through to the legacy stick fallback (~3 counts/px/poll). A
~15 px flick crossed the watch's +/-0x2E threshold, which on a selected
option picks OFF/ON (game_option_toggle_input) and otherwise flips pages.
The mouse now contributes nothing while the watch is open or paused;
keyboard and pad drive the watch as before. Save/load round trip ruled out.
Build clean; bunker1 golden 3/3. Mouse feel-check owed (needs real motion).
The D318/D320 guard freeze is not a platform-invariant lock. modelTickAnim
folds numticks into one modelSetAnimFrame2WithChrStuff call, which only
advances framea/frameb when floor(frame) crosses an integer. At 1 tick/frame
(port at 60 fps) and attack speed 0.5-0.8, a pair left equal by the aim-hold
endframe clamp is re-emitted stale after the endframe is raised, so the
model pins forever. At >=2 ticks/frame (N64's normal regime) it cannot.
- model.c (#ifdef PORT, Rule-2 sign-off): when the loop did not run and the
current endframe no longer blocks the next frame, re-derive frameb as
modelSetAnimFrame does. GE_D329=0 disables, GE_D329LOG=1 logs triggers.
- Verified: lockstep clk=1 no longer pins (Facility ai_19, Control ai_9,
WSL + native Windows, watchdog off); 0 triggers at clk=2; user playtest
confirms N64-matching Ourumov timing. Bunker1 golden failure predates it
(identical with the fix off).
- d318bprobe (TEMP, env-gated): attack-state probe + GE_D318B_CLK lockstep.
- d320repro: fix jump=<chr>,... parser (documented form never parsed).
- findings D329 + D318/D320/D321 corrections, porting-notes D13.
GE's texLoadFromGdl picks a handler per G_NOOP texture reference from w0 & 7.
TEXTURETYPE_LOD/MIPMAP (texHandleType0/Type2) issue ONE load block at TMEM 0,
so tiles 0..n are LOD levels of that one image. TEXTURETYPE_DETAIL
(texHandleType1, src/game/tex.c) issues TWO: the detail texture at TMEM 0 and
the real base image at TMEM texGetSizeInBytes(detail,0) on tiles 1+.
gfx_lod_tile_offset folded every G_TL_LOD draw to tile 0 (D107 -- correct for
the first two, wrong for DETAIL), so the port drew the detail texture and never
imported the base image at all. All four Surface 1 treeline cards
(tex1198-tex1201, RGBA5551 64x17 cut-outs) are DETAIL bindings sharing one
detail texture, tex2465: an opaque 32x32 IA8 noise tile. That is the same
texture D267/D276 measured at 1023/1024 texels opaque and called the mystery
"noise painter" -- hence 20+ passes chasing decode, alpha, mip and geometry
theories for what was a binding bug.
Fix (port layer only): under G_TL_LOD, if tile fi+1 differs from tile fi in
BOTH TMEM address and fmt/siz, sample tile 1. Asymmetric on purpose -- LOD
levels of one image always share a format, so a mip chain can never trip it,
and a detail pair that happened to share a format just keeps the old
behaviour. Config Video.DetailBaseTile, default on.
Verified: Surface 1 idle spawn goes from an opaque tiled noise wall to discrete
snow-covered pines with ground/snow unchanged; Surface 2 shows a discrete night
treeline; Depot renders clean; bunker1 golden framediff is bit-identical with
the fix on and off. Offline scan of every level's bg: DETAIL bindings exist
only in sevx (84), depo (39), sevb (4), sev (4), so every other level is
provably untouched. User play-test confirmed.
Also refuted and removed pass 25's fix direction: cut-out mip suppression
changed nothing, and its alpha census showed the wall is painted by a fully
opaque texture -- retiring the mip / 1-bit-alpha / XLU-smear branch. The
GE_D236P25 probe is gone with it.
tools_pc/gen_env_probes.py: stop counting a "GE_FOO[" log-line TAG in the doc
as a documented env var. GE_TEXDUMP_ADDR prints "GE_TEXA[...]", which is not
the name of any getenv(), so the check reported a permanent phantom GONE.
Generalised as docs/porting-notes.md D12; full trail in findings.md D236 pass
26. Carries the pending D75/M-197 sidecar-converter fix and the env-gated
D236/D75 probes, which the doc gates require to land alongside their rows.
Extended M-167's freeze technique to render_pos (Bond's own draw
position), aiming at the model-duplication symptom M-167 didn't fix.
Two live crashes along the way, both from the same root cause: model
pointers get reused by the allocator across different logical model
instances, so any attempt to skip subcalcmatrices() based on inspecting
render_pos's value or caching pointer identity can be fooled into
treating a brand-new, uninitialized instance as already-validated --
leaving render_pos at its fresh tombstone value for a later consumer
(sub_GAME_7F06C768, computing zDepth) to crash on.
Fixed by never skipping subcalcmatrices() at all -- it always runs now,
guaranteeing render_pos is never left invalid regardless of model
lifecycle. The freeze is purely a contents copy on top of a
now-guaranteed-valid buffer. Live-playtest stable (no crash, full
session).
But the result is informative-negative: freezing unconditionally also
suppresses the LEGITIMATE per-shot camera/model repositioning
(AI_TRYTeleportingChrToPad moves Bond between several distinct shots
during the cutscene), not just the illegitimate intra-shot cycling --
so the camera now looks the wrong direction for later shots and Bond
still appears stuck. Both GE_D243X3/GE_D243X4 remain diagnostic
experiments, not real fixes. Documented two next-step candidates
(delta-threshold re-baseline, or hooking the teleport call directly)
for distinguishing legitimate repositioning from the cycling bug.
porting-notes.md gets the generalized lesson: freezing a value and
freezing a pointer target are different operations -- the latter needs
the call that keeps the pointer valid to always run, with the freeze
applied to contents afterward, not to whether the call happens.
M-163's live playtest smoothed prop->pos completely (log-verified) but
the visible camera shake was unchanged -- the camera's look-at filter
doesn't read prop->pos at all. It reads field_488.pos, a separate
variable computed from render_pos (the model's own animated-skeleton
transform), independent of prop->pos even though both happened to show
the same period-4 cycling symptom through a probe that only logged one
of them.
Retargeted the suppression at field_488.pos's actual write site
(bondview2.c:10495-10497, GE_D243X3-gated, freezes it while POSEND is
active). User's live playtest: "yep camera shake is gone now" -- the
first positive result across the whole D243 investigation.
Not a real fix -- GE_D243X3 unconditionally freezes state rather than
correcting why the model's skeleton animation cycles in the first
place. User also reports Bond's own rendered body still visibly
duplicates/glitches, expected since this only freezes the camera-feed
copy, not the render_pos draw-path read -- likely the same underlying
mechanism observed through a different consumer, not yet isolated.
porting-notes.md D11 gets the generalized lesson: confirm the actual
consumer reads the field you fixed before spending a live-test cycle
on it -- two fields can show the same symptom from one shared root
cause while being genuinely independent data paths.
D300: new hipDirectCompute() in port/src/input.c writes vv_theta/vv_verta
directly for hipfire mouse-look, mirroring GEPD's linear px->degree model
and the existing D194 aim-mode pattern -- replaces the N64 stick-curve
routing that saturated at ~13 px/poll (issue #89). Ported GEPD's
dead/watch/pause safety gates. Input.MouseDirectLook=1 default, escape
hatch to the legacy path. User-confirmed better feel in a live playtest.
D243: added a permanent onscreen-visibility probe (D243M: onscreen,
chr.c) to test whether chrTick's posIsOnScreen gate explains the
abseil cutscene's frozen render position (M-159). A live capture
refuted that and found the real mechanism instead: chrTick overwrites
prop->pos with a stale value via its own chrUpdateAnim/modelTickAnim
animation-position logic, racing the AI script's scripted write
(M-160). A decisive throwaway experiment neutralizing that overwrite
produced no visible change in a live playtest (M-161) -- the mechanism
is real but isn't the dominant visible cause. Experiment reverted;
D243 parked pending a new lead after ~15 session-passes without a fix.
Docs: findings.md/findings-index.csv/porting-notes.md/GE-ENV-PROBES.md
updated throughout.
fileValidateSaves() -- the only place saves[] is populated from EEPROM
-- is called exclusively from the legal screen's init, which
Game.SkipIntro's boot-to-file-select shortcut skips entirely. saves[]
was left all-zero, so fileGetSaveForFoldernum() returned NULL for
every folder except whichever one a zeroed save_data happened to
decode to, crashing file-select's unguarded fileGetIsCheatUnlocked()
for the rest. Call fileValidateSaves() from the shortcut too
(port-layer completeness fix, zero game-logic change).
Live-verified by user: all 4 save slots now selectable with
SkipIntro=1, intro still correctly skipped.
Chasing D252 further, got the first-ever screenshot proof of this bug
family: a plain bullet-impact spark on a Bunker1 door cycling magenta ->
purple -> green -> teal -> purple across consecutive shots, captured
headlessly via GE_INPUTSCRIPT (Z fire pulses) + GE_PCDUMP together, then
reading the resulting PPMs directly (PIL) instead of only grepping a
probe log.
Traced the call chain (glass2.c's bullet_spark_render, a different code
path from both D219's explosion.c particles and D252's IMAGE_FIRE_N
system) and ruled out three mechanisms with actual code evidence before
finding one that fits:
- Not D252's fire-tile system (GE_D252 got zero hits on this repro).
- Not a struct-layout/ABI bug (sImageTableEntry is 12 bytes on both
32- and 64-bit, no pointer members -- checked before assuming, per
this session's own rule-2 bucket-(a) discipline).
- Not the primitive color (g_BulletSparkColors[1], a hardcoded literal
for world-prop hits -- always pale yellow).
- The texture itself (explosion_smokeimages, IA8, verified via the
asset table) has no color channels and decodes correctly.
That leaves M-110's G_LIGHTING-not-cleared mechanism (already proposed,
unconfirmed, for explosion.c) as the one that fits: bullet_spark_render
never clears G_LIGHTING either (only ORs in G_CULL_BACK), so if lighting
is on, fast3d reinterprets the vertex color bytes as a lighting normal
and computes a scene-light SHADE color instead -- independently
converging on the same mechanism at a second call site, for a visually
distinct symptom (bullet sparks vs explosions). Flagged honestly as
unresolved bucket (b) vs (c): bullet_spark_render's missing lighting
clear is decomp-verified N64 ground truth, so a src/game fix needs the
port-layer alternative ruled out first, per docs/dev-process.md's own
new taxonomy -- not assumed just because the mechanism fits.
Also documented the headless-visual-capture technique itself
(GE_INPUTSCRIPT + GE_PCDUMP together, then actually opening the PPMs)
in porting-notes.md as a reusable method -- several other long-open
visual findings (D290/D291/D294) have been stalled purely for lack of
eyes on the pixels, not for lack of a working probe.
No src/game or port/ changes -- diagnosis only, screenshots are
session-local (scratch/, gitignored). findings-index.csv regen was a
no-op.
Ran the M-152 next-step probe (GE_D288=1 GE_PCDUMP=560-745:5, backgrounded,
25s bare -level_20 boot). Two problems surfaced with the probe itself, not
the underlying bug: it fires 116k times in ~1500-2000 frames (the
bbox_frac>0.15 threshold catches ordinary near-camera geometry throughout
the whole run, not just the reported ~562-741 window), and the target
color signature (83,62,44) never appears in the log. No GE_PCDUMP PPM
output was produced either, so no visual cross-check was available.
Confidence unchanged from M-152; this is a probe-refinement finding, not
a root-cause finding. Recorded the concrete next step (frame-gate GE_D288
on num_dls the way GE_D236RM already does) in the D288 entry.
Also caught and documented a real tooling gotcha: a backgrounded
ge007.x86_64.exe launch from this Bash tool doesn't reliably die to
`pkill -f` (process shows as bare `ge007.x86_64` in ps, no .exe suffix) --
a stray instance ran ~90s past its intended capture window before being
caught via `taskkill //F //IM ge007.x86_64.exe`. Added to porting-notes.md
so the next session uses taskkill from the start.
findings-index.csv regen was a no-op (no new Dxx label).
Tried to keep the -level_36 deterministic boot's camera in the outdoor
room past its scripted indoor transition, to re-run GE_D236RM facing the
tree line directly (the concrete next step pass 10 named). Three
GE_INPUTSCRIPT variants (sustained SDOWN, sustained SUP, a START-skip +
delayed SUP) each changed the pre-transition room (curRoom 7/12/13) but
none held the camera outdoor past the transition, which fires at roughly
the same real time regardless of direction and earlier when START skips
faster -- timer/event-driven, not position-driven.
One new data point: the START-skip run's very first two GE_D236RM
samples hit count=10 then 6 (vs pass 10's flat max of 4) before
collapsing back to 2 then 0 within ~100 draw-list calls. Still far short
of D280's ~720 free-run figure -- best current explanation is that
D280's original number came from a genuinely different, later, closer
vantage than anything reachable in this boot's opening seconds, not a
CPU-side instantiation bug at this specific vantage.
No src/game or port/ file touched (GE_D236RM unmodified from pass 10).
Added the "opening cutscene isn't fully input-inert, but its transition
may still be time-gated not position-gated" quirk to porting-notes.md.
findings-index.csv regen was a no-op (no new Dxx label).
Per the rule-2-clarification pass-10 brief in findings.md, applied the new
three-bucket taxonomy to D280's leading theory that the 0x0c184b50
discrete-tree-card render-mode class instantiates inconsistently across
runs of the deterministic -level_36 boot (~4 tris vs ~720).
Added a small env-gated diagnostic (GE_D236RM, port/fast3d/gfx_pc.cpp
gfx_sp_tri1) counting that class's per-frame triangle count, heartbeat-
logged every 300 frames even at zero. Three independent -level_36
launches (one with sustained GE_INPUTSCRIPT camera rotation) produced
byte-identical per-frame counts, falsifying the "run-to-run dependent"
premise outright. Cross-referencing with the existing GE_D104 room-census
probe in the same run shows the count drops to zero at the exact frame
the player's curRoom transitions from 12 (17-20 rooms drawn, an outdoor
vista) to 18 (1-5 rooms, an enclosed indoor space) via the boot's own
scripted intro camera — ordinary portal/room occlusion, not a bug. None
of buckets (a)/(b)/(c) apply; no src/game or port/ fix made or warranted.
The original "wall not discrete sprites" symptom is untouched by this
pass — narrowed the real next step to getting a camera into room 12
facing the tree line directly, in docs/dev/findings.md.
Added the generalisable "deterministic boot flag != deterministic camera
path" quirk to porting-notes.md §E. findings-index.csv regenerated.
Widens the written scope of AGENTS.md non-negotiable #2's ABI exception to
match established practice (runtime struct/union misreads, not just
ROM-serialized structs — the D209/D210/D255 pattern), states explicitly
that diagnosis into src/game is never restricted (only the fix is), and
defines a real three-bucket taxonomy + sign-off procedure (dev-process.md
§7) for the rare genuine-behavior-difference case, replacing the
undefined ad hoc "ask the user" gesture that's been referenced without
being defined across ~10 places in the docs.
Mirrors the same framing into CONTRIBUTING.md and the PR template for
external contributors. Extends porting-notes.md §A1 with D255 as a third
worked example and a tell-tale-signs checklist, explicitly distinguishing
it from D284 (same epistemic shape, different mechanism, no rule-2
question at all). Appends a ready-to-dispatch pass-10 brief to the D236
entry applying the new taxonomy to its actual next step.
Docs-only; no code/build changes. Per docs/dev/notes/RULE-2-CLARIFICATION-PLAN.md.
scratch/d287_freeze_gdb.txt, cited as this finding's freeze-time
evidence, does not contain it -- grepped for every specific number the
write-up quotes (frame 17616, the three heartbeat stall times) and got
zero hits. The file on disk is an unrelated clean normal-exit capture.
No real freeze-time thread state has actually been analyzed for this bug
yet; corrected the entry so a future session doesn't treat it as
narrowed-and-confirmed.
Also traced the consumer/producer architecture statically (mainThread's
only OS_MESG_BLOCK send is rsp.c:271's osSendMesg into the scheduler's
cmdQ; GE_D60 doesn't cover sends), and found portHeartbeatCheck hard-caps
at 3 reports/process -- "heartbeat stopped logging" in the original
write-up is that cap, not new evidence. Both quirks added to
porting-notes.md. No code change -- diagnostic-only pass, needs a real
live capture next.
Traced the watch controller-graphic render path end to end: page gating,
model/item wiring, ROM resource-table entry, and the generic equip/load
pipeline all check out byte-matched or generic-and-proven-elsewhere.
Rules out src/game/ with high confidence. Best-narrowed hypothesis (not
confirmed): a fast3d translucent-blend edge case for the one-off
PROP_TYPE_PLAYER draw used during the watch's fade-in, matching this
project's recurring one-off-combiner-mode bug class. Falsifier for the
next live session recorded in the finding. No code change.
Read gfx_sp_tri1 against D233/D106/D271's existing guards. D233's
trivial-reject fix is ruled out as a direct cause -- GL clips well-formed
clip-space geometry correctly downstream of it. Found a new, previously
uncatalogued unguarded site: the G_CULL_BOTH backface-cull cross-product
divides raw vertex w with no epsilon guard, which can NaN/Inf near w=0
and silently skip both cull branches. Not yet confirmed against a live
repro. Added GE_D288 (cached-getenv, D250-safe) to catch the culprit
triangle's w/bbox at draw time on the next -level_20 run.
No game-logic change; port/fast3d/ diagnostic only, inert unless
GE_D288 is set.
No discoverable in-UI way to quit (Deck/controller-only setups had only
window-close/Alt+F4). Adds a ROW_ACTION row kind (not config-backed, same
pattern as the existing __Resolution row) that triggers the identical
configSave()+exit(0) path SDL_QUIT already uses. Build-verified on Windows
and Linux; verify.sh dam PASS, no regression.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Folds in the zero-code-risk ADOPT items from scratchpad/GEVR-TRIAGE.md
(a prior survey of the sibling GEVR VR port, same n64decomp/007
lineage, reference-only): a crash-triage heuristic for non-canonical
x86-64 pointer faults, two frame-rate-assumption notes that pre-empt a
future uncapped/high-refresh rabbit hole, a process lesson about
grep-built site lists standing in for a real read, and the
portal-scissor site map as pre-flight for whenever widescreen work
resumes.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
R2 extraction from the parked D236 investigation tree
(park/d236-probes-m139): pulls out only the two verified, behavior-real
fixes and drops every D267-D280 debug probe (census counters, PPM
dumpers, per-tri/per-portal logging) that had accumulated alongside them.
- D266: import_texture_ia4 decodes RDP IA4 as I2:A2 (was I3:A1, an
inherited PD assumption); gfx_opengl.cpp's CVG_X_ALPHA "texedge" path
now keys off the real alphacompare field (dither/threshold/none)
instead of a hardcoded 0.19 cutoff, and gfx_pc.cpp excludes AC_DECAL
tiles from the texedge path entirely (it never discards on real
hardware).
- D271: the D106 near-plane portal-straddling guard's `lim` constant
widens from 1e5 to 1e38 so finite out-of-range bounds flow through to
bgRectIntersect and clamp/cull exactly as they do on N64, instead of
being force-mapped to full-screen (was drawing whole rooms N64 culls).
Checked D271 against D249 (distant-geometry drop-out on Streets/Egyptian)
before landing per M-139's overlap concern: no overlap, different code
paths (near-plane portal guard vs. far-plane/LOD symptom); D249 stays
open.
Verified: 242/242 clean build; full 21-level verify.sh sweep clean (one
dam NO-FRAMES on the sweep pass reproduced PASS on an immediate solo
re-run - a load-sensitivity flake, not a regression).
The D267-D280 probe code and the rest of the D236 tree-wall
investigation stay parked on park/d236-probes-m139 for whenever that
thread resumes (still no root cause for the tree wall itself - see
D280's negative outcome).
Also documents a build-environment gotcha hit this session: this Bash
tool's shell reports MINGW64 (uname/$MSYSTEM) but is actually
Git-for-Windows Bash, missing cmake/ninja/gcc from PATH until
/c/msys64/mingw64/bin and /c/msys64/usr/bin are prepended explicitly
(docs/porting-notes.md SS E).
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Bing Webmaster re-flagged the site after the first SEO pass (32f193a0):
- Every Jekyll-processed doc page (building.md, dev-process.md, internals.md,
porting-notes.md, dev/agentic-development.md, plus index.md caught last
time) opens with its own "# Heading" on top of the Cayman theme's masthead
<h1 class="project-name">, so all of them emitted two h1 tags, not just the
homepage. Demoted each page's opening heading to h2 -- anchor slugs are
unaffected (kramdown ids don't depend on heading level).
- index.md's meta description was 161 chars (1 over Bing's 160 limit) and
still said v0.2.0; re-trimmed to 133 chars and bumped to v0.2.1.
- README.md and docs/index.md's Status lines/comparison table bumped from
v0.2.0 to v0.2.1 to match the actual current release.
Verified against a live, cache-busted fetch of the deployed Pages site
(the first Bing report was reading a stale CDN edge cache from before the
32f193a0 deploy, per its own Last-Modified header) that the underlying fix
was already live; this pass addresses what was still genuinely wrong.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
- Drop-in ROM wording everywhere (README quick start, Pages landing,
release notes, bundled README): first launch auto-generates the
derived assets via the frozen converter; no Python/toolchain needed.
PAL/JP marked not-yet-supported with issue #85 pointers.
- building.md: Linux build is CI-compiled and ships in the bundle
(was 'never built or run on Linux'); bundle note now describes the
frozen ge007-convert auto-spawn; PAL/JP sidecar caveat (D258).
- Status rewording across README/Pages/release notes: drop the
'first open-source port to our knowledge' claim; cutscene glitch
description aligned to 'mostly with James Bond'; index.md status
no longer contradicts the open D255 Deck crash.
- Em-dash cleanup across all GitHub Pages docs (index, building,
internals, porting-notes, dev-process, agentic-development,
release notes); internal dev logs untouched.
* 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.
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