Commit Graph
287 Commits
Author SHA1 Message Date
James Dansereau 3dcceaafd9 ci: route windows-build to self-hosted runner (G2); land ROM-gated sweep workflow (G1) (#65)
* ci: route windows-build to self-hosted runner (G2); land ROM-gated sweep workflow (G1)

G2: windows-build now runs on the self-hosted box (corpyvt6agent01) for
push/workflow_dispatch, using local ccache instead of the actions/cache
round-trip and skipping msys2/setup-msys2's install once packages are
already present. pull_request always stays on windows-latest — fork PRs
must never reach a box that will hold a ROM.

G1: adds .github/workflows/selfhosted.yml, a dispatch-only workflow that
builds + runs the 21-level headless sweep (tools_pc/verify.sh sweep --json)
against a runner-cached ROM. Inert until manually dispatched.

Still pending (manual, physical steps on the box — not done by this
commit, see docs/dev/notes/SELFHOSTED-RUNNER-PLAN.md rollout table):
- register corpyvt6agent01 as a runner against this repo
- confirm MSYS2 at C:\msys64 with the required mingw64 package set
- one-time ROM seed into {runner_home}/_rom-seed/
- first workflow_dispatch sweep + parity check against a local run
- flip G1 from workflow_dispatch to push:[main] once green

Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU

* fix(selfhosted.yml): remove invalid concurrency.max-parallel key

Found by testing: dispatching the workflow failed with a hard parse
error (HTTP 422, "Unexpected value 'max-parallel'"). GitHub Actions'
workflow-level `concurrency` block only supports `group` and
`cancel-in-progress` — `max-parallel` is a job `strategy:` key for
matrix jobs, not a concurrency-block key. Serialization was already
achieved via group + cancel-in-progress:false; the stray key just
blocked every dispatch.

Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU

* fix(selfhosted.yml): register the msys2 shell wrapper (setup-msys2 step)

Found by testing: the first fixed dispatch failed every msys2-shell step
with "msys2: command not found". Unlike ci.yml's windows-build, this
workflow used `shell: msys2 {0}` without ever running msys2/setup-msys2,
which is what registers that shell alias for the runner — MSYS2 being
pre-installed on the box doesn't help if Actions doesn't know the shell
exists. Adds the same setup-msys2 step ci.yml uses, with update:false
since the packages are expected already present (fast no-op per plan).

Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU

* ci: stop reinstalling MSYS2 per run on the self-hosted box

The self-hosted paths (selfhosted.yml; ci.yml windows-build's push/
dispatch route) no longer call msys2/setup-msys2 or pacman -S at all.
MSYS2 + the mingw64 package set are now assumed hand-maintained on
corpyvt6agent01 (docs/dev/notes/SELFHOSTED-RUNNER-PLAN.md), invoked by
calling its bash directly (`C:\msys64\usr\bin\bash.exe -leo pipefail
"{0}"`, the same invocation setup-msys2's generated wrapper uses) with
MSYSTEM=MINGW64 so /etc/profile still puts mingw64/bin on PATH.

Adds tools_pc/check-selfhosted-deps.sh: a fast, install-free check that
exits 0 immediately when the required packages (toolchain, cmake,
SDL2, zlib, python, ccache, zip, unzip) are present, or fails with the
exact `pacman -S --needed ...` line to run otherwise — so a missing
package on the box surfaces immediately with a clear fix, instead of
as a confusing failure deeper in the build.

ci.yml's windows-latest (PR) path is unchanged: ephemeral runners still
get a full msys2/setup-msys2 install every time, since there's nothing
to persist there.

Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU

* fix: use msys2/setup-msys2 release:false instead of a raw bash-path shell

The previous attempt (invoking C:\msys64\usr\bin\bash.exe directly as a
custom `shell:` command) failed on the actual runner with "Second path
fragment must not be a drive or UNC name" — a real limitation, not a
typo; it failed identically in both files, including the one with a
static (non-templated) shell string, so it wasn't the ${{ }} expression
either.

Root-caused by reading msys2/setup-msys2's actual source: `release:
false` makes it reuse the existing C:\msys64 as-is (hardcoded path in
that branch, confirmed in main.js) instead of downloading+installing a
fresh copy, and — verified in the same source — omitting `update` and
`install` skips every pacman call entirely (just one `uname -a` + a
pacman.conf sed tweak, no network). This is the officially documented
mechanism for self-hosted runners with a pre-existing MSYS2 install
(see the action's README, "release" section).

ci.yml's windows-build now runs setup-msys2 unconditionally but with
release/update/install all keyed off github.event_name: full install on
windows-latest (PR, ephemeral), reuse-only on the self-hosted box
(push/dispatch). selfhosted.yml always uses release:false. Both then
run tools_pc/check-selfhosted-deps.sh to verify the box's hand-
maintained packages are actually present.

Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU

* fix(selfhosted.yml): cp -r for the ROM sidecar directories

Found by testing with the ROM seed actually in place: pcmodels-ntsc-final
and pccg-ntsc-final are directories (not single files, unlike
ge007.ntsc-final.z64), so a plain `cp` failed with "omitting directory"
and left the seed step failing on every run even with the seed populated.

Claude-Session: https://claude.ai/code/session_01J5PZ3sTHiuW6MdQnepasSU

* ci: drive MSYS2 MINGW64 directly via ci-shell.cmd, drop msys2 shell shim

The self-hosted runs were dying with 'msys2: command not found' because the
msys2/setup-msys2 action's shell shim was never registered on the box. Replace
the dependency on that shim with a small committed launcher (tools_pc/ci-shell.cmd)
that headlessly reproduces the local 'MSYS2 MINGW64' terminal:

  - MSYSTEM=MINGW64 + /mingw64/bin on PATH
  - NON-login bash, so it keeps the workspace cwd (a login shell cd's to $HOME
    and would break every relative path in the workflow)
  - -e -o pipefail for GitHub-style error propagation

Both workflows now set defaults.run.shell to C:\msys64\ci-shell.cmd {0}; a
bootstrap step (shell: cmd) refreshes the launcher from the repo into C:\msys64
each run. selfhosted.yml drops setup-msys2 entirely; ci.yml keeps it only for
the ephemeral windows-latest PR path (which installs MSYS2 at C:\msys64).

.gitattributes: force CRLF for *.cmd so cmd.exe parses the launcher correctly.

* Revert "ci: drive MSYS2 MINGW64 directly via ci-shell.cmd, drop msys2 shell shim"

This reverts commit a976b2589c.

* docs(D244): log self-hosted-runner link failure — undefined reference to objectiveGetStatus_WEAK

First real build attempt on the reinstalled corpyvt6agent01 box (run
34707798707) fails at link with an unresolved #pragma weak alias. A local
repro of the identical alias pattern links fine on current MSYS2
(gcc 16.2.0/binutils 2.47); the box reports gcc 16.1.0 — likely a stale
toolchain, consistent with rollout step 2's earlier package-hygiene gap.

Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3

* fix(verify.sh): surface run.log tail on NO-FRAMES instead of silently discarding it

sweep mode's per-level run.log (the game process's own stdout/stderr, where
an SDL/GL init failure would show up) was captured into a mktemp capdir but
unconditionally rm -rf'd with no diagnostic ever printed for the NO-FRAMES
(no-crash) case -- every level failed this way on the self-hosted runner's
first real sweep attempt with zero clue why.

Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3

* fix(selfhosted.yml): stop deleting the ROM seed dir -- cache save silently fails on this box

actions/cache/save has been failing to persist the ROM cache entry on
corpyvt6agent01 (WARNING annotation, tar.exe exit code 2) but doesn't fail
the step -- by design, a cache-save failure is non-fatal, so the workflow
carried on and the now-unconditional "Delete one-time seed dir" step wiped
the only copy of the ROM every single run. Every dispatch since has failed
at "Seed ROM cache" with an empty seed dir and an empty cache -- exactly
the user-reported "it wipes out the ROM data each run".

Fix: don't delete the seed dir. It's cheap disk (three files, a few hundred
MB) and the existing restore-then-seed-if-missing logic already treats it as
the fallback source whenever the cache comes back empty, so leaving it in
place is safe and makes the workflow tolerant of the cache-save bug until
that's root-caused separately.

Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3

* fix(verify.sh): sweep --json never actually produced a verdict array; fix NO-FRAMES diagnostic clipping it further

Two bugs, found together while chasing the self-hosted runner's first real
sweep attempt:

1. sweep mode's `out=$(verify_level ...); echo "$out" | head -1` always
   discarded emit_verdict's second (--json) output line -- RESULTS_JSON was
   declared for assembling a verdict array but never populated or printed,
   so `sweep --json` has never produced anything but `[]` for the
   workflow's "Extract JSON verdict" step. Now builds and prints a real
   `[{...}, {...}]` array before the RC exit.

2. My own D244-session addition (printing a NO-FRAMES level's run.log tail)
   wrote to stdout, which the same `out=$(...)/head -1` capture turned into
   the *new* first line, clipping the real verdict summary. Moved to stderr,
   which bypasses that capture.

Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3

* docs: regenerate findings-index.csv for D244

Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3
2026-09-12 15:33:07 -04:00
James Dansereau 7e99344741 docs(D148/D160/D173): re-confirm propDef stride ruled-out; rule out PadRecord.stan and act_anim aliasing for puppet-position bug (#61)
Investigation pass on the cutscene-reliability cluster (D148/D160/D173),
part of the 1.0 acceptance-bar #4 push. No build/runtime toolchain was
available in this worktree (no gcc/cmake on PATH, MSYS2 MINGW64 not
active), so this is a static-analysis-only pass; no code changes.

D160 (Dam end-cutscene / D148 label): independently re-derived (not
just re-cited) that hypothesis (a), a propDef sizepropdef() stride
desync, is ruled out — CutsceneRecord (propDef type 46) is all-scalar,
28B, zero pointer/union fields, so the D122/D126/D132/D209 pointer-
width-growth class cannot apply. Confirms the existing M-31 audit
recorded in porting-notes.md. Hypotheses (b)/(c) (AI trigger flow /
invisible puppet render) remain unconfirmed — still need the shipped
GE_D160=1 trace driven live.

D173 (third-person Bond puppet floats / spins in cutscenes): checked
the two most D209-shaped candidates for a puppet Y-position aliasing
bug and ruled out both. (1) PadRecord.stan is a ROM-serialized pointer
field zeroed by tools_pc/d88_emit.py's emit_pad() with a "dead-on-load"
comment; verified against the N64 source asset tables (UsetupdamZ.c)
that every PadRecord literal initializes stan to 0 on N64 too, so the
PC zeroing is byte-identical to ground truth, not a converter bug.
(2) The anim-root-motion Y-accumulation chain was already exhaustively
audited during D193/D209 (M-80/M-81) and the padding[]-union sweep
that found D209/D210 explicitly cleared act_anim/act_dead as
layout-stable. Root cause not found; the D209-style struct-aliasing
pattern does not appear to be the shared explanation here, unlike the
hope stated in the dispatch brief. Flagged next steps: a live Cradle
capture, and locating the actual two-actor Cuba end-credits code path
(not found this session).

Added a generalizable porting-notes.md quirk: before assuming a zeroed
ROM-serialized pointer-width tail slot is a converter miss, check the
N64 source literal — some fields are legitimately always NULL on both
platforms.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-12 10:15:12 -04:00
James Dansereau 650ac57fc6 fix(D233): fast3d trivial-reject drops visible triangles with a behind-camera vertex (#63)
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
2026-09-12 10:11:51 -04:00
James Dansereau 067c8384d0 docs(M-106): log user QA report as D233-D239 (#60)
* docs(M-106): log user QA report as D233-D239 (delegate triage)

7 new open findings from local-delegate triage of a fresh user QA pass:
- D233: indoor geometry vanishes near doors while world objects still draw
  (portal/frustum-cull suspect; screenshots committed, D106/D222 neighbours)
- D234: Dam guard towers invisible except the first (instance suspect)
- D235: Facility wrong textures, reported as a regression (bisect vs
  pre-D217-fix build first)
- D236: Surface 1 billboard trees render as a texture-sheet wall, not
  discrete camera-facing sprites
- D237: F10 overlay QoL — wheel scroll, more exposed settings incl.
  Game.NoHitFlash, category grouping
- D238: mouse sensitivity rework — separate RMB aim sensitivity, single
  linked master by default, fix up/down vs left/right asymmetry
- D239: click-to-lock is already default; drop always-grab entirely if
  parity isn't cheap (re-confirms M-82, closes D192 by deletion)

findings-index.csv regenerated via tools_pc/gen_findings_index.py.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* docs(M-106): file D230 — in-level music quality (bass instrument corrupted)

Repro obtained from the user, deterministic (not intermittent): Control
(elevator + regular track, throughout), Cavern (elevator, throughout),
Runway (a bass instrument enters ~5-7s in and stays bad). User confirmed
against original N64 audio that a bass instrument belongs on Runway --
instrument selection is correct, only its sound is wrong on PC. Determinism
scopes this to a static decode/synthesis bug (csplayer.c/synthesizer.c or
bank loading), not the D147/D152/D202 live voice-stealing family.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* docs(M-106): dedupe D230 -- fold M-106 repro into the original M-105 filing

The M-105 session already filed D230; the M-106 delegate triage didn't
know that (branched off a stale local main before pulling) and filed a
second D230 row with the refined repro. Merged into one entry -- kept
the M-105 filing as the base, folded in the M-106 deterministic repro
(Control/Cavern/Runway) and the bass-instrument-is-correct clarification.
findings-index.csv regenerated via tools_pc/gen_findings_index.py.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* docs(D235): clarify trigger is death/restart, not a Facility-only regression

User clarification: the wrong-texture symptom isn't specific to Facility
and isn't a general build regression -- it's triggered by dying/
restarting a level (seen on other levels too). Reframes the leading
hypothesis from "bisect against #47/#49" to a texpool/CI-cache reset gap
on level re-entry (D217/D228 neighbourhood). Updated GRAPHICS-BACKLOG.md
+ findings.md; findings-index.csv regenerated.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-12 10:00:01 -04:00
James Dansereau 646798be56 docs(M-102): fix findings-index false-positive, release <version> bug, land parked site cleanup (#53)
* docs(M-102): fix findings-index false-positive, release <version> bug, land parked site cleanup

Docs triage pass (docs/dev/notes/PROMPT-docs-triage-session.md).

- gen_findings_index.py: D194's row was silently bucketed FIXED (an open
  design item) because it had no distinct status cell, so the generator's
  fallback keyword search matched "fixed" somewhere inside 6,500 chars of
  narrative -- the same misleading-index failure mode that cost a session
  on D227 (see docs/dev/findings.md SS H "D227 -- M-98"/"M-99"). Gave D194
  a real status cell. Also extended LEAD_RE/bucket() with 5 verdict tokens
  already in use in the doc (ANALYZED, GUARDED, DIAGNOSTIC SHIPPED,
  ROOT-CAUSED, NOT A BUG) and added a leading verdict word to 4 more rows
  that had a status cell but no recognized token -- fixes all 10 rows the
  generator previously bucketed "?" (D175 D174 D173 D220 D160 D133 D136
  D156 D153, plus D194). findings-index.csv regenerated; --check passes;
  diff reviewed, only these 10 rows moved.
- ci.yml: release-notes.md's <version> placeholders were never substituted
  before `gh release create --notes-file`, so every published release body
  (including the live v0.1.0 release) ships the literal text. Now sed'd to
  the tag first.
- Landed docs/dev/notes/UNLANDED-docs-site-cleanup-steps3-5.{md,patch}
  (parked M-101, never pushed, branch deleted): deletes the orphaned
  docs/StructureGuide.md (0 tracked references), adds title/description
  frontmatter to 5 published Pages docs, rewrites docs/index.md's stale
  "Phase 2 of 4 / no audio yet / 19-of-21" status text to match the
  current README. The 2 files whose hunks no longer applied via `git am`
  (docs/index.md, docs/dev/agentic-development.md) were redone by hand
  against current main.
- docs/BACKLOG.md (local, gitignored): retargeted a dead
  docs/PORT-LEARNINGS.md reference to docs/porting-notes.md.

Larger triage items (findings.md monster-row convention, full
status-accuracy sweep, duplicate-defect clustering, dev-notes plan
triage, HANDOFF rotation policy) scoped but not started -- see
docs/dev/notes/FOLLOWUP-docs-triage-session.md (gitignored).

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* docs/index.md: fix stale in-level-music claim before merge

This PR predates the M-102c/M-105 correction (already landed in the
README via PR #55): in-level music is NOT absent, it plays with some
tracks having wrong-sounding instruments / occasional garbling (D77
fixed, D230 filed for the residual). This branch's own rewritten Status
paragraph still said music 'doesn't yet' play -- fixing it to match
before merge so main doesn't briefly regress to a stale claim this
session already corrected elsewhere.
2026-09-12 09:33:54 -04:00
James Dansereau a24495672e docs: scrub stale status claims (D77 music, D227 sky, D217 green textures) (#55)
* docs: scrub stale status claims (D77 music, D227 sky, D217 green textures)

The M-104 roadmap refresh flagged four live inaccuracies; this fixes all
four plus files the missing music-quality finding:

- README: in-level music no longer described as silent/absent (D77 fixed,
  PR #42); sky line no longer 'still being fixed' (D227 merged, PR #43) --
  D229 green water is now the named sky-path residual.
- GRAPHICS-BACKLOG.md: D77 row updated PARTIAL -> RESOLVED with root cause.
- findings.md: D217 status cell led 'OPEN -- root cause characterised
  (M-86)' although the green defect was fixed and merged (PR #47/#49, two
  human playtests); now leads FIXED with the D228 split-out noted.
- New D230: in-level music quality residual (wrong instruments on some
  tracks -- elevator named specifically -- plus occasional garbling).
  Scope pointed at the seq/instrument-bank decode path, not the proven
  mixer/trigger layers.

findings-index.csv regenerated; gen_findings_index.py --check passes.

* docs(D190): root-caused and closed — level 45 is MP-only, no solo setup in ROM

M-48's 'Linux-only crash' was a misframing. Full chain (reproduced on
Windows this session, 0xc0000005 in modelLoad via domakedefaultobj with
obj=0x784f):

- setup_text_pointers[45] = "UsetupimpZ" but that name is absent from the
  ROM file table; only the MP variant "Ump_setupimpZ" exists. BASEMENT is
  a 4P MP level (front.c multi_stage_setups[]) — its solo name was never
  shipped, like the other MP-only levels (STACK/TEMPLE/COMPLEX/CAVES/
  LIBRARY).
- A solo -level_45 boot requests the nonexistent file. On PC the inert
  rmon stubs leave fileGetIndex's dynamic-path response uninitialized ->
  size-0 table entry -> 0-byte load into a stale pool buffer -> the
  propDef walk runs over stale memory (M-48 hit add_ptr_to_objective;
  same garbage walk, different contents).
- N64 parity: file genuinely absent there too; the boot is broken in the
  original as well. No fix taken (a port guard cannot synthesize a valid
  setup; fabricating ROM content has no ground truth).
2026-09-12 09:19:04 -04:00
James Dansereau 3c11e40c13 fix(D223): directional mouse-wheel weapon cycling (#54)
* fix(D223): directional mouse-wheel weapon cycling

Wheel-up keeps the existing 2-poll A pulse (weaponForwardOffset: fresh
A edge, Z released). Wheel-down now runs a 2-poll backward sequence:
A-only poll, then A+Z_TRIG -- the fresh-Z-edge-while-A-held combo that
weaponBackOffset requires (bondview2.c:5337-5340); ordering matters
because the edge is computed against the game's own previous poll.
moveData.triggerOn (bondview2.c:5419) requires A to be released, so
the synthesized Z edge can never fire a shot.

Roles are stable: the port forces CONTROLLER_CONFIG_HONEY
(bondview.c:1484), whose scheme gives invButtons=A_BUTTON /
shootButtons=Z_TRIG -- the same roles the pre-fix forward pulse
already relied on. While RMB aim is held the game flips controldef to
KISSY, under which neither direction cycles -- identical to the old
forward-only behavior in that state (pre-existing, not a regression).

Port-only; no src/ changes.

* docs(D223/D196): mark D223 FIXED (M-105); correct stale D196 status

D223 row now records the implemented fix and its known aim-mode
limitation. D196 was already fixed and merged (a17d5466, the
prescribed 1-line applyCursorVisibility() after reconcileGrab()) but
its status cell still led OPEN -- same stale-claim class the roadmap
flags; corrected. findings-index.csv regenerated.

* D223 follow-up: fix wheel-down backstep racing the real game tick

User playtest (M-105 cont.) found the backward-cycle combo unreliable:
PP7->AK47->PP7->wheel-down sometimes correctly landed on melee, but
sometimes landed back on AK47, and separately AK47->wheel-down sometimes
skipped past PP7 straight to melee.

Root cause: the wheel-down pulse staggered the synthetic 'hold A, tap Z'
combo across two polls (A alone, then A+Z one poll later). Whenever those
landed in two SEPARATE game ticks -- which happens unpredictably depending
on real-time poll/tick alignment (same D117-class nondeterminism as
everywhere else in the port) -- the 'A alone' tick was itself a fresh A
edge with no Z held, which bondview2.c's weaponForwardOffset formula (both
control-scheme sites) reads as a genuine cycle-FORWARD request, firing a
real, visible weapon change *before* the correcting backward tick ran.
Confirmed with an instrumented trace build: every wheel-down after the
very first one in a session mis-fired as a forward step.

Fix (port/src/input.c only, no game-logic touched): present A and Z
together from the first poll instead of staggering them, so whichever
single tick samples the 0->(A|Z) transition sees them rising
simultaneously. weaponForwardOffset requires Z NOT held, so that's
unambiguous -- only the backward path can fire, regardless of tick
timing. moveData.triggerOn (actual fire) is separately gated off while
A/invButtons is held, so this doesn't introduce an accidental shot.

User-verified: repeated up/down cycling through PP7/AK47/melee/Skorpion
in both directions with no more mismatches.
2026-09-12 09:18:43 -04:00
James Dansereau 1a97895a37 docs(issue #6): resolve 3 of the drop-in-ROM plan's open questions (#57)
Research pass (local Qwen delegate, repo-mapper role, findings spot-checked
against the actual files):

- filelist.u.csv hard-coding in d43_emit.py/d69_emit.py is confirmed real
  (converts PAL/JP ROMs with NTSC byte offsets); per-region filelist.e.csv/
  .j.csv already exist and have the same shape, so the fix is narrow. Real
  remaining unknown: whether modelFileHeader.inc.c's NS/NT differ cross-
  region (needs an actual PAL/JP ROM to check).
- Native converter output path for Part B nailed down exactly:
  data/pcmodels-<region>/{pcmodels.bin,manifest.csv} and
  data/pccg-<region>/{pccg.bin,manifest.csv}, via pcmodels.c/pccg.c's
  existing $S//$E//./ + country-byte region resolution.
- Part B's CMake codegen scope narrowed to one real artifact: a 512-entry
  model-symbol -> (NS,NT) table from modelFileHeader.inc.c. Everything else
  the emitters read is either runtime-readable from disk or a small
  hand-transcribable constant.
2026-09-12 09:18:16 -04:00
James Dansereau 741dfe78b9 fix(D227/D176a): sky texcoords are S10.5, not texels — fixes seam, over-tiling and scroll speed (#43)
* fix(D176a): sky scroll/tiling too fast -- perspective-correct tc interpolation

skyPortRenderPoly (Path B, D176(a) black-sky fix) placed sky quad verts
under a plain w=1 ortho projection, so fast3d's GPU pipeline interpolated
texture coords linearly in screen space across the primitive. The source
S/T data (unk20/unk24) is computed upstream from a world-space projection
without a perspective divide -- exactly like the real N64 RDP's
G_TRI_SHADE_TXTR coefficient block, which relies on real perspective-
correct interpolation to divide it back out per pixel.

A single sky quad's near (top) and horizon-grazing (bottom) vertices can
differ up to ~38x in camera-space w. Under linear interpolation that
smears the entire S/T delta evenly across every pixel instead of
concentrating it near the horizon, producing a uniformly dense aliased
'hatching' over the whole sky -- reported as 'scrolls too fast / tiles
too visibly'.

Fix: build a custom projection matrix that gives each vertex its real
camera-space w (already computed upstream, SkyRelated38.unk0c) while
keeping the same screen-space x,y, so the GPU's normal perspective divide
recovers the intended position but interpolates tc (and colour) with
real 1/w weighting -- the same pipeline every other textured draw in the
game already depends on. No port/fast3d changes; #ifdef PORT only, N64
path untouched.

Verified: tools_pc/verify.sh bunker1 PASS (no regression on a non-sky
level); -level_22 (Statue) and -level_41 (Cradle) GE_PCDUMP captures show
the expected before/after change (uniform parallel aliasing -> a proper
perspective-converging fan), and Cradle's sky is no longer black at
default FOV. Defect 2 (horizon-band tessellation) and the FOV-coupled
black-sky-past-frustum symptom are unchanged / still open.

docs/dev/findings.md: new D176(a) subsection + summary-row update.
docs/porting-notes.md: generalized the screen-space-overlay quirk to
call out the w=1-vs-real-w interpolation pitfall.

* chore(D176a): regen findings index

* docs(D176a/D227): M-93 - human playtest of PR #43 confirms Cradle black-sky fix + partial Statue scroll improvement; new split-sky residual filed as D227



* docs(D227): M-94 - visual evidence + a concrete mechanistic hypothesis for the sky seam (per-primitive wScale quantization), not yet fixed

Headless -level_22 capture (this shell can do offscreen GE_PCDUMP capture
via the game binary directly, run from the repo root with the MSYS2
mingw64 PATH set -- the earlier GUI-launch flakiness in this session was
specific to a live window, not this path) shows a real, static-frame
crease in the sky's cloud texture. Screenshot committed:
docs/img/bugs/d227-sky-seam-statue.png.

Hypothesis: skyPortRenderPoly (D176(a)'s fix) computes wScale per call,
and skyRenderTri/skyRenderFull issue up to 5 separate calls per frame
(one per triangle in the sky fan). The position math is exact for any
wScale (re-verified algebraically), but Vtx.ob[] is s16, so two
triangles sharing an edge can quantize a logically-shared vertex to two
slightly different values depending on which triangle's independently-
chosen wScale computed it -- crack at the shared edge, reads as "two
regions with a different pattern."

Not yet confirmed (didn't isolate camera motion from sky-scroll motion
to test the "moves at a different rate" half of the report) and not
fixed -- documented the fix direction (one wScale shared across the
whole fan, not recomputed per triangle) for whoever picks this up next.
Cleaned up capture scratch files after pulling the one evidence shot.

* docs(D227): M-94 addendum - static-camera test confirms the sky genuinely animates over time, independent of camera motion

* fix(D227): M-95 - one shared wScale across the whole sky fan instead of one per triangle, closes the visible seam

skyRenderTri/skyRenderFull draw up to 5 triangles per frame from a
shared pool of up to 5 vertices (built once, then fan-triangulated
across 1-3 calls). D176(a)'s fix had each call compute its own wScale
from only its own 2-4 verts; two triangles sharing a vertex could each
pick a different wScale, and since Vtx.ob[] is s16, quantize that
logically-shared vertex to two different values depending on which
triangle's call computed it -- a crack at the shared edge (D227, the
"two sky halves" report).

Fix: skyPortBeginFan()/skyPortEndFan() (new, #ifdef PORT) compute one
wScale across the whole fan and skyPortRenderPoly uses it when set,
falling back to the old per-call computation otherwise. N64 path
unchanged (these are PORT-only additions around existing #ifdef PORT
code, no #else branch touched).

Verified:
- Clean build, no new/duplicate symbols.
- Full verify.sh sweep: 21/21 PASS, both with and without this change.
- Same-frame before/after screenshots at -level_22 frame 345
  (docs/img/bugs/d227-sky-seam-statue{,-fixed}.png): visible kink in
  the cloud streaks in the before shot, gone in the after shot, same
  exact frame.
- bunker1's nonzero framediff worst_cell is unaffected by this change
  (reproduced identically pre-fix) -- confirmed pre-existing stale
  golden-baseline issue, unrelated to sky.c.
- Cradle's black-rectangle artifact is also confirmed pre-existing
  (reproduced identically pre-fix) -- the separate, already-documented
  FOV-coupled black-sky-past-frustum-edge symptom (D218/D222 family),
  not touched by this fix.

Not re-verified: whether the "moves at a different rate" half of the
original report is also resolved, and the aspect-ratio-artifact
alternate theory was never independently ruled out. Human eyeball
still owed before merge, same as every other rendering fix landed this
session without interactive display access.

Also generalized the quirk (per-call vs per-shared-vertex-group scale
quantization) to docs/porting-notes.md alongside the existing D176(a)
entry.

* docs(D227): M-96 - human playtest on Statue: seam/crack STILL PRESENT despite M-95 fix

The M-95 wScale-sharing fix only verified the static per-primitive
quantization theory (single-frame screenshot diff); it explicitly left
the 'two halves move at different rates' temporal theory from the
original report unverified. This playtest is that missing
verification, and it says the seam is still visible in live play.

D227 NOT resolved. PR #43 stays in draft pending investigation of the
temporal/rate theory -- do not merge on the M-95 fix alone.

* docs(D227): M-96 addendum - N64 sky should be near-static, this build scrolls extremely fast

New data point from the same Statue playtest session, separate from the
seam/crack: the user reports the intended N64 scroll rate is near-static,
stronger than M-93's 'still reads too fast'. Bears on D176(a)'s fix
magnitude itself, not just the D227 seam -- flagged as a possible shared
root cause (wrong per-frame tc delta could drive both symptoms), not yet
investigated.

* fix(D227): M-98 - share sky S/T fold across whole fan, not per-triangle

skyPortBeginFan() (M-95) shared wScale across a whole sky fan so shared
vertices quantize position consistently, but skyPortRenderPoly's S/T
fold (foldS/foldT -- floorf(minS/64)*64, keeps texel coords in s16
range) was still computed per-call, from only that triangle's own 2-4
verts. Same bug class as the wScale fix, one field over, never fixed --
which is why M-96's human playtest still saw the seam after M-95.

Two triangles in one fan can floor a shared vertex's S/T to different
multiples of 64 texels; nominally invisible (GL_REPEAT period 64), but
each triangle bakes (S-fold)*32 into its own s16 Vtx buffer, so two
folds 64 texels apart produce tc values 2048 apart -- a real crack at
the shared edge.

Fix: extend skyPortBeginFan()/skyPortEndFan() to also compute one
shared fold across the whole fan, threaded into skyPortRenderPoly the
same way as wScale. #ifdef PORT only, no game-logic change.

Also measured (per M-96/M-97's ask): cross-correlated 11 consecutive
frames at a static camera -> ~7 px/tick horizontal scroll at 640px
capture width, a full screen-width sweep every ~1.5s. Confirms
"extremely fast" quantitatively; root cause of that magnitude is not
yet found (accumulator + vertex geometry are unmodified N64 logic, so
if it's genuinely wrong the bug is in how the Path B substitution maps
texel-ticks to screen pixels, not the tick rate itself -- next step
spec'd in findings.md).

Verified: build clean; -level_22 frame 345 (same frame M-95's
before/after used) shows no visible crease vs. the pre-M-95 reference
screenshot; verify.sh bunker1 PASS, worst_cell unchanged (known stale
golden baseline, not a regression). Human eyeball still owed on both
the seam-close and the scroll-speed question before PR #43 leaves
draft.

* docs(D176a): M-98 - Cradle side of PR #43 checked; tower starburst is Defect 2

Checked the Cradle side of the D227 fold-sharing fix (M-97 left this
outstanding). No regression -- verify.sh cradle PASS -- but found a
much more visible pre-existing artifact while there: looking up the
suspension tower near spawn, roughly half the sky renders as a sharp
radiating starburst instead of clouds.

Confirmed pre-existing (pixel-identical with sky.c reverted to
pre-M-98) and confirmed static, not the D227 "moves at different
rates" symptom (pixel-identical 30 ticks apart at a fixed camera).
This is the already-known, already-open D176(a) Defect 2
(horizon-band tessellation) -- the code's own fold-logic comment
already predicts exactly this failure shape for a triangle spanning
>1000 texels near the horizon, which looking straight up the tower
produces. Not fixed (tessellation is separate, bigger work); logged
with a concrete repro + screenshot so it's no longer "inconclusive."

* docs(D227): M-99 - human playtest negative (seam persists, view-angle-dependent); headless repro blocked

Human playtest after M-98's fold-sharing fix: the seam is still present
on Statue and now confirmed to move/change with mouse/view position --
also reported still bad on Cradle and present on Surface. M-98's fix
was real but is not the dominant mechanism live play is hitting, same
pattern as M-95 before it.

Ruled out this pass: the cross-quad water/cloud texture-scale mismatch
theory, on the two IsWater==0 levels actually tested (Statue, Cradle)
-- untested on any IsWater==1 level, which is now the top ranked
candidate for next session.

Blocked on: headless camera-pan repro via GE_INPUTSCRIPT does not
work -- confirmed neither analog-stick nor C-button tokens rotate the
camera, so this needs either a human playtest or a debug hook into the
real mouselook path before it can be chased further headlessly. Full
ranked candidate list + environment notes for next session in
findings.md and (session-local) docs/HANDOFF.md.

* docs(D227): M-100 - root cause PROVEN: s16 texture-coordinate overflow

The sky seam is not a crack, not a quantization mismatch, and not
view-dependence. One sky quad spans ~30,000 texels in S and T, but
Vtx.tc is S10.5 in an s16 = +/-1024 texels, so 7 of its 8 texture
coordinates overflow and wrap to arbitrary values. The fold cannot help
against a span 29x the representable range.

Unblocked by a user-supplied emulator-vs-ours screenshot pair on Surface:
the real symptom is a radial starburst, not a crack, which is a different
mechanism from what M-94..M-98 were chasing. M-95 (shared wScale) and M-98
(shared S/T fold) each fixed a real second-order bug; neither could touch
this one. Both stay - per-fan consistency is exactly what will make
subdivision seamless.

Measured with a new env-gated GE_D227V probe (GE_D176 style) on the Cradle
frame-350 camera. All four w are positive, so no projective pole. The quad
is split gSP2Triangles(0,1,3, 0,3,2) on the 0-3 diagonal, and vertices 0
and 3 are exactly the two that keep one coordinate in range - hence "two
halves with different patterns, split by a line", the original D227 report
verbatim. Apparent view-dependence needs no view-dependent mechanism: S/T
depend on view, so the wrap point moves.

Confirmed by a diagnostic build: baking tc at 1-texel instead of
1/32-texel granularity (32x range, deliberately wrong scale) makes the
starburst and the split completely disappear. No second defect behind it.

D227 == D176(a) "Defect 2" == M-98's "Cradle tower starburst". M-98
misfiled that capture as unrelated, which is what made D227 look
headlessly-unreproducible in M-99 - it reproduces statically.

Fix not implemented: subdivide until span < ~960 texels/primitive. The
subdivision math and cut placement are worked out in the finding (S is
linear in w, so cut uniformly in w), but the vertex budget is an open
decision - dynAllocateVertices is an unchecked 50 KB/frame pool and pure
tessellation wants ~40% of it.

* docs(D227): M-100 addendum - RDP tile shift (option D) dominates; fast3d already implements it

shifts/shiftt is the RDP's native precision-vs-range knob and gfx_pc.cpp
implements it fully including the left-shift case. Emitting tc at 32/2^k
units with shifts = 16-k buys 2^k range with no tessellation, no fast3d
change and no vertex-budget cost. k=5 covers the measured 29,830-texel
span outright.

* fix(D227): M-100b - sky texcoords are S10.5, not texels; drop M-82's stray *32

Fixes the sky seam/starburst, the over-tiling, and the "scrolls too fast"
complaint - all three were the same bug.

M-82 ("D176(a) Defect 1") read unk20/unk24 as texel counts and baked tc as
(S - fold) * 32 to convert texels into tc's S10.5 units. They are not texel
counts. skyRender sets them as worldpos * 0.1f and the N64 path hands them
to the RDP coefficient block unscaled (skyRenderTri: S * unk34 * sp368), so
they are already in the RDP's native S10.5 1/32-texel units.

That stray *32 made every sky texture coordinate 32x too large, producing
three symptoms previously tracked as separate bugs:

  1. D227 - spans of ~30,000 texels against an s16 tc holding +/-1024, so 7
     of a quad's 8 coordinates overflowed and wrapped to arbitrary values.
     The quad splits on the 0-3 diagonal and verts 0 and 3 are exactly the
     two that keep one coordinate in range, hence "two halves with different
     patterns" - the original D227 report verbatim.
  2. Over-tiling - ~466 repeats of the 64-texel cloud per quad, not ~15.
  3. M-96's "the N64 sky is nearly static, ours races", measured at ~7
     px/tick in M-98. g_SkyCloudOffset advances 1 unit/tick = 1/32 texel,
     not 1 texel. This is why M-98's re-derivation of the tick path kept
     coming back clean: the accumulator was never wrong, its unit was.

Fix: tc = (S - fold) 1:1, with the fold rebased to a whole tile period
(SKY_TC_WRAP = 64 texels * 32 = 2048 units). The ~30,000-unit span then fits
an s16 unaided - which is also why the original N64 code needs no
tessellation here: it never had a range problem to solve.

Kept: M-95's shared wScale and M-98's shared fold (both real bugs; per-fan
consistency keeps a fan's shared vertices bit-identical). Also kept as a
safety valve, since only ~4% of s16 headroom remains on the observed span:
skyPortPickShift/skyPortCaptureTile/skyPortEmitTileShift, which trade tc
precision for range via the RDP's own tile shifts/shiftt (port/fast3d
already implements it, so no fast3d change). skyPortCaptureTile replays the
exact G_SETTILE texSelect emitted with only the shift fields patched, so it
cannot drift from texSelect's format/line/mask derivation.

Also adds the env-gated GE_D227V per-vertex probe (GE_D176 style) that
measured all of this.

Verified: -level_41 Cradle frame 350 (the M-98 starburst repro) now shows
soft cloud bands at the correct scale with no starburst, diagonal split or
moire; GE_D227V confirms kS=kT=0 and max tc 31,513 of 32,767. -level_22
Statue frame 345 clean. verify.sh bunker1 PASS (worst_cell 10.32, the known
stale-golden-baseline number). Human play-test still owed.

* D229: file green/pulsating water; decouple D227's tc safety valve from it

New finding D229: on IsWater levels (Frigate, Surface 2) the water renders
green and pulses blue<->green on an interval.

Pre-existing, proven by A/B: built plain main with none of the D227 work and
captured -level_26 frame 340 - same green, only the tiling density differs.
D229 is independent of D227.

The gun half of the same report was NOT a bug: the branch was 2 commits
behind main and those 2 commits were exactly the D217 fixes (#47 texpool
alignment, #49 CI cache palette-content key). After merging main the user
confirms the gun is good on Frigate.

Mechanism so far: the water quad is sky.c's IsWater block1 (Frigate confirmed
IsWater=1 via GE_D176, which also settles M-99's ranked item 1).
sub_GAME_7F09343C binds tile 0 and tile 1 to the SAME tmem, offsets tile 1 via
gDPSetTileSize(1, 90, 150, 0, 0), and cross-fades TEXEL0/TEXEL1 with a
PRIM_LOD_FRAC driven by sinf(). That sine is the reported pulsing, so one
texunit resolves blue and the other green. Ruled out the shade path by
measurement (it spans dark blue to pale yellow, never green; sampled pixels
have R and B crushed). Not yet root-caused.

Code change here is only the decoupling: skyPortBeginFan takes allowShift and
the water quad passes FALSE, leaving that path byte-identical to its pre-D227
behaviour. The water quad is a two-texunit draw sharing one set of vertex tc,
so a tc rescale needs a matching shift on both tiles - and shifting both makes
them sample identically, which flattens the cross-fade. An intermediate build
that did so turned the water from pulsating into constantly green, observed
live. Also fixes skyPortCaptureTile to capture after all tile setup (not just
after texSelect) and to handle both tiles, so a future shift cannot clobber a
later re-declaration.

Cradle sky re-verified unaffected (frame 350 still correct). bunker1 golden
framediff reports REGRESSION at 55.02, but plain main scores WORSE at 73.24 -
the baseline is stale relative to main's D217 texture fixes, the re-baseline
owed since M-83/M-84. Not caused by this branch.

* docs(D227/D229): regenerate findings index; mark D227 FIXED

The generated findings-index.csv was hand-edited during the main merge and
when D229 was filed, so CI's 'Check generated dev indexes are current' failed
on PR #43. Regenerated with tools_pc/gen_findings_index.py.

Also updates the D227 row's status to lead with FIXED (M-100b) rather than
the M-94 'not yet fixed' text, so the index reflects the user-confirmed fix.

* docs: register GE_D227V in the env-probe index

CI's 'Check generated dev indexes are current' also runs
gen_env_probes.py --check, which flags any GE_* probe with live sites that
has no row in docs/dev/GE-ENV-PROBES.md. GE_D227V (added in M-100 to measure
the sky texcoord overflow) had none.
2026-09-11 20:45:08 -04:00
James Dansereau 50d73c519c D217 (M-90): align texpool arena bases to 8 bytes on PC — fixes solid wrong-colour weapon/model CI tiles (#47)
* D217 (M-90): align texpool arena bases to 8 bytes on PC — fixes solid wrong-colour weapon/model CI tiles

Root cause: texAlignIndices pads CI index rows to ABSOLUTE 8-byte
boundaries, and model-DL G_LOADTLUT palette offsets are a formula that
only matches the real layout when the texpool base is 8-aligned. On N64
the memp bump allocations feeding these pools happened to be 8-multiples;
PC pointer bloat (D36/D37-class struct growth) made some STAGE-bank sizes
not, so weapon-pool bases land %8==4 (e.g. FACILITY). Every palette in a
misaligned pool then sits 4 bytes early inside its allocation while the
draw reads the N64 offset: the PP7 grip's count=1 TLUT lands on the next
tenant's 8-byte header slot (texnum=26 -> LE s16 -> 0x1A00 = the green),
and larger loads read a 2-entry zero pad then the real palette (the M-86
'leading zero pad' tell). BUNKER1 was clean only because its bases
happened to be aligned.

Fix: texInitPool rounds the arena base up to 8 under #ifdef PORT; end
unchanged (pool loses <=7 bytes). No-op on N64 where bases were already
aligned. ABI/layout-class edit per AGENTS.md rule 2 narrow exception —
no game logic touched.

Verified: FACILITY 188/188 decodes %8==0, draw reads pal0=0001 (was
1a00), 0 green pixels in frames 250-450 (pre-fix ~1300); BUNKER1 + DEPOT
still clean; full verify.sh sweep 21/21 PASS. Pending: human playtest
eyeball of the M-87 weapon matrix (closes defect 1). Defect 2 (CI cache
content-hash key, fix/d217-model-texture-palette) remains separate/parked.

findings.md: new section H 'D217 - M-90' + D217 row update; GRAPHICS-BACKLOG
D217 status.

* docs(D217): M-92 - human playtest of PR #47 confirms PP7/PPK/watch fixed; AK47 + NPC-held world-model weapons are a distinct, still-open white-texture residual

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* docs(D217/D228): M-97 human playtest - green FIXED, AK47 white patches split out as D228

Second independent playtest (after M-92) of PR #47 on FACILITY, this
time with the branch merged up to date with main. Confirms the same
split the M-90 write-up predicted: PP7/PPK grip+hammer, watch face and
world-model green are all fixed by the texpool alignment; NPC-held
AK47 white missing-texture patches are untouched.

White is a different signature from wrong-colour -- a wrong palette
binds a real TLUT with wrong contents, white means the texture was
never decoded/uploaded at all -- so this is a separate mechanism, not
an incomplete fix. Split out as D228 so D217 can close on the green
mechanism it actually describes instead of carrying an unrelated open
failure mode indefinitely.
2026-09-11 18:06:03 -04:00
James Dansereau 9d45c6aae6 chore(D221): regen findings-index.csv (CI validate) 2026-09-11 17:43:52 -04:00
James Dansereau 4dd596a570 docs(D221): record human mouse-playtest verification (M-91)
Clicking directly over each visible file wallet selects it immediately
across all slots and the Copy/Erase icons; no crash. Closes the last
open item on PR #50.
2026-09-11 17:41:42 -04:00
James Dansereau cc332d7287 merge main into fix/d221-fileselect-hitbox
Resolves the D219/D220 findings.md conflict: main had the M-90 D219
runtime-recheck update (from merged PR #48), this branch had the M-91
D220 analysis (its own second commit) -- kept both.
2026-09-11 17:39:48 -04:00
James Dansereau 972b7e69bf docs: README update - trim repeated framing, fix stale status claims, drop going-stale badges (#52)
* docs: step 2 of the docs/site cleanup - README rewrite + agentic-development.md numbers refresh

README: trim the repeated AI-collaboration pitch to one mention with a
link (was stated near-verbatim 3x in this file alone); drop the status/
tested_on/built_with badges (CI + license only - the others just go
stale); cut the duplicate 'by the numbers' table and mermaid diagram
(both live, and are current, in docs/dev/agentic-development.md); tighten
the comparison-table Why-it-exists/Status cells; update the Documentation
table for the site-config change in the prior PR. Fixes the contradictory
status claims: audio (in-level music does not play yet, D77 -- was
claimed both silent and working in different sections) and Linux (not
actively tested since v0.1.0 -- was claimed both untested and level-swept
in different sections).

docs/dev/agentic-development.md: refreshed the 'By the numbers' table
with git-verified figures (calendar range, commit count, bug count,
ABI-edit file/line counts) -- the old numbers were from an earlier
session and had drifted well behind main. Trimmed the italic pitch
block under the H1 to one plain paragraph; left the timeline, milestone
table, and effort-split estimate untouched (maintainer's own record, not
independently re-derivable from git).

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* docs: README fixup pass - trim bold/em-dash density, drop redundant WARNING callout

Benchmarked against comparable projects (Perfect Dark PC port, the
GoldenEye-Recomp competitor, ripgrep as a general engineering-README
gold standard): this file was still running ~42 bold spans and 48 em
dashes across ~2,600 words, well above what any of those use, and the
alpha-status callout box duplicated the Status section's own opening
paragraph.

- Bold audit for real this time: kept proper nouns on first mention,
  table row labels, and one bullet-lead-phrase convention used
  consistently in three lists; cut everything that was just mid-sentence
  emphasis or a second bold of something already established (the ROM
  phrase, Windows/Linux, v0.1.0, a bolded URL). 42 spans -> 33.
- Un-bolded and merged the duplicate audio/music caveat (was bolded once
  in Working and again in Not-yet-working for the same fact).
- Dropped the alpha WARNING callout; folded its one load-bearing
  sentence into the Status section's existing opening paragraph instead
  of stating it twice in two different box styles. Kept the ROM-supply
  IMPORTANT callout - that one's legally load-bearing and not
  duplicated elsewhere.
- Varied em-dash-as-everything-connector into periods/colons/semicolons/
  parens where a flowing sentence called for it (not touched: the
  term-dash-description bulleted lists in Credits/How-it-works/Not-yet-
  working, which are a legitimate, consistent convention, not filler).
  48 -> 33, and the worst offender (a double-em-dash-wrapped parenthetical
  in Background) is gone.
- Reworded the intro's research-angle sentence away from "doubles as a
  research project" to plain prose.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-11 17:37:52 -04:00
James Dansereau 4d45e78176 docs: Pages site cleanup step 1 - curated publish set + cross-link fixes (#51)
* docs: step 1 of the docs/site cleanup - whitelist Pages site config, fix cross-links to excluded docs, drop game-behavior-reference index bullet

Publish only the curated set (index/building/internals/porting-notes/
dev-process/agentic-development); everything else under docs/ stays in
the repo but out of the Pages build and sitemap. Cross-links from the
published pages to excluded docs now point at GitHub blob URLs so they
still resolve. No content rewrite yet -- steps 2-5 (README update, per-
page descriptions, light copy passes, repo hygiene) remain.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG

* docs(dev-process): add §6 -- keep PR/git-history noise low

Squash-merge single-purpose/docs PRs, batch same-session docs updates
into one branch instead of one-PR-per-finding, keep unrelated
housekeeping off active branches, declare stacked-PR dependencies, and
triage the open-PR list every session -- close stale PRs (superseded
by newer main content) rather than force-merging or endlessly
rebasing them.

Prompted by this session's PR #26: real conflicts against main because
D191/D193/D149 were all fixed/closed since it was opened; closed
rather than rebased.
2026-09-11 17:31:36 -04:00
James Dansereau fd0f6eb749 docs/D220: analyze muzzle-flash FovScale drift -- correct perspective behavior, not an unscaled-viewmodel bug
The flash nodes share the gun DL and the (scaled) per-frame projection, so
flash-to-muzzle attachment is geometrically preserved; the whole camera-
anchored viewmodel migrates toward screen center as FOV widens. Disposition:
human call -- QoL viewmodel re-anchor if it bothers, not a fidelity bug.
2026-09-11 01:39:50 -04:00
James Dansereau 257e9c3fc1 front/D221: pass explicit {min,max} coord2d pairs to projectRectCornersTo2D in file select
interface_menu05_fileselect passed four separate f32 locals (&xmin, &ymin)
to a function that takes {min,max} coord2d pairs, relying on the N64
compiler laying each max adjacent to its min. The PC toolchain lays the
locals out in declaration order, so arg1->f[1] read ymax (as an X coord)
and arg2->f[1] read an unrelated stack float (sometimes NaN) -- the file-
select hit band collapsed to a ~14px strip at each wallet's bottom edge:
'click below the file to select it' (D221, M-87 user QA).

Pass explicit pairs under #ifdef PORT; identical values to what the N64
binary used, N64 call untouched. ABI/layout exception per AGENTS.md rule 2.

Verified headless: same cursor position + A press -- pre-fix never
selected folder 0, post-fix selects and advances (findings.md §H D221).
2026-09-11 01:36:12 -04:00
James Dansereau 0cb38c6bdc docs(D219): M-90 runtime re-check — D172's fix demonstrably covers Dam's particle DLs
Scripted-fire repro (GE_INPUTSCRIPT, 64 Z-pulses) on -level_33 with
GE_D172=1: 40/40 particle multitex tris sample SAMPLED1=tile1 (tmem=392),
the correct RGBA fire tile (pre-fix behaviour was tile0/smoke); zero
BUG/unknown-GBI/D146-abort lines; 0 purple/magenta pixels across 28
captured frames. Row can close on a human by-eye pass (folds into the
D217 PR #47 playtest).
2026-09-11 01:18:41 -04:00
James Dansereau f0b87ae0f5 Merge pull request #46 from jkdansereau/docs/d219-triage-note
docs(D219): triage note — likely already covered by D172's fix
2026-09-11 00:09:09 -04:00
James Dansereau a6b26f430f docs(D219): M-89 local-Qwen triage note - likely already covered by D172's fix, needs a runtime re-check
Static analysis found no separate/unfixed code path -- the explosion
DLs use the identical G_CC_INTERFERENCE/G_CC_MODULATEIA2 two-tile
mechanism D172 already fixed in gfx_lod_tile_offset. Not yet runtime-
confirmed (needs a scripted explosion repro on Dam with GE_D172=1).

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-11 00:07:37 -04:00
James Dansereau 3d3044a469 docs(GE-ENV-PROBES): add GE_D217TEX row (validate CI gate) 2026-09-11 00:06:25 -04:00
James Dansereau 78f002e5cf probe+docs(D217): M-89 cont. - decode proven correct+identical; parked defect 2 fix does NOT fix this; new stale-arena-pointer theory
Added a GE_D217TEX env-gated probe (src/game/image.c, texInflateZlib)
logging texnum/numcolours/palette[0..1] straight off the decode
bitstream -- the most direct check of whether the SAME texture number
decodes to the SAME bytes across levels.

Result: texnum=1608 (the PP7 grip, confirmed by its pal0=0001 matching
the previously-documented BUNKER1-correct value) decodes IDENTICALLY on
both BUNKER1 and FACILITY. This disproves the 'different ROM bytes per
level' theory and confirms the bug is strictly downstream of a provably
correct decode.

Then built and tested the parked fix/d217-model-texture-palette
(f6fc0399, the CI-texture-cache content-hash fix, defect (2)) directly
against the FACILITY repro -- still renders green. That fix does not
close this row; defect (1) and (2) are confirmed separate bugs.

New leading theory: a stale/dangling tex->data pointer into the
texpool arena, re-resolved once and not re-checked at draw time, so a
later texture decode reusing the same arena address on a texture-heavy
level silently overwrites the grip's palette bytes. Documented as the
concrete next probe for whoever picks this up.

Probe left in tree, env-gated (GE_D217TEX), inert by default.
tools_pc/verify.sh bunker1: PASS, no regression.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-11 00:01:57 -04:00
James Dansereau aa6733436a docs(D217): M-89 - refute the gfx_dp_load_tlut pitch/width=1 hypothesis; new leads
Local-Qwen triage proposed hardcoding pitch=1 in gfx_dp_load_tlut
(port/fast3d/gfx_pc.cpp), on the theory that N64 LOADTLUT addressing
never strides by the SetTextureImage width. Verified by hand before
applying it, and refuted:

- gDPSetTextureImage/gSetImage (include/PR/gbi.h) pack width-1 into the
  raw command field, and texWriteLoadToTmemAddr always passes width=1,
  so pitch = (1-1)+1 = 1 ALREADY for every model-DL TLUT load. The
  proposed patch is a no-op for this call site.
- Continuing the derivation confirms base = tex->data + len (the
  documented pixel-data-then-palette layout from texInflateZlib) for
  every call, regardless of the uls/ult 10-bit-field split -- the
  addressing math is correct by inspection.
- tex->unk0a = numcolours - 1 exactly explains gfx_dp_load_tlut's
  count = unk0a+1 term, matching the M-85 GE_D217Z count observations
  precisely -- the grip tile's count=1 is a genuine 1-color palette,
  not a truncated load.

New leads (neither probed this session): (a) does g_Textures[texturenum]
/_imagesSegmentRomStart resolve to identical ROM bytes across levels for
this weapon (a different image-table path than the already-ruled-out
per-model TextureID/d43_emit.py/Globalimagetable/D68 path), or (b) does
the shared texpool arena (image.c texLoad, two-ended leftpos/rightpos
allocator) serve a stale/aliased tex->data pointer for what should be a
fresh per-level decode.

Docs-only. No code changes -- the proposed patch was not applied.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 23:53:12 -04:00
James Dansereau 97946633dd chore(D77): regen findings index 2026-09-10 23:38:26 -04:00
James Dansereau 199182d1d9 fix(D77): in-level music silent — missing return in sub_GAME_7F0C0BF0 (D6-class UB)
Root cause: src/game/mp_music.c's sub_GAME_7F0C0BF0() is a one-line wrapper
around get_mTrack2Vol() with no return statement. On N64/IDO the value
happened to already sit in $v0; at the PC release build's -O2 the return
register is genuinely undefined (docs/porting-notes.md D6 class, same as
D187). Every in-level music trigger goes through set_missionstate()'s
MISSION_STATE_1/4 cases (the only path that starts a level's background
track), which feeds this garbage/zero value straight into
musicTrack1ApplySeqpVol()/musicTrack3ApplySeqpVol() right before
musicTrack1Play()/musicTrack3Play() — silencing the whole track for its
entire duration while note-on processing continues normally. Front-end/menu
music bypasses set_missionstate() entirely (front.c calls musicTrack1Play()
directly), which is why it was never affected.

Fix: return the tail call under #ifdef AVOID_UB, #else keeps the N64 body
verbatim. One line, no game-logic change.

Diagnostic: added a temporary, cheap (~1/s, gated on GE_AUDIOTRACE)
[AUDIOLVL] peak/RMS probe of the exact buffer audioSetNextBuffer() hands to
SDL (port/src/audio.c), interleaved into the same audiotrace.log as the
existing csplayer.c [MUSICNOTE] probe via the shared geTracePrintf lock.
Before the fix: 173 [MUSICNOTE] events over a 28s BUNKER1 capture, but every
single [AUDIOLVL] reading (29 samples spanning the full run) read
peak=0 rms=0.0. After the fix: same note count, every [AUDIOLVL] reading
sustained non-zero (peak ~2700-10300, rms ~800-2900). tools_pc/verify.sh
bunker1: PASS, no visual regression.

Docs: docs/dev/findings.md D77 updated from stale "nothing reaches PC
speakers" framing to the actual root cause + verification; new D6 instance
logged in docs/porting-notes.md.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 23:34:40 -04:00
James Dansereau d285a30725 docs(D217): M-88 - defect 1 is NOT a tools_pc/d43_emit.py sidecar bug
Dispatched to root-cause + fix D217 defect (1) (wrong palette bytes on
model-DL CI textures) confined to tools_pc/d43_*.py, working theory: a
4-byte/2-entry model-TLUT source-pointer offset in the sidecar.

Investigation refutes that theory: traced the full texture reference
chain (ModelFileTextures TextureID -> Globalimagetable -> runtime
texInflateZlib/NonZlib decode -> texWriteLoadToTmemAddr -> fast3d
gfx_dp_load_tlut) and confirmed tools_pc/d43_emit.py never touches
texture or palette content for model files - it only converts
geometry/node/GDL structure and passes TextureID through unchanged.

Reproduced the exact reported bytes with the existing GE_TEXDUMP env
probe (no code changes): BUNKER1 pal[0]=0001 (black, correct) vs
FACILITY pal[0]=1a00 (green, matches the originally reported defect)
for the same tile=1x1 CI4 grip texture.

Narrowed the likely real mechanism to port/fast3d/gfx_pc.cpp
(gfx_dp_load_tlut palette-address pitch math) interacting with
src/game/tex.c (texWriteLoadToTmemAddr's literal width=1
SetTextureImage) - both out of this dispatch's file scope, so no fix
applied per the brief's own instruction to stop and write up rather
than edit out-of-scope code.

No tools_pc/*.py files modified. docs-only commit.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 23:27:36 -04:00
James Dansereau f19022d2e1 docs(WIDESCREEN-FOV-PLAN): correct overstated claim about SCREEN_RATIO_16_9
User pushed back asking whether the two PD-survey divergence notes were
architecturally sound, not just factually accurate. On review, the
SCREEN_RATIO_16_9 note overstated the plan: it's a live code path but an
old anamorphic-stretch toggle, not true wide-FOV rendering, and this
doc's own pre-existing Open Questions section already leans toward a
separate new Video.Aspect config as Option B's real trigger, leaving the
legacy flag dormant - the same separation-of-concerns choice PD itself
made, not the opposite. Not yet decided (Phase 1). Overscan note (full
frame, no crop) reviewed and confirmed sound as-is - standard modern-
remaster convention, matches Nightdive's own approach.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:59:00 -04:00
James Dansereau 037f71dff8 docs: fold PD-legacy-survey findings into D224/QOL-INVENTORY/widescreen+FPS plans
Reviewed docs/dev/notes/PD-LEGACY-SURVEY.md (local Qwen agent's read-only
survey of the PD PC port for N64-legacy concepts worth modernizing/
noting) and folded its 8 candidates in:

- D224 (rumble): the stated fix is NOT sufficient alone - two upstream
  blockers (CONT_CARD_ON never set; osPfsInit returns PFS_ERR_NOPACK
  instead of PFS_ERR_DEVICE) sit before osMotorInit is ever reached.
  Both must land alongside the SDL_GameControllerRumble wiring.
- WIDESCREEN-FOV-PLAN: recorded two deliberate GE-vs-PD divergences so
  they're never mistaken for gaps - full-frame render (no PD-style
  overscan crop) and keeping the SCREEN_RATIO_16_9 path GE already ships
  (PD deleted its equivalent) as the widescreen mechanism.
- QOL-INVENTORY per-pad tuning row: PD's incremental hot-plug with
  remembered seat is the natural implementation shape, sidesteps GE's
  boot-latch controller-mask merge.
- D223 note: PD's per-player PC-controls-mode (save-compat shim) filed
  for later, only relevant if P2+ keyboard input is ever a real ask.
- UNLOCKED-FPS-PLAN: PD's TickRateDivisor/ExtraSleep noted as a fallback
  lever only if this plan stalls and GE stays fixed-tick.

Candidates 6/7 (MP-setup persistence, PD's PC-only gameplay options)
correctly judged net-new / rule-#2-cost-too-high by the survey - no
action, not re-noted elsewhere.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:55:05 -04:00
James Dansereau 1351e9db34 docs(D194): user confirms aim-mode over-sensitivity, suggests unifying sensitivity base
User independently confirms D194 part (a) and suggests the fix: aim
sensitivity should scale off the same base per-pixel sensitivity as
normal hipfire-turn mouse movement, not its own disconnected AIM_GAIN/
AimBand floor+clamp curve. Noted this is orthogonal to the SOLITAIRE
scheme change (aim mode is a separate insightaimmode-gated codepath,
untouched by natural-pitch/turn) - needs its own fix either way.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:52:10 -04:00
James Dansereau c729fe4750 docs(D194/D166/D223): SOLITAIRE control scheme gives free continuous mouselook
User correction (they were right): the digital-C-pitch-vs-analog-yaw
asymmetry only applies to the 1.1 HONEY scheme the port currently
forces. User recalled that 1.2 SOLITAIRE uses C-buttons for movement
(fwd/back/strafe) and gives the analog stick full continuous pitch+yaw
('natural pitch/turn') - verified in code (bondview2.c:5195-5253 digital
step movement; :5950/6008 canNaturalPitch/Turn drive
analogPitch//70.0f/analogTurn/70.0f directly, gated only by
!insightaimmode, no threshold/clamp; :4881-4882 these default to the
primary stick's raw X/Y for every 1.x scheme).

This is architecturally identical to modern FPS mouselook. Recommended
fix: force controldef=SOLITAIRE instead of HONEY, WASD -> digital step
buttons, mouse X/Y -> stick X/Y directly and continuously. Resolves the
vertical/horizontal asymmetry with zero digital-pulse emulation (D166's
hack becomes largely moot) and likely much of 'feels unresponsive' too.
Bonus: SOLITAIRE keeps the same shootButtons/invButtons roles as the
current default (not grouped with KISSY/GOODNIGHT's different button
roles), so D223's wheel-cycle plan needs no changes if SOLITAIRE is the
target. GOODNIGHT also gets natural pitch/turn but would need D223 rederived.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:50:06 -04:00
James Dansereau 59cd0e5785 docs(D224): QOL-INVENTORY - deprioritized, gamepad-only
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:43:05 -04:00
James Dansereau bc287cd97d docs(D224): deprioritized — gamepad-only, this release targets mouse/keyboard
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:42:30 -04:00
James Dansereau fad4703bc3 docs(D176): sky partially works but scrolls/repeats too fast; FOV-black is a 3rd wide-FOV-edge case
Sky isn't just solid black - where it renders, it scrolls too fast and
repeats too visibly (likely the M-82 phase-fold's 64-texel wrap period).
Cradle (and others) still black specifically at higher FovScale - a
third instance of the 'wide FOV exposes an edge nothing was built past'
family alongside D218 (fog/draw-distance) and D222 (culling); ties to
D176(a)'s already-logged horizon-band coverage/tessellation gap.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:41:41 -04:00
James Dansereau c7e9e490f2 docs(D217): note severity/type varies per level, not uniform
Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:40:48 -04:00
James Dansereau a3b9eeabe5 docs(D226): new — player HUD scale for ammo/pickup/dialogue text
User ask: adjustable HUD scale for the ammo counter, bottom-left pickup/
item status text, and top-of-screen dialogue. All three route through
textRenderOutlined() (textrelated.c:688, doc comment confirms ammo
counter + bottom-left HUD messages), called from exactly two in-game
sites in bondview2.c (:10005 ammo/pickup, :10163/10165 dialogue) -
distinct from the many other callers in front.c/options.c/mpmenu.c
(menu/UI text), which must NOT be scaled by this feature (own layout,
D211 already established HUD anchors stay put). Underlying mechanism:
plain RDP gSPTextureRectangle at native glyph size, dsdx/dsdy=0x400
(1:1 texel:pixel), no scale factor in the chain. Two implementation
directions logged (surgical wrapper at the 2-3 call sites vs. a scale
param threaded through the shared function). No PD precedent. Not
designed or implemented - research only.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:39:14 -04:00
James Dansereau ebbd743232 docs(D225): new — one-function debug unlock-all for testing (all missions + all cheats)
User ask: a debug toggle that always enables all level access and all
cheats, for easier testing. Root-caused a single choke point:
fileGetIsCheatUnlocked() (file2.c:391) is a pure, side-effect-free
save-bitfield read that BOTH the mission-select lock (file.c:59) and the
cheat-menu availability check (frontCheckIfCheatIsUnlocked, front.c:1009)
route through. Short-circuiting it to always-true under an env flag
unlocks both surfaces in one change. Bonus: exposes leftover Rare dev
cheats otherwise unreachable (CHEAT_LINEMODE wireframe - useful for
D222-style culling bugs, CHEAT_BONDPHASE, CHEAT_DEBUG_POS/UNK5). Plan:
#ifdef PORT env-gated (GE_DEBUG_UNLOCKALL), default off = zero behavior
change, no save-data mutation. Not implemented yet.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:36:01 -04:00
James Dansereau f5af9f3b05 docs(D224): new — rumble fully dropped, PD wires it to real gamepad rumble
Survey of the PD PC port for other N64-legacy things worth dropping/
modernizing (per user ask, following D223's control-preset finding).

D224: GE genuinely has a rumble subsystem (motor.c, joy.c) but the
port's osMotorInit/Start/Stop are hard stubs returning -1 / NOPACK
unconditionally - every rumble event (recoil, explosions) computes and
calls into a dead path. PD's PC port wires the equivalent straight
through to SDL_GameControllerRumble via inputRumbleSupported/inputRumble
+ a per-pad RumbleScale config + options-menu slider - same 'pretend the
N64 accessory is there' pattern this port already uses for osMemSize/
Expansion Pak, just applied to a real capability instead of faked.
Straightforward port-only fix, no game-logic touch.

Also checked and found NOT applicable here: PD's Controller Pak
formatting UI has no GE equivalent (front.c has no PFS/pak menu code),
so there's no dead UI to hide there.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:24:57 -04:00
James Dansereau c3a548a7be docs(D223): PD-PC-port precedent — decouple from N64 control presets entirely
User asked what the PD PC port did about N64 controller presets for
mouse/keyboard. Checked: PD doesn't route through the N64 controller-
config enum at all - it has its own logical CK_* control-key layer fully
decoupled from it, and treats mouse-wheel-up/-down as two independent
bindable virtual keys (not a single 'wheel = cycle' event). PD's
controldef is hardcoded, not left to the in-game control-style picker.

This GE port already has the equivalent layer: D214's Input.Bind system.
Recommendation logged: add IA_WEAPON_NEXT/IA_WEAPON_PREV bindable
actions that synthesize the invButtons/shootButtons combo, default-bound
to wheel up/down and open for a future gamepad-bumper default; force a
single fixed controldef so the synthesis target is stable. Matches the
user's broader direction (N64 presets aren't meaningful for mouse/kb;
modern Xbox/PS support should be first-class, not routed through N64
presets). Not implemented - design note only.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:22:25 -04:00
James Dansereau ce69b8456b docs(D217): scope clarified — NPC-held weapon world models affected too, not just Bond
User confirms the glitched-color/missing-texture symptom also shows on
NPC-held weapon models (other characters' guns in third person), not
just Bond's 1P viewmodel / 3P world model. Points at the shared
weapon-model-DL/TLUT loading path in general rather than anything
Bond-model-specific.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:20:07 -04:00
James Dansereau 8f615bde14 docs(D223): new — directional mouse-wheel weapon cycling, root-caused
QoL ask: wheel up = next weapon, wheel down = previous, instead of both
directions cycling forward. Root-caused: GE already computes separate
weaponForwardOffset/weaponBackOffset signals from
invButtons(A)+shootButtons(Z) held/edge combos (bondview2.c:5338-5347,
the N64 'hold A tap Z' backward-cycle trick); triggerOn is suppressed
while invButtons is held so this never accidentally fires. Port's
inputPostWheel() discards wheel direction and only ever synthesizes the
forward (bare A pulse) case. Fix is port-only (input.c), synthesize the
A-held+G-edge combo for wheel-down instead - no game-logic change.
Open question: which N64 controldef preset is actually active in port
saves, since invButtons/shootButtons differ per preset.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:18:48 -04:00
James Dansereau 9016cf183c docs(D222): new — high FovScale breaks culling, NPCs/objects vanish near screen edges
Split out of D211's 'cosmetic edge-cull-early' caveat - user confirms
this is a real functional bug, not cosmetic: NPCs and some world objects
stop rendering near screen edges at high FovScale. Root cause: frustum
cull / visibility math still runs at nominal unscaled fovy while the
render projection is portFovScale-widened. Fix direction + a real risk
flagged: some of these cull call sites may double as AI
awareness/visibility checks, not just render culling - must audit before
changing so portFovScale never leaks into NPC behavior (non-negotiable
#2, game logic unmodified).

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:14:37 -04:00
James Dansereau 4e96a60b3f docs(D211/D218): M-87 QoL asks — FOV in real degrees, draw-distance/LOD wanted
D211/WIDESCREEN-FOV-PLAN: user wants Video.FovScale exposed as a real
horizontal-or-vertical degree value instead of the current
percent-of-original-vertical-FOV scale. Not critical, backlogged. Noted
the cleaner path (relabel as degrees, drop the percent indirection) vs
keeping percent internally and just displaying the computed degrees.

D218: user confirms the high-FOV draw-distance/fog artifact still
occurs, and now wants Video.DrawDistance implemented (previously
declined) plus broadened to draw-distance/LOD in general, not just the
fog-boundary case this row root-caused. Open question logged: does GE
have a separate object/geometry LOD-swap distance mechanism worth
exposing, or is Visibility.FarFog the whole story.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:13:07 -04:00
James Dansereau d3e3819561 docs(D194): M-87 design-goal note — mouse feel should target Quake/PD-remaster responsiveness without losing GE aim nuance
User wants overall mouse feel closer to Quake Remaster / the Perfect Dark
PC port (raw, direct, responsive) while explicitly preserving GE's
aim-mode vs hipfire split (the reason D118/D194 exist). Reframes the fix
order: normalize by a fixed reference dt first (D194 part b, the
frame-rate coupling - likely the dominant cause of 'feels
unresponsive'), then re-derive the aim-mode curve, and evaluate raw
PD/Quake-style hipfire mouse-look as a real option outside aim mode
rather than assuming N64 stick-emulation is mandatory everywhere.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:11:08 -04:00
James Dansereau 60a31cbc2e docs: M-87 playtest results — D178/D149 fixed, D182 still broken, D77 rescoped, D221 new
D178 (briefing objectives blank) and D149 (front-end 3D models garbled) -
user-verified fixed, closed.
D182 (file-select round-trip degradation) - still broken, confirmed.
D77 (no music) - rescoped: music plays on intro/cast-roll/main menu, only
missing in-level. Points at the level-start music cue, not the
mixer/playback layer.
D221 (new): SELECT FILE mouse hit-box vertically offset from the visible
box (click below a file to select it) under the shipped default
MouseCaptureMode=1 - distinct from D192's clamp-range bug under legacy
mode 0, likely same front-end grid-coordinate family.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:10:30 -04:00
James Dansereau c82430abcf docs: M-87 playtest notes — D176/D173/D148 still current
D176: skybox still glitchy broadly (not just Surface); Cradle named as
still showing solid black where sky/backdrop should be. Confirms (a)
never landed a verified fix.
D173: Bond hover/float cutscene glitch still present.
D148/D160: new specific instance - Bond spins in circles in some
cutscenes (end-of-Cradle Trevelyan-falls cutscene named) - likely same
actor-position/anim-root cause as D173, manifesting as bad rotation.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:07:11 -04:00
James Dansereau ad82c0cac6 docs: M-87 playtest notes — D211 good, D217 matrix, D219/D220 logged
D211: user-verified good at big FovScale.
D217: playtest matrix across weapons/levels (PPK/AK47/M16, Dam/Facility/
Jungle) - green, blue, white (missing), orange, all consistent with the
per-weapon-material upstream theory, not one fixed offset. Watch still
green.
D219 (new): PPK explosions render purple on Dam - likely D172 particle-
color family, different DL.
D220 (new): 1P muzzle flash shifts upward at higher FovScale (Jungle
M16) - likely same unscaled-viewmodel-fovy root as D211's residual
caveat.

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 22:04:13 -04:00
James Dansereau 6b66c9ddc9 docs(D217/D211): M-86 — D217 root cause characterised; D211 aim-zoom verified
D217: two defects found. (1) The visible green grip/hand is upstream of
fast3d — the grip's count=1 model G_LOADTLUT is fed different source bytes
per level (Facility 1a 00 -> 0x1A00 green; Bunker 00 01 -> black); every
larger model TLUT carries a 2-entry leading zero pad, so the port's
model-TLUT source pointer is likely 4 bytes low (or a d43_emit.py blob
offset). fast3d byteswap is correct (proven by Bunker). (2) A real CI
texture-cache collision (no palette-content key) — fix landed on branch
fix/d217-model-texture-palette f6fc0399 (palette FNV-1a hash in the CI
key); owes a 21-level sweep; does not fix (1). porting-notes + graphics-
backlog updated.

D211: aim-zoom compose verified correct by construction (single post-lerp
multiply at the fr.c chokepoint; zoom ratio preserved). Only a low-risk
cosmetic eyeball remains (viewmodel/frustum-cull use unscaled fovy).

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
2026-09-10 18:40:48 -04:00
James Dansereau 2cdecd3aa6 docs(D217): rule out stale-tail; best theory = CI-cache/palette collision
GE_D217Z experiment (zero rdp.palette[count..255] after each partial
load) did NOT change the green grip -> bad texels index within [0..count),
so the wrong palette is *bound* to the grip tile, not tail overflow.
Best remaining theory: TextureCacheKey collision / missing CI-cache
invalidation on palette change (key has no palette-content hash). Facility
renders green-uniformed guards right before the viewmodel -> a stale
cache HIT / palette_addrs collision makes the grip decode against the
guard-fatigue palette. Next probes + the all-levels regression
requirement noted in the finding.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-09 23:43:38 -04:00
James Dansereau 2e3d8be7e6 docs(D217): M-85 probe results — partial-count TLUT + stale upper palette
User confirms D217 is widespread (many levels, multiple weapons), not
Facility-specific. GE_D217 temp probe (reverted) shows GE weapon/character
model DLs issue partial-count G_LOADTLUT commands (count 1/28/125/172/193/
...); count=125 writes rdp.palette[0..124] and leaves [125..255] holding
the previous TLUT's entries, which differ per level. Weapon TLUT sequence
is identical BUNKER1 vs FACILITY (same PP7) + tiles import palidx=0
cache=HIT, so the delta is purely which stale upper-palette entry a weapon
CI8 texel lands in when it indexes >= its palette count. Root cause open:
CI8 decode reading past the base image (RC2/D183) vs GE relying on N64
TMEM-TLUT leftover semantics. Concrete next probes + a zero-the-tail
experiment noted in the finding. Wants a dedicated debug session.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-09 23:40:50 -04:00
James Dansereau 717a5210d3 docs(D217): repro pinned to FACILITY post-cutscene
BUNKER1 is clean in any config; -level_34 FACILITY reproduces at ~frame
300 (just after the vent-drop cutscene). PP7 grip = solid green block,
green ring at the barrel joint, hand has a green cast; slide/barrel upper
is correct black. GE_TEXI confirms the weapon CI tiles import with a
loaded TLUT (fmt=2 palfmt=0x8000 palidx=0) -> gDPLoadTLUT does reach
gfx_pc.cpp for model DLs. Green-tinted hand => wrong TLUT bound, not
corrupt index data. Working theory: FACILITY pre-level cutscene leaves
fast3d palette/tile state (or a TextureCacheKey collision on
rdp.palette_addrs[] at gfx_pc.cpp:990) that the cutsceneless BUNKER1
never hits. Next steps + suggested subagent scope in the finding.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-09 23:33:34 -04:00