An online room this build hosts asks the lobby server for transport "host"
(match_caps.relay): the host carries the match on its own UDP port and
guests dial it; the server's relay (SFU) is the fallback when a guest's
probe fails (recomp-net-server WS_LOBBY.md "Host relay"). The mechanics are
recomp-net's recomp_net/host_relay.h, pinned here (lib/recomp-net b0751b7);
this engine's own lobby-client copy gets the same glue as rnet_lobby_client:
the cap (emitted only when asked), the launch's transport ->
PsxLobbyJoinInfo.transport_host (force_input_relay 0; the host accepts
first / hubs instead of dialling the guest's LAN address), each seat's
published path / path_fresh, host_relay_step() from psx_lobby_pump, and
unmap on leave / disconnect.
psx_netplay: transport_host takes the LAN / hub path instead of the "MotK
online needs the SFU" rule. main.cpp: relay_host_get/set + relay_status
for recomp-ui (Network Settings / MATCH SETTINGS / ROOM "Match Relay"),
transport_host on the launch and into PsxNetplayConfig, and the
host==guest-advertise SFU inference skipped on a host launch; all under
RECOMP_LAUNCHER_HAS_HOST_RELAY so older recomp-ui pins still build.
Compile-checked against workspace recomp-ui 58604b0 (MOTK scratch
configure: full build and link).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hub holds the lobby seat; the game settles everything itself
(retcomm-launcher docs/NETPLAY_HANDOFF.md):
- psx_lobby_ingest_record replays the hub's seat/room/launch messages
through handle_server_json, so fill_launch answers as in-game.
- --netplay-query answers what this build's lobby would send: disc_fp,
set_ready (BIOS offer), start (default caps; settled from a room).
- set_ready/start builders and the host start-caps settle are factored out
unchanged and shared by in-game PLAY and the query.
- A handed-off match ends the process (the hub holds the room).
Two CTR processes from records, local relay: all 80 live digests equal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve current segmented dispatch, active-plan mod hooks and software scale limits. Extend function filters through the active plan, retain the last-store pointer with overlay ABI 25, and keep default config fingerprints stable. Refresh BIOS emitter stamps after byte-identical regeneration and reconcile regression fixtures.
A buildable dump used to verify as usable and then block at Play behind a
modal while the module compiled. Now:
- ae_bios_verify reports a buildable, not-yet-cached dump as needs_regen,
and the runtime wires recomp-ui's new bios_prepare_with_progress job
(ae_bios_prepare) whenever the toolchain is present and no Generate host
is: the launcher runs the build as a progress job from the wizard's
"Prepare BIOS" button or the Play prompt, re-verifies, and resumes the
wizard. Generate & rebuild is never offered for a BIOS in a bundled build.
- psx_bios_module.c gains a progress sink fed from the builder's output
lines (dump / emit / compile / self-check / published map to stage
percentages with readable messages); run_command streams lines on both
platforms.
- The launch-time build stays as the fallback for --bios / --no-launcher.
Requires recomp-ui 9f86c49 (GameInfo.bios_prepare_with_progress).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Bundled releases link OpenBIOS only; retail-BIOS-derived C never ships.
"Bring your own BIOS" now works without it: when a dump matching a shipped
profile (psx_bios_known_images.h) is selected, the runtime builds that
backend on the player's machine with the overlay toolchain the release
already carries, caches it, and loads it as one more registered backend.
- runtime/include/psx_bios_module.h: the module contract. An overlay-style
self-contained shared library (no imports from the executable) exporting
psx_bios_module_{abi,codegen_hash,init,backend}; reaches the runtime only
through OverlayCallbacks plus a new PsxBiosModuleCallbacks table (traps,
dirty-RAM/kernel-bless probes, fntrace, log_probe) and pointer aliases of
the globals the generated dispatcher touches. Its #defines rewrite the
emitter's own extern lines, so the generated C compiles unchanged.
- runtime/include/psx_bios_module_glue.c.inc: module-side forwarders and the
four exports, compiled after overlay_dispatch_preamble.c.inc.
- runtime/src/psx_bios_module.c: host side. Cache path
<exe>/cache/bios/<os-arch>/bm<abi>_<codegen hash>_f<flavor>/<STEM>_<crc>,
ABI + codegen-hash gated load, callback fill, and a blocking build via
overlay_toolchain/bios_module_build.py (gcc via compiler.txt or PATH,
else tcc), then psx_bios_register().
- psx_bios_backend.c: the registry is now a mutable table seeded from the
CMake-generated psx_bios_builtin_registry by a constructor, with
psx_bios_register(); every existing walker keeps working.
- main.cpp: validate_bios_for_launch acquires a module (announcing a first
build) before declaring a mismatch; discovery accepts a buildable dump;
the launcher verify reports it usable ("ready" / "compiled on first
launch") with no Generate & rebuild; the BIOS row shows whenever the
toolchain is present; bundled_only respects module support.
- emitter: the CPS-marker constructor is not emitted in a module build.
- overlay_api.h: OverlayCallbacks gains a struct tag;
overlay_loader_callbacks() exposes the host table before loader init.
- tools/bios_module_build.py (ships in the toolchain): identity + profile
SHA-256 gate, staged emitter tree, emit, three-TU compile, ctypes
self-check, atomic publish. release_stage.py stages psxrecomp-bios, every
bios/*.toml, the seeds and the builder into overlay_toolchain/.
- Tests: runtime/tests/test_bios_module_host.c against three fake modules
(good / wrong ABI / wrong hash) on real dlopen; recompiler/tests/
test_bios_module_build.py builds the bundled OpenBIOS end to end and reads
the descriptor back; guards extended.
Verified on Twisted Metal 4 (bundled build + staged toolchain, headless):
first launch with --bios SCPH1001.BIN builds and loads the module and boots
on image=SCPH-1001; the second launch loads it from cache.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A netplay match that ends early returns both players to the lobby. The
runtime knew why (netplay_soft_exit's origin) but only printed it to
stdout, so players saw the lobby again with no reason. A boot-digest
mismatch (peers booted different BIOS images or boot settings) first
held a black window for the full 20 s admit-stall watchdog.
netplay_soft_exit now records a sentence for its origin, and the soft
return to the lobby puts it on the launcher's status line through
last_error (the room and browser views already show it). The dig0 gate
timestamps a mismatch; once the same pair of digests has held for 3 s,
the watchdog ends the match with its own reason. A peer that gets the
other side's BYE while it sees the same mismatch reports the mismatch,
not "the other player left". A CLI match prints the sentence to stderr.
The origin-to-text map and the grace decision live in
netplay_exit_reason.c, covered by netplay_exit_reason_test. Builds on
the previous commit's psx_lobby_set_last_error.
The lobby's BIOS identity was a two-value token, openbios or scph1001,
and scph1001 meant "some retail dump". An SCPH-5552 peer and an
SCPH-1001 peer both offered it, the host settled it, each peer booted
its own image, and the rollback boot digest never matched: about 20 s
of black window, then both players back in the lobby with no reason
(reported in #243).
Each offer now carries the CRC-32 of the retail image that peer would
boot (online bios_offer.retail_crc; LAN JOIN line 6). The host settles
retail only when every seat offers the same CRC, else OpenBIOS when
every seat links it, else refuses to start and puts the reason on the
launcher's status line ("Players use different BIOS images (SCPH-5552
and SCPH-1001) ..."). The settled CRC ships in match_caps
(session_bios_crc) and on MOTK1 START line 7, and each peer boots only a
dump with that CRC. Rooms where every peer has the same image keep
working, including SCPH-5552-only builds with no OpenBIOS. Old peers
send no CRC, so a mixed old/new room falls back to OpenBIOS or refuses;
a session from an old host still accepts any retail dump. The lobby
server relays bios_offer and match_caps as opaque objects, so it needs
no change.
The rule lives in netplay_bios_settle.c, shared by the online and LAN
paths and covered by netplay_bios_settle_test. last_error grows to fit
one sentence, with psx_lobby_set_last_error to set it.
docs/SEGMENT_AWARE_CODE.md §5.7 (decision 4). #417 made every KUSEG and
KSEG1 overlay dispatch fall to the interpreter, because every shard was
compiled for KSEG0. The loader now keys shards by segment, so a shard
compiled for KUSEG or KSEG1 runs for PCs of that segment.
Cache layout. KSEG0 shards keep their directory and {phys}_{crc} names, so
no existing cache is invalidated. KUSEG and KSEG1 shards live in the cache
tag's seg-kuseg/ and seg-kseg1/ subdirectories, under the same names; both
tiers scan them, and a shard's segment is its directory's
(cache_seg_from_path).
Manifest. A KUSEG or KSEG1 manifest starts with `S <segment>` (00000000 or
A0000000) before its first F. F entries are full VAs in that segment; the
parser no longer forces them to KSEG0. R records stay KSEG0-spelled byte
extents. Without S a manifest is KSEG0, as before. Refused, when indexing,
when loading and through live publication alike:
- a manifest whose segment differs from its directory's;
- an F entry outside its segment;
- S after an F, S twice, or S that is not a RAM segment base.
Dispatch. Each candidate records its segment. The physical indexes stay (the
bytes are one identity), and every lookup matches the PC's segment:
- the entry chain (idx_head_seg);
- CPS continuation ownership;
- the lazy manifest index and try_load_region;
- the interpreter's native-call contract.
The lazy-miss and range-owner memos are keyed by the full PC. A PC in a
segment that maps no RAM never runs a shard. Exports are resolved by their
full VA.
Counters. segment_alias_interp (from #417) now counts KUSEG/KSEG1 dispatches
the loader leaves to the interpreter because no shard of their segment is
cached or valid yet. The new segment_native counts those a shard of their
own segment runs. Both are in the TCP overlay_loader_status reply. A warm
cache takes the first to 0.
Full-VA compares:
- the overlay idle note's return test compares the resume PC with $ra
exactly, and counts it as internal only for a running shard of its segment;
- the shadow diff's two "returned to the caller" tests compare the exact PC;
- the diff mode's kernel-window exclusion tests the physical address, so
KSEG0 and KUSEG kernel candidates are treated alike. Before, it tested the
full PC, so only a KUSEG PC ever matched, and #417's gate never let one reach
it. This is opt-in (PSX_OVERLAY_DIFF).
Tests (loader harness, overlay_segment_gate):
- a KUSEG shard runs natively for its KUSEG PCs, entry and continuation,
while KSEG0/KSEG1 aliases are interpreted;
- one shard per segment for the same word, preloaded and lazy;
- a stale own-segment shard is interpreted rather than handed to another
segment's;
- a KUSEG continuation where KSEG0 has an entry at that word, and a lazy one
whose competing KSEG0 bundle is newer;
- refused manifests, including through live publication;
- the native-call contract;
- a source check that the interpreter records the segment from the full PC,
and that no site clears dispatch evidence without its segment bits.
The harness gains a segment-run scenario that drives real compiled shards
through a CPS dispatch loop. overlay_init_guard and overlay_retry_c11 follow
the per-segment lookup, and so do the disabled interpreter_perf_guards'
negative-miss and try_load_region checks (it still stops where it did on D).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/SEGMENT_AWARE_CODE.md §5.7. Overlay bytes are segment-free, but a PC
is not: a KUSEG, KSEG0 or KSEG1 entry of the same word links, traps, fetches
and stamps store PCs in its own segment, so each needs a shard compiled for
that segment. Capture kept only the physical word, so every shard was
compiled for KSEG0 and #417's gate interpreted the other segments forever.
dirty_ram_interp.h adds one sibling bitmap per segment that maps RAM
(g_dirty_ram_dispatch_seg_bitmap[3]; 768 KiB for the 8 MiB backing).
dirty_ram_dispatch_inner sets the bit for the full PC next to the existing
any-segment bit, once per interpreted dispatch. A PC in 0x20000000-0x7FFFFFFF
or KSEG2 addresses no RAM in Beetle and gets no bit.
dirty_ram_dispatch_evidence_clear() drops both together at every site that
dropped dispatch evidence: boot reset, a store into an executed page, DMA
preservation and the autocapture epoch. Snapshot jobs copy the siblings.
Capture schema v3 adds, per region,
"dispatch_entry_segments": {"kuseg": [...], "kseg0": [...], "kseg1": [...]}
with each PC spelled in its segment. dispatch_entry_pcs keeps its v2 meaning
(dispatched in any segment) and KSEG0 spelling, so existing readers are
unaffected. A record without the field is KSEG0 only.
Guest behaviour is unchanged: the bitmaps are host-only capture evidence and
are not saved in savestates. overlay_capture_retry_test checks the v3 record
through both writers (the background epoch and the synchronous final
capture), including that a cleared epoch's segment does not outlive it.
psx_ram_runtime_map_stubs.c defines the new bitmap, which memory.c's write
paths now reference, so psx_ram_runtime_map_test still links on GNU
toolchains. The disabled interpreter_perf_guards check follows the evidence
clear (it would otherwise contradict overlay_segment_gate's wiring check).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
psx_call_contract and the BIOS dispatch loop compared a return PC with the
call's link physically ((a ^ b) & 0x1FFFFFFF). A PC carries a segment, and
the link a call writes is in the segment of the body that made it
(docs/SEGMENT_AWARE_CODE.md §5.5). A guest that returns to another
segment's alias of the call site did not return to that body, so letting
the suspended C continuation run there would execute it with the wrong
segment's PCs. §5.5 deferred these checks to this PR so one call contract
has one rule. All five are exact now:
- psx_call_contract (cpu_state.h): the bail-resolve PC and the $ra check;
- the emitted BIOS dispatch loop: the return boundary, the bail resolve, the
wild-return test and the exception-stack straddle.
Since B routes every baked link through runtime_pc() and C/D compile each
body for the segment it runs in, a legitimate return always matches in
full. The ledger gains the exact-return-contract guard: it compiles
psx_call_contract and checks that another segment's alias starts a bail and
resolves nothing, and that the emitted OpenBIOS dispatch has the four exact
checks and no masked one.
OpenBIOS and SCPH-1001 dispatch C change in those four lines; game C does
not change. cpu_state.h is in the codegen hash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SCPH-1001's reset code enters its relocated kernel through the uncached
alias (lui $a0,0xA000; addiu $a0,$a0,0x500; jr $a0 at ROM 0xBFC0044C).
Beetle charges those four instructions as uncached fetches, 20 cycles; the
BIOS dispatch normalized 0xA0000500 to the kernel body compiled for runtime
0x00000500, which charges cached fetches, 11. That is the -9 cycles left at
SCPH-1001's kernel entry since #429 (docs/SEGMENT_AWARE_CODE.md §3.3).
A BIOS seed that is a runtime PC inside a relocated copy window, in another
segment than the window runs in, now asks for a segment variant (§5.4): the
function at those ROM bytes and its direct-edge closure (branch and J/JAL
targets that leave it, the call return and fall-through past its end) are
emitted again with the seed's segment. Every PC a variant computes from its
own PCs is in that segment (links, fetch tags and the uncached test, IRQ
resume, branch, J and fallthrough targets, store and exception PCs), a KSEG1
variant charges a fetch per instruction, and a jump-table case in another
segment is dispatched instead of continuing locally. Variants and their
continuations get exact-PC rows in a segment_variant_table. A variant seed
that names no emitted function entry stops the build, and so does one in a
segment that does not map physical memory (0x20000000-0x7FFFFFFF, KSEG2).
The dispatch still finds the word by its normalized key (and applies the
kernel-bless guard); a new psx_bios_hit_body() then picks the body for the
exact PC. A variant compiled for that PC runs. Any other PC in another
segment than the one the word's home body was compiled for still runs the
home body, as before, but is now recorded as a BIOS segment miss in the
segment-miss ring (kind "bios", with the home PC), so a new alias entry is
loud instead of silent (§5.5). psx_bios_key_home_pc(), emitted from the
address model, gives each key's home runtime PC: its copy window's runtime
address, or the ROM run in place. The ring, TCP segment_misses and the exit
report carry each entry's kind; tools/collect_game_misses.py lists a BIOS
segment miss for the BIOS seeds instead of writing it into the game's.
SCPH-1001's seeds gain 0xA0000500 (kernel_entry_kseg1): a 4-instruction
KSEG1 variant of kernel_trampoline_0. Every BIOS's dispatch C gains the two
helpers and calls psx_bios_hit_body() where it called the home body. A BIOS
without variant seeds (OpenBIOS, SCPH-101, SCPH-5552) keeps its full C;
SCPH-1001's full C gains the variant body and its dispatch C the one-row
segment_variant_table.
New bios_segment_variants test: OpenBIOS with KSEG1 variants of kernel
0x12BC (closure 0x6F8 by jal and 0x70C by fall-through) and 0x13F4 (0x1424
by a conditional branch only) and a KSEG0 variant of kernel memset 0x540 (a
KUSEG jump table), plus a static check of SCPH-1001's seed. It compiles the
emitted lookup, with and without variants, and queries every variant row,
every home key's home PC (checked against the PC each function body was
compiled for) and every other segment's alias of it. Seeds in a segment
that does not map physical memory stop the build, and a runtime PC in its
window's own segment stays a discovery seed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PC carries a segment, and the game dispatch table is about to be keyed by
the full PC (docs/SEGMENT_AWARE_CODE.md §5.5). A PC whose physical word has a
compiled row only in another segment then has no body of its own: dispatch
interprets it through the existing clean-text-miss path and never runs the
other segment's body. Decision 2 in §10 is to interpret such a miss loudly
until the title is regenerated, so record it:
- psx_segment_miss.{c,h}: classification (the other segment's row at the
same word, through psx_game_is_function_entry), an always-on ring with the
full PC, the row that exists, $ra, $sp and the frame, and a per-PC count.
- dirty_ram_dispatch_inner hands every clean-text miss to
psx_segment_miss_note(), which records it when the PC has no row of its
own while another segment's alias does.
- TCP: new `segment_misses` (summary, or {"tail":N}); `dispatch_stats`
gains segment_miss_total/_unique; the emu-thread ping reports the total.
- psx_last_run_report.json gains a `segment_misses` section (top 50 PCs).
- psx_segment_miss_test covers classification, the note helper, the ring,
wrap-around and the summary order. segment_miss_wiring_test (a source
guard) pins the helper's call in dirty_ram_dispatch_inner, the TCP
command and its summary row shape, the dispatch_stats and ping fields and
the exit-report section, which no unit test can reach.
Nothing is recorded with a dispatcher generated before the exact lookup: its
physical lookup finds a row for every alias, so the check never fires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only CPU stores are cache-isolated. Mods, the FMV skip, debug pokes, the
bg2d ring fill and the exception sentinel write guest memory from the host;
they now go through psx_host_write_*, which bypasses isc_store the way DMA
does, so a host store inside a FlushCache window reaches memory instead of
rewriting an I-cache tag. The render-pass checkpoint restores the new depth
counter like the DMA depth. isc_store_test covers it (a lane-0 host store in
tag-test mode would otherwise mark a cold line valid).
Docs: the SCPH-1001 residual starts at kernel init 0x598, and without #429
the fix removes 113 of the 126 OpenBIOS flush cycles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
memory.c dropped every store made while SR.IsC was set. Beetle's
PS_CPU::WriteMemory sends them to the caches instead
(mednafen/psx/cpu.cpp:482-512), before any address decode:
- I-cache enabled (BIU bit 11) with a tag-test, invalidate or lock mode
bit: the store rewrites the tag and valid bits of its line. This is how
FlushCache (A 44h) and the boot cache init invalidate the I-cache.
- D-cache enabled with lock mode clear ((BIU & 0x81) == 0x80): the store
lands in the scratchpad at addr & 0x3FF.
- Nothing reaches the bus, the BIU register at 0xFFFE0130 included.
psx_icache_isc_store transcribes the tag write; memory.c's three store
chokepoints call it (and the scratchpad write) first, so compiled code,
overlay shards and both interpreters share it. DMA writes are not CPU
stores and now bypass IsC, as in Beetle. A render pass applies the same
effect (its checkpoint restores tags and scratchpad), and the overlay
shadow record fails closed on an isolated store, which its replay
cannot redo.
isc_store_test drives the real memory.c and psx_icache.c: 15 of its
checks fail on master.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beetle's ReadInstruction never fills an I-cache line for a fetch at
0xA0000000 or above: every such fetch costs +4 and clears the pending load
give-back. The dirty-RAM interpreter charges that by fetching at every PC.
Both static emitters emitted psx_icache_fetch only at cache-line leaders,
which is exact for cached code only. In OpenBIOS, 5,477 of the 9,592
in-place ROM (KSEG1) instruction sites were uncharged: 21,908 cycles short
per pass through that code, each also missing its give-back clear.
Both emitters now charge a fetch before every instruction whose runtime PC
is uncached. The predicate is psx_fetch_uncached in psx_instr_cost.h, which
psx_icache.c now uses too. The A0/B0/C0 call-vector native stubs charge one
fetch per executed word, so a KSEG1 call pays like the interpreter. Cached
code keeps the leader rule. R4's regenerated game C is byte-identical, and
the OpenBIOS diff is insertions only. psx_instr_cost.h joins the codegen
hash list, since the emitters fold it into emitted code.
Tests:
- ctest uncached_fetch_charge runs psxrecomp-bios on OpenBIOS and compares
per-instruction fetch charges three ways: the compiled fetches, the
interpreter's fetch path (psx_icache_fetch_interp) at every PC, and a
Beetle transcription.
- uncached_fetch_codegen_test checks the game emitter at KSEG1 and KSEG0.
Both tests fail on master. This closes bios-kseg1-fetch-charge in the
segment_aware_codegen ledger (#419).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only passes that run are measured. The first passes of a race can all run in
a transient (a busy host, code run for the first time): in earlier Grand
Prix runs the three warm-up passes measured 13-15 ms against a steady 3-7 ms
at the same setting. An estimate that high prices most plans out, no pass
runs to correct it, and a plugin that crossfades while passes are shed (R4
Frame Rate) stayed on the crossfade for most or all of the run: 4, 9 and 29
passes in 22 s at 300 Hz, 7 in a verify run at 120 Hz, against about 1400
when the first passes were fast.
render_pass_cost_note_plan() (render_pass_plan.c) counts the plans that
wanted passes since the last measured pass. After 30 (about a second of a
30 Hz game) the warm-up restarts, so plans ask for one pass until three have
been measured again. When the new median is more than 3/4 of the old
estimate, the old one was right (a size that is truly too expensive, or a
host at its limit) and the next wait doubles, up to 960 plans, so these
one-pass warm-ups stay rare; a re-measure that finds the estimate stale
keeps the wait at 30. render_pass_stats reports cost_rewarms.
render_pass_plan_test covers the restart after 30 plans, an estimate that
passes keep measuring never restarting, the minimum wait after a stale
estimate, and the doubling waits when re-measures confirm the estimate
(47 ms at 4K, 10 ms re-measured as 9); render_pass_guards pins the planner's
call and its one-pass re-plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The emitter writes both helpers at a listed bgez_sites or
clip_edge_x_load_sites address whose instruction matches in overlay code
too, but overlay_dispatch_preamble.c.inc did not define them, so such a
shard failed to link (macOS, Windows) or to load (Linux dlopen RTLD_NOW)
and its overlay stayed interpreted.
The preamble now defines both over psx_ws_x_margin(), with the same pure
math gpu.c uses (ws_cull_edge.h, stdint-only, staged with the other
runtime headers). ws_cull_edge.h joins the shard compile surface in
codegen_hash_sources.cmake. overlay_shim_compile_contract links a shim
that calls both with the emitter's extern declarations, and
overlay_widescreen_callbacks runs them in an executable: identity with no
margin callback, widened at margin 120, interior clip edges unchanged.
Both fail without the definitions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_pass_plan_phases() treated an unknown pass cost (0) as free, so the
first plans of a process scheduled every phase up to the slot cap. The
passes that allocate textures are left out of the average (up to 8 in a
row), so the first two frames ran at full count, and after an aspect or
resolution change the stale cost of the old size stayed in force. Passes
run on the emulation thread: at 4K (46.9 ms a pass) the first race frames
stalled the guest by several passes each.
- An unknown cost with a budget now plans one pass: it measures the cost
and costs the guest at most one pass per frame while it is learnt.
- The cost is a RenderPassCost (render_pass_plan.c), replacing
render_pass_cost_sample: the same allocation rule, then a warm-up of
three kept samples whose median seeds the average. Until then the
estimate is unknown. With one-pass plans a single slow first pass (a busy
host) would otherwise set the average alone, and a plan that sheds every
pass never measures again.
- The renderer keys the cost to the presented image size: a plan at
another size passes an unknown cost, and the first pass there starts the
cost over.
- RENDER_PASSES.md no longer claims passes never slow the guest, and says
a cost above every frame's budget keeps passes shed until the size
changes.
render_pass_plan_test covers the one-pass plan (none without budget), the
warm-up median (one outlier ignored; a truly expensive size still prices
itself out) and the allocation rule; render_pass_guards pins the size key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A generation's timeline is fixed at plan time (t_len = plan period x
VBlank). When the game frame stayed up longer (a 30 Hz tick that lags to
three VBlanks, or host-interval drift), pass_gen_present expired the
generation past phase 1 + 0.5/period and the presenter fell back to its own
capture, which in FLIP mode is the frame's phase-0 image: the output ran
0 -> .25 -> .5 -> .75, stepped back to 0, then jumped to the next frame.
render_pass_gen_select() (render_pass_plan.c) now keeps render_pass_select's
hold of the newest image past phase 1 and only expires a generation after
RENDER_PASS_GEN_HOLD_MAX (4) frame lengths without a flip, i.e. a game that
stopped flipping. render_pass_stats reports late_presents (presents more
than 1/64 of a frame past its planned end). The plan keeps the plugin's
period: its phases were placed on the output deadlines of a frame of that
length, and the flip tracker's period is the previous frame's, which cannot
predict a sporadic lag.
render_pass_plan_test covers phases 1 to 4 (hold) and past 4 / NaN
(expire); render_pass_guards pins pass_gen_present to the rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream now has one live main-RAM geometry: retail 2 MiB mirrored four
times across the 8 MiB decode window, or 8 MiB unique with the opt-in
8 MB RAM mod (psx_memory.h). memory.c's RAM array is sized for the
largest geometry, so the pass store policy was handed 8 MiB even on a
retail boot, and a store to a mirror (0x80200000..) would have landed in
the unused upper backing bytes instead of folding onto RAM. With the
8 MB map live, the checkpoint copied and restored only the low 2 MiB.
- memory.c passes the store policy psx_ram_live_bytes(), so pass stores
fold exactly as psx_ram_map_write does outside a pass.
- render_pass.c checkpoints, restores and (PSX_RENDER_PASS_VERIFY)
hashes memory_get_ram_bytes() bytes; the copy grows if a later session
boots with the larger map.
- render_pass_sandbox_test: policy and a real pass with 8 MiB live
(unique addresses up to 0x7FFFFF, all 8 MiB restored), then a retail
pass folding mirrors again. Restoring a fixed 2 MiB fails it twice;
folding with the backing size fails it twice. render_pass_guards pins
the live size in memory.c and in the checkpoint, restore and hash.
- psx_ram_runtime_map_test (upstream's 2/8 MiB test over the real
memory.c and dma.c) links those files with section GC. The pass paths
make six more symbols reachable there (the pass flag and store policy,
the synchronous GPU linked-list walk); its stubs double them, trapping
if a pass path ever runs. With the macOS-only link gaps stubbed as well,
the test passes on this branch as on master.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in the R4 integration build at 4K (internal resolution 9x): the first
passes of a session create their image slots and backup framebuffers at
2880x2160 and cost ~70 ms; the steady passes after them cost ~10 ms. The
plan's cost average (seeded by the first sample) then sat near 20 ms, above
the ~14 ms the frame had to spare, so every later plan was shed for time --
and since a shed plan runs no pass, the average was never measured again:
Interpolated drew no in-between frame for the rest of the session.
A pass that made a pass texture or framebuffer (pass_make_color_fbo, a new
pass image slot) is no longer a cost sample: render_pass_cost_sample()
leaves it out, at most RENDER_PASS_ALLOC_SKIPS (8) in a row so the average
can never freeze on a path that allocates every pass. The time still counts
towards the frame's pass-time budget as before.
render_pass_plan_test covers the rule (allocating pass skipped, next steady
pass seeds the average, the skip cap, no counter = every pass counts);
test_render_pass_guards pins that both allocation sites count, that each
pass marks its start, and that the average goes through the rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in the R4 integration build (widescreen + Interpolated): R4 copies
2x1 VRAM pixels at 0,0, outside the pass rect, every frame. The journal
backs such writes up and restores them, but pass_journal_protect() refused
whenever a native-wide surface existed, so with the widescreen view on
every pass was rolled back ("render pass refused copy 0,0 2x1 outside
0,240 320x240", "rolled back (VRAM write outside the pass rect)") and no
in-between frame was ever shown.
The refusal was not needed: fills (gpu_fill), copies (gpu_copy_rect),
uploads (glb_vram_transfer_in / flush_cpu_upload) and pokes
(glb_vram_write) write the hr surface, the raw mirror and the CPU VRAM
rows only -- exactly what the journal backs up -- never a wide surface.
The wide margins come from mirrored draws, which the pass scissor already
keeps inside the pass band.
test_render_pass_guards pins both halves: pass_journal_protect() must not
test g_wide, and none of those five write paths may reference a wide
surface. With the old refusal put back the guard fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store policy and the CPU side of the out-of-rect VRAM journal move into
render_pass_plan.c as pure functions (render_pass_store_to,
render_pass_vram_policy, render_pass_journal_add/_rollback); memory.c and
gpu_gl_renderer.c only wire them to their arrays and GL copies. Behaviour is
unchanged.
render_pass_sandbox_test covers what the plan's U5a list asked for:
- every device class a pass may not reach (SPU key-on and control, CD,
timer, other DMA channels, GP1, SIO, memory control, MDEC, expansion,
KSEG2) is dropped under its class and never delivered, while GP0,
GP1 DMA mode/info, I_STAT/I_MASK, GPU/OTC DMA and DPCR reach the device;
- a real pass (render_pass.c, psx_cycles.c, timers.c) that makes those
stores, acknowledges and masks every interrupt, runs 10^6 cycles while
timer 2 is armed to interrupt every 1000, and changes a timer behind the
store policy's back: drops counted per class, no device advanced, no
interrupt raised, RAM, scratchpad, I_STAT/I_MASK, timers and clock
restored, and the timer interrupts again once time is live;
- fast-forward and backend status refuse the pass without running it;
- VRAM writes inside the rect are allowed, outside ones journaled once
(covered writes not twice, clipped to VRAM) and rolled back exactly,
newest first; a full journal or a mode that cannot journal refuses.
Mutations it catches: SPU or CD mis-classed, device service not frozen,
rollback order or entry skipped, I_STAT, timer, RAM or scratchpad restore
removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every pass backed up the display rect (hr colour + stencil and raw mirror
blits, CPU rows) although the previous pass of the same frame had just
restored exactly that backup. On Apple's OpenGL each blit ends the render
pass and submits a command buffer, which was about half of a pass's begin
time. A pass now reuses the previous pass's backup when render_pass.c
vouches that no guest code ran since its restore (same plan, guest clock and
store count unchanged) and the rect, scale and native-wide source match; the
first pass of every frame still takes a fresh backup.
R4 Grand Prix race, 240 Hz interpolated: average pass begin 0.28 -> 0.16 ms.
render_pass_stats reports backups_reused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Manual fast-forward presents every second VBlank, so the plan still saw
presents and kept rendering passes, spending host time the fast-forward
wants, although the API said turbo refuses them. main.cpp now records when
presentation is sped up (manual fast-forward, turbo-through-loads, FMV
auto-skip, TCP turbo) and the plan and passes refuse meanwhile.
psx_mod_render_pass_status() tells a plugin why passes cannot run, the
host-time budget aside: NO_PRESENTER, BACKEND (the renderer declines passes
in its current mode), DISABLED (after repeated faults), SESSION, FAST_FORWARD
or BUSY. The GL backend reports its part through
gl_renderer_pass_unavailable(), where a renderer mode that cannot host
passes reports BACKEND. psx_mod_set_frame_interpolation_blend() now also
takes effect when called from a hook after activation, so a plugin can show a
crossfade while passes are unavailable and return to HOLD afterwards.
Debug: PSX_RENDER_PASS_REFUSE=1 or TCP render_pass_refuse on=1 makes the
backend decline passes, to test a plugin's fallback; render_pass_stats
reports the status.
Default off: nothing reads the new flag or status unless a plugin uses
render passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pass_slot_cap() limits a generation to what two generations fit in 256 MiB,
but pass_gen_textures() allocated all 17 slots of each generation at
internal-resolution size as soon as passes ran, whatever the cap: 34
textures, about 167 MB at 4x and 1.5 GB at 12x for at most two used slots.
Slot textures are now made on first use, never past the cap, so the budget
holds at any internal scale; a size change frees the old set, and context
teardown frees every pass texture, backup and journal FBO and forgets their
names (a new context no longer reuses dead ones). render_pass_stats reports
image_textures and image_bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watchdog leaves a runaway pass by longjmp from inside guest code, which
skips the exits of every frame it leaves: generated functions' bb_defer
cleanup, overlay_loader_call_native's unit depth, the native-shard stack and
cycle-flush hook, the DMA kick's exec depth, and the dirty-RAM interpreter's
active/phase/precise flags, resume latch and pending load. The checkpoint
now saves all of it and the restore puts it back (bb_defer with the cycle
freeze), as the scheduler and exception landings do for theirs. An overlay
shard's unpublished pass cycles are flushed into the frozen clock at the
landing so they never reach the live one.
PSX_RENDER_PASS_VERIFY also checks that a pass which returned normally left
the nesting balanced, since the restore would hide that. render_pass_stats
gains nesting_repairs and the fault log names what the landing undid.
render_pass_abort_test aborts a pass from five nested frames (real
render_pass.c and psx_cycles.c, mock devices and presenter) and checks every
value, the clock and RAM; it fails without this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Publish an overlay DLL's pending cycles before a pass freezes the clock;
otherwise the pass's first store would publish them into the frozen clock
and the restore would drop them from the live timeline.
- The pass store policy moves into render_pass_plan.c as
render_pass_mmio_class() and gets a table test (SPU key-on, CD, timers,
SIO, MDEC and other DMA channels dropped; GP0, GP1 DMA mode/info, GPU and
OTC DMA, DICR/DPCR, I_STAT/I_MASK allowed).
- PSX_RENDER_PASS_VERIFY also hashes CD, SPU, SIO and MDEC state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frame_fingerprint reset_on_load=1 restarts the rolling hashes and the ring
when the next savestate load completes, so two runs that load the same state
(for example with and without a presentation mod) compare frame by frame
from the same guest point. Debug tooling only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
psx_mod_render_pass_plan() / psx_mod_render_pass() let a trusted plugin
redraw its game between two logic ticks with the game's own draw code. A
pass is a sandboxed nested guest call:
- time is frozen: cycles are counted (GTE and mult/div deadlines keep
working) but no device advances, no VBlank or device event fires and no
interrupt is delivered; GPU linked-list DMA and delayed completions finish
synchronously (psx_cycles freeze, interrupts, dma);
- stores go straight to RAM/scratchpad, bypassing code-page tracking,
overlay watch and write traces; MMIO is an allow-list (GP0, GP1 DMA mode /
info, GPU and OTC DMA, I_STAT/I_MASK) and SPU, CD, timer and other device
stores are dropped and counted (memory.c);
- afterwards CPU (with the GTE), RAM, scratchpad, I-cache tags, interrupt,
timer, DMA and GPU registers are restored exactly (render_pass.c, a GPU
register checkpoint without savestate side effects), with a guest-cycle
watchdog that rolls a runaway pass back;
- on OpenGL the declared display rect (hr colour + mask stencil, raw mirror,
native-wide band, CPU VRAM rows) is backed up, drawn, captured and rolled
back; writes outside it are journaled and restored, or refused.
The presenter keeps two generations of images (the game's own frame plus
one image per pass phase), promotes one when the flip-aware source sees the
display flip to its rect, and shows the newest image at each output
deadline's phase, crossfading across gaps when passes were shed. Presents
that fall due while passes run are made between them. The plan predicts the
output deadlines of the coming frame and sheds passes to fit a host-time
budget learnt from the presenter's idle time (PSX_RENDER_PASS_BUDGET).
PSX_MOD_FRAME_INTERPOLATION_HOLD repeats the newest frame where no pass
image applies, so a pass-driven title never shows anything later than stock.
Default off: nothing runs unless a plugin calls the API, and the plan
refuses in netplay, rollback, rewind, turbo, self-check resimulation, while
suspended (FMV) or without GL flip-aware interpolation. Pass writes are kept
out of frame_fingerprint. Debug: render_pass_stats, render_pass_dump,
PSX_RENDER_PASS_VERIFY=1 (state + VRAM hashes around every pass).
The freeze API lives in the runtime-only psx_cycle_freeze.h. psx_cycles.h is
part of the overlay codegen hash and stays byte-identical, so titles that
never use passes keep their overlay caches and savestates.
Tests: render_pass_plan_test (deadline-exact phases per rate, shedding,
selection), render_pass_freeze_test (10^6 frozen cycles, exact restore,
watchdog), render_pass_guards (sandbox choke points; the codegen-hashed headers stay
free of the pass API).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
interp_capture's FLIP decision moves into frame_flip_is_new_frame()
(frame_interpolation.c), unit-tested in frame_interpolation_schedule_test.
A new source guard, frame_interpolation_flip_source (recompiler ctest),
pins what no unit test reaches: main.cpp hands the session's source to the
renderer at every session start, right after its interpolation settings;
interp_capture feeds the origin and the redraw test to the decision and the
tracker and returns before the copy on a duplicate; both present paths pass
the display origin and present_dirty_test; a source change restarts the
history. Deleting the hand-off, or never returning on a duplicate, now fails
the guard (both passed every test before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>