From 6d083c2c7c8ff67ccb890af357e4b19fa86f11ad Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:17:17 -0400 Subject: [PATCH] D194: user playtest verdict -- spazz accepted (95%), remaining work is sensitivity co-tune + FPS control --- docs/dev/findings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 1e8d78b5..7c80bc0e 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -393,7 +393,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D190 | **Linux `-level_45` SIGSEGV at load in `add_ptr_to_objective` (`objective.c:64`) (M-48, open).** `objective_ptrs[objective->menu] = objective;` faults — `objective` wild or `objective->menu` a garbage index → OOB write. Almost certainly the D122/D126/D132 propDef-stride / ROM-serialized-struct-widening family (`struct objective_entry` / the type-23/type-1E setup-text record chain read at the wrong stride on PC). Found in the M-48 Linux level sweep: **5/6 spot-checked levels (`-level_09/20/27/34/37`) load + render fine; `-level_45` is the one failure.** Not a v0.1.0-alpha blocker (alpha is explicitly "expect crashes"). Windows: `-level_45` load status not re-checked this session. | **CLOSED — not a port bug; level 45 is MP-only and has no solo setup file in the ROM (root-caused M-105, reproduced on Windows).** Full chain: `setup_text_pointers[45]` = `"UsetupimpZ"` (`chraidata.c:824`), but that name is **not in the ROM file table** (`assets/obseg/file_resource_table.inc.c`) — only the MP variant `"Ump_setupimpZ"` is. BASEMENT is a 4-player MP level (`front.c:557` `multi_stage_setups[]`, alongside STACK/TEMPLE/COMPLEX/CAVES/LIBRARY — exactly the other missing solo names); MP play prepends `mp_` to the name (`prop.c:1261-1269`), so the solo name was never shipped. A solo `-level_45` boot (debug flag only; no solo mission references stage 45) requests a nonexistent file: on PC the inert rmon stubs (`osReadHost`/`osWriteHost`, `n64stubs.c`) leave the CheckFileExists response in `fileGetIndex`'s dynamic path (`ob.c:421`) **uninitialized** → fresh table entry with size 0 → `fileIndexLoadToBank` allocates bank memory, loads 0 bytes → the setup rebase + propDef walk in `proplvreset2` runs over **stale pool memory** → garbage records (M-105 Windows crash: `modelLoad` via `domakedefaultobj`, `obj=0x784f`; M-48 Linux hit `add_ptr_to_objective` — same garbage walk, different stale contents). N64 parity: the file is genuinely absent there too (indy host answers not-found → index-0 load) — this boot is broken in the original as well. No fix taken: a port-layer guard cannot synthesize a valid setup (the walks need `-1`/`PROPDEF_END` terminators no zeroed buffer provides), and fabricating ROM content for an out-of-scope MP level has no ground truth. The M-48 "Linux-specific" framing was wrong — the failure is platform-independent; it was merely found on Linux first. | | D191 | **Windows `-O2` release-bundle crash: SIGSEGV in `modelGetNodeRwData` (`src/game/model.c:478`) — the `root->Parent` node-tree walk (M-49, user playtests of the v0.1.0 win64 bundle).** **Two instances, identical fault PC `0x1400801b1`:** (1) fresh save, killed the first guard right after escaping the cell in Bunker ii; (2) Statue (`-level_49`?), right after the Trevelyan meeting cutscene. `ge007.crash.log` (auto-dump; symbols intact in the shipped exe, `addr2line` resolves): PC = `modelGetNodeRwData` at `model.c:478` — the `switch (root->Opcode & 0xff)`, i.e. `root` (a `ModelNode *`) is bad; `addr2line` puts it in the inlined self-recursion at `:532`, so the `while (root->Parent) { root = root->Parent; … }` walk (`model.c:527-540`) handed a garbage node into the recursive call. Instance 1: `Rdx = 0x70267bc000000000` — a `~0x70266xxx` heap address in the **high 32 bits, zero low half** = 32-bit pointer field read as 64-bit (or a byte-swapped `u32` pair). Instance 2: `Rdx = 0`, FAULT ADDR `0x0` — `root` (or `root->Parent`) resolved to NULL. Both: `Rcx=Rdi` = the `~0x7008xxxx`/`~0x7007xxxx` object ptr, `R8 = 0x1d` (29). So the `Parent` chain in some model contains a **truncated or NULL link** — pointer-width / byte-swap family (D3x, D119 `weapons_held[]->chr` type-pun, D122/D126/D132 propDef-stride). NOT weapon-fire-specific — common factor is `modelGetNodeRwData` running on a character/cutscene model whose node tree has a bad `Parent`. Crash logs saved: `scratchpad/crashlogs/`. **Repro aid:** shipped exe has full symbols; `debug-crash.ps1` in the bundle dir runs it under gdb, `GE_D51=1` → `d52rw.log` rwdata-pool trace. The crash.c EBP backtrace breaks immediately (`#01: 0x1d`) — need a real `bt` + `p *root` + `p *Objinst` at the fault. Not yet reproduced under a debugger; no fix. | FIXED + PLAYTEST-VERIFIED (M-79). User replayed Bunker ii (first-guard kill) and Statue (post-Trevelyan cutscene) on the fix build — **both no longer crash** → 21/21 solo levels crash-free (v0.2.0 gate met; Cradle's remaining issue is D193, not a crash). Build clean; bunker1 golden framediff 3/3. **Real gdb backtrace (M-79):** the fault is NOT in the `root->Parent` walk — `root` arrives *already* garbage (`0x70267a9000000000` = a half-word-swapped `u32` `0x70267a90`) from frame #1 `bondviewSelectCuff(model=0x70076808, header=0x700763a0, switchindex=29)`, called from `gunUpdateAndFire(GUNRIGHT)` on the frame the player fires. `bondviewSelectCuff` byte-indexes `header->Switches` (a `ModelNode *` array — **8-byte stride on PC**, widened per D43/D45) with `offset = switchindex << 2` (N64 4-byte stride), so every `base[N]` deref lands mid-slot and yields a misaligned half-pointer that is non-NULL by luck → `modelGetNodeRwData(root=garbage)`. **Exactly the D140 bug class** — D140 fixed the identical pattern in `gunfire.c:1740`; this call site was missed. Fix: PORT-only `offset = switchindex * (s32) sizeof(ModelNode *)`; the `base = (u8*)switches + offset` math and the `#else` N64 line are unchanged. ABI/pointer-width only (D3x class), no game-logic change. The Statue instance (`Rdx=0`, NULL fault addr) is the same site landing on a slot whose bytes read as 0. | | D193 | **NPC AI locomotion is too slow — characters don't move to their destinations at the original game's rate (M-49, user playtest; clarified after the FULL 21-level playthrough — "the biggest overall flaw").** Not (primarily) an animation-playback bug — the *travel speed* of scripted/AI movement is wrong: guards, named NPCs (Trevelyan, Ourumov) and friendlies (Natalya) all take noticeably longer to run/walk to their goal positions than on N64. General, not chr-specific. **Leading hypothesis — the D117/D134/D155/D156 wall-clock-timing family:** `chr.c` / `chrai.c` integrate movement as `pos += speed * g_GlobalTimerDelta` and step sim loops `while (i < g_ClockTimer)` / `-= g_ClockTimer` (`chr.c:1437,1446,1491-1502,1862,2359,2569`; `chrai.c:76`). On the port `osGetCount()` is free-running wall-clock (D117), `g_GlobalTimerDelta`/`g_ClockTimer` are derived from it, and D155's `FRAMETIMING_PORT_MAX_CATCHUP = 6` clamps `nextFrameTime`. If the effective per-second sum of `g_GlobalTimerDelta` (or the `g_ClockTimer` tick count) runs low — quantization, the catch-up clamp biting during normal play, or a units mismatch (`g_JP_GlobalTimerDelta` vs `g_GlobalTimerDelta`, `chr.c:1435/1437`) — every delta-scaled AI motion integrates slow while **player** movement (input-driven per frame) still feels right, which is exactly the reported split. Related: **D170** (Ourumov/Trevelyan "flee" looks wrong — flagged there as possibly the same locomotion-speed issue rather than an AI divergence). Also possible: `chrGoToBond/Chr(SPEED_RUN/WALK/SPRINT)` speed enum → path-follow step, or pathfinding advancing ≤1 waypoint per frame-quantized tick. **Instances seen (M-49 full playthrough):** (a) Facility — the post-scripted-scene beat with Ourumov + the soldier squad: the soldiers **walk in super slowly and take a long time to path into firing positions** before they start shooting (this is regular squad combat AI, not a named-NPC script — good evidence the slowdown is general delta-scaled `chr` locomotion / path-following, not one ailist); (b) the named-NPC flee behaviour on Silo (Ourumov) and Cradle (Trevelyan) — see D170; (c) **Natalya (the escort NPC) always walks really slow** — on every escort level (Bunker ii, Statue, Control, Archives) she trails far behind, forcing the player to wait. Escort follow-AI is the same `chrGoToChr`/path-follow path as (a). **WORST CASE — Cradle (`-level_51`) is "bugged out completely" due to Trevelyan's behaviour (M-49).** The final level is Trevelyan-fleeing-and-scripted end to end (he runs the antenna cradle, climbs, drops to the cradle floor, the helicopter beat); if his locomotion timing / path-following is off he never hits the trigger points and the level's scripted progression stalls or goes haywire. This makes D193 effectively a **can't-properly-finish-the-game** bug, not just cosmetic sluggishness. **Next:** log `g_GlobalTimerDelta` + `g_ClockTimer` per frame during normal play and check the 1-second sum vs the N64's ~60; time a guard's run across a known distance vs reference footage; then watch Trevelyan's chr state / current AI command on Cradle. | **FIXED + user-playtest-verified (M-81) — root cause is D209**: `act_ubytes.padding[45]`, a raw-byte union alias for `act_gopos.unk59` (the SPEED tier), lands on offset 45 which is `unk59` on N64 but is byte 5 of `waypoints[1]` at 64-bit — always `0x00`. So the locomotion-animation selector `get_sound_at_range()` always received tier 0 and bound `ANIM_DATA_walking`/`walking_unarmed` for **every AI chr in the game**, and since travel is anim-root-motion driven they all moved at walk pace no matter what the ailist commanded. User verified on Cradle: Trevelyan and the guards run. Full elimination trail below — **steady-state timing keystone MEASURED and the "sim sums low" hypothesis is CONTRADICTED (M-80).** Added a `GE_D193=1` per-wall-second probe in `waitForNextFrame()` (`src/game/frametiming.c`, `#ifdef PORT`, env-gated): logs render frames, sim ticks delivered (= sum of the `deltaFrames` fed to `updateFrameCounters` = sum of `g_ClockTimer`), catch-up clamp hits, and max raw pre-clamp delta. Built ntsc-final, ran `-level_09 / _20 / _27 / _34` idle-after-load, ~20 s each. **Every level, rock-steady: render = 30.0 fps, sim = 60.0 ticks/s, clamped = 0, maxraw = 2.** i.e. each rendered frame carries `g_ClockTimer = 2` and the per-second sum is *exactly* the N64's ~60 — the wall-clock timer is NOT running low in steady state, and `FRAMETIMING_PORT_MAX_CATCHUP` never bites in normal play. VSync on/off made no difference (still 30/60). So the B1 premise ("`g_GlobalTimerDelta`/`g_ClockTimer` sum low over a real second") is wrong for the common case; D193 must come from elsewhere. **Residual quantization risk (not yet observed):** `nextFrameTime = (dt_counts + 387937) / 775875` is integer-rounded per frame with no remainder carry, so a scene that renders at a *steady* rate between 30 and 60 that is not an integer divisor of 60 (e.g. a solid 50 fps → dt≈20 ms → rounds to 1 → sim 50/s, ~17 % slow) would under-advance; the fix there is a fractional accumulator in the `#ifdef PORT` path. But the port renders a hard 30 (software RSP ≈ 60 µs/frame — not GPU-bound; the cap is the `pendingGfx < 2` double-buffer + sched round-trip), so this only matters if a real AI-heavy scripted scene drops into the 20–30 band. **Next (needs input, interactive session):** drive Bond into the Facility Ourumov/squad beat (D193a) and Cradle Trevelyan run with `GE_D193=1` — confirm render/sim hold at 30/60 under real AI load; if they do, pivot off timing entirely and instrument a single guard's world position vs. distance/time (anim root-motion rate, `chrGoToChr` SPEED_* enum → step size, path-node spacing) — the travel-speed bug is likely in the locomotion/anim path, not the frame clock. `GE_D193` catalogued in `docs/dev/GE-ENV-PROBES.md`. **M-80 cont. — LIVE-LOAD CONFIRMED, pivot is final.** User ran Cradle (`GE_D193=1`, `-level_41` — see the level-id note below) and drove Bond through the Trevelyan sequence while Trevelyan was visibly "walking really slow". Probe throughout: **`render=30 sim=60 clamped=0 maxraw=2`, dead steady** — no dips, no clamp hits, no `[D156]` guard fires. So under the *exact* failing scene the sim clock delivers a perfect 60 ticks/s. **D193 is NOT a timing/scheduler bug — it is in the character locomotion/animation path.** Best current hypothesis: GE character *travel* is animation-root-motion driven — `modelTickAnim` advances `frame += playspeed*speed` per tick (numticks = g_ClockTimer, correct), then `modelSetAnimFrame2WithChrStuff` (`model.c:3060+`) extracts per-anim-frame translation via `sub_GAME_7F06D3F4(...&pos)` and applies `scale = model->scale * model->anim_translation_scale`. The **player does NOT use anim root motion** (explicit speedgo/speedstrafe physics in bondview2.c), which explains the "player fine / all AI slow" split precisely. Suspects, in order: (1) the anim translation keyframe data is decoded with wrong units/scale by its sidecar converter (D43/model family); (2) `model->anim_translation_scale` or `model->scale` misread (ABI/struct-stride — D3x/D99 class); (3) `model->animrate`/`playspeed`/`speed` wrong. **M-80 cont. — port side instrumented (`GE_D193A` in `chrTick`, `GE_D193B` in `sub_GAME_7F06D3F4`; both `#ifdef PORT`, catalogued in GE-ENV-PROBES.md). Findings:** (i) `g_GlobalTimerDelta = 2.0`, `model->playspeed = 1.0`, `model->scale = 0.1`, `model->anim_translation_scale = 1.0` for every ticked chr on Silo + Facility — the scalars that feed the root-motion accumulation are all nominal, matching the identical N64 C (no `#ifdef PORT` in this hot path bar the non-firing D156 NaN guards). `model->animrate = 0.0` universally but that is the default — only written by `modelSetAnimPlaySpeed` on a blended transition (`startframe > 0`), so not the bug. (ii) Patrolling guards (`act=14 ACT_PATROL`, `model->speed=0.5`) travel **~60–99 world-u/s** on the port — no N64 number to compare against yet. (iii) `GE_D193B` (Facility, walk anim `706af4d8`): the decoded root-motion triple `tmp` ≈ (±0-3, ~1080-1094 hip height, ±0-50) with **`base = 0` and `angle = 0` on every call** (could be legit for a straight-line walk — joint 0 channel A at index 0, no per-frame facing change — or could be a mis-selected/zeroed channel; can't tell without the N64 side). **Bottom line: every port-side input to the locomotion math reads nominal**, so the divergence is either (a) subtle wrong magnitudes in the decoded anim bitstream values themselves (`tmp` triple / the descriptor decode), or (b) something in the `pos34` accumulation / `header->unk*` state — neither visible without a reference. `ModelSkeleton` **ruled out** — the skeletons are statically defined in C (`New_ModelSkeleton`/`MODELSKELETON` macros, `chrobjdata.c` etc.), not ROM-serialized, so `Joints` is a native x86-64 pointer and `ModelJoint` is 6 B on both platforms; `base` is correct. That leaves the ROM-serialized `ModelAnimation` bitstream (`bitDescriptors`/`bitStream`, D32-widened) and the anim-table load path as where a magnitude error could enter. **M-80 cont. — anim-data endianness (D33) audited, looks complete for this path.** `ptr_animation_table` is `romCopy`'d then byte-swapped by `romdataFixupAnimationData` (`port/src/romdata.c`, D33): per 20-byte record it bswaps `+0x00/+0x04/+0x08/+0x0C/+0x0E/+0x10` and per 6-byte `ModelAnimBitField` descriptor bswaps `+0 bitOffset` / `+4 valueOffset` (bitCount/pad identity); the bit*stream* is left raw (correct — `modelAnimReadRootMotionValue` walks it byte-by-byte, MSB-first, endianness-neutral, same as PD's `anim_read_bits`). `PTR_ANIM_walking/running/sprinting` **are** in `animation_table_ptrs1` so guard-locomotion records do get fixed up; `_ptrs2` is only 3 helicopter/plane anims (no overlap → no double-swap). No `bad descriptor range` / `bad record offset` errors in any run. Every `anim->unkNN` the locomotion path reads (`unk04`, `unk06`, `unk07`, `unk0C`) is within the fixed-up 0x14 bytes. `sizeof(ModelAnimation)` = 64 B both platforms (D32 kept the embedded ptrs as `u32`); `sizeof(ModelAnimBitField)` = 6 B both. So the anim decode is structurally sound as far as static review goes — **a magnitude error, if any, needs the N64 reference to see.** **Next — two viable paths (needs a fresh session; toolchain not present here — no `mips-linux-gnu-*`, no `qemu-irix`, no emulator):** (A) set up the MIPS toolchain + IDO-recomp + an N64 emulator with `osSyncPrintf` capture, build the ROM with `#ifndef PORT` `GE_D193B`-equivalent logging, navigate to the same scene, diff `tmp`/`base`/`angle`/travel-speed against the port capture; (B) user records N64 footage (real HW or their emulator) of one guard traversing a measured distance, frame-count it → gives the port-vs-N64 speed ratio (a clean 2× would point at a per-frame vs per-tick doubling; a fractional ratio at a scale/data error). Recommend (B) first — cheap, and the ratio narrows (A) massively. **Highest-impact playtest finding — breaks Cradle.** **Level-id note:** the earlier "Cradle = `-level_51`" in this row and HANDOFF was WRONG — `LEVELID_CRADLE = 41` (`src/bondconstants.h:1662`; STATUE=22, so CRADLE = 22+19). `-level_51` = `LEVELID_EAR` (unused slot) and SIGSEGVs at load in `stanBuildRoomData` (`stan.c:281`, NULL deref) — a non-issue (bad level id), noted here only so it isn't re-investigated as a real crash. **M-81 — SOLVED, and none of the M-80 suspects were the cause.** Re-verified the whole timing chain independently (`waitForNextFrame` -> `speedgraphframes` -> `g_ClockTimer` -> `g_GlobalTimerDelta`; `g_GlobalTimer += g_ClockTimer` at `lv.c:1022` is **tick**-based, so the ~75 AI timeout comparisons against it are frame-rate independent) and re-checked the D33 fixup *widths* against the real structs (they match exactly, and `(bs-bd) % 6 == 0` passing is independent evidence the two embedded pointers decode correctly). Root-motion accumulation is self-consistent (`pos24 = pos34 + rotate(pos)` at `model.c:3322`, so `pos` is a per-frame delta in x/z and an absolute hip height in y — matching the observed `tmp` shape). Extending `GE_D193A` with the visibility-gate counters (`tick`/`move`, i.e. chrTick entries vs `chrUpdateAnim` calls) killed the culling hypothesis too — chr 0 is `31/31` every second. The tier was then shown correct (`tier=1 SPEED_RUN`) and the *animation* wrong (`aidx=40/107`, never 42/41), which is **D209**. Note for future readers: `chrlvApplySpeed`'s `speedPtr` out-param is a **turn** rate, not a travel rate (`chraction.c:8070` passes `&act_runpos.turnspeed` into it), so `act_gopos.speed == 0` just means "running in a straight line" — it is not evidence of a stalled character. | -| D194 | **Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** Two parts. **(a) Aim mode (RMB) is near bang-bang, not proportional** (`port/src/input.c:697-707`): any per-poll delta past `AIM_MOVE_THRESH` emits stick `m = clamp(61 + |Δ|·(MouseAimSpeed/100)·AIM_GAIN, 61, 60+AimBand)` = `[61,80]` — with the M-29 defaults (`MouseAimSpeed=16`, `AIM_GAIN=4.0`, `AimBand=20`) `m` is already 61 (fast) at ~1.5 px/poll and saturates at 80 by ~30 px/poll, so the usable proportional band is tiny and sits near the top of GE's aim range → "way too sensitive," turn rate barely tracks how fast you actually move the mouse. Needs a real response curve (low floor, wider proportional band, maybe a gamma), not a threshold+clamp. **(b) Overall sensitivity correlates with movement/frame rate** (user's words: "correlated with movement speed, does not feel good"): `input.c` emits raw stick counts **per controller poll** with no `g_GlobalTimerDelta` / polls-per-frame normalization (hipfire yaw `sx += edx·(MouseTurnSpeed/100)·6.0` at `:709`; aim as above). GE integrates the stick scaled by its sim delta, and the port's poll cadence vs render/sim cadence drifts with scene load — so effective sensitivity shifts when the framerate does (which reads as "when I'm moving"). Fix direction: accumulate mouse delta and convert to stick using a fixed reference dt (or feed the game a dt-consistent value), so a given hand motion = a given view rotation regardless of fps. Lineage: D118a (hipfire pitch digital vs analog), D165 (menu pointer), D166 (hipfire pitch pulses), D180-B3 (`MouseAimSpeed` 25→16). New adjacent QoL ask, root-caused → **D223** (mouse-wheel direction should reverse weapon cycle direction, currently both directions cycle forward). Config knobs exist (`Input.MouseAimSpeed/AimBand/MouseTurnSpeed/HipfirePitchSpeed`) but the *shape* of the mapping is the problem. **M-87 design-goal note (user):** overall mouse feel should move toward **Quake Remaster / the Perfect Dark PC port** — raw, direct, responsive 1:1 mouse-look — while explicitly **not losing GE's original aiming nuance** (the analog-stick aim-mode vs digital-hipfire split this whole D118/D194 lineage exists to preserve). Reframes the fix direction from "just widen the proportional band" to: normalize by a fixed reference dt first (part (b), the frame-rate coupling — this alone should fix most of "feels unresponsive"), then re-derive the aim-mode response curve, and treat raw hipfire mouse-look (PD/Quake-style, bypassing the N64 digital-stick emulation entirely when the player is *not* in aim mode) as a real design option to evaluate against "keep GE's aim-mode feel," not assume the N64 stick-emulation path is mandatory everywhere. **M-87 — found it: GE already has exactly this control scheme, unused by the port.** User asked why vertical mouse feels less responsive than horizontal; investigation found the port forces `controldef = CONTROLLER_CONFIG_HONEY` (1.1), under which hipfire pitch is genuinely digital-only (C-up/C-down) while yaw is analog — an intentional N64-era asymmetry (D166), not a port bug, but avoidable. **`CONTROLLER_CONFIG_SOLITARE` (1.2) / `GOODNIGHT` (1.4)** — user's own recollection, verified in code — redirect forward/back/strafe-L/R onto digital C-buttons/D-pad (`bondview2.c:5195-5253`, `moveData.digitalStep{Forward,Back,Left,Right}`), which frees the **entire primary analog stick** for continuous, unclamped pitch **and** yaw: `moveData.canNaturalPitch`/`canNaturalTurn` (set `!insightaimmode`, i.e. active outside aim mode, not gated behind holding aim) drive `analogPitch/70.0f` / `analogTurn/70.0f` directly (`bondview2.c:5950`/`6008`) — no threshold, no digital step, no `[61,80]`-style clamp band (that clamp is specific to the *aim-mode* zoom-turn path, a separate codepath, `insightaimmode && stick_y>60`). `analogPitch`/`analogTurn` default to the primary stick's raw X/Y for every 1.x scheme (`bondview2.c:4881-4882`) unless a 2.x dual-controller scheme overrides them from a second controller. **This is architecturally identical to a modern FPS mouselook**: continuous analog look on the stick, discrete step movement on buttons — a legitimate, unmodified original GE control scheme, not a port invention. Recommended fix: force the port's `controldef` to SOLITAIRE or GOODNIGHT instead of HONEY, map WASD to the digital step-movement buttons (already close to how keyboard hipfire movement works today) and mouse X/Y directly to the primary stick's continuous X/Y — this should resolve the vertical-vs-horizontal asymmetry completely (no more digital-pulse emulation needed at all, D166 becomes largely moot) and likely fixes much of "feels unresponsive" too, since it's direct continuous stick input rather than any thresholded emulation. The frame-rate-coupling issue (part b) is orthogonal and still needs fixing regardless of scheme. Cross-ref D166, D223 (control-preset forcing — this gives a concrete "which preset" answer). **Aim-mode note:** the port's aim-mode code (`input.c:777-784`) already applies the same gain formula to both mouse axes symmetrically — if vertical still feels worse *while aiming* (RMB held), that's a separate, unconfirmed issue, not explained by any of the above. **M-87: user independently confirms part (a)** ("right mouse aim is way too sensitive") and suggests the design fix — aim sensitivity should scale off the **same base per-pixel sensitivity as normal (hipfire-turn) mouse movement**, not its own disconnected curve. Concretely: today hipfire turn is a direct accumulation (`sx += edx·(MouseTurnSpeed/100)·6.0`, `input.c:709`) while aim is the separate floored+clamped `[61,80]` stick-value synthesis (`AIM_GAIN`/`AimBand`, unrelated constants) — two independently-tuned systems that don't agree on what "1 inch of mouse" should mean. Aim mode is a distinct codepath from hipfire (gated by `insightaimmode`, **untouched by the SOLITAIRE scheme change above** — natural-pitch/turn is explicitly `!insightaimmode`), so this needs its own fix regardless of the control-scheme switch. Fix direction: derive the aim-mode mapping from the same base sensitivity constant as hipfire (`MouseTurnSpeed`-equivalent), scaled down only by the aim-zoom's FOV narrowing if any adjustment is wanted at all, rather than the separate `AIM_GAIN`/`AimBand` floor+clamp system — so moving the mouse the same physical distance feels the same whether or not aim is held, without losing GE's own aim-precision character. | **PARTIAL — M-120 human playtest: hipfire/primary look is GOOD now, aim-mode (RMB/Shift) is STILL too sensitive, unusable for precision aiming.** Part (b) (dt-decouple) + the SOLITAIRE natural-pitch scheme switch (part of this PR's combined pass) are user-confirmed working well on PR #62 — normal look feels right. **Part (a) (aim-mode response curve) does not deliver the intended fix despite being "implemented."** User's words: "right mouse / shift aiming with crosshair is still way too sensitive and does not feel right / is not usable in game." Code-level re-read of `port/src/input.c`'s `aimHeld` branch (~line 939) finds a likely concrete cause the original D194(a) design didn't eliminate: `m = AIM_STICK_MIN + pow(normX, gamma) * (AIM_STICK_GAME_MAX - AIM_STICK_MIN)` — the response curve (`aimCurveGamma`, gamma=1.6 default) only shapes the range **above** the `AIM_STICK_MIN=61` floor; the instant `|aimEdx| >= AIM_MOVE_THRESH` (a tiny 0.3 px/poll), stick jumps straight to 61, i.e. **10% of the game's max aim-turn speed (`(stick-60)/10` clamped 1.0), with no ramp from zero.** For hipfire comparison, the non-aim path (`hipEdx * hipSens * MOUSE_TURN_GAIN`, no floor, no threshold-snap) is fully proportional to zero — structurally different, and the one the user says feels good. Aim mode's floor+threshold-snap behavior reads exactly like the original "near bang-bang" M-49 complaint the curve was meant to fix, just now confined to the bottom of the range instead of the top. **Not yet build/runtime-verified** — this is a static code-read hypothesis, not a confirmed root cause; needs an env-gated probe or a tuning pass (e.g., drop `AIM_MOVE_THRESH` toward 0 and make the curve span `[0, AIM_STICK_GAME_MAX-AIM_STICK_MIN]` from a true zero floor, or lower `AIM_STICK_MIN` itself for a genuine near-zero low end) before another playtest. **PR #62 (`fix/d194-mouse-dt-decouple`) stays held, unmerged** — part (a) needs another iteration; parts (b)/SOLITAIRE are ready to land whenever the aim-mode fix is folded in and re-tested together. **M-122: all four stick-synthesis models above were abandoned.** GEPD's own source (`MouseInjectorPlugin/games/goldeneye.c`) was found and read: it never synthesizes a stick for aim at all — it directly overwrites `crosshair_x/y_pos`, `gun_azimuth_angle/turning` (its formulas, RATIOFACTOR=1, basefov) and edge-scrolls the camera past 72% of the way to the screen edge. The port now mirrors that exactly in `port/src/input.c aimGepdCompute()` (knobs `Input.AimAbsolute`, `Input.GepdSens`); no look stick is emitted while aiming, and the game's own damped crosshair update (`caclulate_gun_crosshair_position_rotation`, gunfire.c:4819) is left running — GEPD patches nothing there either, its per-frame overwrite simply wins. **User verdict: "mechanically it works... the aim mode works how we'd want."** Committed `ef49d9e6`. Residuals: ① crosshair/arm spazz glitch, ② aim-mode FPS drop, ③ sensitivity tuning (see M-122 in HANDOFF). **M-123: playtest log (`d194_gepd.err`, 1964 gepdaim lines) mined for ①.** Result: the port's own writes are rock-stable — zero crosshair/camera jumps while aim is held; every large camera delta in the log turned out to be user hipfire stick swings between polls. Exactly ONE true anomaly: a +359° one-tick spike in raw `vv_theta` (cam 0.5 → 359.7, log line 534) — the port's own `[0,360)` wrap of the edge-scroll accumulator firing when the game held a small positive angle and a leftward scroll crossed zero. GEPD does a bare `camx += ...` with no wrap, and the game itself never wraps `vv_theta` in on-foot play (`bondviewApplyVertaTheta` only sin/cos's it; tank code is the sole wraper), so the wrap was removed (port-only). Note: the handoff's theory that this feeds the crosshair via `speedtheta` does not hold — `speedtheta` comes from stick input (`bondview2.c:6056`), never a `vv_theta` delta; all solo-play consumers of `vv_theta` are sin/cos or `(360−θ)` rotations (raw-delta consumers at bondview2.c:1361/9397 are MP-only). The spazz must therefore live in the game-side terms added on top of our overwrite each tick (`turn_x = autoaimx | speedtheta*0.3`, `turn_y = autoaimy | −speedverta*0.1`, gunfire.c:4819 loop) — prime suspect: auto-aim pull oscillating as lock-on jitters, worst near enemies (matches the user's report). The gepdaim log line now also prints the pre-overwrite residue (`game=` = last tick's `pos·damp+turn`), `aa=(autoaimx,autoaimy)`, `st/sv` (speedtheta/speedverta) so a live capture can confirm or kill that. Tick ordering checked: our write runs BEFORE `bondviewProcessInput` each tick (osContStartReadData → inputComputePad, libultra.c:1094), vs GEPD writing after game code — but the displayed fixed point (`X·damp+turn`) is identical either way in steady state, so ordering is not a spazz source by itself. Suspect (c) 2px deadband does not apply to the GEPD path (deadband lives only in the legacy fallback); suspect (d) centre-spring nudge is ~0.1–0.3° over its 2 ticks — negligible, kept (it clears `docentreupdown`, which must not fight aim). | +| D194 | **Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** Two parts. **(a) Aim mode (RMB) is near bang-bang, not proportional** (`port/src/input.c:697-707`): any per-poll delta past `AIM_MOVE_THRESH` emits stick `m = clamp(61 + |Δ|·(MouseAimSpeed/100)·AIM_GAIN, 61, 60+AimBand)` = `[61,80]` — with the M-29 defaults (`MouseAimSpeed=16`, `AIM_GAIN=4.0`, `AimBand=20`) `m` is already 61 (fast) at ~1.5 px/poll and saturates at 80 by ~30 px/poll, so the usable proportional band is tiny and sits near the top of GE's aim range → "way too sensitive," turn rate barely tracks how fast you actually move the mouse. Needs a real response curve (low floor, wider proportional band, maybe a gamma), not a threshold+clamp. **(b) Overall sensitivity correlates with movement/frame rate** (user's words: "correlated with movement speed, does not feel good"): `input.c` emits raw stick counts **per controller poll** with no `g_GlobalTimerDelta` / polls-per-frame normalization (hipfire yaw `sx += edx·(MouseTurnSpeed/100)·6.0` at `:709`; aim as above). GE integrates the stick scaled by its sim delta, and the port's poll cadence vs render/sim cadence drifts with scene load — so effective sensitivity shifts when the framerate does (which reads as "when I'm moving"). Fix direction: accumulate mouse delta and convert to stick using a fixed reference dt (or feed the game a dt-consistent value), so a given hand motion = a given view rotation regardless of fps. Lineage: D118a (hipfire pitch digital vs analog), D165 (menu pointer), D166 (hipfire pitch pulses), D180-B3 (`MouseAimSpeed` 25→16). New adjacent QoL ask, root-caused → **D223** (mouse-wheel direction should reverse weapon cycle direction, currently both directions cycle forward). Config knobs exist (`Input.MouseAimSpeed/AimBand/MouseTurnSpeed/HipfirePitchSpeed`) but the *shape* of the mapping is the problem. **M-87 design-goal note (user):** overall mouse feel should move toward **Quake Remaster / the Perfect Dark PC port** — raw, direct, responsive 1:1 mouse-look — while explicitly **not losing GE's original aiming nuance** (the analog-stick aim-mode vs digital-hipfire split this whole D118/D194 lineage exists to preserve). Reframes the fix direction from "just widen the proportional band" to: normalize by a fixed reference dt first (part (b), the frame-rate coupling — this alone should fix most of "feels unresponsive"), then re-derive the aim-mode response curve, and treat raw hipfire mouse-look (PD/Quake-style, bypassing the N64 digital-stick emulation entirely when the player is *not* in aim mode) as a real design option to evaluate against "keep GE's aim-mode feel," not assume the N64 stick-emulation path is mandatory everywhere. **M-87 — found it: GE already has exactly this control scheme, unused by the port.** User asked why vertical mouse feels less responsive than horizontal; investigation found the port forces `controldef = CONTROLLER_CONFIG_HONEY` (1.1), under which hipfire pitch is genuinely digital-only (C-up/C-down) while yaw is analog — an intentional N64-era asymmetry (D166), not a port bug, but avoidable. **`CONTROLLER_CONFIG_SOLITARE` (1.2) / `GOODNIGHT` (1.4)** — user's own recollection, verified in code — redirect forward/back/strafe-L/R onto digital C-buttons/D-pad (`bondview2.c:5195-5253`, `moveData.digitalStep{Forward,Back,Left,Right}`), which frees the **entire primary analog stick** for continuous, unclamped pitch **and** yaw: `moveData.canNaturalPitch`/`canNaturalTurn` (set `!insightaimmode`, i.e. active outside aim mode, not gated behind holding aim) drive `analogPitch/70.0f` / `analogTurn/70.0f` directly (`bondview2.c:5950`/`6008`) — no threshold, no digital step, no `[61,80]`-style clamp band (that clamp is specific to the *aim-mode* zoom-turn path, a separate codepath, `insightaimmode && stick_y>60`). `analogPitch`/`analogTurn` default to the primary stick's raw X/Y for every 1.x scheme (`bondview2.c:4881-4882`) unless a 2.x dual-controller scheme overrides them from a second controller. **This is architecturally identical to a modern FPS mouselook**: continuous analog look on the stick, discrete step movement on buttons — a legitimate, unmodified original GE control scheme, not a port invention. Recommended fix: force the port's `controldef` to SOLITAIRE or GOODNIGHT instead of HONEY, map WASD to the digital step-movement buttons (already close to how keyboard hipfire movement works today) and mouse X/Y directly to the primary stick's continuous X/Y — this should resolve the vertical-vs-horizontal asymmetry completely (no more digital-pulse emulation needed at all, D166 becomes largely moot) and likely fixes much of "feels unresponsive" too, since it's direct continuous stick input rather than any thresholded emulation. The frame-rate-coupling issue (part b) is orthogonal and still needs fixing regardless of scheme. Cross-ref D166, D223 (control-preset forcing — this gives a concrete "which preset" answer). **Aim-mode note:** the port's aim-mode code (`input.c:777-784`) already applies the same gain formula to both mouse axes symmetrically — if vertical still feels worse *while aiming* (RMB held), that's a separate, unconfirmed issue, not explained by any of the above. **M-87: user independently confirms part (a)** ("right mouse aim is way too sensitive") and suggests the design fix — aim sensitivity should scale off the **same base per-pixel sensitivity as normal (hipfire-turn) mouse movement**, not its own disconnected curve. Concretely: today hipfire turn is a direct accumulation (`sx += edx·(MouseTurnSpeed/100)·6.0`, `input.c:709`) while aim is the separate floored+clamped `[61,80]` stick-value synthesis (`AIM_GAIN`/`AimBand`, unrelated constants) — two independently-tuned systems that don't agree on what "1 inch of mouse" should mean. Aim mode is a distinct codepath from hipfire (gated by `insightaimmode`, **untouched by the SOLITAIRE scheme change above** — natural-pitch/turn is explicitly `!insightaimmode`), so this needs its own fix regardless of the control-scheme switch. Fix direction: derive the aim-mode mapping from the same base sensitivity constant as hipfire (`MouseTurnSpeed`-equivalent), scaled down only by the aim-zoom's FOV narrowing if any adjustment is wanted at all, rather than the separate `AIM_GAIN`/`AimBand` floor+clamp system — so moving the mouse the same physical distance feels the same whether or not aim is held, without losing GE's own aim-precision character. | **PARTIAL — M-120 human playtest: hipfire/primary look is GOOD now, aim-mode (RMB/Shift) is STILL too sensitive, unusable for precision aiming.** Part (b) (dt-decouple) + the SOLITAIRE natural-pitch scheme switch (part of this PR's combined pass) are user-confirmed working well on PR #62 — normal look feels right. **Part (a) (aim-mode response curve) does not deliver the intended fix despite being "implemented."** User's words: "right mouse / shift aiming with crosshair is still way too sensitive and does not feel right / is not usable in game." Code-level re-read of `port/src/input.c`'s `aimHeld` branch (~line 939) finds a likely concrete cause the original D194(a) design didn't eliminate: `m = AIM_STICK_MIN + pow(normX, gamma) * (AIM_STICK_GAME_MAX - AIM_STICK_MIN)` — the response curve (`aimCurveGamma`, gamma=1.6 default) only shapes the range **above** the `AIM_STICK_MIN=61` floor; the instant `|aimEdx| >= AIM_MOVE_THRESH` (a tiny 0.3 px/poll), stick jumps straight to 61, i.e. **10% of the game's max aim-turn speed (`(stick-60)/10` clamped 1.0), with no ramp from zero.** For hipfire comparison, the non-aim path (`hipEdx * hipSens * MOUSE_TURN_GAIN`, no floor, no threshold-snap) is fully proportional to zero — structurally different, and the one the user says feels good. Aim mode's floor+threshold-snap behavior reads exactly like the original "near bang-bang" M-49 complaint the curve was meant to fix, just now confined to the bottom of the range instead of the top. **Not yet build/runtime-verified** — this is a static code-read hypothesis, not a confirmed root cause; needs an env-gated probe or a tuning pass (e.g., drop `AIM_MOVE_THRESH` toward 0 and make the curve span `[0, AIM_STICK_GAME_MAX-AIM_STICK_MIN]` from a true zero floor, or lower `AIM_STICK_MIN` itself for a genuine near-zero low end) before another playtest. **PR #62 (`fix/d194-mouse-dt-decouple`) stays held, unmerged** — part (a) needs another iteration; parts (b)/SOLITAIRE are ready to land whenever the aim-mode fix is folded in and re-tested together. **M-122: all four stick-synthesis models above were abandoned.** GEPD's own source (`MouseInjectorPlugin/games/goldeneye.c`) was found and read: it never synthesizes a stick for aim at all — it directly overwrites `crosshair_x/y_pos`, `gun_azimuth_angle/turning` (its formulas, RATIOFACTOR=1, basefov) and edge-scrolls the camera past 72% of the way to the screen edge. The port now mirrors that exactly in `port/src/input.c aimGepdCompute()` (knobs `Input.AimAbsolute`, `Input.GepdSens`); no look stick is emitted while aiming, and the game's own damped crosshair update (`caclulate_gun_crosshair_position_rotation`, gunfire.c:4819) is left running — GEPD patches nothing there either, its per-frame overwrite simply wins. **User verdict: "mechanically it works... the aim mode works how we'd want."** Committed `ef49d9e6`. Residuals: ① crosshair/arm spazz glitch, ② aim-mode FPS drop, ③ sensitivity tuning (see M-122 in HANDOFF). **M-123: playtest log (`d194_gepd.err`, 1964 gepdaim lines) mined for ①.** Result: the port's own writes are rock-stable — zero crosshair/camera jumps while aim is held; every large camera delta in the log turned out to be user hipfire stick swings between polls. Exactly ONE true anomaly: a +359° one-tick spike in raw `vv_theta` (cam 0.5 → 359.7, log line 534) — the port's own `[0,360)` wrap of the edge-scroll accumulator firing when the game held a small positive angle and a leftward scroll crossed zero. GEPD does a bare `camx += ...` with no wrap, and the game itself never wraps `vv_theta` in on-foot play (`bondviewApplyVertaTheta` only sin/cos's it; tank code is the sole wraper), so the wrap was removed (port-only). Note: the handoff's theory that this feeds the crosshair via `speedtheta` does not hold — `speedtheta` comes from stick input (`bondview2.c:6056`), never a `vv_theta` delta; all solo-play consumers of `vv_theta` are sin/cos or `(360−θ)` rotations (raw-delta consumers at bondview2.c:1361/9397 are MP-only). The spazz must therefore live in the game-side terms added on top of our overwrite each tick (`turn_x = autoaimx | speedtheta*0.3`, `turn_y = autoaimy | −speedverta*0.1`, gunfire.c:4819 loop) — prime suspect: auto-aim pull oscillating as lock-on jitters, worst near enemies (matches the user's report). The gepdaim log line now also prints the pre-overwrite residue (`game=` = last tick's `pos·damp+turn`), `aa=(autoaimx,autoaimy)`, `st/sv` (speedtheta/speedverta) so a live capture can confirm or kill that. Tick ordering checked: our write runs BEFORE `bondviewProcessInput` each tick (osContStartReadData → inputComputePad, libultra.c:1094), vs GEPD writing after game code — but the displayed fixed point (`X·damp+turn`) is identical either way in steady state, so ordering is not a spazz source by itself. Suspect (c) 2px deadband does not apply to the GEPD path (deadband lives only in the legacy fallback); suspect (d) centre-spring nudge is ~0.1–0.3° over its 2 ticks — negligible, kept (it clears `docentreupdown`, which must not fight aim). **M-123 user playtest (post wrap-fix + extended logging, `GepdSens` 25): "looks good... other than sensitivity we're good, 95%"** — residual arm/gun/crosshair glitching accepted as playable; ① closed as acceptable, remaining D194 work is ③ live sensitivity co-tune (`Input.GepdSens`, ini at 30 at session end) and the still-uncontrolled ② aim-mode FPS retest (logging off). | | D195 | **Transparency / alpha surfaces broken on Control (M-49, user playtest)** — Natalya's console room, the glass partitions + translucent projected wall displays. Originally reported as a blend-state defect. Reinvestigated this session (M-116) after the user reported the glass rendering **solid black** — traced to a real, separate, much bigger bug: `G_TEXTURE_GEN` (RSP environment-mapped texture coordinates, used by shiny/reflective materials game-wide — gold/silver weapon skins, chrome trim, and this glass) was never implemented in fast3d. D72.1 (an earlier session) removed the PD-inherited envmap UV-generation block wholesale to fix the Rareware logo, reasoning "GE never generates texture coordinates from vertex normals" — true for the logo (lit, but does not set `G_TEXTURE_GEN`) but false in general: every envmap draw was left sampling whatever raw `tc[]` happened to be authored (near 0), landing on a fixed corner texel of the reflection map every frame regardless of view angle — for a typically dark-edged reflection texture, a solid near-black surface. | **FIXED (`port/fast3d/gfx_pc.cpp`, `gfx_sp_vertex`) — restored envmap UV generation gated on the real `G_TEXTURE_GEN` bit** (ported from the reference Perfect Dark PC port's already-correct implementation), so D72.1's actual fix is untouched (the logo never sets this bit) while every other envmap draw gets real reflection coordinates. **User-verified live:** console glass and shiny gold/silver guns are now visibly reflective instead of flat black. Confirmed crash-free on a full 21-level sweep. **The remaining "not translucent" complaint is NOT a bug**: user confirmed after seeing the fixed reflective glass that it was never see-through on the original N64 either — it's a reflective (mirror-like) surface by design, not transparent glass. Traced (before the user's confirmation settled it) to the material's own combine formula, `alpha = (TEXEL0/1 LOD-crossfade alpha) * SHADE_alpha + PRIMITIVE_alpha` — an additive constant term that saturates alpha near 1.0 by design, matching "reflective, mostly opaque" intent, not a blend-state defect. **Closed.** Cross-ref: this same envmap fix likely also explains part of D228 (white NPC-weapon patches) if any of those are chrome/shiny weapon variants — worth checking before further D228 investigation. | | D197 | **Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).** Reads as `G_TX_CLAMP` not honoured (or a non-power-of-two wrap period) on a head/face texture tile — the texture repeats around the mesh instead of clamping at the UV edge. Not yet isolated to a specific character or texture; no `GE_PCDUMP`/`GE_TEXDUMP` capture taken. Likely fast3d texture-tile setup (clamp/wrap mode bits from the tile descriptor not round-tripped) rather than a data bug, given it's cosmetic and geometry-independent. **Next:** `GE_TEXDUMP` the offending head texture's tile descriptor on Silo, diff clamp/mirror bits against `gmain.s`'s expectations for that material. | OPEN — not investigated (M-49), needs a capture | | D198 | **`alAdpcmPull`/`_decodeChunk` used `K0_TO_PHYS` (an unconditional `& 0x1FFFFFFF` mask) on 64-bit heap pointers (`src/libultra/audio/load.c`).** `K0_TO_PHYS` is the N64 kseg0→physical mask, valid only because N64 pointers fit in 32 bits; on PC's 64-bit heap it truncates any pointer whose value exceeds 512MB, corrupting the `aLoadADPCM`/`aSetLoop`/`aADPCMdec` DRAM addresses at 3 call sites (`load.c:71`, `:468`, `:474`). Every sibling call site in `env.c`/`resample.c`/`reverb.c` already resolves state pointers via `osVirtualToPhysical` (the port shim, `port/src/libultra.c`), which is bit-identical to `K0_TO_PHYS` for real N64 kseg0 addresses but correctly PC-shimmed. **Fix (ABI-layer, `#ifdef`-free — same call resolves correctly on both platforms):** replace the 3 `K0_TO_PHYS(...)` calls with `osVirtualToPhysical(...)`. No behavior change on N64 (identical result for kseg0 pointers); fixes PC. Surfaced while building the Phase-3 software mixer (D199) — the acmd list these opcodes feed now actually executes on PC instead of being ignored. | FIXED (`src/libultra/audio/load.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation |