THE BUG. cd_diff proved the two emulators' CD request streams are
byte-identical through the palette load (125054, 125071, 125088,
125105..109, 125111, 125113 on both), and cd_verify proved every request
was executed exactly: delivered == requested, data heads matching the
ISO, lost=0. Identical asks, identical answers, different result -- so
the divergence was in the BUFFERING between them.
psx-runtime had ONE 2340-byte sector buffer. Real hardware has eight
rotating ones (nocash; DuckStation NUM_SECTOR_BUFFERS = 8). The game's
loader drains TWO sectors per Setloc+ReadN cycle -- the requested sector,
then its continuation via the following data-ready -- and with a single
buffer the continuation overwrote the buffer mid-drain, so the second
drain re-read stale bytes. The palette load shifted by one sector and
raw file data was rendered as vertex colours: the hard-edged wedges.
Implements the ring faithfully:
- a delivered sector fills the NEXT slot, never the one being drained;
- the INT1 that announces a sector latches the read slot (DS: read =
write at DataReady delivery);
- a drain that empties a slot re-announces a sector already waiting in
the newest slot, matching DS's redeliver-missed-sector behaviour --
without it a game draining two per cycle never hears about the second;
- multi-sector DMA steps across slot boundaries inline, preserving the
seamless-refill shape but without clobbering;
- drive-state changes reset the whole ring (DS ClearSectorBuffers);
- the savestate carries all eight slots plus both indices; the section
is length-checked, so older states are rejected rather than misparsed.
New counters ring_dropped (guest a full ring behind -- hardware drops
too) and int1_redelivered (missed sectors recovered) in cdrom_state.
Builds and links clean in build-release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The command history settled the request stream: near the file tail the
game switches to per-sector Setloc+ReadN (105..109 in one frame, INT1s
pended behind the command INT3s -- normal), then requests 111 and 113,
having consumed 110 and 112 as ReadN continuations. So 'the game asks
for the wrong sector' was wrong too: the request stream is a normal
streaming pattern, and the question is which DELIVERED sector each DMA
actually drained -- the table ends with 113's bytes where 112's belong.
The DMA log only stamped the game's last SetLoc, which cannot answer
that. Each entry now also records the sector actually in the buffer at
DMA time (continuations differ from the SetLoc stamp), the frame, and
the first 8 bytes of the transferred data for ISO matching -- ground
truth for dest <- which sector, one replay to capture.
Trace-ring lesson recorded: per-byte FIFO reads flood the 64K register
ring to ~1.5 frames of history during loads; the command-history ring
(473 entries, flood-immune) is what survived to answer the request-side
question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flow-control fix did not change the symptom, and the reason is in
game.toml: no disc_speed is configured, so the divisor is 1 and
accelerated_read_active() is never true. The sector is being lost at
AUTHENTIC cadence -- the guest has a full sector period and still misses
-- which makes this a timing divergence, not a pacing-enhancement bug.
The flow-control commit stands (it is correct for accelerated configs);
the theory that it explained THIS failure was wrong.
Also learned by reading: cd_read_log stamps entries with the game's last
SetLoc (s_setloc_lba updates only in the SetLoc handler), so
'125111 -> 125113' means the GAME issued those two requests itself.
Earlier pairs in the log share one LBA across two destinations (one
SetLoc, multi-sector read); the failing pair does not. Either an INT1
loss desynced the game's own sector bookkeeping, or its request stream
diverged for another reason -- the per-sector delivery records decide.
cdrom_timing_dump exposes the existing L1.5 ring per record: LBA, due /
buffer / irq-arm / INTC cycle stamps, data/dma/pended/lost flags,
filterable by LBA. cd_verify.py arms it, waits for the table load,
and reports all three layers from one run: the request stream and its
gaps, delivery records around LBA 125112 (read at all? pended? lost?),
and whether the table ended up correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
`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>
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>
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
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
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.
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>
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>
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.
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
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
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
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
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.
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
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.
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
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
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
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
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
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.
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.
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.
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.
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.