A plugin that draws its own overlay primitives needs to know where the
right-hand edge of the screen is, and it cannot work that out for itself.
GPUSTAT carries the horizontal-resolution bits, so a plugin can recover
the coarse mode width, but the visible width also depends on the GP1(06h)
X1/X2 range -- and that register is write-only and mirrored nowhere a
plugin can read.
Ape Escape is the worked example. It scans out 384 (368-mode, dot clock 7,
horizontal range 539..3227) while its mode width is 368, and the usual
assumption of 320 is wrong by 64. A HUD row right-aligned to 316 lands 68
pixels short of the edge, which is exactly what the Quick Gadget Select
mod does today.
psx_mod_display_width()/psx_mod_display_height() return what the presenter
uses, straight from gpu_get_display_info(). Zero means the geometry is not
established yet, and the header tells callers to skip drawing rather than
substitute a guess -- an overlay in the wrong place is worse than an
overlay that waits a frame.
mod_runtime_test covers the passthrough with a stubbed GPU, including the
384-vs-368 case that motivates it and the zero case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The memory-card ACK path cleared I_STAT bit 9 (SPU) before raising the
SIO0 interrupt. That is cross-device interrupt ownership: an SIO event
consumed a pending SPU source, so SPU-IRQ-driven audio (Konami intro,
MGS dialogue streaming) lost its interrupt during card scans. The clear
originated as a netplay digest-parity experiment (probe ACKs landing on
i_stat_before 0x200 vs 0x000) and was never a hardware behavior.
Remove the clear. A card ACK now only raises bit 7; a pending SPU
interrupt stays pending until the guest performs the SPU/INTC
acknowledgement sequence.
Regression: sio_spu_istat_ownership_test drives the card READ preamble
with SPU pending and asserts bit 9 survives every ACK delivery (fails
before this change, passes after). The pre-existing DISABLED
sio_card_protocol_test baseline is unchanged at 147/309 failures.
(cherry picked from commit f14bd43dcb7f97acfe60e22870d9f0c6c201afb5)
Cancel the previous command's outstanding second-response interrupt when
a new CD command is accepted (psx-spx command flow; DuckStation
BeginCommand semantics). Without this, a Pause(0x09) INT2 survives into
the guest's next Setmode/Setloc/ReadN sequence and fires mid-read as a
stale COMPLETE — LIBDS drivers treat it as a read failure, corrupting
the streamed resource chain.
Cross-validated: C-12 Final Resistance mission 1 PC=0 null-callback
termination disappeared after absorbing this fix (DEVLOG 2026-08-05).
MOHU Mission 1 freeze in a DsRead FATAL/retry loop on single-sector
marker reads.
Also guard beetle SIGSTOP trap behind #ifndef _WIN32 (Windows has no
SIGSTOP).
(cherry picked from commit 2c43fc69c66242a86183156a6f1e60b2f81b1e47)
(cherry picked from commit c03fe5ba3d49241dfaedb344db6ad9c977070722)
Supersedes the global HYBRID controller-mode shape from PR #233 with a trusted plugin callback that can choose analog or digital presentation per input sample.
Keeps analog mode hardware-faithful by no longer folding physical D-pad input onto the left stick, while leaving debug injection able to steer stick-only paths.
Includes the pad/cursor probe tooling from #233 for diagnosing input doubling.
Split from parked PR #193. Adds the standalone keyboard_pad_chord_test target and verifies that default keyboard bindings emit Select, Start, and Select+Start without one key masking the other.
Split from parked PR #193 commit 42109c58. SDL3 audio streams convert from the requested stream format to the device format internally, so psx_sdl_audio_open must report the requested stream input spec back to the runtime. Reporting the device format made the runtime generate at the wrong rate before SDL resampled again.
Split from parked PR #193 commit 1469550c. The mod-state preservation half is already covered on master by the framework mod staging cleanup, so this extraction keeps only the independent save-state fix: when the resume hint is 0, wait for a published block-leader PC instead of writing a state that would later resume at pc=0.
`default: return 0` made every savestate written by a build with one extra
section a poison pill for every build without it. Worse than unloadable: by the
time the apply loop hit the unknown tag it had already restored RAM, VRAM and
CPU state, so the refusal left a HALF-RESTORED machine that wedged or died at
PC=0 -- which presented as "loading a save crashed the whole thing", with the
restored-but-untracked VRAM showing low-res in the wreckage.
A sectioned state format exists precisely so readers can step over what they do
not know. Known-but-malformed sections still fail as before; only genuinely
unknown tags are skipped.
This also makes the states saved during the short-lived BS_SEC_TEXPACK window
load cleanly again (the extra section is now ignored), and it is the
forward-compatibility prerequisite for ever adding that section back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects that only bite a MinGW C++17 build, both still live on master.
runtime.cmake declared only `c_std_11` on the runtime target, leaving the C++
dialect to whatever the parent project happened to set. A game CMakeLists that
omits CMAKE_CXX_STANDARD therefore compiled mod_packages.cpp as a pre-17
dialect. State the runtime's real requirement instead of inheriting it.
mod_packages.cpp captured a structured binding (`for (const auto& [id, ...])`
with `id` used inside a lambda). That is a C++20 extension; clang accepts it
silently, MinGW/GCC at C++17 rejects it. Bind the map entry to ordinary locals
before the lambda.
Cherry-picked from ae9432a3 on feat/rbengine-bak, minus that commit's unrelated
lib/recomp-net submodule bump. Verified by building psx-runtime on Linux.
The overlay-region model assumed the boot EXE sits at the bottom of RAM:
[KERNEL_END, text_end) is main-EXE text, [text_end, RAM) is runtime-loaded
overlay code. That holds for a load_address of 0x80010000, where the text
base equals the kernel-window end and the region below the text is empty.
It is false for a boot EXE that loads HIGH and streams its gameplay code
into the RAM beneath itself. Klonoa loads a 46 KB stub at 0x80180000 and
runs its overlays from 0x10000-0x130000, so the floor landed at 0x18B000
and the entire overlay region fell below it:
- dirty_ram_clear_image_baseline() wiped the dirty bits across
[0x10000, 0x18B000) believing it was the clean compiled text image.
- the dirty_ram_dispatch_inner admit heuristic requires phys >= floor,
so a JALR into an overlay page the CD had DMA'd there (never touched
by the CPU write hooks, so never marked dirty) was refused by the
interpreter, fell through to psx_unknown_dispatch and fail-fast
exit(1) — Klonoa, frame 687, target 0x80123D00.
Pin the text BASE (g_text_image_lo) alongside the floor and route the
gates through one predicate: the overlay region is both sides of the text
image, not just above it. Street Fighter Alpha 3 (0x80113B00) and
Bomberman Fantasy Race (0x8003004C) load high too.
No-op for bottom-loading games: g_text_image_lo stays at its 0x10000
default, the below-text clause is empty, and the baseline clear starts at
the same page as before.
Klonoa now runs 6874+ frames at a locked 60 fps with fail-fast on and
zero unknown dispatches; it previously died at frame 687.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit cf742f3190)
(cherry picked from commit 30e8a71f93539c4e10ff6767c44c24137c9dd714)
(cherry picked from commit 94f184f744)
Reported in mstan/psxrecomp#224. All three are test-only defects; no product
code is at fault.
test_launcher_vulkan_option: `gi` became a pointer and the surrounding code was
reformatted, so two assertions no longer match the source. The issue reports one
because Python `assert` stops at the first failure; line 25 fails identically and
also renamed `vulkan_offered` -> `vulkan_offered_b`. Blame is 61279a9d ("bios
select and rematch pathing fix", 2026-08-10), not 9228f32c -- 9228f32c ADDED
both the source lines and the assertions as a matching pair.
test_vk_present_wait_stage: not a stale assertion. `652be7ba` added a forward
declaration of submit_present above the definition, and the regex
`static void submit_present\(.*?\n\}` latches onto the prototype, so `body`
became the *next* function (finish_present) and submit_present was never
examined. VK_PIPELINE_STAGE_TRANSFER_BIT passed coincidentally on
finish_present's barrier -- a false pass. Anchor the match on the definition by
requiring `)` followed by `{`.
test_aot_overlay_discovery (check_atomic_dll_publication): the test hardcodes
".dll" shard names, but load_region_coverage re-derives the shard path via
overlay_ext(), which is ".so" on Linux. The writer held
<stem>.dll.pair-lock while the coverage reader took <stem>.so.pair-lock --
different files, no contention, so the reader crossed the publication window in
0.000s. This is why only one of the two readers failed: shard_pair_files_complete
is handed the .dll path directly and locked correctly. The pair-lock machinery in
compile_overlays.py is sound (fcntl.flock; all five renames in the expected
order); only the test's naming was wrong. Derive the extension instead.
Verified on Linux: tests 1 and 2 pass; check_atomic_dll_publication now blocks
both readers (1.004s / 1.016s) and passes in 2.4s.
NOT fixed here: aot_overlay_discovery still fails later, in
check_candidate_capacity_publication, from the SAME class of defect -- 80
hardcoded ".dll" literals remain in that file and cache_candidate_inventory also
filters on overlay_ext(). That is a wider Linux port of the test than #224
describes and needs an owner. Note also that recompiler/CMakeLists.txt:485 claims
this test is DISABLED, but no set_tests_properties marks it so; only the three
known-failing guards are actually disabled.
(cherry picked from commit 25555579dc)
Legend of Mana's land-creation effect rendered as hard-edged coloured
wedges instead of a soft bloom. The cause was in the CD-ROM controller,
not the renderer.
The game streams a file by issuing Setloc(next)+ReadN roughly 10,800
guest cycles after each sector arrives. Every ReadN called
start_read_stream(), which clears the sector buffer -- destroying the
sector that had already been delivered and announced but not yet drained
by the guest. Measured at the palette load:
buffer psx-runtime DuckStation
0x0E1F18 125111 125111
0x0E2718 125113 125112 <- the effect's colour palette
0x0DCF18 125113 125113
Sector 125112 carries the effect's 5-colour palette (0x0888F8 =
(248,136,8), 0xB0F8F8 = (248,248,176), verified against the disc image).
Destroyed before the guest could take it, its buffer received 125113's
raw file bytes instead, and the colour routine faithfully scaled those
into 216 distinct vertex colours -- the wedges. The table now holds 5.
DuckStation only calls BeginReading -- the function that clears the
buffers and re-arms the read-start latency -- when the request actually
moves the drive:
if ((!setloc_pending || setloc_position.ToLBA() == GetNextSectorToBeRead())
&& (drive_state == Reading || ...))
"Ignoring read command with pending/same setloc, already reading"
else
BeginReading();
A Read issued while the drive already streams the sector the pending
Setloc names is the game saying "keep going", not "start over". The
command is still ACKed with the same INT3; only the restart is skipped.
Two things fall out of it. The un-drained sector survives, so the game
receives the consecutive sectors it expects (109,110,111,112,113 rather
than 109,111,111,113,113). And each request stops paying
initial_read_delay_cycles() again: inter-sector gaps drop from ~456,000
cycles to 225,792 -- exactly the 2x cadence -- so this load takes 2 guest
frames instead of 13, matching DuckStation. int1_pended goes 10 -> 0.
Also included, developed alongside and verified in the same runs:
- An 8-slot sector buffer ring, as the hardware has (nocash;
DuckStation NUM_SECTOR_BUFFERS = 8). A delivered sector fills the
NEXT slot and the INT1 that announces it latches the read slot, so a
sector arriving mid-drain no longer clobbers the FIFO. The read
pointer follows the slot each INT1 announces, captured when the INT1
is RAISED: this drive free-runs, so latching "newest" at
presentation time handed the guest a sector seven ahead of the one
it asked for.
- The seamless no-IRQ refill advances the read pointer, because it is
a continuation of the drain already in progress. Without it a
multi-sector DMA starves mid-transfer (measured: 1,128,960 starved
drains).
- A pended data-ready is released on a short schedule rather than
inside the guest's ack store, matching DuckStation's
QueueDeliverAsyncInterrupt.
- Flow control for accelerated (non-1x) reads: never overwrite a
sector the guest has not consumed while running faster than
hardware, capped at the authentic sector period. Inert at authentic
timing.
New cdrom_state counters ring_starved and ring_dropped, plus
cdrom_timing_dump for per-sector deadline/delivery records, are the
regression tripwires: ring_starved and int1_lost must stay 0.
Verified on Legend of Mana (SLUS-01013): the effect renders correctly,
the palette table holds 5 colour words, sectors arrive at the 2x
cadence, and no notification is lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>