Commit Graph

1070 Commits

Author SHA1 Message Date
Alex Vanderveen 493055846d cdrom: flow-control accelerated reads -- never outrun the guest's ack
THE BUG, measured end to end. Legend of Mana's land-creation effect
renders as hard-edged full-screen wedges because ONE sector of a
multi-sector scene load never arrives: cd_read_log shows LBA 125111 ->
0x0E1F18 followed by LBA 125113 -> 0x0E2718 -- destinations one sector
apart, LBAs two apart. Sector 125112, which the ISO shows holds the
effect's 5-colour per-vertex palette (0x0888F8 = (248,136,8), 0xB0F8F8 =
(248,248,176)), was skipped; sector 125113's raw file bytes landed at
its address and were faithfully rendered as 216 distinct vertex colours.
DuckStation places 125112 correctly and draws the clean bloom.

The skip is manufactured by accelerated pacing. At authentic cadence the
guest has a full sector period (~6.6 ms at 2x) to ack each data-ready
INT1; the one-deep pend-then-lose below is Beetle-faithful, and a loss
there means the game truly was too slow. With the speed divisor or
bounded-instant mode active, sectors arrive in a fraction of that
period, so a guest that is merely BUSY -- here, mid scene-load
decompression -- gets a sector clobbered that hardware would have
delivered. s_int1_lost counted it; the stream corrupted silently.

Fix, class-level per RULE -1: when running FASTER than hardware, never
overwrite a sector the guest has not consumed -- hold delivery, capped
at the authentic sector period, then fall through to faithful clobber
semantics. Accelerated mode is thus never slower than hardware and never
lossier than hardware; loads stay near-instant while the guest keeps up
and degrade toward guest speed when it does not. XA/streaming modes are
excluded by the same gates as apply_read_speed and keep authentic
timing. Warm routes keep their existing indefinite handshake.

New counters accel_consumer_waits/_wait_cycles in cdrom_state: nonzero
is healthy (the enhancement waited); int1_lost rising while the divisor
!= 1 is the regression tripwire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 287d9a1c22 probe: actually FILL the register array, and refuse a capture that did not take
Every register came back 0x00000000 — including $sp, which cannot be zero in
running code. The struct had gpr[32] and the JSON emitted it; nothing ever
wrote to it. The patch that added the fill did the replacement in memory and
never wrote the file, and the next edit re-read from disk without it. Two
thirds of a change, compiling and running and reporting 32 zeros.

Zero then flowed all the way downstream without anything objecting: 0 minus the
load offset masks to 0x1FFFFFF4, which looks like an address, so colour_inputs
read "psx-runtime's table" from unmapped memory and reported a difference
against the oracle. The same document carried "differing_bytes": 0 and
"source_identical": false — the read had come back short, so zip() compared
nothing while the buffers still differed.

Three guards, because one bad value crossed four steps unchallenged:

  * probe_registers refuses a capture where every register is zero and says so,
    rather than returning zeros as data
  * plausible_pointer() rejects anything that is not a RAM address before it is
    used as one, including the masked underflow that got through
  * the comparison refuses unequal read lengths instead of reporting a
    difference between a buffer and nothing

Scratchpad is deliberately not a plausible table pointer: $sp lives there, and
accepting it would compare the wrong region while looking entirely reasonable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen ed96761c2a probe: capture every GPR at a block leader, and find the enclosing block
The $s4 gap blocked the same question twice, so it is closed with the cheaper
of the two options rather than by teaching the recompiler to emit
per-instruction hooks.

pc_probe captured seven hand-picked registers -- t0, fp, v0 and four
draw-specific fields -- chosen for an earlier investigation. The next question
always wants a different one, and this time it was $s4 and $s6, the source
pointer and scale for the packet colour routine. It now captures all 32, named
exactly as the DuckStation oracle names them in pc_hit_last so the two sides
compare without a translation table. 64 samples x 32 registers is 8 KB.

The other half is that the probe fires at basic-block LEADERS, and a colour
store at 0x8006844C is mid-block, so arming it directly never matches. The
block is found by arming a spread of candidates BACKWARDS from the target -- a
leader is at or before the instruction it contains, and searching forward would
find the next block, whose registers describe what happens afterwards. Whatever
fires is a real leader; the nearest one below the target encloses it.

The accuracy cost is stated in the output rather than left to be remembered.
Registers are read at block ENTRY: for callee-saved registers set outside the
loop ($s4, $s6) that is the same value as at the target, and for one the block
computes ($v0, $t6, $a0 there) it is not. Each requested register is flagged by
class, because a value that is real, plausible and taken from the wrong instant
is the hardest kind to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen d690776fb3 step: count FRAMES, not calls — and never present an empty trace as a result
`step n=1` could park before a single frame completed, so the write trace ran
over nothing and the Studio showed an empty table with no indication anything
had gone wrong. Measured before the fix: step n=10 advanced 2 frames, step n=1
advanced 0.

debug_server_wait_if_paused() is called from three places -- the vblank path in
main.cpp and two in dirty_ram_interp.c -- and each one decremented the step
counter, so it drained up to 3x per frame. It now only counts a decrement when
s_frame_count has actually changed, and handle_step stamps the frame it is
leaving so that one does not count. Verified live: n=1 advances 1, n=5 advances
5, n=10 advances 10.

This also fixes the Studio's Step button, which had the same defect.

packet_writers.py no longer trusts the paused flag to mean a frame ran -- it
compares frame NUMBERS across the step and errors out if nothing advanced. And
an empty trace is now an explicit error rather than a report with zero rows: the
packets had just been walked, so "no writes" means the trace window was wrong,
not that nothing writes them. An empty table is indistinguishable from a
finished search, which is the worst way for a tool to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:57 -04:00
Alex Vanderveen b5764b6e9a debug: restore pause/step, repaint hold, and the GPU analysis tools
All of this existed and was lost to a `git pull --rebase` in this submodule
while uncommitted. Committing so it survives the next one.

Engine:
  * pause / continue / step / run_to_frame are real again, debug-server only —
    no key, button, or menu reaches them, so a shipped build cannot be frozen.
    The wedge that got them removed the first time is fixed: the park loop
    keeps calling debug_server_poll() (which delivers `continue` and feeds the
    4-second starvation watchdog) and auto-resumes after s_pause_timeout_ms of
    client silence, so a debugger that dies cannot take the game with it.
  * present_hold_tick() / is_window_repaint_event(): a frame held on screen no
    longer rots when another window crosses it. Previously nothing presented
    while stalled, so the expose went unanswered and the frame smeared.

Tools:
  * psx_gpu_frame.py: frame signatures (compare frames by opcode+blend rather
    than by function — a game that DMAs an ordering table reports one submit PC
    for every packet, so function attribution has one answer per frame), plus
    reading display lists out of guest RAM.
  * gpu_display_list.py: walk an ordering table and decode it. Finds the list
    by scanning for chained nodes whose payload is a real GP0 command of
    exactly the right length. Following DMA ch2's MADR does NOT work: a
    finished linked-list transfer leaves the 0xFFFFFF terminator there, which
    masks to 0x1FFFFF and looks like a valid address.
  * gpu_colour_parity.py: compare vertex-colour distributions across emulators.
    Distributions rather than a diff because two emulators cannot be aligned to
    the same instant without deterministic replay, and an animating effect
    sampled at two phases differs everywhere — which says nothing.
  * read_ram is capped at 16 KB/call: the oracle advertises 65536 but replies
    over a socket that truncates it, and the read then hangs. Measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:57 -04:00
Alex Vanderveen 2403e60fd3 Merge master into feat/rbengine-bak (PR #194)
Brings in the observability window-trap work (per-game watch list,
window traps that read as results) and the generated-project framework
docs. Conflict was confined to TCP_COMMANDS.md's autogenerated command
index — both branches added commands — resolved by regenerating with
tools/gen_tcp_commands.py against the merged servers (318 commands,
--check passes). Full runtime build + test_cli_project_packaging pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mwbhSkoL66o8BSBuP6DiY
2026-08-23 18:30:26 -04:00
Alex Vanderveen 958e09faa3 runtime: stub psx_netplay_link_desynced in no-recomp-net fallback
The !PSX_HAS_RECOMP_NET section stubs link_base_seat / link_hash_enforced
but never gained a psx_netplay_link_desynced stub when main.cpp's
netplay_barrier_admit started calling it, so any build without recomp-net
fails at link. No netplay compiled in means the link can never be
desynced; return 0 like its neighbors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mwbhSkoL66o8BSBuP6DiY
2026-08-23 16:14:07 -04:00
Matthew Stanley 2cbbbfc4da observability: window traps that read as results, and a per-game watch list
Three related defects in the interp observability rings. All three share a
failure mode that is worse than an error: the tool reports an empty result
that is indistinguishable from a real finding. "total: 0" reads as proof the
events never happened.

1. callret_watch armed on `lo != 0`, making address 0 an off switch rather
   than a legal window floor. `lo=0 hi=0x200000` — the obvious spelling of
   "watch the whole address space" — replied ok and recorded nothing.
   Measured on one build, identical but for the floor:

       lo=0x00000000 hi=0x00200000  ->  total 0
       lo=0x00000004 hi=0x00200000  ->  total 468732

   Four bytes of window between an empty ring and half a million call
   resolutions. This cost real time during beads-eio.3.59: an
   interp->compiled call route was briefly believed absent when the ring was
   simply never on.

   Arm on a NON-EMPTY window (hi > lo) instead. `lo=0` is now an ordinary
   floor. Note that `{"lo":"0"}` was the DOCUMENTED disarm spelling
   (TCP_COMMANDS.md), so that contract is kept explicitly rather than broken:
   lo=0 with no hi still disarms, and now says so in the reply. Added
   `disarm=true` as the unambiguous spelling. A `lo` with no `hi` is refused
   outright instead of silently inheriting the previous ceiling (usually 0,
   i.e. an empty window) — that was the second way to arm nothing by accident.

2. s3_smear_watch had the same `lo`-nonzero gate, so lo=0 with a real hi
   recorded nothing while answering ok. Same fix. It already cleared `hi`
   whenever `hi` was omitted, so `{"lo":"0"}` naturally lands on an empty
   window and still disarms — no special case needed there.

   Both commands now report `armed`, and callret_watch's dump carries `armed`
   plus the active window, so an empty read can always be told apart from an
   unarmed one.

3. The xprobe JAL/JALR watched-target list was six guest addresses from two
   specific titles COMPILED INTO the shared runtime (an MMX6 card-load flow
   and a Tomba target). Every other game therefore watched nothing, silently:
   the `watched` dump came back empty and gave no hint that the build was
   incapable of watching anything. Same defect class as beads-eio.3.21, and
   the direct reason the JAL call site was unusable while measuring
   beads-eio.3.59 — only the JALR callret ring could be used there.

   The list is now configured, never compiled in: `xprobe_watch` TCP command
   to get/set, or PSX_XPROBE_WATCH in the environment so targets are watched
   from instruction zero without a rebuild. Default EMPTY — no title is
   privileged — and a read with nothing watched says so rather than looking
   like a negative result. Matched on the normalised address so KSEG and
   physical forms both work.

Verified against Ape Escape, headless, RelWithDebInfo:
  - PSX_XPROBE_WATCH=0x8002C0E4 -> watching that target from boot
  - xprobe_watch targets=<two addrs> -> count 2; the `watched` dump then
    carries 41 JALR records for 0x8002C0E4 and 21 for 0x8002C14C, with full
    frame/cycle/src/sp/ra context. This title could previously watch NOTHING.
  - xprobe_watch targets= (empty) -> count 0 plus an explanatory note
  - callret_watch lo=0 hi=0x200000 -> armed true, total 77484
  - callret_watch lo=0            -> disarmed, legacy spelling named
  - callret_watch lo=0x40000      -> refused, window left unchanged
  - callret_watch disarm=1        -> disarmed
  - s3_smear_watch lo=0 hi=0x200000 -> armed true (previously silent)
  - pre-fix build, callret lo=0 hi=0x200000 -> plain ok, total 0 (the trap)

TCP_COMMANDS.md regenerated for the new command. Note the regen also picks
up rows that had drifted on master beforehand: `--check` already failed on
pristine master (index said 292, the servers register 304), which is why the
count line jumps 292 -> 305 rather than by one.
2026-08-23 11:57:33 -07:00
Alex Vanderveen 9dad1b7ee2 Merge upstream master into feat/rbengine-bak (PR #175)
Conflict: recompiler/src/pgxp_hook_emitter.cpp, 13 hunks.

Both sides independently fixed the same defect — a PGXP hook appended on
the same line as a trailing `#endif` is swallowed by the preprocessor as
extra tokens and silently discarded, so the MULT/DIV/MFLO/MFHI hooks
never ran.

Took upstream's resolution:

  - Local fix hard-coded a trailing-"#endif" test at function entry and
    spliced "\n{}\n" around the emission.
  - Upstream factors the test into
    emission_ends_on_preprocessor_directive(), which scans the last line
    properly (leading whitespace, any directive, not just #endif) and is
    EXPORTED. code_generator.cpp:1709 and strict_translator.cpp — both
    arriving in this same merge — call it to guard their own same-line
    appenders, so the local version would have left those two callers
    with an undefined symbol.

The local side also newline-guarded the START of the emission. Dropping
that is safe: every block-cycles emission literal already begins with
'\n' (gte_stall, gte_read, the muldiv set/stall blocks), so a directive
never lands mid-line there.

Verified: recompiler builds; emitter_directive_line_test and
lwlr_load_delay_forward pass; generated code for mult/mflo puts
PGXP_MULDIV, PGXP_ALU and the trailing disassembly comment each on their
own line after #endif; the PR #172 deferred-load/PGXP scoping fix still
holds; spu.c compiles against this game's build and test_spu_pitch_zero
passes all 14 assertions.

Pre-existing on this branch, unrelated to the merge: 7 ctest failures
(dirty_text_admission_guards, aot_overlay_discovery,
mod_owned_display_controls, launcher_vulkan_option,
mod_load_acceleration, overlay_decodable_fallback, vk_present_wait_stage)
— all inspect files this merge does not touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 03:18:57 -04:00
Matt Stanley cf918c7be5 Merge pull request #171 from rickguedes/fix/pgxp-hook-after-endif
pgxp: emitted hooks after muldiv land on #endif lines and get discarded (+ link iphlpapi on Windows)
2026-08-22 00:59:32 -07:00
Matt Stanley 13cf125554 Merge pull request #170 from ClaudioVarandas/fix/spu-pitch-zero-holds
fix (spu): a pitch of 0 holds the voice instead of playing at full speed
2026-08-22 00:59:22 -07:00
Alex Vanderveen ecf57bd971 bump 2026-08-21 22:09:46 -04:00
Alex Vanderveen 12c2e0f7ff bump 2026-08-21 21:19:17 -04:00
Alex Vanderveen 3447d02f80 Merge upstream master into feat/rbengine-bak (PR #172)
Conflict: recompiler/src/code_generator.cpp, deferred-load emitter.

Both sides fixed the same bug — the psx_ldd_<addr> temp going out of
scope when PGXP wraps the load in its own `{ ... }` block. Took the
upstream resolution, which is a superset:

  - Local backport hoisted the declaration to function scope.
  - Upstream declares it at the top of the pair block instead (same
    scope fix, tighter), AND repoints the PGXP_LOAD hook's value
    argument at the temp. The local version left the hook reading
    cpu->gpr[N], which still holds the pre-load value because the
    writeback is deferred — so PGXP recorded a stale word.

Upstream's LWL/LWR forwarding (set_lwlr_merge_forward and the
full-function emitter's ldd_lwlr_forward set) auto-merged and is kept.

Verified: recompiler builds; test_lwlr_load_delay_forward.py and
test_dirty_text_continuation_guards.py pass; emitted code for a
dependent load pair declares the temp once inside the pair block, has
PGXP_LOAD read the temp, and keeps the writeback in scope. The six
runtime files touched by both sides compile clean against this game's
generated sources.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:08:20 -04:00
Henrique Guedes aebb11b3d8 build: link iphlpapi into Windows runtime targets
main.cpp includes iphlpapi.h and calls GetAdaptersAddresses for the
launcher's netplay local-address discovery, but no CMake file links the
library. MinGW builds link regardless; MSVC does not link iphlpapi
implicitly, so every MSVC runtime build fails at link with an
unresolved GetAdaptersAddresses.
2026-08-21 21:39:12 -03:00
Alex Vanderveen 2c8e853ae7 backport the pc=0 root-cause cluster + autocompile/overlay fixes
Continues backporting onto this branch rather than moving to feat/rbengine,
keeping the known-good race performance while recovering fixes that landed
after the branch point.

pc=0 cluster (the mid-race 'crash' was a silent top-level GUEST_EXIT):
- 6ead85c9 honor IRQ redirects at compiled leaders + pc0 escape journal. THE
  root cause: every emitted block-leader IRQ check overwrote whatever resume
  PC the exception epilogue published, so a guest RFE to a non-EPC target
  (card-ISR handlers, SetCustomExitFromException, thread switch) lost its
  continuation and a later jr through a null pointer surfaced as PC=0.
  Its own message names this title and the static bake.
- 21a8457e the IRQ-redirect guard must CONSUME the benign same-site resume.
- (2edce1b6 and the async-RFE latch hunks landed in the previous commit.)
- cpu_state.h pc0-journal declarations, which the cherry-picks reference.

Overlay/autocompile correctness:
- d31953fe missing cull-widen ABI symbols; PGXP hooks swallowed by an #endif.
- 45f9d0f8 pin the overlay flavor through the spawn env — the same
  --flavor-defaults-to-0 trap that silently fills a cache this PGXP build
  cannot load.
- 8b323ffe coverage_vault 8 MB dev-RAM aware bounds.

Deliberately NOT taken: 249057f9 / ada331a4 (exact-sign NCLIP + GTE clamp
rescue) — broadens PGXP arming and adds a per-vertex RTPS push, and is the
leading suspect for the race-FPS regression this branch exists to avoid; and
3b698682 / 95a56e23 (image-edge tail-transfer), which 4532ad2f later gated to
CPS-only — this game emits non-CPS, where the legacy behaviour this branch
already has is the correct one.

Emitters rebuilt and game+BIOS regenerated (56 shards changed). Verified: 8 MB
mod plan applies, 60 Hz NTSC at 1.00x, no PC=0 exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 19:42:40 -04:00
Alex Vanderveen 42efb87b03 backport the two pc=0 GUEST_EXIT fixes onto this branch
The link session died mid-race with cpu.pc=0, reason=atexit, exit_origin
unknown and no signal — the 'bogus GUEST_EXIT' signature. Both fixes for it
landed after this branch point, so going back to rbengine-bak reintroduced it.

- cherry-pick 2edce1b6: never publish pc=0 from the same-thread-restore
  epilogue at dispatch depth <= 0. pc=0 there means 'the interrupted native
  frame is still on the host stack below — return and it continues', but at
  top level no such frame exists (statically-baked overlays cross into AOT
  text via CPS returns), so the 0 reaches the main loop as a GUEST_EXIT. Its
  own message names this title: WipEout 3 ntscfull8 static bake, deterministic
  'execution completed, PC=0'.

- port the async-RFE latch hunks from 4532ad2f: consume g_async_rfe_resume_pc
  when it rescues its RFE, and zero it in interrupts_init /
  interrupts_resync_after_restore. Left armed, a stale — possibly pre-reboot —
  resume PC is reused by a later sentinel reach instead of failing loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 19:25:27 -04:00
Alex Vanderveen 9a888f8309 apply the --disc override before the netplay/link mod plan
The mod plan is applied AGAINST a disc — mod_runtime_apply_link_spec (link
follower) and mod_runtime_commit_for_netplay (netplay client) both take
resolved_disc and derive the patched image from it — but the authoritative
resolve_disc_for_runtime() call runs far below, so the plan saw game.toml's
default disc rather than the one actually being launched.

Invisible on a dev checkout, where the default "disc/<name>.cue" resolves
next to the repo and exists; fatal in an install, where it does not. The
PSX-Link follower died with 'cannot apply the driver's mod plan: cannot open
disc CUE: <install>/disc/...cue' while its parent had just handed it the
correct absolute path on the command line — the parent was right and the child
ignored it purely because of statement order.

Mirror the launcher-path override just above the mod block; the authoritative
resolve (and its validation) still runs afterwards and still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 18:50:16 -04:00
Alex Vanderveen 9f33b09424 link pair: hand the follower the STOCK disc, not the patched one
The follower died with 'cannot apply the driver's mod plan: cannot open disc
CUE: <install>/disc/...' whenever a package rewrites the disc. The spawn passed
disc_path_str, which becomes the mod-EFFECTIVE image (the private patched
cache) as soon as mod_runtime_effective_disc_path() is set — but the follower
calls mod_runtime_apply_link_spec(PSX_LINK_MODS, disc), and that takes the
STOCK disc and derives its own patched image, exactly as the driver did.

Handed the already-patched image, the follower validated a modded disc against
the stock digests in game.toml, got nothing, fell back to the game.toml default
path, and failed on a file that does not exist in an install. Pass resolved_disc
instead; PSX_LINK_MOD_FP still turns any residual disagreement between the two
derivations into a refusal rather than a tick-0 digest fork. Vanilla sessions
are unaffected (the two paths are equal when no package rewrites the disc).

Also log which disc the follower is given, so this is diagnosable from one run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 18:41:56 -04:00
Claudio Varandas c35fea6b25 spu: cover a pitch of 0 with a test
The coercion this branch removes had stood since the original SPU commit with
nothing to catch it: no test touched a pitch of zero, and accuracy/axis5_spu.md
lists pitch as implemented without mentioning it.

Four cases, staged directly against spu.c the way
test_spu_end_without_repeat.c does:

  - pitch 0 holds -- phase, sample index, cur_addr and the emitted sample all
    unchanged across 500 output samples, and the voice stays active
  - a held voice does not walk into the blocks that follow it, which is the
    shape the bug took in practice
  - pitch 0x1000 still decodes the next block after 28 samples, and 0x0800
    advances half as fast, so the test cannot pass by the voice being dead or
    the accumulator being treated as a switch
  - the envelope still steps while the voice is held, and a key-off still
    releases and retires it -- holding the sample must not trade one stuck
    voice for another

Three consecutive blocks are staged with different sample data so an incorrect
forward walk changes the output audibly instead of reading more of the same.

Checked in both directions: 14/14 pass with the fix, and reintroducing the
coercion fails exactly the five checks that describe it.
2026-08-21 23:34:25 +01:00
Alex Vanderveen 6cf39ac9cf lan: host was gated out of its own MOTK5 handlers
ae_np_lan_udp_pump ran 'if (!g_lnch_remote_lan) continue;' ABOVE the host-side
MOTK5 handlers, so a host — which joined nothing and is therefore not remote —
took the continue and never reached SEATMOVE, SWAPREQ, SWAPANS or MODOK. All
four test g_lnch_hosting_lan and are host-only by construction, i.e. they were
unreachable on the only machine that runs them.

Symptoms: the host logged receipt of a well-formed 'MOTK5 MODOK' every 2 s
while no seat ever went ready, so mod compatibility was reported wrong and the
guest resent forever; and players could not move or swap seats. LAN lobbies
only, which is why the WS-lobby path never showed it.

Move the gate down to just above the legacy MOTK1-4 UPDATE / KICK block, the
only run of handlers that relies on it — those are unguarded and mutate
g_lnch_remote_lan_state, so a host must still not fall into them. Every
handler now above the gate carries its own explicit host/client guard.

Found by adding an entry probe next to the receipt trace: the host logged
'LAN rx (51 bytes) "MOTK5 MODOK\n<uuid>\n1\n" hosting=1 modok_match=1'
with no matching handler entry, which is only possible if something between
them continued.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 18:32:58 -04:00
Claudio Varandas 69da235063 spu: a pitch of 0 holds the voice instead of playing at full speed
VxPITCH is added to a 12-bit fractional phase accumulator, so a pitch of zero
means the sample counter does not advance and the voice holds its current
sample. spu.c coerced zero to 0x1000 instead -- full-rate 44.1 kHz playback --
so a voice the game had parked streamed forward through SPU RAM, decoding
whatever followed it as ADPCM. DuckStation and Beetle both just add the pitch
to the counter, so zero holds there.

Alien Resurrection (SLUS-00633) parks its ambience when the pause or save menu
opens: pitch 0, envelope left frozen mid-sustain, no key-off. Measured in the
menu, both voices of the hard-panned ambience pair read pitch 0x0000, env
0x5FFF in sustain, zero key-ons over five seconds -- and cur_addr at 0x49160
and 0x49210 against a repeat address of 0x489E0, about 0x790 bytes past their
own loop point and still advancing. A voice with pitch 0 that advances at all
is the defect. The menus turned into a continuous mid-band buzz, 79-82% of the
output energy between 500 Hz and 2 kHz, which is what garbage ADPCM sounds
like. Removing the coercion silences them; verified in play.

The audio pipeline was healthy throughout -- zero underruns, zero overflow
drops, buffer at target -- because nothing was wrong with it. The SPU rendered
faithfully what it had been told to render.

Safe by construction: with phase not advancing the block-step loop simply does
not run, adsr_run still steps the envelope every output sample, and a later
key-off still releases and deactivates the voice.

The line dates to 9b332ba, the original SPU implementation. Nothing documents
it, accuracy/axis5_spu.md lists pitch as implemented without mentioning it, and
no test covers a pitch of zero.
2026-08-21 23:21:42 +01:00
Alex Vanderveen 2ada7c83fb lan: show received datagram bytes; new traces to stdout
Two blind spots kept the host's 'LAN rx MOTK5 MODOK, nothing else' from being
diagnosable:

- The receipt trace stopped at '\n' OR '\0', so a datagram missing its
  trailing newline printed identically to a well-formed one while every
  strncmp(..., "...\n", N) declined it. Print the escaped bytes and the
  length instead.
- The two drop traces went to stderr while every neighbouring LAN trace goes
  to stdout, so a run that did not redirect stderr showed nothing even when
  they fired. Match the neighbours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 18:10:25 -04:00
Alex Vanderveen fed05c4448 lan: report MOTK datagrams no handler claimed
The host logged 'LAN rx MOTK5 MODOK' every 2 s with no seat ever going ready,
and the same silence blocks MOTK5 SEATMOVE/SWAPREQ — so players cannot change
seats either. Both live in one dispatch chain of strncmp(buf, "...\n", N)
tests, and the receipt trace at the top cannot distinguish 'arrived and was
handled' from 'arrived and every handler declined': it prints the first line
and stops at '\n' OR '\0', so a datagram missing its trailing newline (or
carrying CRLF, or truncated) renders identically while every match fails.

Add an end-of-chain trace, rate-limited to 3 s, printing the byte count and
the escaped payload, so the wire format is observed instead of assumed. KICK/
ERR now continues like every other handler so it is not misreported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 17:56:06 -04:00
Alex Vanderveen d04f4fed73 lan mods: say WHY a MODOK report was dropped
A host whose seat never goes ready showed 'LAN rx MOTK5 MODOK' every 2 s and
nothing else, while the guest resent forever because the host's echo never
agreed. The MODOK handler has three early exits before its own trace — two
parse failures and a failed ae_np_read_lan_state — so the one case the trace
cannot explain is precisely the one that reaches none of it.

Name the reason on each, rate-limited to 3 s. The read-lan-state case also
points at the likely cause: ae_np_lan_file() is resolved against the process
working directory, so a host launched from a different cwd than the one that
wrote netplay_lan_lobby.txt reads nothing and rejects every peer report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 17:49:00 -04:00
Alex Vanderveen c445f75ce4 main/mod_plugins: backport psx_mod_set_bezel activation API
The title's bezel activation plugin (src/mods/wipeout3_bezel_plugin.c) calls
psx_mod_set_bezel(), which does not exist on this branch, so any build of the
game repo against it dies with 'call to undeclared function psx_mod_set_bezel'.

The renderer-side bezel plumbing and g_bezel_path already exist here; only the
mod-facing setter was missing. Straight backport of the setter and its header
declaration — no renderer changes, and the bounds checks are kept so a plugin
cannot hand an unbounded path to the loader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 16:48:00 -04:00
Alex Vanderveen 073114e93f mod_packages: an unevaluable image pin is not a target mismatch
An exported/release bundle ships no loose PS-X EXE, so exe_sha256 resolves
empty; comparing a manifest pin against "" rejected every image-pinned
package on exactly the installs players use — 'package does not target this
game/image: wipeout3.enhancement.framerate' — while the same package applied
fine in a dev checkout that happens to have the file. Skip a pin we cannot
evaluate and let the expected-byte guard at patch time verify; a pin we CAN
evaluate is still enforced.

Backport of the same fix from 225283a7 onto this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
2026-08-21 16:35:04 -04:00
Matt Stanley 1dc58357a9 Merge pull request #167 from Alexbeav/fix/full-range-straightline-handoff
fix(runtime): validate full AOT entry before straight-line handoff
2026-08-20 20:44:17 -07:00
Matt Stanley 60465617a2 Merge pull request #166 from rickguedes/fix/spu-voice-address-alignment
spu: voice start/repeat addresses ignore bit0 (16-byte-aligned fetch)
2026-08-20 15:18:41 -07:00
Matt Stanley 43fb799c3f Merge pull request #163 from rickguedes/fix/load-delay-pgxp-scope
autocompile: PGXP load-delay shard fix, host-time capture pacing, wedge visibility
2026-08-20 14:29:11 -07:00
Matt Stanley d56bd0247c Merge pull request #165 from rickguedes/fix/cdrom-cdda-report-irq
cdrom: CD-DA position report interrupts (Setmode bit2) during Play
2026-08-20 11:48:01 -07:00
Alex Vanderveen 45e6bc9cab Merge origin/master into feat/rbengine
Conflict resolutions:
- recompiler/src/main_psx.cpp: keep both — canonicalize via
  psx_ram_canon_code_addr (rbengine 8MB mirror fold), then compare
  29-bit physical addresses (master KUSEG dispatch fix, PR #155).
- runtime/src/main.cpp: keep both — text-base pin for high-loading
  EXEs (rbengine) plus PSX_OVERLAY_REGION_FLOOR env override (master).
- runtime/src/text_xlate.cpp: keep rbengine's build-flavour capture
  default (PSX_NO_DEBUG_TOOLS) which subsumes master's default-off fix.

Verified: psxrecomp-game rebuilds; test_kuseg_dispatch_lookup.py passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdzpVVH912V9ktt4n4kntj
2026-08-20 14:39:42 -04:00
Alex Vanderveen a198b2b768 bump 2026-08-20 11:24:16 -04:00
Alexandros Mandravillis 73eb746479 fix(runtime): validate full AOT entry before straight-line handoff
The dirty-RAM interpreter reused a suffix-aware validity check when resuming generated code after a patched call site. If an entry prologue had been overwritten but its resume suffix still matched, execution could enter stale AOT code and fall through to another generated piece without redispatch.

Emit a whole-entry predicate for straight-line handoffs, retain suffix checks for explicit guest transfers, and fail closed for older generated dispatchers until regeneration.

Validated with the MGS PAL 6,875-frame route, manual codec-to-heliport gameplay, focused continuation-guard tests, and a current-upstream recompiler build.
2026-08-20 14:51:26 +03:00
Matt Stanley 95366a6504 Merge pull request #164 from rickguedes/fix/overlay-close-input-bleed
runtime: swallow the overlay-closing press so it doesn't bleed into the game
2026-08-19 22:13:38 -07:00
Matt Stanley 79766c6b8b Merge pull request #159 from Alexbeav/fix/launcher-cli-disc-prefill
runtime: seed launcher initial disc from CLI --disc override
2026-08-19 22:13:35 -07:00
Matt Stanley 75bede67fc Merge pull request #160 from Alexbeav/feat/overlay-region-floor-env
runtime: PSX_OVERLAY_REGION_FLOOR override for streamed-over text ranges
2026-08-19 22:13:31 -07:00
Alex Vanderveen 29bbabb800 bump 2026-08-19 23:40:24 -04:00
Alex Vanderveen 3cb2a60dc3 bump 2026-08-19 22:50:13 -04:00
Alex Vanderveen 4076b26dd3 bump 2026-08-19 22:03:29 -04:00
Alex Vanderveen 7bf316d330 bump 2026-08-19 21:46:38 -04:00
Rick Guedes 537b4e182d spu: voice start/repeat addresses ignore bit0 (16-byte-aligned fetch)
The ADPCM start and repeat address registers count 8-byte units, but
hardware ignores bit0 — a voice's block fetch is always 16-byte
aligned. DuckStation implements exactly this (Voice::KeyOn,
adpcm_start_address & ~1u, and the same masking on the repeat-address
jump); Beetle aligns identically. psx-spx documents the 8-byte units
and is silent on the low bit.

Honoring the raw bit makes a voice keyed at an odd 8-unit address
decode from mid-block: sample bytes get parsed as headers, typically
hitting a spurious END+REPEAT within a few blocks. Twisted Metal III
(SCUS-94249) depends on the alignment — its sound-bank tables produce
odd start addresses (samples are packed at 8-byte granularity) whose
real streams begin at the aligned twin. Unaligned, its explosion
one-shots collapsed into ~10 ms looping blips; offline ADPCM decode of
the aligned twin recovers the full 1.3 s sample, and in-game audio
matches a reference emulator after the fix.

Masked at the two intake points: the KEYON latch of the current and
repeat addresses, and the live repeat-register write.
2026-08-19 21:01:06 -03:00
Rick Guedes b919f2a401 cdrom: CD-DA position report interrupts (Setmode bit2) during Play
The controller never generated position reports, so a driver that
mutes, starts Play in report mode, and demutes only once the first
report confirms playback waits forever with CD audio muted.

Per psx-spx "Command 03h - Play": INT1(stat, track, index, mm/amm,
ss+80h/ass, sect/asect, peaklo, peakhi), sent on every 10th frame of
absolute time — asect BCD 00/20/40/60h carry absolute MSF, 10/30/50/70h
track-relative MSF with bit7 set on the seconds byte. Peak bytes hold
the sector's per-channel peak level, measured pre-mute (the controller
keeps processing audio while muted); the L/R toggle bit15 is
effectively stuck on PSX and stays 0. A report that would collide with
an unacknowledged IRQ is dropped — hardware does not queue reports.

Verified on Twisted Metal III (SCUS-94249), whose CD-DA music driver
uses exactly this handshake: with reports flowing it demutes on the
same frame its Play command lands, and all 36 soundtrack tracks play.
2026-08-19 20:59:54 -03:00
Henrique Guedes 20b9ba6cc8 runtime: swallow the overlay-closing press so it doesn't bleed into the game
Closing the save-state menu (Square save / Circle back / toggle key) or
the rewind filmstrip resumes the guest while the closing press is still
held on the pad, so the game samples it as gameplay input - saving a
state in a fighter throws a punch, and since the queued save executes
after the guest resumes, that punch gets baked into the snapshot.

Loads already solve exactly this with savestate_input_guard_arm(),
which feeds neutral pads until the held input is released (min 90 ms,
capped at 700 ms). Arm the same guard whenever either host pause loop
hands control back to the guest, covering every close path: save,
cancel/back, Esc, the toggle hotkey, and rewind accept/cancel.
2026-08-19 18:38:23 -03:00
Henrique Guedes b46e77911b runtime: report which gate autocapture is parked on
A latched overlay_autocapture_tick() gate is invisible from outside:
triggers, futility and pressure counters simply stop moving, which
reads as "nothing to do" while in reality no new overlay variant will
ever be captured again and every freshly loaded RAM image stays
interpreted for the rest of the session. Observed live: a full disk
made the capture manifest write fail and requeue forever, and the only
outward symptom was the game getting slower with every new area.

Record which gate the tick last returned at and when the last pressure
sample completed, and surface both through autocompile_status as an
additive "autocapture" object: wedged (enabled yet no sample for 30 s),
gate, ms_since_sample, and the raw write-job/provider state. No
behavior change — observability only.
2026-08-19 17:07:12 -03:00
Henrique Guedes bbdf7bb01f runtime: pace autocapture gates on host time as well as vblanks
overlay_autocapture_tick()'s pressure-sample and cooldown gates counted
only emulated vblanks (120 / 300 frames, ~2 s / ~5 s at full speed). The
situation autocapture exists to fix — a fresh RAM image with no native
coverage, running in the interpreter — is exactly the situation where
vblanks stop arriving, so the recovery loop was throttled by the very
stall it should end: at an observed 0.26 fps, one pressure sample every
~8 minutes of wall clock and one capture fire every ~19.

Give both gates host-time twins at their 60 Hz equivalents
(AUTOCAP_CHECK_MS 2000, AUTOCAP_COOLDOWN_MS 5000) and open each gate on
whichever budget expires first. At full speed the budgets coincide, so
healthy-title cadence is unchanged; behavior differs only while the
guest runs slower than real time. The futility backoff multiplies the
host cooldown exactly as it multiplies the frame cooldown.
2026-08-19 17:06:03 -03:00
Alexandros Mandravillis 4a30c5cc01 runtime: add PSX_OVERLAY_REGION_FLOOR override for streamed-over text ranges
Some titles stream level code over pages inside their own static text
range (Driver 2 streams mission code over its boot text), leaving those
pages permanently diverged: below the overlay floor they are excluded
from overlay capture and fall into single-instruction dispatch thrash
(hundreds of thousands of one-instruction dispatches per window).

PSX_OVERLAY_REGION_FLOOR lowers the floor per title so diverged text
pages become overlay-cache candidates and chain through local flow.
Value is clamped above the kernel window; default behavior is unchanged
when the variable is unset. On Driver 2 this took text-blocked
interpreter work from 278M to 37M instructions per window.
2026-08-19 16:47:15 +03:00
Alexandros Mandravillis af9202ff4e runtime: seed launcher initial disc from CLI --disc override
Passing --disc suppressed the remembered settings/disc.cfg disc for the
launcher's initial state (the has_disc_path gate) without seeding the
launcher itself, so the window opened with "No disc selected" and the
player had to re-pick the disc on every launch even though a valid disc
was named on the command line.

Seed resolved_disc from the validated CLI path before the launcher
window opens. resolve_disc_for_runtime still applies the override
authoritatively for the runtime after the launcher returns, so
precedence is unchanged.
2026-08-19 16:46:49 +03:00
Alexandros Mandravillis 91ea482b17 runtime: default string-inventory capture off, PSX_XLATE_CAPTURE=1 re-enables
The always-on Shift-JIS string-inventory scan walks every guest store
argument even when no translation table is loaded. Measured cost:
26-35% of whole-lane throughput on streaming-heavy titles (three GT2
race lanes; reproduced on Driver 2, where disabling it took one lane
from 19 to 43 vblank/s).

Capture now defaults off; PSX_XLATE_CAPTURE=1 turns the scan back on
for translation authoring. Substitution (armed table + language) is
unaffected either way.
2026-08-19 16:46:26 +03:00
Alex Vanderveen b7f3642725 psx-dual-link wipeout support 2026-08-19 09:12:06 -04:00