diff --git a/docs/dev/D172-PARTICLE-COLOR-RESEARCH.md b/docs/dev/D172-PARTICLE-COLOR-RESEARCH.md new file mode 100644 index 00000000..3df88f4f --- /dev/null +++ b/docs/dev/D172-PARTICLE-COLOR-RESEARCH.md @@ -0,0 +1,100 @@ +# D172 — particle/flares wrong color (magenta/cyan blood): static research + root-cause candidate + +Symptom (user-reported, GRAPHICS-BACKLOG line 31): bullet impacts / flares / blood spatter +render magenta/cyan instead of dark red. Static audit done; one strong root-cause candidate +with a cheap in-game verification. **No code changed.** + +## 1. How particles get their color state (mechanism) + +Particle triangles (`explosion.c`, `gSPVertex` + `G_TRI_FILL`/`G_TRI_SHADE_TXTR`) carry +**no per-primitive material command**. Their CC/tile state comes from replaying ROM-resident +state records via `gSPDisplayList(gdl++, g_ExplosionDisplayLists[var_s2])` +(`explosion.c:894`, 15-record loop, one record + its particles per iteration). + +- Records: `globalDL_0x078 … globalDL_0x9a8`, a 17-entry table at image-bank offset + 0x28 (`g_pc_gimg_off_globalDL_*`), bank base cart 0x1029D160 (see `image_bank.c:322`). +- Each record is also scanned at load by `texLoadFromDisplayList` (`src/game/image.c:475`) + for its `G_SETTIMG` + ABCD marker word — that part works (textures upload fine). + +## 2. Record layout (decoded from ROM) + +``` ++0x00 E7 C3 FF FE G_RDPPIPESYNC ++0x08 B9 00 03 1D ??? <-- UNHANDLED by fast3d (see §4) ++0x10 second word of the ??? command ++0x18 FC ... G_SETCOMBINE w0 (rgb c0 | alpha c0 <<16) ++0x20 FF ... G_SETCOMBINE w1 (rgb c1 | alpha c1 <<16) ++0x28 FD ... G_SETTIMG w0 (timg pointer) ++0x30 .. 50 50 50 50 G_SETTIMG w1 (ABCD marker) +``` + +Dominant particle combine mode (14 of 17 records), decoded with the standard libultra +bit layout: + +- cycle 0 RGB: `TEXEL0, 0, TEXEL1, COMB_A` +- cycle 0 ALP: `TEXEL0A, COMB_A, SHADE, COMB_A` +- cycle 1 RGB: `COMBINED, 0, SHADE, COMB_A` ← **2-cycle** modulate +- cycle 1 ALP: same as cycle 0 + +## 3. fast3d audit (what is right) + +- `G_SETCOMBINE` bit extraction (`gfx_pc.cpp:2786`) matches the standard libultra layout + exactly (verified field-by-field against the ROM words). Not the bug. +- Store/decode of `rdp.combine_mode` (`gfx_dp_set_combine_mode` vs `gfx_generate_cc` + key shifts) are consistent. Not the bug. +- The generated GLSL combiner implements the RDP formula with the usual special cases; + `SHADER_COMBINED` correctly resolves to the previous cycle's running value. + +## 4. Root-cause candidate: the 0xB9 word aborts the sub-DL (D146 path) + +fast3d's SP walker has **no case for opcode 0xB9** (full case list audited: MTX/MOVEMEM/ +VTX/DL/COL/syncs/all DP immediates/EXT commands — nothing in the 0x80–0xBF group). Its +`default:` (`gfx_pc.cpp:2920`, the D146 guard) **ends the whole (sub-)display list** on an +unknown opcode. + +Consequence for every particle record replay: + +``` +RDPPIPESYNC -> ok (no-op) +0xB9 -> D146 default -> RETURN (sub-DL dead) +G_SETCOMBINE -> never applied +G_SETTIMG -> never applied + -> drawn with STALE combine mode + stale tile/timg state +``` + +Stale CC/tile state = arbitrary colors = the magenta/cyan symptom. On N64, gmain.s +obviously handles 0xB9 (the game works there), so this is a fast3d gap, not a ROM quirk. + +Secondary (only matters if 0xB9 turns out to be a no-op): particle 2-cycle rendering +requires `G_CYC_2CYCLE` in other_mode_h (`gfx_pc.cpp:1523`); the records never set +other-modes, so particles inherit ambient state — same on N64, but worth confirming if +the primary fix doesn't resolve it. + +## 5. Verification (cheap, in-game) + +The D146 guard logs `D146: unknown GBI opcode 0x%02x ...` rate-limited to **20** messages. +Run any level, shoot things / trigger flares, watch console output at startup for +`unknown GBI opcode 0xb9`. If present → root cause confirmed. (The budget fills in the +first frame or two of particles, so check early output.) + +## 6. Fix path (next session) + +1. **Decode 0xB9's semantics.** `ginit.s` (dispatch-table init) is *not in this repo* + (`gmain.s:10` includes it; file absent). Options: (a) locate ginit.s in the original + source / another checkout; (b) trace gmain.s's 0x80–0xBF group handler by hand; + (c) infer from data — w0 is constant `B900031D` across all records, w1 varies + (`0C184B50`, `00504B50`, …); if it's an other-modes/cycle-type setter, w1 bits should + match G_MDSFT_* fields. +2. Implement the handler in fast3d (port-only). Re-run: blood/flares should go dark red; + D146 spam for 0xb9 stops. +3. If colors still wrong after that: log `use_2cyc` + combine key on particle triangles + (env-gated) and check ambient G_CYC_2CYCLE (§4 secondary). + +## Files + +- `src/game/explosion.c` — record table (165), replay loop (894) +- `src/game/image_bank.c:322`, `src/game/image.c:475` — record offsets / load-time scan +- `port/fast3d/gfx_pc.cpp` — SP walker cases (~2600–2930), D146 default (2920), + SETCOMBINE decode (2786), combine store (2270), cc generation (312+), 2CYCLE flag (1523) +- `rsp/graphics/gmain.s` — N64 ucode ground truth for 0xB9 (dispatch table at dmem 0xbc; + ginit.s missing from repo) diff --git a/docs/dev/D176a-SKY-NOTES.md b/docs/dev/D176a-SKY-NOTES.md new file mode 100644 index 00000000..0f506ab4 --- /dev/null +++ b/docs/dev/D176a-SKY-NOTES.md @@ -0,0 +1,85 @@ +# D176(a) Sky — consolidated research notes (read-only) + +Companion to `GRAPHICS-BACKLOG.md` D176(a) and findings.md M-37/M-47. +Purpose: make the next implementation session mechanical. No code changed by +this doc. + +## State of play + +- **Root cause (M-37):** `skyRenderTri`/`skyRenderFull` emit geometry only as + `G_RDPHALF_*` immediates; `gfx_pc.cpp:2901-2903` deliberately no-ops them + (same in the PD port) → solid black sky on every cloud-sky level. Clean + headless repro: `-level_22` (Statue), frame ~360. +- **M-47 draft (Path B)** — `scratchpad/sky.patch`: `#ifdef PORT` hooks in + both functions re-emit the sky as ordinary screen-space GBI via + `skyPortRenderPoly` (ortho pixel projection + `gSPVertex`/triangles). + Geometry reaches GL and textures (top-half luma 0.1 → ~174 on `-level_22`). + Two open defects (below). Draft, not shippable. +- **Path A** (decode RDPHALF in fast3d) is the backlog's original suggestion; + the wire format is now fully decoded (next section), so it is a viable + fallback if Path B's units can't be pinned. + +## RDPHALF wire format (decoded from `src/game/sky.c` + `include/PR/gbi.h`) + +- Opcodes (`gbi.h:102-164,216-223`): `G_RDPHALF_1 = 0xe1`, + `G_RDPHALF_2 = 0xf1` (final word of a stream), `G_RDPHALF_CONT` + (= `G_IMMFIRST-13`). Tri ops: `G_TRI_FILL 0xc8`, `G_TRI_FILL_ZBUFF 0xc9`, + `G_TRI_SHADE_TXTR 0xce`, `G_TRI_SHADE_TXTR_ZBUFF 0xcf`. +- **All payload values are S15.1 fixed point** — converter is + `sub_GAME_7F094298` (`sky.c:218`): `clamp(x, ±32767.9) × 65536`, u32. +- **Packing:** each `(G_RDPHALF_1, G_RDPHALF_CONT)` pair carries four + halfwords = **two S15.1 values**: `valueA = (w1.hi16, w2.lo16)`, + `valueB = (w1.lo16, w2.hi16)`. Multi-value groups are streamed in two + passes — all high halves first, then all low halves (see the explicit + `(x & 0xffff0000) | (y & 0xffff0000) >> 16` packing at `sky.c:1854-1870`). +- **`skyRenderTri` header** (`sky.c:1725-1728`): first word = + `(op << 24) | (winding/sign flag bit23) | lo16(first value)`; the stream + then carries screen-space positions (`unk28×0.25` = pixel X, `unk2c` = Y×4 + family) plus per-vertex attributes. The RSP ucode reassembles and DMAs a + real edge-walked RDP tri command (per the M-46 comment in sky.patch). +- **Vertex attribute vectors are 8 components** (`sky.c:1759-1776`): + `(r, g, b, a, S·w′, T·w′, 32767·w′, z)` with perspective weight + `w′ = unk34 × min(unk0c)/2` — i.e. color + perspective-corrected S/T + + depth. `SkyRelated38` fields: `unk28 = screenX×4`, `unk2c = screenY×4`, + `unk30 = z (0..0x7fff)`, `unk34 = 1/w`, `unk20/unk24 = S/T`. +- GE emits RDPHALF **nowhere except sky.c** — the command surface is closed + and small. + +## Defect 1 — texcoord shear (clouds smeared into streaks) + +`skyPortRenderPoly` uses `tc = unk20 × 32.0f` — a magic number. GBI vertex +`tc` is **S8.8 texel** space; the real RSP path applies the sky image's tile +shifts to the streamed S/T. Fix spec: + +1. Read the base-tile descriptor of the cloud image (`skywaterimages[0]`, + GIMG offset `0xFB4` per M-36) from the extracted assets/sidecar — get W, H + and shifts/shiftt. +2. Cross-check against how `gfx_pc.cpp` addresses texels for a known-good + textured DL (the working reference). +3. Replace the magic: if streamed S/T are image fractions f ∈ [0,1), then + `tc = f × (W << 8)` / `f × (H << 8)`. Verify on `-level_22`. + +## Defect 2 — vertical coverage (band fills only top ~25%) + +Leading hypothesis, from reading the N64 path: **the quad is not a plain +corner quad.** The N64 stream's four vertices are *perspective-interpolated* +points computed in the big interpolation block (`sky.c:1797-1813`, the +`sp210/sp290/sp2b0/sp230` vectors), not the raw TL/TR/BL/BR corners that +Path B's hook passes through. If those interpolated y-values differ from the +corner ys (they will, wherever w varies across the quad), the re-emitted +quad lands short. + +Next steps (one focused session): +1. Per-call probe on `skyPortRenderPoly`: log nverts + each vertex's + (x, y) + the tri op-header word; A/B against N64 reference footage of + `-level_22`/`-level_36`. +2. If confirmed, either (a) replicate the interpolation block under PORT and + emit the 4 computed vertices, or (b) drop to Path A with the decoder spec + above (the ucode's own reassembly is then irrelevant — we decode the same + values directly). + +## Recommendation + +Finish **Path B** first — geometry already works; both defects are unit/ +vertex-set questions answerable in one render-iterate session. Keep Path A +as fallback; it is now fully specified by this doc if B stalls. diff --git a/docs/dev/QOL-INVENTORY.md b/docs/dev/QOL-INVENTORY.md new file mode 100644 index 00000000..5b2a5ae4 --- /dev/null +++ b/docs/dev/QOL-INVENTORY.md @@ -0,0 +1,59 @@ +# QoL Inventory — PD-vs-GE options diff + +Status: **read-only research, parked** (input to project Phase 4). No code +changed. Companion to `WIDESCREEN-FOV-PLAN.md` and `UNLOCKED-FPS-PLAN.md`. + +Method: diffed the config surface of both ports — every `configRegister*` +key in `pd_port/port/src/*.c` vs `port/src/config.c` + the F10 overlay +(`port/src/optionsoverlay.c`) + PD's `optionsmenu.c`. Principle applied: +**where PD has a QoL feature that is achievable here, it is the standard; +GE keeps its own extras.** + +Classification key — **P** = port-only (no rules issues), **G** = needs a +game-code exception (rule #2 sign-off, per `WIDESCREEN-FOV-PLAN.md` framing). + +## GE already has (baseline — keep) + +Input: mouse enable/capture/aim+turn speed/invert Y/Y scale/smoothing/raw +input, aim band, hipfire pitch speed, menu pointer speed + mode (GE's menu +cursor is injected entirely in `port/src/input.c` — no game code), pad +deadzone/trigger %. Video: VSync, FpsCap, MSAA, TextureFilter, +FixMipTextures, WrapFix, Fullscreen, Window geometry. Game: +ScreenShakeIntensity. Debug: FrameDump, InputLog. + +## PD has, GE lacks (candidates) + +| Feature | PD location | Class | Effort | Notes / priority | +|---|---|---|---|---| +| **Key rebinding** (config-string binds per controller/action) | `input.c:69,627-675,1548` (`bindStrs`, `inputParseBindString`) | P | M | **High.** Explicit Phase 4 debt ("rebinding UI" — note PD has *no* UI either; config-file driven). Port-only in `port/src/input.c`. | +| **SkipIntro** | `main.c:139` (STAGE_TITLE → STAGE_CITRAINING) | P | S | **High.** GE analogue: stage-override path already exists (`boss.c:385-393`, `g_StageNum = LEVELID_TITLE` at :98); wire a flag + arg. 10-min check on what feeds `tokenFindLevel`. | +| **DisplayFPS** (+ interval) | config `Video.DisplayFPS(Interval)` | P | S | Cheap; useful for FPS-plan verification too. | +| **Anisotropic filtering** (≤16×), MipmapFilter, TextureFilter2D | `gfx_opengl` texparam path | P | S–M | **High at 4K.** GE's `gfx_opengl.cpp` has mipmap nY but no aniso (`GL_TEXTURE_MAX_ANISOTROPY_EXT`). Port-only. | +| Per-pad tuning: per-stick deadzones, stick scale, rumble scale, device index, swap sticks, C-button mapping | `input.c` padsCfg block | P | M | Medium. Matters for real-controller users; PD pattern is a config section per pad. | +| FakeGamepads / FirstGamepadNum / UseHIDAPI | `input.c` | P | S–M | Niche (local MP on PC); defer until MP is actually played. | +| Window polish: DefaultFullscreen/Maximize, CenterWindow, AllowHiDpi, ExclusiveFullscreen | `video.c` vid* block | P | S | HiDpi + center are the useful ones on modern Windows. | +| VSync adaptive (−1..10) | `Video.VSync` range | P | S | Note for FPS plan — GE's 0/1 toggle is fine until then. | +| **CenterHUD** (0/1/2 = left/center/right) | `Game.CenterHUD` → `g_HudAlignModeL/R` | G* | — | Already scoped in `WIDESCREEN-FOV-PLAN.md` Option B (Phase 1 decision). Not a separate item. | +| Per-player FovY + FovAffectsZoom | `Game.Player%d.FovY`, `g_PlayerExtCfg` | G | M | PD does this in game code. GE's port-only RSP FOV slider (widescreen plan Phase 4) covers the global case; per-player is a stretch goal needing the exception class. | +| MemorySize (4–2048 MB emulated RAM) | `Game.MemorySize` → `g_OsMemSizeMb` | P? | S | Check how GE sizes its OS memory heap; if fixed, make it a config knob (stability lever for heavy levels). | +| MaxExplosions / GEMuzzleFlashes / DisableMpDeathMusic | PD decomp edits | G | S | Low priority; rule-#2 cost > benefit. Skip unless asked. | +| GlareBrightness / OverexposureScale / FramebufferEffects | PD post-FX | — | — | Not applicable: GE's fast3d has no framebuffer-effect path. Excluded. | + +## GE has, PD lacks (keep as-is) + +`Video.FixMipTextures`, `Video.WrapFix`, `Input.AimBand`, +`Input.HipfirePitchSpeed`, `Input.MouseSmoothing`, `Debug.FrameDump`, +`Debug.InputLog`. No action. + +## Suggested Phase 4 order (after audio; ordering vs widescreen/FPS TBD) + +1. **Quick wins (all P, small):** SkipIntro, DisplayFPS, aniso + mipmap + filters, window HiDpi/center. +2. **Rebinding** (P, medium) — the headline Phase 4 item; PD pattern is + config-string driven, no UI required for parity. +3. **Per-pad tuning** (P, medium). +4. Niche: fake gamepads / HIDAPI (when MP gets played); MemorySize knob + (after the heap check). + +Everything here is parked behind audio like the other plans; nothing in this +inventory implies an implementation decision yet. diff --git a/docs/dev/UNLOCKED-FPS-PLAN.md b/docs/dev/UNLOCKED-FPS-PLAN.md new file mode 100644 index 00000000..2fab7622 --- /dev/null +++ b/docs/dev/UNLOCKED-FPS-PLAN.md @@ -0,0 +1,176 @@ +# Unlocked FPS Plan + +Status: **proposal, parked** — documented while audio work (project Phase 3) is +active. Do not start Phase 0 until audio is done; first action when resumed is +the Phase 0 measurement pass. +Related: `docs/dev/findings.md` §D117 (GE_DETERM), §D155 (catch-up clamp), +`port/src/libultra.c` (pacemaker), `src/game/frametiming.c`, `src/sched.c`. + +## Decisions (agreed) + +1. **Default preset = the original pace** — whatever the game currently does + (measured ~30 fps; Phase 0 pins the exact rate). All testing, level + sweeps, determinism runs (`GE_DETERM`), and playtest baselines stay on the + default preset. Its code path must remain byte-for-byte what it is today. +2. **Unlocked FPS (60 / 120 / uncapped) is EXPERIMENTAL.** It ships behind a + clearly-labelled option and may be less polished than the default. +3. **Hard requirement:** raising the presentation rate must never change game + speed. Sim rate is invariant across presets. This is "the main speed-up + issue" and it gates everything else. +4. **Follow the PD PC port where feasible** (`pd_port` checkout): wall-clock + accumulator pacing with remainder carry, vsync + framerate-limit options, + safety cap when vsync is off. + +## Non-goals (for now) + +- Motion interpolation between sim ticks (the only thing that would make 120 + fps *look* like 120 fps). Requires per-entity prev/cur state in the + DL-building game code — rule #2 territory, parked as a separate proposal. +- Changing the sim rate itself (running logic at 120 Hz with halved constants + is explicitly out — game logic). + +## Background: why raising FPS today speeds up the game + +One clock drives everything: + +- `portTickThread` (`port/src/libultra.c`) posts a VI-retrace message on a + free-running grid (60 Hz NTSC / 50 Hz PAL, `g_tickIntervalUs`). +- `src/sched.c` `__scMain` blocks on that retrace; `__scHandleRetrace` + notifies clients → **exactly one sim tick + one render per retrace**. +- `osGetCount()` is wall-clock scaled to 46.5 MHz. `waitForNextFrame()` + (`src/game/frametiming.c`) converts elapsed time to `deltaFrames`, which + becomes `g_ClockTimer` (`src/game/lv.c:1008`) driving the per-tick sim loops. + +The quantization is the trap: `nextFrameTime = (elapsed + Q/2) / Q` (Q = one +frame quantum). The `+Q/2` bias **rounds every interval up to at least 1 +tick**, and `waitForNextFrame` spins until ≥1 tick is due. Consequence: +**sim speed tracks the retrace rate almost 1:1** — 30 Hz retrace → 30 ticks/s, +120 Hz retrace → ~120 ticks/s (each 8.3 ms interval rounds up to a full +tick). That is exactly the observed "anything faster speeds up the game". + +Two useful existing properties: + +- GE's game code already handles `g_ClockTimer == 0` and multi-tick deltas + everywhere (`for (i = 0; i < g_ClockTimer; i++)`, explicit `== 0` guards in + bondhead/bondview2/propobj/explosion). The N64 used this for dropped frames. +- D155 already clamps catch-up to 6 ticks after a real-time hitch. + +## What the PD port does (reference) + +- `src/game/timing.c` `frametimeCalculate()`: wall-clock accumulator with + remainder carry (`lostframetime60t`) → `diffframe60` / `diffframe240` ticks + due **per rendered frame**; render loop runs at display rate. +- Vsync + framerate limit options (`VIDEO_MAX_FPS` 200/240); vsync-off with + no cap forces the safety cap. +- Feasibility mapping to GE: + - *Pacing math (accumulator + carry)* → adoptable **port-only**. ✓ + - *Render frames carrying zero sim ticks* → blocked in GE by + `waitForNextFrame`'s ≥1-tick enforcement (`frameDelay`, only settable + under `LEFTOVERDEBUG`). A crafted `osGetCount()` cannot defeat it (the + spin + `+Q/2` bias always yield ≥1). Needs a game-code change → **parked** + (see "Parked" below), and note it buys little without interpolation. + +## Phases + +### Phase 0 — Baseline & ground truth (port-only) + +Goal: know exactly what the default preset does before touching anything. + +- [ ] Measure actual sim rate and present rate of the current default build + (speedgraph + a `video.c` counter; add a periodic log line + `sim=..Hz render=..Hz`). Confirm NTSC vs PAL build. +- [ ] Find why the default is ~30 rather than the console's 60 Hz. Candidates: + - tick-thread 4 ms chunked-sleep drift under host load; + - `__scTaskReady` double-buffer check (`sched.c`) stalling a task every + other retrace; + - `__scHandleRetrace`'s client flag read `*((s32*)client + 2)` is + **out-of-bounds of `OSScClient`** (struct is only `next`+`msgQ`; the + decomp even says "im wrong size"). For `gfxClient[0]` it happens to + read `gfxClient[1].next` (zero) — but this is layout luck, worth a + documented finding either way. +- [ ] Record a Dxxx finding with the measured rates and root cause. +- [ ] **Decision gate:** if 30 fps turns out to be a port artifact rather than + original behavior, document it and decide with data whether the default + preset keeps the current pace (test continuity) or is fixed. Default + assumption: keep current behavior as the "original" preset either way; + a fix becomes its own item. + +### Phase 1 — PD-style pacing hardening (port-only) + +Makes the default preset stable and drift-free; no game-code changes. + +- [ ] Replace the free-running tick grid in `portTickThread` with a + wall-clock accumulator + remainder carry (PD `frametimeCalculate` math): + post ticks when real time says they're due, carry the remainder, bounded + catch-up after hitches (D155 philosophy). No drift under load, no burst + of back-to-back ticks. +- [ ] `video.c`: proper frame limiter for fractional caps (spin+sleep hybrid), + triple-buffered swap chain, and the PD safety rule (vsync off + no cap → + force a max cap so "unlocked" can't spin). +- [ ] Config: keep `Video.VSync` / `Video.FpsCap`; add whatever Phase 2 needs. + +### Phase 2 — Experimental presentation rates (port-only) + +The user-visible "60 / 120 / unlocked" feature. Game speed is invariant **by +construction**: sim + render stay on the original tick clock; only *present* +runs faster by showing each rendered frame more than once. + +- [ ] Frame duplication at the present stage (`video.c` / fast3d SDL swap + path, near `gfx_pre_swap_hook`): after a fresh frame is presented, + re-present the last finished frame on extra vsyncs until the next sim + tick's frame is ready. Target rates: 60, 120, uncapped (safety-capped à + la PD `VIDEO_MAX_FPS`). +- [ ] Input at present rate: sample SDL input continuously in + `port/src/input.c`; each sim tick consumes the latest state (compare + against PD `joy.c`). This is the real tangible win of the feature: up + to ~half a frame of latency removed even without interpolation. +- [ ] Options menu entries, clearly labelled **experimental** + (`port/src/optionsoverlay.c`), default preset untouched. +- [ ] Audio check: `src/audi.c` uses real-time `osGetTime()` and its own + thread; libaudio buffer sends ride the sim/retrace clock. Verify nothing + divides by an assumed frame rate; expect no changes. + +### Phase 3 — Verification & sign-off + +- [ ] `GE_DETERM` regression: default preset simulation path byte-identical to + pre-change (existing determinism diffs keep passing). +- [ ] Preset-invariance playtest: a timed in-game event (e.g. a fixed-length + animation or timer) takes the same real time on default / 60 / 120 / + unlocked. This is the acceptance test for decision #3. +- [ ] Hitch recovery: force a multi-second stall (debugger pause / asset load), + confirm D155 clamp holds and no speed spiral on any preset. +- [ ] `./build-pc.sh` + `/linkcheck` ritual; new findings in §F/§H with index + labels. + +## Timing-consumer audit (each gets a line in the finding) + +| Consumer | Clock class | Action | +|---|---|---| +| `src/game/frametiming.c` (`waitForNextFrame`, `updateFrameCounters`) | sim-time | unchanged; stays tick-locked | +| `src/boss.c:403,517` (main-loop gate, `MAIN_LOOP_TICK_INTERVAL`) | sim-time | unchanged; re-verify vs new pacemaker | +| `src/usb.c:387,405` (LibDragon flash timeouts) | real-time | fine as-is; document | +| `src/speed_graph.c` | measurement | fine; source of the rate log line | +| `libultrare/audio/env.c`, `reverb.c` `lastCnt` | perf counters only | fine; document | +| `src/audi.c` `g_DeltaTime` etc. (`osGetTime`) | real-time | fine; verify in Phase 2 | + +## Parked (needs explicit sign-off, separate proposals) + +1. **True decoupled sim/render (PD's zero-tick render frames).** Minimal + change class: relax `waitForNextFrame`'s ≥1-tick enforcement so rendered + frames can carry `deltaFrames = 0` (GE already guards `g_ClockTimer == 0` + everywhere). This is a *behavior-affecting* game-code edit gated off by + default — outside the ABI-only D3x exception, needs its own rules decision. + Perceptual benefit over Phase 2 duplication is small **without** + interpolation, so do this only as a prerequisite for #2. +2. **Motion interpolation** (prev/cur entity state, lerp at render time). + The actual "120 fps looks like 120 fps" feature. Large, invasive, in + DL-building game code. Separate proposal; not implied by this plan. + +## Open questions + +- Phase 0 outcome: is the ~30 fps default a port artifact? (fix vs keep — see + decision gate) +- PAL builds: default stays 50 Hz pace; experimental present rates identical + option set. Confirm no PAL-specific pacing assumptions in Phase 1 math. +- Naming/caps for "unlocked": adopt PD's safety-cap value (200/240) or expose + the cap as a slider? diff --git a/docs/dev/VIEWMODEL-RESEARCH.md b/docs/dev/VIEWMODEL-RESEARCH.md new file mode 100644 index 00000000..ec96e00d --- /dev/null +++ b/docs/dev/VIEWMODEL-RESEARCH.md @@ -0,0 +1,88 @@ +# 1P weapon viewmodel — static research (read-only) + +Companion to `GRAPHICS-BACKLOG.md` ("1P weapon viewmodel") and findings D115 +item #5 / AUDIT-M6 #5. Goal: narrow the five audit suspects to a ranked, +probe-ready list. No code changed by this doc. + +## Path map (verified in source) + +- **Setup (per frame):** `bondview2.c:9040` → `gunUpdateAndFireBothHands()` + (`gunfire.c:1018`) → `gunUpdateAndFire(handnum)` (`gunfire.c:327`). Inside, + the weapon-model block at `gunfire.c:599-662`: computes `field_87F` (the + show/hide gate), then if set: `dynAllocate(numMatrices × sizeof Mtxf)` → + identity-fill → `modelCalculateRwDataLen` → `modelInit(HAND_WEAPON_MODEL, + ©_of_body_obj_header[handnum], HAND_WEAPON_RWPOOL)` → + `hand->mtxlist = rwmtx` and (PORT/D102) + `weaponModel.render_pos = (RenderPosView*)rwmtx`. +- **Render (same frame, later):** `gunRenderFirstPersonGunModels` loop at + `gunfire.c:1532-1540`: **`if (handptr->field_87F == 0) continue;`** then + `subdraw(&renderdata, HAND_WEAPON_MODEL(handptr))` (`model.c:5326`) which + does `gSPSegment(gdl, 3, osVirtualToPhysical(mdl->render_pos))` and walks + `mdl->obj->RootNode` — **a NULL RootNode returns silently, drawing + nothing**. + +## Statically ruled out (with evidence) + +1. **Transient-arena lifetime** (the audit's prime suspect). `dynAllocate` + is a bump allocator on the vtx pool (`src/game/dyn.c:123`), reset only by + `dynSwapBuffers()` at frame end. Setup and render both happen inside one + frame, so `render_pos`/`mtxlist` are valid when consumed — in solo play. + (Split-screen mid-frame swaps would be a separate question; out of scope.) +2. **Word-index / record-size mismatch** (`flashvisptr = RWPOOL + + RwDataIndex`, `gunfire.c:650`). D52 kept `Model.datas` as `u32*` on PC + precisely so N64 word math holds, and `RwDataIndex` values are computed + at runtime by `modelCalculateRwDataIndexes` from the *PC* record sizes — + self-consistent. (Self-consistent ≠ in-bounds; see suspect 2.) +3. **Segment/render_pos mechanism itself.** Every working model in the game + (bodies, props) uses the same `gSPSegment(3, render_pos)` + + `bondviewTransformManyPosToViewMatrix` machinery — and it renders. The + **watch menu draws the same weapon models** via a different path + (`set_enviro_fog_for_items_in_solo_watch_menu`, `gunfire.c:1630+`, local + matrices, no 87F gate, no hand rw-pool) and they appear (albeit faint — + D75/D141 note). So the model assets and headers are very likely fine; the + defect is in-level-path-specific. + +## Ranked suspects + probe spec + +**#1 — `field_87F` gating (`gunfire.c:599-610`).** The clear-condition +disjunction contains `(Gun_hand_without_item(handnum) == 0)`. That function +(`gun.c:812-816`) returns `hand_invisible[h] > 0 || (hand_item[h] == 0 && +field_2A44[h] < 0)` — so the term is TRUE (→ gate cleared → gun hidden) +whenever the hand *has* an item and isn't invisible. Read naively, that +hides the gun exactly when you're holding one; since the N64 shows the gun, +the runtime values of `hand_item`/`field_2A44`/`hand_invisible` must differ +from their names suggest — but this is the single most likely place for a +PC-only divergence (e.g. a field the port initializes differently). If 87F +is 0, the render loop `continue`s and the symptom is exactly "no gun, no +crash". + +**#2 — rw-pool capacity.** PC records are wider than N64's (pointer fields); +`weaponRwPool[192]` (768 B) was D102's scaled-up budget, but nothing bounds- +checks it. The `#ifdef DEBUG` print at `gunfire.c:620` still tests the *N64* +32-word threshold — stale on PC. If `numRecords` words exceed 192, +`modelInitRwData` overruns the pool into whatever follows it in `struct +hand` → corruption that could kill the draw (or worse, silently). + +**#3 — stale `copy_of_body_obj_header`.** Populated only at item load +(`gun.c:924` shallow copy / `gun.c:931-951` `load_object_fill_header`). If a +PC asset-load quirk zeroed it for the in-level path, `RootNode == NULL` and +`subdraw` no-ops silently. Lower priority given the watch menu renders the +same models — but cheap to confirm in the same probe. + +**Probe (one session, PORT-only, env-gated `GE_DVM=1`, zero behavior +change):** in the `field_87F != 0` block at `gunfire.c:611+`, print once per +frame while a pistol is held in BUNKER1: `handnum`, `item`, each clear-term +value + final `field_87F`, `©_of_body_obj_header[h]`, `numMatrices`, +`numRecords` (words) vs 192, `RootNode`, `rwmtx`. One run triages all three +suspects: 87F==0 → suspect #1 (log which term); numRecords>192 → #2; +RootNode NULL/garbage → #3. + +## Fix-shape notes (for when the probe lands) + +- If #1: the fix is a `#ifdef PORT` initialization/correction of whichever + hand field diverges — **not** an edit to the gate logic itself (rule #2); + document as Dxxx with the N64-vs-PC value table. +- If #2: enlarge `weaponRwPool` to the measured max + a PORT bounds guard + that prints (mirrors the existing DEBUG print, correct threshold). +- If #3: trace the `load_object_fill_header` call for the in-level weapon + load; likely an asset-offset issue in the port's loader path. diff --git a/docs/dev/WIDESCREEN-FOV-PLAN.md b/docs/dev/WIDESCREEN-FOV-PLAN.md new file mode 100644 index 00000000..ea71e384 --- /dev/null +++ b/docs/dev/WIDESCREEN-FOV-PLAN.md @@ -0,0 +1,241 @@ +# Widescreen & FOV Plan + +Status: **proposal, parked** — like `UNLOCKED-FPS-PLAN.md`, resume after audio +work (project Phase 3). Research current as of this writing. + +Scope decision (agreed): **target exactly two display aspects — 4:3 and 16:9.** +Arbitrary aspect ratios are TBD and out of scope; the window manager should +snap/letterbox to one of the two rather than trying to serve anything else. + +Standard (agreed): **Option B (native 16:9 render, PD-parity) is the goal.** +The PD PC port's widescreen architecture is the reference standard for this +engine family. Option A (4:3-internal + stretch) remains an acceptable +**case-by-case workaround** only where B proves impractical for a specific +surface — each such fallback documented individually. + +## Problem + +At high resolutions (e.g. 4K) in a 16:9 window the game is disorienting. Root +cause, confirmed in code: the port renders in *window-resolution space*. All +320×240 logical screen coordinates are scaled per-axis by +`RATIO_X = win_w/320`, `RATIO_Y = win_h/240` (`port/fast3d/gfx_pc.cpp:53-54`). +In a 16:9 window everything — world geometry **and** HUD — is stretched 4/3 +horizontally: circles become ellipses, and the horizontal FOV balloons from +~78° to ~91° while vertical stays 60°. That stretch is the disorientation. + +## Research findings + +### 1. The FOV model (where FOV lives) + +- Vertical FOV is a fixed 60° base: `FOV_Y_F` (`src/fr.h:137`); per-player + `fovy`/`aspect` fields default to 60 / `DEFAULT_ASPECT` + (`src/game/player.c:454-455`). +- Setters: `viSetFovY` / `viSetAspect` / `viSetFov(fovx, fovy)` + (`src/fr.c:911-934`) → `guPerspectiveF(g_viProjectionMatrixF, …, fovy, + aspect, znear, zfar, 1.0f)` (`src/fr.c:718`). +- **FOV is dynamic**: aim zoom rewrites fovy every frame via + `viSetFovY(g_CurrentPlayer->zoominfovy)` (`src/game/bondview2.c:3123`), and + the camera path resets fovy/aspect per frame + (`bondviewMovePlayerUpdateViewport`, `src/game/bondview2.c:7967-8034`). + ⇒ Any FOV option must be a *multiplicative correction composable with zoom*, + not an override of a constant. + +### 2. The game ships with a (stretch-based) 16:9 path + +This decomp contains a player-facing widescreen path from the original: +options-menu **"Ratio": Normal / 16:9** (`GAME_OPTIONS_INDEX_RATIO`, string +`OPTION_STR_23_169_LF`, `src/game/options.c:129`), save bit +`OPTION_SCREENRATIO` (`src/game/file2.c:1266-1270, 1323`), +`get/set_screen_ratio()` (`src/game/options.c:553-558`). When active, the +camera sets `aspect = (vp_w/vp_h) × 0.75 × 16/9` (= viewport ratio × 4/3, +`src/game/bondview2.c:8007-8034`) — the pre-distortion for a **4:3 render +stretched to 16:9** (N64-out-4:3, TV stretches). HUD compensation exists for +crosshair (`gunfire.c:6316-6318`, half-width ×0.75) and rocket aim +(`gunfire.c:501`); EU-only extras need an NTSC audit. + +⇒ This path is exactly **Option A's** game-side machinery. It is *not* the +PD standard (which renders native 16:9), so under Option B it stays dormant +except as fallback. + +### 3. Current port rendering pipeline (what to build on) + +- Render happens at window size; an FBO path already exists and activates when + internal dims ≠ window dims or MSAA > 1 (`gfx_pc.cpp:3041-3115+`). +- Native-res bookkeeping: `GE_NATIVE_W/H` = 640×480 (NTSC) / 640×400 (PAL) + (`port/src/video.c:40-44`); VI size requests clamp to ≤640 + (`port/src/libultra.c:816`). Auto window defaults to 4:3 + (`port/fast3d/gfx_sdl2.cpp:107`). + +### 4. fast3d EXT aspect commands — already present in GE's RSP + +GE's software RSP (adapted from PD's) implements Rare's PC-port extension +geometry-mode commands `G_ASPECT_{LEFT,RIGHT,WIDE,CENTER}_EXT` +(`gfx_pc.cpp:1954-1979`, decoder at `gfx_sp_extra_geometry_mode`), and +`port/fast3d/gbiex.h` carries the `gSP…EXT` command macros. **The RSP side of +the PD standard already works in this codebase**; only the game-code emission +sites are missing. + +### 5. The PD standard (reference architecture — what Option B replicates) + +From the `pd_port` checkout (N64 PD had no widescreen; the port added it): + +1. **Projection aspect comes from the port**: `videoGetAspect()` returns the + real window aspect (`gfx_current_dimensions.aspect_ratio`, + `port/src/video.c:232-235`). Game code calls `viSetAspect(videoGetAspect())` + (e.g. `title.c:2495`; even `#define TITLE_ASPECT (videoGetAspect())`). + Native 16:9 projection ⇒ correct perspective, vertical FOV preserved. +2. **Per-element screen-space corrections** where an element is positioned in + screen coords: `sightGetAdjustedX(x)`, `×(SCREEN_ASPECT / videoGetAspect())` + scalings (`sight.c:77,921`, `sky.c:2728+`, `menu.c:2332,4771`, + `player.c:3108`). +3. **HUD alignment via the EXT commands**: screen-space draw segments wrapped + in `gSPSetExtraGeometryModeEXT(gdl, G_ASPECT_CENTER_EXT)` … + `gSPClearExtraGeometryModeEXT(…)`, all under `#ifndef PLATFORM_N64`. With + the mode active, fast3d scales 4:3-logical screen coords by the *native* + aspect and centers (or left/right-aligns) them in the wider window — no + distortion. PD scale: **52 emission sites across 10 game files** (sight, + hudmsg, bondgun, menu, radar, title, propobj, credits, activemenu, + game_1531a0). +4. User-facing: vsync/framerate options + HUD-centering option + (`g_HudCenter` → `g_HudAlignModeL/R`, `port/src/main.c:77-83`). + +## Option B — the standard (native 16:9, PD-parity) + +### Architecture + +Render directly at the 16:9 window resolution. World: projection aspect = +window aspect (vertical FOV preserved, no post-stretch). HUD/screen-space: +4:3-logical coordinates mapped undistorted via the EXT aspect modes +(centered by default; left/right alignment available à la PD). 4:3 windows: +byte-identical to today. + +### Change inventory + +**Port layer (no rules issues):** +- [ ] `videoGetAspect()` equivalent in `port/src/video.c` (return window + aspect; GE already tracks `gfx_current_dimensions.aspect_ratio`). +- [ ] Window/aspect policy: `Video.Aspect` {4:3, 16:9}, snap on create/resize; + out-of-scope aspects → nearest + note (TBD). +- [ ] Make `port/fast3d/gbiex.h` reachable from game TUs (include path or a + shim header) — build-system only. +- [ ] HUD alignment mode plumbing if we adopt PD's center/wide option + (`g_HudAlignModeL/R` pattern) — decide in Phase 1; default = centered. + +**Game code (exception class — see below):** +- **(a) Projection aspect path.** `bondviewMovePlayerUpdateViewport` + (`bondview2.c:8007-8034`): under PORT + 16:9, use window aspect instead + of the ×4/3 stretch formula. Plus the menu/title/briefing call sites + `viSetFovY(FOV_Y_F); viSetAspect(ASPECT_RATIO_SD)` (`front.c` — ~7+ + sites; enumerate in Phase 1) → PD pattern `viSetAspect(videoGetAspect())`. +- **(b) HUD / screen-space draw sites.** Audit + wrap with EXT aspect modes + and/or per-element corrections, per surface: gameplay HUD (crosshair — + replaces the existing ×0.75 compensation; ammo/timer/text via + `textrelated.c`), menus (`front.c`), title/briefing/credits, letterbox + bars (`fr.c` gDPFillRectangle calls). GE's known screen-space emitters: + `front.c`, `textrelated.c`, `gunfire.c`, `bondwalk2.c` (initial grep; + Phase 1 completes the audit). Expect PD-comparable scale (~10 files), + possibly less. +- **(c) Anything the audit surfaces** (e.g. mouse→screen mapping for menus, + cutscene camera framing) — each item logged individually. + +### Rule #2 exception framing (needs explicit sign-off to start Phase 2) + +This is a behavior-affecting game-code edit class, outside the ABI-only D3x +exception. The case for granting it: + +1. **Direct sister-engine precedent**: PD's port made exactly these edits on + the same Rare engine/fast3d; the project designates PD as standing + reference for port patterns. We replicate a proven, bounded change class, + not inventing one. +2. **Bounded and gated**: every edit is `#ifdef PORT` (and where sensible + runtime aspect-gated) so the N64 build and the 4:3 default path stay + byte-identical; golden/determinism tests unaffected at 4:3. +3. **The decomp already contains** both a shipped 16:9 branch and `#ifdef + PORT` precedents in game files — the pattern is not foreign to this tree. + +Conditions: explicit user sign-off before Phase 2; each change (or tight +cluster) gets a findings.md entry with §F index label citing the PD analogue; +4:3 byte-identity verified per phase; any edit that would alter *sim* behavior +(not just presentation) stops the work and comes back for review. + +### Fallback (case-by-case, documented) + +If the Phase 1 audit shows a specific surface is disproportionately costly or +can't be made clean under B (candidate: complex cutscene framing), that +surface may fall back to **Option A** — 4:3-internal render + edge-to-edge +stretch using the game's shipped `SCREEN_RATIO_16_9` path (`set_screen_ratio` +is an existing API; FBO+blit infra exists) — recorded as a finding with the +reason. Whole-feature fallback to A only if B fails verification overall. + +## FOV adjustment design (port-only, applies to both options) + +- **Hook: software RSP projection post-scale.** Wherever the RSP applies the + uploaded projection matrix, scale its vertical row by + `tan(fov_orig/2) / tan(fov_adj/2)`. Zoom rewrites the matrix every frame, so + post-scaling whatever arrives composes with aim zoom for free. No game code. +- Rejected: editing `viSetFovY` call sites (game code); writing + `g_ViBackData` from the port (racy). +- UI: `Video.FovScale` (percent of original vertical FOV, e.g. 50–150), + default 100 = byte-identical to today. + +## Phases + +### Phase 0 — Verification research (port-only, read-mostly) + +- [ ] Playtest the in-game Ratio option as-is on PC; document + screenshot + what the shipped stretch path does under the current pipeline. +- [ ] Measure baseline FOVs (4:3 and current 16:9-stretch) from the projection + matrix or a calibration render; record numbers. +- [ ] Confirm `gbiex.h` EXT commands round-trip through GE's RSP with a test + DL (they should — decoder exists). +- [ ] Audit EU-only aspect constants (`g_GunSightAspectRatio`, + `EU_CAMERA_8003642C_ASPECT`) for NTSC/PAL effect. +- [ ] Record a Dxxx finding consolidating baselines. + +### Phase 1 — Port foundation + full change audit (port-only) + +- [ ] `videoGetAspect()`, window/aspect policy, gbiex.h include path. +- [ ] Complete the screen-space draw-site audit for inventory item (b): every + emitter, its coordinate assumptions, proposed wrap/correction, PD + analogue cited. Output = the signed change list for Phase 2/3. +- [ ] Decide HUD alignment default (centered vs PD's center/wide option). + +### Phase 2 — Projection aspect (game-code exception class, small wave) + +- [ ] Item (a): camera path + front.c call sites, `#ifdef PORT`-gated. +- [ ] Verify: world perspective correct at 16:9 (FOV measurement vs Phase 0), + aim zoom composes, 4:3 byte-identical. + +### Phase 3 — HUD alignment wave (game-code exception class) + +- [ ] Item (b), per surface in order: gameplay HUD → menus → + title/briefing/credits → letterbox/misc. Each surface: change, visual + verification at 4:3 + 16:9, finding entry. Fallback-to-A decisions + happen here, per surface, with documented reason. + +### Phase 4 — FOV slider (port-only) + +- [ ] RSP projection post-scale; `Video.FovScale` config + F10 overlay; + verify composition with aim zoom at non-100 scale. + +### Phase 5 — Verification & sign-off + +- [ ] FOV measurements per mode/scale vs Phase 0 baselines. +- [ ] Full HUD pass at 4:3 + 16:9; cosmetics → `GRAPHICS-BACKLOG.md`. +- [ ] 4K performance check; MSAA interaction; config persistence; screenshots. +- [ ] `./build-pc.sh` + `/linkcheck`; findings complete with §F index labels. + +## Open questions + +- HUD alignment UX: always-centered (simple) vs PD's center/wide option + (more faithful, more surface area). Decide Phase 1. +- In-game Ratio menu entry under Option B: the shipped option means "stretch + mode" — keep it as the *fallback* trigger, hide it, or repurpose? Decide + Phase 1 (leaning: leave dormant; port config owns display mode). +- Mouse/menu coordinate mapping at 16:9 (PD had `videoGetAspect()`-scaled + mouse math in menu.c) — confirm GE's menu input path needs the same. +- Ordering vs `UNLOCKED-FPS-PLAN.md`: both parked behind audio; B renders + native so the FPS plan's present-stage duplication slots in unchanged. + Decide order when audio lands. +- PAL: `GE_NATIVE` 640×400 bookkeeping — confirm aspect math per region. +- Split-screen viewports under 16:9 — likely out of scope for PC; note and close.