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.
New env-gated D397SHM probe in gfx_generate_cc dumps which G_CCMUX
inputs a combine feeds as vInput1..N. For the gun-barrow row-strip
shader (id0=0000000010001218): vInput1=PRIMITIVE, vInput2=ENV_ALPHA,
so the front.c LERP renders out = PRIM + (TEXEL-PRIM)*(envA/255).
The PD ground-truth port feeds the identical env_color.a/255 factor for
the same ENV_ALPHA LERP input, so the LERP factor is not a port-side
scaling bug (H1's 1/255-vs-1/32 variant refuted). A uniform factor
cannot produce the *periodic* comb, which points to H2 (row-strip
geometry / 256-tri vertex-ring wrap). Next: Q3 gfx_flush() probe +
Q5 native FBO capture. Findings §D397 + handoff updated.
Per scratch/d397_fs/HANDOFF-D397.md: the stale-RENDERTILE hypothesis is
REFUTED (D161 already routes CI + G_TT_NONE to the I8 import; the GE_TEXI
logs show palfmt=0 on all 299 row strips), so the D397 block added to
import_texture had no effect (12px frame diff) and is removed.
The finding's true state: data path proven clean (byte-exact I8 uploads
vs the ground-truth PGM); MSAA/mips/noise/quant/cache ruled out.
Leading cause H1 = the file-select custom-CC LERP feeding ENV_ALPHA 0x14
as a 20/255 mix factor (black texels render ~0.92x prim = the measured
27/28/17 census); the N64 RDP 5-bit coefficient scaling (gmain.s / PD
CC engine) is the open root-cause question. H2 = possible separate
row-strip-geometry comb. GE_D397SH probe stays in-tree until the root
cause lands. findings table + tail section + CSV corrected.
Root-caused the D397 interlaced/comb symptom on the file-select
background and intro gun-barrel (first display, not round-trip):
titleRenderFolderMenuBackgroundLines loads 299 one-pixel 440-byte I8
rows with gDPLoadTextureBlock(I, 8b) and samples G_TX_RENDERTILE but
never emits gSPSetTile, so the tile's fmt/siz is whatever the previous
screen left (CI/4b spectrum-screen leftover, D116 probe). fast3d ran
the I8 bytes through import_texture_ci4 against a stale/all-zero
16-entry palette -> periodic black rows (GL texture-cache artifact,
not a ground-truth dither — no ROM RLE decode needed).
- gfx_pc.cpp import_texture: route the import through the source's
fmt/siz when a CI/RGBA tile genuinely disagrees with an I8 8b load
(mirrors the D229 case); real CI/RGBA textures untouched.
- gfx_opengl.cpp: GE_D397SH env-gated shader-formula probe for the
file-select gunbarrel combine (label initially written as D398,
renamed to avoid the PC-Options-label finding collision).
- findings: D397 row -> FIX LANDED (by-eye + PCDUMP verification
owed); tail section notes the fix. CSV regenerated.
Cross-ref D182(2) (same symptom family), D159/D183 (disproven leads),
D229 (mirror-case precedent).
The crop tested loaded_texture.line_size_bytes, which is the whole
LoadBlock (1400 B), so the water still uploaded 32x43 while UVs were
normalised to 32 rows; the mip rows showed as coloured dashes. Test the
tile line size instead. User-verified on Frigate; golden 3/3.
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.
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.
Ammo (per-hand corner), bottom messages/title card (bottom-left) and top
dialogue (text origin) scale with crisp glyphs; dialogue and bottom
messages are width-capped because their line breaks are authored. 100%
emits nothing (golden 3/3). Captures verified at 150/200%; Win + Linux.
Env-gated per-frame timing: render phases, tris/batches, game-thread busy,
GL_TIME_ELAPSED GPU timer. Measured ~2.1 ms/frame total CPU on the heaviest
levels here; the GPU timer is only meaningful when GPU-bound. Target-hardware
numbers needed before a low-end preset. Golden 3/3; Win + Linux builds.
D236 was root-caused and fixed in the previous commit, so its diagnostic
scaffolding comes out:
GE_D236RM / GE_D236ORDER / GE_D236ALPHA / GE_D236ZFIX gfx_sp_tri1
GE_D236RAW bg.c room load
GE_D236BT chr.c + model.c
port_d236bt_dump() host stack backtrace
g_d236_last_vtx_seg / _raw G_VTX trackers
Also drops the <windows.h> include that only the backtrace dumper needed, and
rewords the one D288 comment that cross-referenced GE_D236RM. src/game/bg.c,
chr.c and model.c are back to their pre-probe state -- no game-code diff from
D236 remains. The pass-26 fix in gfx_lod_tile_offset is untouched.
Doc rows removed from GE-ENV-PROBES.md; gen_env_probes.py --check and
gen_findings_index.py --check both pass. Build clean. The Surface 1 spawn
capture is unchanged across the strip (settled frame 560 within threshold;
the intro-pan frames never match run-to-run, D117).
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.
New env-gated GE_D75D probe (gfx_pc.cpp) captures per-frame V/T token stream
+ per-triangle rejection/emission accounting for the front-end logo window.
Findings (bare front-end boot, logo = frames ~244+, nv=93/nt=1018):
- Slot-overwrite (M-195's leading hypothesis) REFUTED: the DL is streamed
(V TTT...VTTT interleaved), max triangle vertex index = 15, so no
cross-batch vertex clobbering can occur.
- Rejection gates are not the cause: of 1018 tris/frame, trivial-reject=0,
backface-cull ~520 (normal half), ~500 reach GL, all on-screen (NDC in
[-1,1]^2). Not the invisible flag; vertex colors [0,255]; depth normal.
- PPM analysis: logo window renders 0-33 content px/frame (the 'tiny
fragment'), vs title quads at 8.36% coverage.
- Narrowed cause: combine mode 0x009ffe4f19ffe4f1 parses to
rgb a=TEXEL0 c=SHADE (texture-modulate); gfx_generate_cc sets
used_textures[0]=1, but the material still renders black-on-black. Next
step: trace where the TEXEL0 sampler is lost between combiner build and
draw (shader_id0 -> GL program / prg[tm] / texture upload).
Port-layer fast3d only; no rule-2 sign-off needed. Probes env-gated, inert
without GE_D75D (verified: 0 lines in a normal run).
- gfx_sp_vertex probe (env GE_D75V=lo-hi): logs per G_VTX batch the
transformed x/y/z/w min/max, raw first-vertex ob, dest_index, and the
MP translation row. Proves the logo transform is correct and that every
batch encodes v0=0 (slot-overwrite hypothesis).
- videoGetFrameCount() accessor for frame-gated probes.
- findings.md: M-195 entry in the D75 section (emission + transform
eliminated; G_VTX v0=0 overwrite is the leading suspect).
- GE-ENV-PROBES.md: register GE_D75V, bump reconciled line.
D235 (wrong textures after dying/restarting a level) -- mechanism found
+ fix (M-193). M-108's refutation of the game-side CI-cache theory stands,
but the port has its own TextureCacheMap (port/fast3d/gfx_pc.cpp) keyed by
(source address, palette addrs, fmt, siz, size_bytes, palette_hash) with NO
content check. Stage-texture source addresses live in MEMPOOL_STAGE, which
the per-stage while(!done) block in boss.c resets on every transition
(death/restart included). After the reset, entries keyed into that arena are
stale; if the texpool places a different image at an address that held
another one in the previous run of the level, the lookup hits and returns
the old pixels -- 'wrong textures' only on the reload path, never on a cold
boot. The N64 has no such cache (RSP re-reads DRAM every frame), so this is
a pure port-side defect: videoResetTextureCache() (port/src/video.c) existed
with zero callers and is now called in that block after memaReset
(#ifdef PORT). Re-uploads are pixel-identical; cost is one-time during the
loading screen. GE_D235=1 logs the purged entry count per transition
(cold boot = 0, death/restart > 0) as mechanism evidence; new
gfx_texture_cache_count() accessor backs it. Full 21-level golden sweep:
21/21 PASS (pixel-identical). Live death/restart confirmation is a playtest
item.
D311 (upstream poison source, M-194): every write of model->speed funnels
through modelSetAnimSpeed (startframe<=0 path) or modelSetAnimation2 -- both
now log 'D311: ... ANOMALY' when the incoming value or the resident speed is
non-finite/|v|>1e4 (#ifdef PORT, unconditional-on-fire like the D156
guards; silent in healthy play). The line captures in/resident/startframe/
oldspeed/newspeed/timespeed + model ptr so the next Caverns occurrence pins
the injecting call. Static pass: sub_GAME_7F06FE90 callers all pass
arg2=16.0f (denominator safe); the old->new lerp only circulates values;
bheadAdjustAnimation is the one path where a live player-velocity
corruption reaches model->speed.
D320 (Phase 1 item 1): d318TimelineTick (GE_D318T=1) now also runs a
generic D318-class pin DETECTOR for the sweep's flagged lists (Facility
ai_19, Control ai_9, Depot ai_12): any chr in ACT_ATTACK with a live-fire
attacktype (no TARGET_AIM_ONLY bit) whose modelGetAnimFrame is byte-stable
for 600 ticks logs one 'D320T: PIN?' line with AI-list id/offset/atk/ent/
mot/unk54, repeating every further 600 stable ticks. Detection only -- the
recovery watchdog stays hardcoded to ai_22 by design (D320's explicit
instruction: generalize only after a second list actually freezes in play).
Legit states excluded/self-clearing: monologue pre-aims keep the AIM_ONLY
bit; working fire cycles re-init through chrlvTickAttackCommon so their
frame does not stay byte-stable for 10 s. Smoke: 90 s Facility run, zero
false positives, existing D318T timeline unaffected.
D288 (Phase 1 item 3): per M-155's revised next step, the GE_D288 probe
now skips any_behind_camera==1 triangles (the x/w divide is meaningless for
w<=0 -- that subset was pure false-lead noise) and logs the render-mode
word (oml) so a live capture can ID the culprit asset against level tables.
Re-ran bare -level_20: 1965 sane hits in the 550-755 window, still zero
hits for the target color RGB(83,62,44) -- consistent with M-155's working
theory that the bare boot's scripted camera never reaches the bug state;
headless probing is at its limit here, live capture is next.
bg.c getenv caching (D302-class sweep follow-up): GE_D104 (per room pass,
per frame), GE_D63 primary+secondary render logs (per room, per frame), and
the two GE_D154 inner-GDL-walk sites (per bullet hit) now use the cached
static pattern. All #ifdef PORT blocks; zero behavior change when unset.
- D318 (RESOLVED): as-authored latent race — gas bit mid-monologue derails
c78's pre-aim hold into a permanent ACT_ATTACK anim pin
(actor_fire_or_aim_at_target_update moves only the endframe, never
re-inits; N64-identical). NOT a port bug. Port-side watchdog
port/src/d318watchdog.c (hooked in lv.c after g_GlobalTimer += ) detects
the pinned state and re-inits the attack on confirm; GE_D318T=1 timeline
diagnostic included. random.c gains an env-gated probe (GE_RANDPROBE) for
bit-exact PRNG verification. Scratch evidence (dis/seqcmp scripts, addr
dumps, session handoff) parked per house practice.
- D320 (OPEN): swept every level's AI scripts for the same aim-hold ->
_update shape: 17 lists in 8 levels. Facility ai_19 and Control ai_9 are
HIGH (non-self-healing, same as ai_22); Depot ai_12 MED-HIGH (objective
bit gated behind a stop-check). Bond-combat loops likely safe (sustained
fire stays inside the shoot window by design; exercised every playtest).
Classified as-authored latent race(s), faithfully reproduced. Probe-verify
path + generalize-the-watchdog recommendation documented in findings.md.
- gfx_pc.cpp: GE_D116 debug printf used %f for uint32_t args and %d for
unsigned tile fields; casts added, build warning-free.
Full ntsc-final configure+compile+link PASS. Accuracy sweep write-up in
docs/dev/ACCURACY-AUDIT-2026-09-20.md (PRNG bit-exact; A1/shift32/wrapv/
PORT-hygiene/Gfx-Vtx/osVirtualToPhysical sweeps all clean or already fixed).
D316: optionsOverlayHandleInput() mapped clicks with a naive window-size
scale that ignored the safe-area crop, and overlayRowAtY()'s hit-band was
asymmetric (biased downward) -- together these could land a click on the
row below the one clicked. Fixed by adding gfx_get_ui_screen_rect() (reuses
the real forward transform from gfx_adjust_viewport_or_scissor) to invert
clicks correctly, and centering the row hit-band.
D317 (reported as a crash toggling Game.AllUnlocked): confirmed instant
close, not a hang -- closed as a symptom of D316 (the hit-band bias let a
click on "All unlocked" fall through to the adjacent "Quit to desktop" row).
D314 (F10 flicker on main menu): mechanism still unpinned; landed an
env-gated diagnostic probe (GE_D314=1) for a future repro run. Confirmed
still present after the D316 fix (different bug class) -- deprioritized
per user call.
D315 (menu scaling main-menu vs in-game): disclosed as a known issue,
deferred to v0.3.1.
D319: Video.FpsCap's free 0-360 slider was mostly a dead zone (D186: sim
tick ceiling caps it at 60/50, and values under 30 already got force-
uncapped) -- replaced with a plain 30/60 cycle (ROW_FPSCAP, mirrors the
existing ROW_MSAA pattern); default changed to 60 so a fresh config shows
a reachable value. Uncapped stays valid at the config level for old inis
but is no longer offered in the menu.
- D302: fix a reintroduced D250-class perf regression -- 4 uncached getenv()
calls added by D236 diagnostic passes 10/12/14/16 were running on every
triangle draw in gfx_sp_tri1; cached with the established pattern.
- D304: F10 mouse-sensitivity menu overhaul -- widened Input.AimModeSens'
clamp to match Input.MouseTurnSpeed, then collapsed both plus the
aim/turn link toggle into a single Input.MouseSensitivity control per
user request (avoids decoupling into a confusing state). Fixed two real
overlay bugs found along the way: overlayUpdateScroll() bottom-anchored
the clicked row on every click (not just when off-screen), and
overlayRowAtY()'s hit-test scanned rows past the last one actually drawn,
letting a click near a scroll boundary silently hit the wrong row
(reproduced as "All unlocked" triggering "Quit to desktop" instead).
- D225: GE_DEBUG_UNLOCKALL=1 dev-tooling toggle (file2.c), default off.
- D164/D76: docs-only correction -- the legal-screen one-line fix was
already committed 8dfc578f4; findings.md just never caught up.
- D252: root-caused and fixed a 6+-session-old rainbow-particle bug --
explosionRenderPart() wrote 5 vertices into a 4-vertex allocation,
corrupting the next particle/effect's data in the shared vertex pool.
User-confirmed live: "Rainbow went away."
- D305: Steam Deck SIGSEGV recurrence past D285's own fix -- sndHandleEvent's
preempt-scan dereferenced its list-walk pointer before checking it for
NULL. Added the missing guard. User-verified crash-free on the Deck
across multiple sessions, including toggling Game.AllUnlocked off/on.
D236 pass-16 diagnostic (GE_D236RAW, bg.c) carried along from a prior
session's in-progress investigation; unchanged, env-gated, zero-cost unset.
D243/D173/D292 (Bond cutscene family) -- root cause found and fixed:
bondview2.c's cutscene body-model buffer layout reserved space using a
hardcoded 32-bit sizeof(Model) literal that never accounted for 64-bit
pointer widening (real size 232 vs reserved ~188 bytes), so the model's
own animation-data buffer overlapped its tail fields (playspeed/animrate/
unkac/unkb0/unkb4), corrupting them on every cutscene body-model creation.
Fixed by computing the real sizeof(Model), matching the identical idiom
already used one line above for ModelFileHeader. User-confirmed live.
Follow-up camera-tracking fix: replaced the old shake-workaround freeze
(which also blocked legitimate per-shot tracking) with a write-every-tick
scheme plus an instant re-seed of the look-at filter's accumulator at each
teleport, eliminating the old catch-up shake without freezing anything.
Second bug found in the same review: mouse-look was never gated during
scripted/no-control camera states (an already-documented but never
implemented safety gate in port/src/input.c's hipDirectCompute). Fixed by
adding the missing check, reusing gameScriptedCameraActive() (renamed from
D243's d243mCutsceneActive, since it's no longer D243-specific -- also
used by model.c's M-183/M-185 clamps, which were separately found to be
dead code for real players (gated on the diagnostic env var, not just
camera mode) and fixed to be unconditionally active.
D236 (Surface tree wall) pass 12: new mechanism found (both competing
texture classes are Z_UPD=0 decal-mode draws, so on-screen precedence is
decided by draw order, not depth) and a headless capture finally landed on
the actual bugged view -- but that view has zero tree-class triangles
present, so the mechanism couldn't be tested there. Two new env-gated
diagnostic probes (GE_D236ORDER, GE_D236ZFIX) left in tree for the next
pass; not a fix, not v0.3.0-blocking.
All findings.md summary-table rows touched this session cross-checked
against their full entries and corrected where stale (D219/D252/D246/D247
had been fixed in a prior session but their index rows still said
OPEN/NOT-A-BUG).
- Widescreen/FOV Phase 4: automatic vertical-FOV scale by window aspect
ratio (Video.WidescreenAuto, default on), composed before the existing
manual Video.FovScale multiplier. New symmetric FOV floor (20 deg)
alongside the existing 160 deg ceiling.
- D247/D246: the game's own TV-overscan safe-area viewport inset (real on
N64, hidden by CRT overscan, visible as black bars on PC with no crop)
now gets cropped instead of shown -- gfx_adjust_viewport_or_scissor
remaps against the actual captured gameplay viewport bounds (vertical)
plus a measured, resolution-independent 1-logical-unit trim (horizontal)
instead of the fixed VI canvas size. New Video.SafeAreaCrop toggle,
default on.
- D219 (rainbow bullet-spark colors), a real 32->64-bit ABI bug:
bullet_spark_render (glass2.c) read the spark's stored RGBA color via a
hardcoded raw byte offset instead of the named s_bullet_spark struct
field -- on 64-bit PC an earlier void* field's widening + alignment
padding shifts the true offset by 4 bytes, so the read landed in
unrelated f32 velocity fields instead. Fixed by reading the named
fields. Also fixed an adjacent UB (a 16-byte Vtx read from a 4-byte
global) in the same function. This turned out to be the actual root
cause behind the D252 "lingering particle sprites after an explosion"
report as well (same render path via objHit and friends) -- closed as
a duplicate.
- Moved docs/dev/WIDESCREEN-FOV-PLAN.md to docs/dev/notes/ (gitignored
local notes), matching this project's convention of keeping
in-progress planning docs out of the tracked tree.
User live-verified all of the above interactively this session.
Went back and actually tested the G_LIGHTING hypothesis from the
previous commit instead of leaving it as an untested code-reading
inference. Two things prompted this: (1) noticed a code comment near
the existing GE_D172 probe documenting a live result never written up
in findings.md -- "G_LIGHTING is ruled out (5000/5000 samples off)" for
explosion.c's particles specifically, meaning the sibling case this
session's theory leaned on was already empirically disproven, not
merely unconfirmed; (2) that's exactly the kind of unchecked assumption
this session's own rule-2 taxonomy work was written to prevent.
Added GE_D157 (gfx_pc.cpp, gfx_sp_vertex, env-gated, cached getenv):
logs geometry_mode/LIGHTING + resulting shaded RGB whenever a vertex's
raw color bytes match one of glass2.c's g_BulletSparkColors[] entries.
Re-ran the identical Bunker1 repro that produced the magenta/green/cyan
screenshots -- only 2 hits, both LIGHTING=off, both unaffected white,
no hit at all for the pale-yellow value the door-spark path should
produce. Reading: G_LIGHTING likely isn't the mechanism here either
(consistent with the sibling case), though the probe may simply not be
catching the real spark vertices yet (exact-byte-match is fragile).
Corrected the D219 finding to flag the "cross-validates M-110" framing
from the previous commit as superseded, not delete it -- the reasoning
trail (including the mistake) stays visible so a future session doesn't
repeat it. Root cause of the visually-confirmed rainbow bug remains
open; next steps revised to a texture-identity-keyed probe variant.
Added GE_D157 to GE-ENV-PROBES.md. No src/game changes. findings-index
regen was a no-op.
Gated GE_D288 to num_dls 550-755 (the reported bug window, same pattern
GE_D236RM already uses) and raised the bbox threshold from 0.15 to 0.5.
Volume dropped 116k -> 28,883, but that's still not the isolated signal
-- traced the remaining noise to a bad assumption in the probe itself:
bbox_frac from a raw x/w divide is meaningless once any_behind_camera=1
(w<0 or near-zero), which is exactly why the surrounding D233 code already
defers to GL's own clipper for that case rather than trusting the divide.
Restricting to any_behind_camera=0 (2,128 hits) still found no match for
the target color (83,62,44); the persistent hits across the window look
like static level geometry, not the transient bug triangle.
Working theory: the bare -level_20 boot's scripted intro camera may not
reach the same state the original visual bug report came from -- the same
"deterministic boot flag != deterministic camera path" pattern this
session's D236 passes independently hit on a different level. Recorded a
revised next-step list in findings.md; a live human repro is now more
promising than further headless probing of this specific approach.
No src/game changes. findings-index.csv regen was a no-op.
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.
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.
The prior "prime suspect" (import_texture_rgba16 width/height derivation)
assumed line_size_bytes comes from the tile's MASK_16 wrap period, but
assets/oddtextures.c's actual gsDPSetTile for the fire render tile sets
line=4 explicitly, giving the correct 16x14 dimensions -- matching the
pre-existing is_fire_shape==16x14 filter already hardcoded into the
GE_TEXDUMP dumper. Neither named D252 hypothesis (width/height, GEVR's
byte-group-order alternative) survives; root cause stays open. Adds an
env-gated GE_D252 diagnostic (dims + raw texel dump) for whoever next
gets an interactive session to trigger the actual fire draw.
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
* 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.
GE_TEXDUMP on Facility (-level_34) found ~14% of LOD-textured CI uploads
whose palette is IA16 (intensity-alpha, not RGBA16) rendering solid
near-white. A GE_D228 correlation probe ruled out the texpool/RC2-clip
hypothesis this row started with -- decoded size exactly matched the base
tile every time, so the data reaches import_texture fine.
The real bug: palette_to_rgba32's G_TT_IA16 branch read
intensity = palentry & 0xff (low byte), alpha = palentry >> 8 (high byte)
-- backwards relative to the already-correct direct-IA16 decode
(import_texture_ia16) a few lines above it in the same file, which reads
intensity from the first byte (high, after gfx_dp_load_tlut's PD_BE16
swap) and alpha from the second. A dim, fully-opaque palette entry (e.g.
intensity=0x58, alpha=0xff) decoded backwards as bright and
mostly-transparent -- the white-patch symptom.
Fix: swap the two lines to match the working convention.
User-verified live: AK47 and other metallic guns render correctly.
Headless -level_34 run crash-free pre-verification; a full sweep is
still owed (the user's own game instance was running concurrently this
session, so verify.sh/taskkill were avoided).
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
GE uses real N64 RSP environment-mapping (G_TEXTURE_GEN) for every
shiny/reflective material: gold/silver weapon skins, chrome trim, and
Control's reflective console glass. An earlier session (D72.1) removed
the PD-inherited envmap UV-generation block wholesale from gfx_sp_vertex
to fix the Rareware logo, reasoning "GE never generates texture
coordinates from vertex normals" -- true for the logo (lit, but doesn't
set G_TEXTURE_GEN) but false in general. Every envmap draw was left
sampling whatever raw tc[] happened to be authored (near 0), landing on
a fixed corner texel of the reflection texture regardless of view angle
-- reading as solid black for a typically dark-edged reflection map.
Restored the envmap UV generation (ported from the reference Perfect
Dark PC port's already-correct implementation), gated specifically on
rsp.geometry_mode & G_TEXTURE_GEN so D72.1's actual fix is untouched
(the logo never sets this bit) while every other envmap draw gets real
reflection coordinates back.
User-verified live: console glass and gold/silver guns are now visibly
reflective instead of flat black. The remaining "not translucent"
complaint on the glass is confirmed NOT a bug -- it was never see-through
on the original N64 either (reflective by design, not transparent).
Full 21-level sweep crash-free post-fix.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
texSelect loads the water image as ZLIB+CI8 (raw 8-bit palette indices);
GE's custom RSP ucode expands them to RGBA16 in TMEM at load time, so a
later RGBA/16b tile re-declaration over the same slot samples real blue
pixels. fast3d has no such expansion and read the raw index byte-pairs as
16-bit texels (g dominates -> green mottle); the "pulsing" was always the
pre-existing PRIM_LOD_FRAC sinf() cross-fade between two mis-decoded tiles.
Track each TMEM load's source G_IM_FMT in LoadedTexture.src_fmt; in
import_texture, an RGBA/16b tile over a CI-sourced slot now routes through
the CI8 palette import path instead. Fires only on the genuine format
disagreement, so real RGBA16 textures are untouched.
Verified live on Frigate (-level_26): water renders blue, user confirms
"looks better". Sweep + Surface 2 by-eye still owed.
Also files D245 (water seam), D246 (edge strips), D247 (black bars) from
the same playtest session.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
Live-tested against 4 real user playtests (not scripted) tonight, each
confirming purple/blue explosions still reproduce on current main:
1. G_LIGHTING hypothesis (previous session's lead) RULED OUT: extended the
D172 probe to flag lit particle draws uncapped (the original 40-hit cap,
sized for a short scripted repro, silently went dark after ~40 ordinary
bullet-impact particles well before any real explosion -- masking the
result, not confirming it). Fixed with an always-on periodic counter.
Result: 5000+ particle draws logged across a real playtest with
explosions triggered, 100% LIGHTING=off, 0 suspect hits.
2. Texture-cache-collision hypothesis RULED OUT: extended the same probe to
log the actual bound TEXEL1 address/GL-texture-id/byte-count. 7 distinct,
internally-consistent 448-byte (16x14 RGBA16) textures observed -- no
aliasing, no stale-slot signature.
3. ROOT CAUSE: used the existing GE_TEXDUMP feature (extended to exempt
16x14 RGBA images from its own 400-image cap, same masking problem as
above) to dump all 15 IMAGE_FIRE_0..14 particle textures as PPM during a
live playtest with confirmed-purple explosions. Every single one decodes
blue/purple-dominant; swapping the decoded R and B channels turns all 15
into a plausible warm fire palette, consistently. This is NOT a bug in
the shared RGBA16 decoder (port/fast3d/gfx_pc.cpp import_texture_rgba16)
-- that function is proven correct by every other RGBA16 texture in the
game rendering fine, and the general C-array byte-order normalization
already runs upstream of it. The wrongness is in the decoded bytes
themselves: either the Globalimagetable entries for these 15 image ids
resolve to the wrong ROM offset, or these assets use a sub-format the
shared decoder doesn't special-case. Do NOT blanket-swap R/B in the
shared decoder -- it would break every correctly-rendering RGBA16
texture in the game.
Also fixes a mistake made mid-session: a follow-up raw-byte-dump probe
(GE_TEXRAW) initially keyed its own cap-exemption on the wrong field
(gsDPSetTileSize's logical 56x56 wrap size instead of the real 16x14 pixel
content), so it never matched and burned a live-playtest cycle for nothing.
Fixed to key on the real signature: a 448-byte load (16*14*2, RGBA16).
Diagnostic infra left in tree, env-gated and inert by default:
GE_D172=1 (uncapped lit-case + periodic counter in gfx_sp_tri1),
GE_TEXDUMP=1 (16x14-exempt-from-cap fire dumper in gfx_opengl_upload_texture),
GE_TEXRAW=1 (fixed 448-byte-exempt raw dump in the texture import path).
* docs+probe(D219): new lead -- explosion.c never clears G_LIGHTING before particle draws
explosion.c's 15 particle DLs (assets/oddtextures.c globalDL_0x078..0x9a8,
all G_CC_INTERFERENCE+G_CC_MODULATEIA2) never get G_LIGHTING cleared by any
of explosion.c's own gSPClearGeometryMode calls. If lighting is left on from
a prior draw (e.g. a nearby lit character model), the port's vertex path
(gfx_pc.cpp:1301-1346) reinterprets the particle's authored RGBA tint as a
normal vector, turning SHADE into a scene-light color instead of red/orange
-- state-dependent, would explain "game-wide, live-play-only" exactly as
reported, and why the M-90 scripted repro (no nearby lit models) came back
clean.
Extends the existing D172 multitex-tri probe (GE_D172=1, already proven
reliable and uniquely keyed to this particle family) to also flag
geometry_mode & G_LIGHTING. Sanity-checked against the known-clean M-90
repro: still fires 40/40, all LIGHTING=off, matching the prior clean result
-- confirms the probe works without false-positiving.
Not a fix -- needs one live GE_D172=1 capture during a real explosion to
confirm or rule out before touching any rendering logic.
* fix: regenerate findings-index.csv via tools_pc/gen_findings_index.py
Hand-edited the CSV directly in the previous commit instead of running the
generator -- CI's "validate" job caught the drift.
gfx_sp_tri1's CPU-side trivial-reject AND-test trusted per-vertex outcodes
(x<-w / x>w / y<-w / y>w / z>w) that are only valid half-space tests when
w>0. A vertex that has crossed behind the camera plane (w<0 -- common when
the camera is close to a large room-structure polygon, e.g. walls/ceiling
trusses near a doorway) flips the comparison sense, so its outcode can come
out wrong. When that spurious bit happens to match the other two genuinely
correct vertices' bits, the AND-reduction fired and dropped the whole
triangle before it ever reached OpenGL -- even though GL's own homogeneous
clipper (which handles w<0 correctly) would have rendered the visible part
of it.
This reads exactly like D233 (indoor geometry intermittently vanishing near
doors while props/furniture keep drawing): large near-camera polygons are
far more likely to have a vertex cross behind the eye than small
self-contained prop meshes, and the backface-cull code six lines below
already special-cases this same w-sign hazard for its own cross-product
sign -- the trivial-reject block above it never got the same treatment.
Fix: skip the trivial-reject AND-test whenever any of the triangle's three
vertices has w<0, deferring to GL's clipper instead. This can only ever
send MORE triangles to the GPU than before, never fewer, so it cannot make
anything newly invisible.
Also investigated D234 (Dam guard towers, only the first renders) under
the same M-106 hypothesis that it shares a root cause with D233/D222 --
did not find fast3d-side evidence for it; left open in findings.md with
the ruled-out mechanisms and a next-step recommendation. D222 (FovScale
breaks culling) is a separate, already-characterised game-logic bug in
src/game/fr.c/bondview2.c, out of this fast3d-only fix's scope.
Build verified green (MSYS2 mingw64 cmake+ninja, full link succeeds).
Could not visually verify -- this worktree has no ROM/data and no
interactive display; see docs/dev/findings.md D233 entry for the exact
human verification steps (GE_PCDUMP on a doorway-heavy interior).
docs/dev/findings.md: D233 (FIXED, high confidence on mechanism/unverified
visually) + D234 (OPEN, not root-caused) entries added.
docs/porting-notes.md: generalised the w<0 trivial-reject hazard as a
recurring class for any future CPU-side "skip this triangle" heuristic.
docs/dev/findings-index.csv regenerated.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
GE assembles weapon and character model display lists in scratch arena RAM
and reissues gDPLoadTLUT from a repeated source address with different
palette *content* between materials and frames. The CI TextureCacheKey
keyed only on {texel addr, palette_addrs[0/1], palette_index, size_bytes}
with no content check, so gfx_texture_cache_lookup() (a plain map.find)
could return a stale HIT whose GL texture was decoded against a different
palette. Proven with an env-gated probe on -level_34: the same
{addr, paladdr} pair re-imported many times per frame with materially
different rdp.palette content, all taking HITs.
Fix: FNV-1a hash of the 512-byte rdp.palette, recomputed only in
gfx_dp_load_tlut() (rare vs draws -> no per-texel or per-draw cost), added
as a trailing field of the CI cache key. Non-CI keys carry 0. Cannot
produce a worse decode than the stale HIT it replaces; worst case is a few
extra texture uploads per frame (~2 measured on -level_34).
Partial for D217: this removes a real stale-palette-HIT class (the D217
finding's own "test a palette-content hash" next-step) but does NOT resolve
the visible green PP7 grip. That has an upstream cause - the grip's count=1
model TLUT is fed different source bytes per level (Bunker 0x0001 -> black,
Facility 0x1A00 -> green) for the same tile; fast3d faithfully stores what
it is given. See scratchpad D217-report.md. Full 21-level world-CI
regression sweep still owed before merge.
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
D211 didn't affect the in-level view (user test): the fast3d
gfx_apply_fov_scale hook only recognised *pure* perspective matrix loads,
but GE draws the world through g_CurrentPlayer->field_10E0 -- a
projection x view matrix pre-combined on the CPU (bondview2.c:8224) --
which failed the guard. Only the pause/watch model + sky FOV changed.
Fix: scale fovy game-side at the guPerspectiveF chokepoint
(src/fr.c, #ifdef PORT, frFovY *= portFovScale, clamp 160deg), which
lands before the CPU combine. Removed the fast3d hook + gfx_api.h decl;
port/src/video.c now sets f32 portFovScale. Gated to
lvlGetCurrentStageToLoad() != LEVELID_TITLE so the front end (which
renders through the same path) keeps its authored FOV.
User-verified: menu unaffected, in-level view widens (Dam tunnel).
Caveat: currentPlayerSetCameraScale keeps nominal FOV for frustum-cull
/ screen<->world math (edge geometry culls slightly early at high scale).
Adds src/fr.c as a #ifdef PORT game-code touch (D181 policy class).
Docs (this test pass):
- D214: config.c sections by the last dot, so binds live in [Input.Bind]
not [Bind]. Doc-only fix (finding, index, QOL-INVENTORY); code unchanged.
- D217 (OPEN): viewmodel + 3rd-person Bond model textures render garbled
-- stale-palette CI-texture bug (D161 family), surfaced by D215.
- D218 (LOGGED, parked): wide FOV exposes the per-level FarFog
draw-distance boundary; Video.DrawDistance fix proposed.
Three port-only knobs, each defaulting to byte-identical behaviour:
- Video.FovScale (D211): multiplicative half-angle scale on every perspective
projection matrix as it is loaded (gfx_apply_fov_scale, gfx_pc.cpp). Columns
0+1 scaled together so H/V FOV widen uniformly and aspect is preserved; ortho
(HUD/menu) matrices untouched. Composes with the game's per-frame aim-zoom
rewrites for free. Percent of vertical FOV, 50-150, default 100 => guarded
early-return (zero FP ops, golden dumps byte-identical). WIDESCREEN-FOV-PLAN
Phase 4, landed standalone.
- Video.Anisotropy (D212): expose fast3d's already-implemented GL anisotropy
hook as config (1-16). New gfx_set_anisotropy_level() extern-C wrapper clamps
to the GL max. Default 4 = the value fast3d already hardcoded => no delta.
- Video.DisplayFPS (D213): PD-parity top-right FPS readout, sampled once per
emitted frame (optionsoverlay.c fpsTick). Config-only; default 0 => emit
still returns NULL with the panel closed.
F10 overlay gains 'Anisotropic' and 'FOV scale %' rows (DisplayFPS is file-only
- the panel is at its 13-row layout limit, matching PD).
Build clean (ntsc-final); bunker1 framediff worst_cell 12.219 vs 12.221
baseline = render noise, no-op confirmed. Interactive eyeballs owed (see
docs/HANDOFF.md M-83).
findings.md: D211-D213 rows + index regen. WIDESCREEN-FOV-PLAN Phase 4 and
QOL-INVENTORY rows marked done.
ROOT CAUSE FOUND (GE_D172 probe, user Silo firefight capture): particle
draws (G_CC_INTERFERENCE = TEXEL0*TEXEL1) configure tile 0 = IA8 smoke @
tmem 0 and tile 1 = RGBA16 fire @ tmem 0x188, but gfx_lod_tile_offset()
returns 0 unconditionally on the !gfx_detail_textures_enabled path (a D107
blurry-ceiling fix), so texunit 1 samples the smoke texture too. smoke *
smoke -> the magenta/cyan blood/spark colour.
Probe evidence, 40/40 particle tris:
cfg1[tile=1 tmem=392 fmt=RGBA siz=16b] SAMPLED1=tile0(tmem=0 fmt=IA)
Fix: only fold texunit i to the base tile when LOD is actually active
(rdp.tex_lod). D107's mip case (tex_lod=1) is unchanged; genuine non-LOD
multitexture now gets tile i. bunker1 verify PASS (worst_cell 11.3, the
D107 repro level, no regression).
Needs an in-game eyeball: blood/sparks should read dark red, not magenta.
The 16-line budget filled during the Silo intro with LOD-bilerp tris
(TEXEL1 = mip level, T0==T1 is correct there). New probe: only fires when
the DL configured tile first+1 at a DISTINCT tmem (genuine 2nd material),
logs tex_lod + which tile each texunit actually SAMPLES vs what was
configured, flags TEXEL1==TEXEL0. Budget 40.
The old 'cyc2 combine at cycletype=1CYC' probe over-fired on trivial
passthrough cyc2 terms. Replace with one that fires only when a 2-cycle
triangle actually consumes TEXEL1 (the G_CC_INTERFERENCE particle case) and
dumps both texunits' tile/tmem/fmt/siz. One Silo capture then shows whether
TEXEL1 resolves to the fire tile (tmem 392, RGBA16) or something wrong.
Build clean; bunker1 verify PASS.
Real particle records (assets/oddtextures.c globalDL_0x078..) DO set
gsDPSetCycleType(G_CYC_2CYCLE); GE_D172=1 probe confirms fast3d applies it
(particle combine fc26a004 logged at cycletype=2CYC). Both the 0xB9-abort
and the cycle-type hypotheses are now dead. Bug is the G_CC_INTERFERENCE
two-tile combine / TEXEL1 path (IA8 smoke x RGBA16 fire @ tmem 0x188).
Probes (#ifdef PORT, GE_D172=1, inert otherwise):
- gfx_pc.cpp G_SETCOMBINE: distinct combine + active cycletype
- gfx_pc.cpp gfx_sp_tri1: tris with cyc2 combine drawn at cycletype!=2CYC
Needs a display capture to go further (headless can't see particle colour).
New port/src/optionsoverlay.c + port/include/optionsoverlay.h: an
immediate-mode options overlay drawn as its own fast3d 2D display list,
appended after the game DL. Implements docs/dev/OPTIONS-MENU-PLAN.md §2/§3.
- config.c/h: configForEachOption() + configSetOptionMeta() label/step/enum
side table; CONFIG_OPT_* enum. config.c stays free of specific-key knowledge.
- gfx_pc.cpp gfx_run(): one-line hook runs optionsOverlayEmit() after the game
DL, before gfx_flush. Returns NULL when closed -> zero bytes appended, golden
dumps byte-identical.
- video.c: F10 toggles (videoPumpEvents, next to F12); ESC closes; wheel
scrolls; videoRequestLiveConfig()/liveCfgDirty re-applies VSync/FpsCap/
TextureFilter at frame start.
- input.c: while open, controller 0 returns a neutral pad and nav
(arrows/enter/wheel/mouse) routes to optionsOverlayHandleInput() — mirrors
the D180 WI-1 swallow pattern.
- v1 rows: VSync, FpsCap, MSAA(restart), TextureFilter, MouseAimSpeed,
MouseTurnSpeed, MouseInvertY, MouseCaptureMode, ScreenShakeIntensity.
Live-apply where possible; configSave() on close.
- GE_OPTIONSOVERLAY=1 auto-opens at boot (env-gated diagnostic, left in).
Port-layer only, no src/ edits, no #ifdef PORT in game code. Build links
241/241. Runtime framediff/soak/eyeball owed on an interactive machine
(this headless env kills the GUI process early and writes 0-byte PPMs).