From 161ef53b20274f459c1091c208e104893e9ff79b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 13:08:46 -0700 Subject: [PATCH 1/4] Add deterministic post-SPU PCM callbacks for trusted mods --- overlay_toolchain/include/mod_plugins.h | 303 +++++++++++++++++++- runtime/include/mod_packages.h | 2 + runtime/include/mod_plugins.h | 7 + runtime/include/spu.h | 3 + runtime/src/mod_packages.cpp | 24 +- runtime/src/mod_runtime.cpp | 19 ++ runtime/src/spu.c | 7 + runtime/tests/test_mod_runtime.cpp | 17 ++ runtime/tests/test_spu_end_without_repeat.c | 23 ++ 9 files changed, 400 insertions(+), 5 deletions(-) diff --git a/overlay_toolchain/include/mod_plugins.h b/overlay_toolchain/include/mod_plugins.h index 3c3422f4..5ff1224f 100644 --- a/overlay_toolchain/include/mod_plugins.h +++ b/overlay_toolchain/include/mod_plugins.h @@ -17,7 +17,15 @@ extern "C" { typedef void (*PSXModVBlankCallback)(void); typedef void (*PSXModActivationCallback)(void); +/* Emulation-thread stereo S16 at 44100 Hz, after SPU/shadow synthesis, before + * capture/host mute. Called for every rendered block, including discarded + * turbo/rollback output. Add with saturation; never call guest code here. + * Keep mutable cursors in allocated guest memory for save/rewind/rollback. + * Host audio/device state must not affect sample generation. */ +typedef void (*PSXModPCMCallback)(int16_t* stereo, uint32_t frames); struct CPUState; +/* A callback that runs guest code inside psx_mod_render_pass() can be + * abandoned by a watchdog longjmp; see the note at psx_mod_render_pass(). */ typedef void (*PSXModFunctionEntryCallback)(struct CPUState* cpu, uint32_t address); /* Return nonzero to finish this opt-in function with the callback's return @@ -34,6 +42,7 @@ int psx_mod_register_activation_plugin(const char* id, PSXModActivationCallback callback); int psx_mod_register_vblank_plugin(const char* id, PSXModVBlankCallback callback); +int psx_mod_register_pcm_plugin(const char* id, PSXModPCMCallback callback); /* Runs after successful guest-state restore, before the restored PC resumes. * Rebind host hooks here; do not advance guest gameplay as a VBlank would. */ int psx_mod_register_savestate_plugin(const char* id, @@ -91,6 +100,16 @@ void psx_mod_set_texture_filter(int mode); * Save/load and rewind must wait until that host context has returned. */ int psx_mod_function_entry_active(void); +/* [timing] guest_cycle_scale mod gate (docs/config_schema.md, Timing block). + * With guest_cycle_scale_gated = true in game.toml the scale applies only + * while a trusted plugin holds this gate open (and any declarative + * guest_cycle_scale_gate RAM predicates hold). Every mod session reset + * shuts it; open it from the activation callback. Mod plans are cleared + * online, so this gate keeps a scale that depends on a mod out of netplay. + * The gate state is part of savestates and the netplay rollback snapshot. */ +void psx_mod_set_guest_cycle_scale_gate(int open); +/* Live scale: the configured one while every gate is open, else 1. */ +uint32_t psx_mod_guest_cycle_scale(void); /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); /* Read an original mounted-disc file without changing guest CD state/timing. @@ -98,6 +117,9 @@ int psx_mod_game_started(void); * otherwise capacity must hold the entire file. Active sector mods apply. */ int psx_mod_read_disc_file(const char* path, void* buffer, uint32_t capacity, uint32_t* size); +/* Effective-disc LBA and byte size of a file (directory records patched by + * active mods apply, so a relocated file reports its new extent). */ +int psx_mod_disc_file_extent(const char* path, uint32_t* lba, uint32_t* size); /* Experimental retained-texture service (currently OpenGL only). IDs are * nonzero, stable game-owned identities, NOT GL names. Banks are immutable * 16-bit PS1 texels/indices with a caller-selected row pitch (width). @@ -135,6 +157,59 @@ void psx_mod_write_word(uint32_t address, uint32_t value); */ void psx_mod_write_code_word(uint32_t address, uint32_t value); +/* + * Services shared by seamless-loading adapters (resident disc data: see + * mod_resident.h). Emulation-thread callbacks only. + * + * Run a guest function to completion from a hook: a0..a3 and $ra are set, + * control returns when the guest reaches return_address, and the caller's + * GPRs, PC, HI and LO are restored. COP0, GTE and timing deadlines keep the + * callee's effects, as they would after an ordinary call. Snapshots wait + * until the call returns (the host stack holds the continuation). Returns v0. + */ +uint32_t psx_mod_call_guest(struct CPUState* cpu, uint32_t function, + uint32_t return_address, uint32_t a0, uint32_t a1, + uint32_t a2, uint32_t a3); +/* psx_mod_call_guest in zero guest time, for CPU/RAM work a game spends guest + * time on while it loads (allocators, decoders, table builds). Every RAM, + * register and device effect of the callee is kept, as if it executed in an + * instant at the call's guest cycle: none of its cycles are charged, no + * device advances and no interrupt is taken while it runs, and the caller's + * pending mult/div, GTE and load-pipeline timing is as it was before the + * call. Device work the callee starts (a DMA, a CD command) proceeds in guest + * time after the return. A callee that executes more than budget_cycles + * (0 = no limit) is charged in full from then on, so one that waits on a + * device still completes; *charged (optional) is then set to 1. Inside a + * render pass or another uncharged call, time is already frozen and this is + * psx_mod_call_guest. The outcome depends only on guest state, so netplay + * peers and rollback re-simulation agree. Returns v0. */ +uint32_t psx_mod_call_guest_uncharged(struct CPUState* cpu, uint32_t function, + uint32_t return_address, uint32_t a0, + uint32_t a1, uint32_t a2, uint32_t a3, + uint32_t budget_cycles, int* charged); +/* Deliver disc sectors into RAM exactly as a completed CD-ROM DMA would + * (overlay capture, executable-page invalidation, CD DMA log when lba >= 0). + * Word-aligned address and length. Returns 0 when the span leaves RAM. */ +int psx_mod_dma_write_ram(uint32_t address, const void* data, uint32_t bytes, + int lba); +/* Store bytes through the CPU store path (any alignment): for data the + * original code produces with CPU stores, e.g. a decompressor's output. */ +int psx_mod_host_write_ram(uint32_t address, const void* data, uint32_t bytes); +/* PsyQ SpuWrite by DMA, completed synchronously: transfer address, DMA-write + * transfer mode, the words through the SPU's DMA write path (address + * advance and sample-IRQ checks), then transfer mode stop when stop_after. + * spu_address 8-aligned, guest_source/bytes word aligned, at most 512 KiB; + * the transfer address wraps at the end of SPU RAM as on hardware. + * Library bookkeeping (transfer callbacks, busy flags) stays the caller's. */ +int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, + uint32_t bytes, int stop_after); +/* PsyQ LoadImage completed synchronously: texture-cache flush, GP0 A0h + * rectangle copy of w*h 16-bit pixels from guest RAM (provenance attributed + * per word), then GP1(04h) DMA direction CPU->GP0 for uploads the library + * would DMA (16 words or more). Caller drains earlier GPU work first. */ +int psx_mod_psyq_load_image(uint32_t x, uint32_t y, uint32_t w, uint32_t h, + uint32_t guest_source); + /* * Allocate opt-in enhancement memory from Expansion 1. Until the first * allocation, the region remains hardware-faithful open bus. The returned @@ -189,8 +264,12 @@ void psx_mod_set_native_wide_nclip_previous_site(uint32_t address, uint32_t expe void psx_mod_tag_hud_primitive(uint32_t primitive, int edge); /* Packet-guarded screen-space anchor: -1 left, +1 right, 0 stays centred. * Unlike the legacy role tag above, zero explicitly protects centred text - * from automatic layout transforms. Word immediately before the GP0 colour - * command; for a compound E1+SPRT packet this is its E1 word (P_TAG+4). */ + * from automatic layout transforms. Native-wide translates the group; + * projection-and-stretch squashes around the selected screen anchor. This + * works across separate DMA lists and is inert at native 4:3. Title code + * owns classification and must tag each completed packet. Pass the word + * immediately before the GP0 colour command; for a compound E1+SPRT packet + * this is its E1 word (P_TAG+4). */ void psx_mod_anchor_hud_primitive(uint32_t primitive, int edge); /* Screen-space masks identified by their title's producer, P_TAG addresses. * Flat panels extend only their exterior boundaries. Radial flat/Gouraud @@ -312,6 +391,22 @@ int psx_mod_current_resource_bytes(const char* resource_id, int psx_mod_append_disc_extent(const char* resource_id, uint64_t byte_offset, uint32_t sector_count, uint32_t* first_lba); +/* Optional CD-DA playlist, built only during activation. Audio has a separate + * TOC/timeline (track 1 is a data placeholder); data/XA LBAs are unchanged. + * Sources are verified immutable raw 2352-byte stereo PCM sectors, or audio + * tracks on the mounted disc. Playback, reports and saves use the native CD + * controller. No conversion, resampling or host clock is involved. */ +typedef struct PSXModCDDATrack { + const char* resource_id; /* non-NULL: verified external raw PCM */ + uint64_t byte_offset; + uint32_t sector_count; + uint32_t disc_track; /* resource_id == NULL: mounted audio track */ +} PSXModCDDATrack; +/* Append the whole group atomically. Both output arrays have count elements; + * failure leaves the previous playlist intact and clears all outputs. */ +int psx_mod_append_cdda_tracks(const PSXModCDDATrack* tracks, uint32_t count, + uint32_t* first_lbas, uint32_t* sector_counts); + /* Display aspects have no framework ceiling: the native-wide surfaces size * themselves from the live width, and each title caps its own view at what * it has validated (fixed ratio, or the adaptive maximum below). Requests @@ -333,6 +428,15 @@ int psx_mod_set_fixed_display_aspect(uint32_t numerator, */ int psx_mod_set_adaptive_display_aspect(uint32_t max_numerator, uint32_t max_denominator); +/* + * HUD size for the auto-UI widescreen HUD ([widescreen] auto_ui_squash): + * 0 keeps it at the display height's scale ("original"), 1 makes it + * proportional (unchanged up to 16:9, shrinking by sqrt((16:9) / aspect) + * beyond, about each widget's anchors). Overrides game.toml + * [widescreen] auto_ui_size for the session; every session start restores the + * title's setting before activation. + */ +int psx_mod_set_widescreen_hud_size(int proportional); /* * Set the wall-clock cadence of simulated guest VBlanks. A value of zero * removes frontend pacing; 60 and higher request that many native guest @@ -433,7 +537,14 @@ uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, uint32_t shown_after_vblanks, uint32_t* alpha_q16, uint32_t max); /* Returns 1 when the pass ran and its image was queued, 0 when it was refused - * or rolled back (state is restored either way). */ + * or rolled back (state is restored either way). + * A watchdog abort rolls the pass back by longjmp, past every frame between + * the watchdog and psx_mod_render_pass(): plugin callbacks, guest functions + * and function-entry hooks they called. Runtime nesting state (including the + * function-entry context) is restored, but plugin-owned state, held locks and + * C++ destructors in those frames are NOT unwound. A callback run inside a + * pass must keep nothing that needs cleanup (no locks, no RAII objects, no + * partially updated plugin state) across guest code, or tolerate the abort. */ int psx_mod_render_pass(struct CPUState* cpu, const PSXModRenderPass* pass, PSXModRenderPassFn fn, void* user); /* @@ -461,6 +572,108 @@ enum { PSX_MOD_RENDER_PASS_BUSY = 6 }; uint32_t psx_mod_render_pass_status(void); +/* Simultaneous stereo capture, independent of temporal interpolation. Each + * eye starts from the same guest state; CPU/RAM/devices/VRAM are restored + * before the other eye and on failure. Publish only after both succeed. + * The caller provides a draw-only callback at a main-thread frame boundary. + * period_vblanks (1..8) describes the game's draw cadence for whole-pair cost + * shedding. No alpha/time phase is used. Returns 1 for a published pair. */ +enum { PSX_MOD_EYE_LEFT = 0, PSX_MOD_EYE_RIGHT = 1 }; +typedef struct PSXModStereoFrame { + uint32_t struct_size; + uint32_t period_vblanks; + uint16_t x, y, w, h; +} PSXModStereoFrame; +typedef int (*PSXModStereoFn)(struct CPUState*, void*, uint32_t eye); +int psx_mod_render_stereo(struct CPUState *cpu, const PSXModStereoFrame *frame, + PSXModStereoFn fn, void *user); +uint32_t psx_mod_render_stereo_status(void); +/* 0 disables stereo output; 1 shows a complete pair side by side. Capture + * itself does not enable presentation. Default 0; applies to this session. */ +int psx_mod_set_stereo_presentation(uint32_t mode); +/* Camera-space addition to RT*V+TR before RTPS/RTPT perspective division. + * Valid only inside a render callback. Replaces (never accumulates) the host + * offset and is restored automatically on completion or watchdog abort. + * Units are the game's GTE camera units; IPD/world-scale calibration is game + * specific. Zero is faithful. Guest TR registers are not modified. */ +int psx_mod_render_view_offset(int32_t x, int32_t y, int32_t z); +/* Rigid camera transform after guest RT*V+TR, before division. Rotation is + * row-major Q12; translation uses camera units. Optional projection supplies + * focal lengths and centre deltas from guest OFX/OFY in Q16 pixel units. + * Identity rotation with projection=0 preserves the architectural path. + * projection=1 REPLACES the guest X/Y projection (guest H, widescreen squash and + * [video] fov_scale do not apply); projection_h_ref != 0 scales the focal lengths + * by (fov-scaled guest H)/ref. Valid only inside a render callback; restored with it. */ +typedef struct PSXModRenderView { + uint32_t struct_size; + int32_t rotation_q12[9], translation[3]; + uint32_t projection; + uint32_t projection_h_ref; /* 0 absolute FOV; otherwise scale focal lengths by guest H/ref */ + int32_t fx_q16, fy_q16, cx_delta_q16, cy_delta_q16; +} PSXModRenderView; +int psx_mod_render_view(const PSXModRenderView *view); +/* Begin locates both views at one predicted time. End submits only a fresh + * complete pair; failed/shed redraws submit zero layers. */ +int psx_mod_openxr_enable(int enabled); +int psx_mod_openxr_begin(uint32_t width, uint32_t height, double units_per_meter); +int psx_mod_openxr_view(uint32_t eye, PSXModRenderView *view); +int psx_mod_openxr_end(int pair_rendered); +/* Frame-local UI surface: the fresh pair's left image is shown to both eyes + * on a head-relative quad. Call after begin, outside the draw transaction. + * Dimensions and distance are meters; zero distance restores projection. + * Requests reset at begin/end and never affect faithful guest rendering. */ +int psx_mod_openxr_quad(double distance_m, double width_m, double height_m); +/* Opt-in native presentation surface for boot, videos and menus. Copies the + * freshly drawn desktop content before host overlays, without guest replay. + * Zero distance disables it. Applications disable it before scene begin and + * re-enable when their scene renderer is inactive. Dimensions are meters; + * height follows the presented content aspect. No retained stereo substitution. */ +int psx_mod_openxr_native_surface(double distance_m, double width_m, + double units_per_meter); +void psx_mod_openxr_recenter(void); +/* Fresh action sample at the offline input boundary, never in an eye replay. + * Positive Y is forward/up in XR. active[] refers only to thumbsticks; + * other actions have independent activity. Unavailable/unfocused actions + * return zero values. Click masks are active-high, unrelated to PSX pad bits. */ +#define PSX_MOD_XR_PRIMARY 1u /* left X / right A */ +#define PSX_MOD_XR_SECONDARY 2u /* left Y / right B */ +#define PSX_MOD_XR_MENU 4u /* Touch left Menu */ +#define PSX_MOD_XR_STICK 8u /* thumbstick click */ +#define PSX_MOD_XR_CLICKS 15u +typedef struct PSXModOpenXRInput { + uint32_t struct_size, focused, active[2], synthetic; + float stick[2][2]; + uint64_t sequence; + float trigger[2], squeeze[2]; /* [0,1] */ + uint32_t trigger_active[2], squeeze_active[2]; + uint32_t buttons[2], buttons_active[2]; /* PSX_MOD_XR_* masks */ +} PSXModOpenXRInput; +int psx_mod_openxr_input(PSXModOpenXRInput *input); + +/* Read-only controller snapshot from the latest located XR frame. Grip and aim + * share the eye poses' predicted time and LOCAL space; no action sync or locate + * occurs here, including during eye replay. Positions are meters; quaternions + * are x,y,z,w. The origin matches the rendered view's recenter basis. Consumers + * must check focus, activity, validity and age before using a cached pose. */ +enum { PSX_MOD_XR_GRIP_POSE = 0, PSX_MOD_XR_AIM_POSE = 1 }; +#define PSX_MOD_XR_ORIENTATION_VALID 1u +#define PSX_MOD_XR_POSITION_VALID 2u +#define PSX_MOD_XR_ORIENTATION_TRACKED 4u +#define PSX_MOD_XR_POSITION_TRACKED 8u +typedef struct PSXModTrackedPose { + uint32_t active, flags; + float position_m[3], orientation_xyzw[4]; +} PSXModTrackedPose; +typedef struct PSXModOpenXRHands { + uint32_t struct_size, focused, synthetic, origin_valid; + uint64_t sequence, predicted_time; + uint32_t age_ms; /* UINT32_MAX when no real frame has been located */ + double origin_position_m[3], origin_orientation_xyzw[4]; + PSXModTrackedPose pose[2][2]; /* [left/right][grip/aim] */ +} PSXModOpenXRHands; +int psx_mod_openxr_hands(PSXModOpenXRHands *hands); + + /* * When the game flips relative to the pass point. PENDING (default, reset at @@ -479,6 +692,10 @@ enum { PSX_MOD_RENDER_PASS_FLIP_SHOWN = 1 }; int psx_mod_set_render_pass_flip(uint32_t mode); +/* A trusted title may opt into presentation-only passes during forward + * netplay. Resimulation and lockstep replay still refuse them. The backend + * restores authoritative CPU VRAM as well as the captured display surface. */ +int psx_mod_set_render_pass_netplay(int enabled); /* * The per-frame plumbing every frame-rate plugin repeats (render_pass_frame.c). @@ -624,6 +841,86 @@ int psx_mod_set_controller_presentation_policy( uint32_t initial_mode, int config_capable); +/* ---- External offline input: ONE ordered resolution per player ---------- + * Two optional, mod-supplied inputs can act on an offline player's pad. The + * runtime resolves them in a fixed order in pad_external_input.h: + * 1. physical/local capture (keyboard, controllers: buttons, sticks, type) + * 2. offline controller source (psx_mod_set_controller_source): buttons = + * source AND physical; sticks/type come from the source, through the + * same mode override / multitap rule / presentation policy as a physical + * pad. A declined/invalid sample delivers neutral, not the last input. + * 3. local mouse policy (psx_mod_set_local_mouse_policy), P1 only: may + * override ONLY the right analog axes of the pad resolved by 1-2. It sees + * the final buttons/analog flag/right stick, so it composes with a + * source (a source never suppresses it) and no capture is taken to be + * discarded. A deflected source right stick, a digital pad, or Start + * held disables it exactly as for a physical pad; a source release, + * no device, or an armed input guard resets (releases) the capture. + * Gating. Neither input reaches the guest under netplay or rollback resim, + * selfcheck replay, headless, or a debug-server input override (which wins and + * is never mixed with external input). The mouse policy is additionally live + * only outside selfcheck input lock/resim, render passes, rewind, the + * savestate menu and the savestate input guard (pad_ext_live). A source is + * still sampled while the input guard is armed but its output is discarded + * (neutral). With no source and no mouse policy registered, pad bytes and + * host event handling are exactly the faithful defaults. + * + * ONE registration rule for both: call only on the main (emulation) thread; + * struct arguments are validated at registration (struct_size) and a bad one + * is rejected (return 0, logged to stderr); results of each callback are + * validated per use and fall back to neutral. These are mod-trusted APIs -- + * arbitrary native code, no caller check -- not launcher settings. A source + * is per player (NULL detaches, with one neutral release frame); the mouse + * policy is one per session (a second registration returns 0). Callbacks run + * on the main thread only: sources once per frame, in the normal offline + * sampler. + * The source's `analog` is the pad capability; the final type still goes + * through the multitap rule, mod mode override and presentation policy. + */ +typedef struct PSXModControllerState { + uint32_t struct_size, buttons, lx, ly, rx, ry, analog; +} PSXModControllerState; +typedef int (*PSXModControllerSource)(PSXModControllerState *state); +int psx_mod_set_controller_source(uint32_t player, PSXModControllerSource source); + +/* Local P1 mouse policy. The runtime delivers ordered events on the SDL owner + * (main) thread, owns relative capture and folds the resulting right-stick + * bytes after native input/presentation (and after any controller source), + * before normal SIO delivery. No SDL type, guest address, gesture, or game + * setting belongs in this interface. */ +enum { + PSX_MOD_MOUSE_HOLD_NONE = 0, + PSX_MOD_MOUSE_HOLD_RIGHT = 1, /* Mouse3 in keybinds.ini */ + PSX_MOD_MOUSE_HOLD_LEFT_ALT = 2, + PSX_MOD_MOUSE_RESET = 0, + PSX_MOD_MOUSE_ACQUIRED = 1, + PSX_MOD_MOUSE_MOTION = 2, + PSX_MOD_MOUSE_HOLD_PRESS = 3, + PSX_MOD_MOUSE_HOLD_RELEASE = 4 +}; +typedef struct PSXModMouseEvent { + uint32_t struct_size; + uint32_t type; + uint64_t time_ns; + double dx, dy; +} PSXModMouseEvent; +typedef struct PSXModMouseOutput { + uint32_t struct_size; + uint32_t override_right; + uint32_t rx, ry; +} PSXModMouseOutput; +typedef struct PSXModMousePolicy { + uint32_t struct_size; + uint32_t hold_control; + /* Re-read persistent guest context, including pause/menus, on every event + * and local sample. Must not infer it from a host Start toggle. */ + int (*eligible)(uint32_t native_buttons); + void (*event)(const PSXModMouseEvent* event); + /* A query: must not consume motion, change the event anchor or deadlines. */ + void (*sample)(uint64_t now_ns, PSXModMouseOutput* output); +} PSXModMousePolicy; +int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy); + /* * Register a C plugin before main() on the compilers supported by the runtime. * The registry itself uses function-local initialization, so constructor order diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index fb3811a9..97bcdbdd 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -646,6 +646,8 @@ bool mod_register_activation_plugin(const std::string& id, void (*callback)(void bool mod_register_session_reset_plugin(const std::string& id, void (*callback)(void)); void mod_reset_plugin_sessions(); bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); +bool mod_register_pcm_plugin(const std::string& id, PSXModPCMCallback callback); +PSXModPCMCallback mod_pcm_callback(const std::string& id); bool mod_register_savestate_plugin(const std::string& id, void (*callback)(void)); void mod_invoke_savestate_plugin(const std::string& id); /* A function-entry hook is a trusted implementation like the others: manifests diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 7e174645..1acf1716 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -10,6 +10,12 @@ extern "C" { typedef void (*PSXModVBlankCallback)(void); typedef void (*PSXModActivationCallback)(void); +/* Emulation-thread stereo S16 at 44100 Hz, after SPU/shadow synthesis, before + * capture/host mute. Called for every rendered block, including discarded + * turbo/rollback output. Add with saturation; never call guest code here. + * Keep mutable cursors in allocated guest memory for save/rewind/rollback. + * Host audio/device state must not affect sample generation. */ +typedef void (*PSXModPCMCallback)(int16_t* stereo, uint32_t frames); struct CPUState; /* A callback that runs guest code inside psx_mod_render_pass() can be * abandoned by a watchdog longjmp; see the note at psx_mod_render_pass(). */ @@ -34,6 +40,7 @@ int psx_mod_register_session_reset_plugin(const char* id, PSXModActivationCallback callback); int psx_mod_register_vblank_plugin(const char* id, PSXModVBlankCallback callback); +int psx_mod_register_pcm_plugin(const char* id, PSXModPCMCallback callback); /* Runs after successful guest-state restore, before the restored PC resumes. * Rebind host hooks here; do not advance guest gameplay as a VBlank would. */ int psx_mod_register_savestate_plugin(const char* id, diff --git a/runtime/include/spu.h b/runtime/include/spu.h index c05d0905..01dc37b9 100644 --- a/runtime/include/spu.h +++ b/runtime/include/spu.h @@ -200,6 +200,9 @@ int spu_shadow_tap_count(void); uint32_t spu_snapshot_bytes(void); int spu_snapshot_len_ok(uint32_t len); +/* Optional host-owned implementation, not serialized. The caller owns any + * mutable playback state and restores it with its guest snapshot. */ +void spu_set_pcm_overlay(void (*callback)(int16_t* stereo, uint32_t frames)); void spu_snapshot_write(uint8_t *p); int spu_snapshot_read(const uint8_t *p, uint32_t len); uint8_t *spu_get_ram_ptr(void); diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index acb32088..8ade5c30 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -45,6 +45,7 @@ struct RegisteredPlugin { PSXModActivationCallback activation = nullptr; PSXModActivationCallback session_reset = nullptr; PSXModVBlankCallback vblank = nullptr; + PSXModPCMCallback pcm = nullptr; /* Generated-function entry hooks, keyed by guest address. One id may * observe several functions. */ std::vector function_entries; @@ -1527,6 +1528,19 @@ bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) { return true; } +bool mod_register_pcm_plugin(const std::string& id, PSXModPCMCallback callback) { + if (!valid_id(id) || !callback) return false; + auto& plugin = registered_plugins()[id]; + if (plugin.pcm) return false; + plugin.pcm = callback; + return true; +} + +PSXModPCMCallback mod_pcm_callback(const std::string& id) { + const auto found = registered_plugins().find(id); + return found == registered_plugins().end() ? nullptr : found->second.pcm; +} + bool mod_register_function_entry_plugin(const std::string& id, uint32_t address, PSXModFunctionEntryCallback callback) { if (!valid_id(id) || !address || !callback) return false; @@ -1559,7 +1573,8 @@ bool mod_register_function_override_marker(const std::string& id) { bool mod_plugin_registered(const std::string& id) { const auto found = registered_plugins().find(id); return found != registered_plugins().end() && - (found->second.activation || found->second.session_reset || found->second.vblank || found->second.savestate || + (found->second.activation || found->second.session_reset || found->second.vblank || + found->second.savestate || found->second.pcm || !found->second.function_entries.empty() || !found->second.guest_functions.empty() || !found->second.instructions.empty() || found->second.function_override); } @@ -1657,7 +1672,8 @@ std::vector mod_registered_plugin_ids() { std::vector ids; for (const auto& entry : registered_plugins()) if (entry.second.activation || entry.second.session_reset || entry.second.vblank || - !entry.second.function_entries.empty() || !entry.second.guest_functions.empty()) + entry.second.pcm || entry.second.savestate || + !entry.second.function_entries.empty() || !entry.second.guest_functions.empty() || !entry.second.instructions.empty()) ids.push_back(entry.first); return ids; /* std::map keeps them sorted */ } @@ -4410,6 +4426,10 @@ extern "C" int psx_mod_register_vblank_plugin( PSXRecompV4::mod_register_vblank_plugin(id, callback) ? 1 : 0; } +extern "C" int psx_mod_register_pcm_plugin(const char* id, PSXModPCMCallback callback) { + return id && PSXRecompV4::mod_register_pcm_plugin(id, callback) ? 1 : 0; +} + extern "C" int psx_mod_register_activation_plugin( const char* id, PSXModActivationCallback callback) { return id && diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 6dc15a73..3ccb88fd 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2,6 +2,7 @@ #include "host_launch_timing.h" #include "host_overlay.h" #include "cpu_state.h" +#include "spu.h" #include "disc_path.h" #include "host_file_identity.h" @@ -308,6 +309,18 @@ std::vector& active_function_entry_hooks() { } unsigned function_entry_depth; +struct ActivePCMHook { + PSXModPCMCallback callback; + const ModResolution::Plugin* plugin; +}; +std::vector pcm_hooks; +void mix_plugin_pcm(int16_t* stereo, uint32_t frames) { + for (const auto& hook : pcm_hooks) { + PluginCallbackScope scope(state(), hook.plugin); + hook.callback(stereo, frames); + } +} + std::vector& active_guest_functions() { static std::vector value; return value; @@ -342,6 +355,8 @@ void update_function_entry_count() { } void clear_function_entry_hooks() { + spu_set_pcm_overlay(nullptr); + pcm_hooks.clear(); active_function_entry_hooks().clear(); update_function_entry_count(); active_guest_functions().clear(); @@ -377,6 +392,10 @@ void build_function_entry_hooks(const RuntimeMods& s) { std::stable_sort(instructions.begin(), instructions.end(), [](const auto& a, const auto& b) { return a.key < b.key; }); g_psx_mod_instruction_hooks = (uint32_t)instructions.size(); + for (const auto& plugin : s.plan.plugins) + if (auto callback = mod_pcm_callback(plugin.id)) + pcm_hooks.push_back({callback, &plugin}); + if (!pcm_hooks.empty()) spu_set_pcm_overlay(mix_plugin_pcm); } /* Function overrides queue here at constructor time and are ARMED into the diff --git a/runtime/src/spu.c b/runtime/src/spu.c index 4a4cdf05..df885392 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -1118,6 +1118,11 @@ void spu_init(void) { spu_output_flush(); } +static void (*pcm_overlay)(int16_t*, uint32_t); +void spu_set_pcm_overlay(void (*callback)(int16_t*, uint32_t)) { + pcm_overlay = callback; +} + static void spu_render_block(int16_t* out_stereo, int frames) { if (!out_stereo || frames <= 0) return; @@ -1248,6 +1253,7 @@ static void spu_render_block(int16_t* out_stereo, int frames) { last_peak = block_peak; if (block_peak > peak) peak = block_peak; spu_shadow_process(out_stereo, frames); + if (pcm_overlay) pcm_overlay(out_stereo, (uint32_t)frames); audio_trace_pcm(AUDIO_TAP_SPU_OUT, out_stereo, frames); return; } @@ -1442,6 +1448,7 @@ static void spu_render_block(int16_t* out_stereo, int frames) { * while proven. No-op (byte-identical) when disabled. The canon mix above * stays the authoritative output AND the verify oracle. */ spu_shadow_process(out_stereo, frames); + if (pcm_overlay) pcm_overlay(out_stereo, (uint32_t)frames); /* T1 tap: the SPU's final output block as handed to the host layer * (post-shadow, pre host fade/mute). Placed here so every spu_render diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 6d3c0359..28ee0315 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -50,6 +50,11 @@ static int plugin_calls; static int restore_calls; static int option_renderer_activations; static void option_renderer_activate(void) { ++option_renderer_activations; } +static PSXModPCMCallback pcm_callback; +extern "C" void spu_set_pcm_overlay(PSXModPCMCallback callback) { pcm_callback = callback; } +static void test_pcm(int16_t* samples, uint32_t frames) { + for (uint32_t i = 0; i < frames * 2; ++i) samples[i] += 12; +} static PsxLobbyMatchCaps test_match_caps; extern "C" const PsxLobbyMatchCaps* psx_lobby_match_caps(void) { return &test_match_caps; @@ -752,6 +757,12 @@ int main(int argc, char** argv) { std::string error; PSXRecompV4::mod_clear_plugins_for_tests(); + check(psx_mod_register_pcm_plugin("runtime.test-vblank", test_pcm) == 1, + "active PCM plugin registers"); + check(psx_mod_register_pcm_plugin("runtime.test-disabled-entry", test_pcm) == 1, + "disabled PCM plugin registers"); + check(!psx_mod_register_pcm_plugin("runtime.test-vblank", test_pcm), + "duplicate PCM callback is rejected"); check(PSXRecompV4::mod_register_activation_plugin( "runtime.test-vblank", test_activation_plugin), "runtime test activation hook must register"); @@ -854,6 +865,11 @@ int main(int argc, char** argv) { mod_runtime_on_vblank(); check(plugin_calls == 1, "resolved trusted plugin must run on guest VBlank"); + int16_t overlay_pcm[4] = {}; + check(pcm_callback != nullptr, "active plan binds the SPU overlay"); + if (pcm_callback) pcm_callback(overlay_pcm, 2); + check(overlay_pcm[0] == 12 && overlay_pcm[3] == 12, + "only active PCM plugins mix every stereo sample"); check(g_psx_mod_function_entry_hooks == 1, "activation must flatten exactly the active plan's entry hooks"); psx_mod_function_entry(&entry_cpu, 0x80003000u); @@ -900,6 +916,7 @@ int main(int argc, char** argv) { check(activation_calls == 2 && plugin_calls == 1 && g_psx_mod_function_entry_hooks == 0 && active_entry_hits == 4, "a netplay session must stay vanilla: no activation, VBlank or hook"); + check(!pcm_callback, "an empty session unbinds PCM overlays"); check(PSXRecompV4::mod_runtime_commit(cue_path, &error), error.c_str()); mod_runtime_activate_plugins(); check(activation_calls == 3, diff --git a/runtime/tests/test_spu_end_without_repeat.c b/runtime/tests/test_spu_end_without_repeat.c index 65d12a42..eafbeea7 100644 --- a/runtime/tests/test_spu_end_without_repeat.c +++ b/runtime/tests/test_spu_end_without_repeat.c @@ -129,9 +129,32 @@ static void test_end_with_repeat_keeps_playing(void) { "END with REPEAT records a loop event"); } +static uint32_t overlay_frames; +static void test_overlay(int16_t* stereo, uint32_t frames) { + overlay_frames += frames; + for (uint32_t i = 0; i < frames * 2; ++i) stereo[i] = 1234; +} +static void test_pcm_overlay_paths(void) { + int16_t output[14]; + spu_init(); + spu_set_pcm_overlay(test_overlay); + spu_render_block(output, 7); /* disabled SPU / general path */ + CHECK(overlay_frames == 7 && output[0] == 1234 && output[13] == 1234, + "overlay plays even with disabled native SPU"); + spu_regs[reg_index(0x1F801DAAu)] = 0x8000; + spu_render_block(output, 7); /* enabled SPU without voices / fast path */ + CHECK(overlay_frames == 14 && output[13] == 1234, + "voice-free SPU path also advances the overlay"); + spu_set_pcm_overlay(NULL); + spu_render_block(output, 7); + CHECK(overlay_frames == 14 && output[0] == 0 && output[13] == 0, + "unbinding preserves native silence without advancing playback"); +} + int main(void) { test_end_without_repeat_is_silent(); test_end_with_repeat_keeps_playing(); + test_pcm_overlay_paths(); printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); return failures ? 1 : 0; } From f29503ffcd7bfe3e79d963f3ad1b9ad029ed7423 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 14:31:31 -0700 Subject: [PATCH 2/4] Poll staged user snapshots before interrupt maintenance fast returns --- runtime/include/savestate.h | 4 ++++ runtime/src/interrupts.c | 16 +++++++++++++++ runtime/src/savestate.c | 22 +++++++++++++++++--- runtime/tests/test_host_snapshot_guard.c | 26 ++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/runtime/include/savestate.h b/runtime/include/savestate.h index 276127b1..665d9ab5 100644 --- a/runtime/include/savestate.h +++ b/runtime/include/savestate.h @@ -126,6 +126,10 @@ int savestate_request_load_blob_protocol(const void* data, size_t size); /* 1 while a staged save/load has not yet been consumed by savestate_poll. */ int savestate_pending(void); +/* Read-only hot-path hint for interrupt maintenance. Updated with staged + * requests; actual execution still passes every savestate_poll safety gate. */ +extern int g_psx_savestate_pending; + /* Machine-readable lifecycle receipt for deterministic debug harnesses. */ void savestate_status_json(char* buf, size_t cap); typedef struct PSXSavestateStatus { diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index c229733b..0ea08733 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -27,6 +27,7 @@ #include "interrupts.h" #include "mod_runtime.h" +#include "savestate.h" #include "psx_memory.h" #include "sio.h" #include "timers.h" @@ -1118,6 +1119,15 @@ int psx_interrupt_delivery_needed(const CPUState* cpu) { return 1; } +static void poll_staged_user_snapshot(CPUState* cpu) { + extern uint32_t g_dirty_safe_resume_pc; + if (!in_exception && g_psx_savestate_pending) { + uint32_t pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + savestate_poll(cpu, pc); + } +} + void psx_check_interrupts(CPUState* cpu) { /* Frozen guest time (psx_cycle_freeze.h): no delivery, and no * check-boundary bookkeeping that the stock timeline would not do. */ @@ -1197,6 +1207,12 @@ void psx_check_interrupts(CPUState* cpu) { } } + /* Quiet native/HLE gameplay may execute far fewer than 0x4000 BB checks + * in a frame. A staged user snapshot must not wait for that amortized + * maintenance counter: short actions/voices can finish before capture. + * Keep exception, host-adapter and resume-PC gates in savestate_poll, and + * retain the canonical netplay edge gate above. Idle work stays amortized. */ + poll_staged_user_snapshot(cpu); s_irq_path_entry++; /* MotK VLC / FMV hot edge: sticky unmasked I_STAT (CD/VBlank) while diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index 4ea99231..a939b4e6 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -61,10 +61,13 @@ static int s_content_scope_valid = 1; static char s_content_token[71]; /* "_mods_" + complete SHA-256 + NUL */ static int s_save_pending = -1; /* slot, or -1 */ static int s_load_pending = -1; +int g_psx_savestate_pending = 0; static int s_load_completed = 0; static int s_load_failed = 0; static uint32_t s_status_generation = 0; static int s_status_pending = 0; +static uint32_t s_status_polls; +static const char* s_status_wait = ""; static int s_status_last_ok = 0; static int s_status_last_load = 0; static int s_status_last_slot = -1; @@ -788,6 +791,9 @@ static int request_save_inner(int slot) { s_last_save_pc = 0; /* block netplay transfer until this write stamps a PC */ s_save_defer_slot = -1; s_save_pending = slot; + s_status_polls = 0; + s_status_wait = "boundary"; + g_psx_savestate_pending = 1; s_status_pending = 1; s_status_last_load = 0; s_status_last_slot = slot; @@ -814,6 +820,9 @@ static int request_load_inner(int slot) { s_load_failed = 0; s_load_completed = 0; s_load_pending = slot; + s_status_polls = 0; + s_status_wait = "boundary"; + g_psx_savestate_pending = 1; s_status_pending = 1; s_status_last_load = 1; s_status_last_slot = slot; @@ -866,6 +875,7 @@ int savestate_request_load_blob_protocol(const void* data, size_t size) { s_load_failed = 0; s_load_completed = 0; s_load_pending = 0; /* non-negative: poll will prefer the blob */ + g_psx_savestate_pending = 1; return 1; } @@ -886,10 +896,11 @@ void savestate_status_json(char* buf, size_t cap) { if (!buf || cap == 0) return; snprintf(buf, cap, "\"generation\":%u,\"pending\":%d,\"last_ok\":%d," - "\"last_op\":\"%s\",\"last_slot\":%d", + "\"last_op\":\"%s\",\"last_slot\":%d," + "\"polls\":%u,\"wait\":\"%s\"", (unsigned)s_status_generation, s_status_pending, s_status_last_ok, s_status_last_load ? "load" : "save", - s_status_last_slot); + s_status_last_slot, s_status_polls, s_status_pending ? s_status_wait : ""); } int savestate_take_load_completed(void) { @@ -917,7 +928,9 @@ uint32_t savestate_last_save_pc(void) { void savestate_poll(CPUState* cpu, uint32_t resume_pc) { if (s_save_pending < 0 && s_load_pending < 0) return; /* hot path: nothing staged */ extern int psx_mod_function_entry_active(void); - if (psx_mod_function_entry_active()) return; + ++s_status_polls; + if (psx_mod_function_entry_active()) { s_status_wait = "mod_callback"; return; } + s_status_wait = "resume_pc"; if (s_save_pending >= 0) { int slot = s_save_pending; @@ -950,6 +963,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { if (now - s_save_defer_t0 < 2000.0) return; s_save_pending = -1; + g_psx_savestate_pending = s_load_pending >= 0; s_save_defer_slot = -1; s_last_save_pc = 0; s_save_failed = 1; @@ -970,6 +984,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { psx_frontend_on_savestate_notify(0, slot, 0); } else { s_save_pending = -1; + g_psx_savestate_pending = s_load_pending >= 0; s_save_defer_slot = -1; if (pc != resume_pc && resume_pc == 0u) { fprintf(stderr, @@ -1018,6 +1033,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { (s_load_blob && s_load_blob_len > 0) ? "blob" : "file"; #endif s_load_pending = -1; + g_psx_savestate_pending = s_save_pending >= 0; char path[600]; const double t_load0 = savestate_mono_ms(); double t_after_boot = t_load0; diff --git a/runtime/tests/test_host_snapshot_guard.c b/runtime/tests/test_host_snapshot_guard.c index 970ceeef..de9a7b20 100644 --- a/runtime/tests/test_host_snapshot_guard.c +++ b/runtime/tests/test_host_snapshot_guard.c @@ -5,6 +5,12 @@ int g_cosim_dirty_pump_site; int g_call_unit_depth; int g_psx_dispatch_depth; uint32_t g_dirty_safe_resume_pc; +int g_psx_savestate_pending; +static unsigned snapshot_polls; +static uint32_t snapshot_pc; +void savestate_poll(CPUState* cpu, uint32_t pc) { + (void)cpu; ++snapshot_polls; snapshot_pc = pc; +} static void check(int ok, const char* message) { if (!ok) { fprintf(stderr, "FAIL: %s\n", message); exit(1); } @@ -12,6 +18,26 @@ static void check(int ok, const char* message) { int main(void) { uint32_t pc = 0x80025070u; + CPUState cpu = {0}; + s_compiled_interrupt_resume_pc = pc; + poll_staged_user_snapshot(&cpu); + check(snapshot_polls == 0, "idle snapshot work stays amortized"); + g_psx_savestate_pending = 1; + poll_staged_user_snapshot(&cpu); + check(snapshot_polls == 1 && snapshot_pc == pc, + "staged request reaches the next native boundary"); + in_exception = 1; + poll_staged_user_snapshot(&cpu); + check(snapshot_polls == 1, "exception cannot service a user snapshot"); + in_exception = 0; + g_dirty_safe_resume_pc = pc + 4; + poll_staged_user_snapshot(&cpu); + check(snapshot_polls == 2 && snapshot_pc == pc + 4, + "staged dirty-code request uses its own resume boundary"); + g_psx_savestate_pending = 0; + poll_staged_user_snapshot(&cpu); + check(snapshot_polls == 2, "completed request stops urgent polling"); + g_dirty_safe_resume_pc = 0; check(psx_irq_resume_context_snapshot_safe_at(pc), "native boundary admitted"); psx_snapshot_host_call_begin(); psx_snapshot_host_call_begin(); From 8c87b03241a697d2fdabdd9996347b8e8b4e599c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 23:52:08 -0700 Subject: [PATCH 3/4] Verify mod PCM callbacks follow the guest SPU clock --- runtime/tests/test_spu_guest_clock.c | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/runtime/tests/test_spu_guest_clock.c b/runtime/tests/test_spu_guest_clock.c index cf6eb6ff..16c8e1fc 100644 --- a/runtime/tests/test_spu_guest_clock.c +++ b/runtime/tests/test_spu_guest_clock.c @@ -122,10 +122,51 @@ static void test_gate_and_rewind(void) { "a rewound guest clock re-anchors without rendering"); } +static uint32_t pcm_frames; +static void pcm_overlay_test(int16_t* stereo, uint32_t frames) { + pcm_frames += frames; + for (uint32_t i = 0; i < frames * 2u; ++i) stereo[i] = 1234; +} + +static void test_pcm_overlay_uses_guest_clock(void) { + g_cycle = 0; + spu_init(); + spu_set_pcm_overlay(pcm_overlay_test); + spu_catch_up(); + g_cycle += 768u * 3u; + spu_catch_up(); + CHECK(pcm_frames == 3u && spu_output_available() == 3u, + "PCM overlay advances with guest samples while the native SPU is off"); + int16_t output[8]; + CHECK(spu_output_pop(output, 4u) == 3u && output[0] == 1234 && + output[5] == 1234 && pcm_frames == 3u, + "host output consumption neither skips nor advances mod playback"); + spu_set_output_gate(suppressed); + s_suppress = 1; + g_cycle += 768u * 2u; + spu_catch_up(); + CHECK(pcm_frames == 5u && spu_output_available() == 0u, + "suppressed output still advances deterministic mod PCM state"); + s_suppress = 0; + spu_set_output_gate(NULL); + spu_write(0x1F801DAAu, 0x8000u); + g_cycle += 768u * 4u; + spu_catch_up(); + CHECK(pcm_frames == 9u && spu_output_available() == 4u, + "native voice-free fast path advances the same mod PCM clock"); + spu_set_pcm_overlay(NULL); + spu_output_flush(); + g_cycle += 768u; + spu_catch_up(); + CHECK(pcm_frames == 9u && spu_output_pop(output, 4u) == 1u && output[0] == 0, + "unbinding restores native silence without advancing mod playback"); +} + int main(void) { test_envelope_rises_between_accesses(); test_snapshot_keeps_clock_phase(); test_gate_and_rewind(); + test_pcm_overlay_uses_guest_clock(); if (failures) { fprintf(stderr, "%d failure(s)\n", failures); return 1; } puts("spu_guest_clock_test: ok"); return 0; From 97c9aa4018e57b014cfcb5f0976d0624598cae6a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 25 Oct 2026 00:12:00 -0700 Subject: [PATCH 4/4] Keep overlay PCM declarations scoped to the new API --- overlay_toolchain/include/mod_plugins.h | 300 +----------------------- 1 file changed, 5 insertions(+), 295 deletions(-) diff --git a/overlay_toolchain/include/mod_plugins.h b/overlay_toolchain/include/mod_plugins.h index 5ff1224f..85f0b47f 100644 --- a/overlay_toolchain/include/mod_plugins.h +++ b/overlay_toolchain/include/mod_plugins.h @@ -24,8 +24,6 @@ typedef void (*PSXModActivationCallback)(void); * Host audio/device state must not affect sample generation. */ typedef void (*PSXModPCMCallback)(int16_t* stereo, uint32_t frames); struct CPUState; -/* A callback that runs guest code inside psx_mod_render_pass() can be - * abandoned by a watchdog longjmp; see the note at psx_mod_render_pass(). */ typedef void (*PSXModFunctionEntryCallback)(struct CPUState* cpu, uint32_t address); /* Return nonzero to finish this opt-in function with the callback's return @@ -100,16 +98,6 @@ void psx_mod_set_texture_filter(int mode); * Save/load and rewind must wait until that host context has returned. */ int psx_mod_function_entry_active(void); -/* [timing] guest_cycle_scale mod gate (docs/config_schema.md, Timing block). - * With guest_cycle_scale_gated = true in game.toml the scale applies only - * while a trusted plugin holds this gate open (and any declarative - * guest_cycle_scale_gate RAM predicates hold). Every mod session reset - * shuts it; open it from the activation callback. Mod plans are cleared - * online, so this gate keeps a scale that depends on a mod out of netplay. - * The gate state is part of savestates and the netplay rollback snapshot. */ -void psx_mod_set_guest_cycle_scale_gate(int open); -/* Live scale: the configured one while every gate is open, else 1. */ -uint32_t psx_mod_guest_cycle_scale(void); /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); /* Read an original mounted-disc file without changing guest CD state/timing. @@ -117,9 +105,6 @@ int psx_mod_game_started(void); * otherwise capacity must hold the entire file. Active sector mods apply. */ int psx_mod_read_disc_file(const char* path, void* buffer, uint32_t capacity, uint32_t* size); -/* Effective-disc LBA and byte size of a file (directory records patched by - * active mods apply, so a relocated file reports its new extent). */ -int psx_mod_disc_file_extent(const char* path, uint32_t* lba, uint32_t* size); /* Experimental retained-texture service (currently OpenGL only). IDs are * nonzero, stable game-owned identities, NOT GL names. Banks are immutable * 16-bit PS1 texels/indices with a caller-selected row pitch (width). @@ -157,59 +142,6 @@ void psx_mod_write_word(uint32_t address, uint32_t value); */ void psx_mod_write_code_word(uint32_t address, uint32_t value); -/* - * Services shared by seamless-loading adapters (resident disc data: see - * mod_resident.h). Emulation-thread callbacks only. - * - * Run a guest function to completion from a hook: a0..a3 and $ra are set, - * control returns when the guest reaches return_address, and the caller's - * GPRs, PC, HI and LO are restored. COP0, GTE and timing deadlines keep the - * callee's effects, as they would after an ordinary call. Snapshots wait - * until the call returns (the host stack holds the continuation). Returns v0. - */ -uint32_t psx_mod_call_guest(struct CPUState* cpu, uint32_t function, - uint32_t return_address, uint32_t a0, uint32_t a1, - uint32_t a2, uint32_t a3); -/* psx_mod_call_guest in zero guest time, for CPU/RAM work a game spends guest - * time on while it loads (allocators, decoders, table builds). Every RAM, - * register and device effect of the callee is kept, as if it executed in an - * instant at the call's guest cycle: none of its cycles are charged, no - * device advances and no interrupt is taken while it runs, and the caller's - * pending mult/div, GTE and load-pipeline timing is as it was before the - * call. Device work the callee starts (a DMA, a CD command) proceeds in guest - * time after the return. A callee that executes more than budget_cycles - * (0 = no limit) is charged in full from then on, so one that waits on a - * device still completes; *charged (optional) is then set to 1. Inside a - * render pass or another uncharged call, time is already frozen and this is - * psx_mod_call_guest. The outcome depends only on guest state, so netplay - * peers and rollback re-simulation agree. Returns v0. */ -uint32_t psx_mod_call_guest_uncharged(struct CPUState* cpu, uint32_t function, - uint32_t return_address, uint32_t a0, - uint32_t a1, uint32_t a2, uint32_t a3, - uint32_t budget_cycles, int* charged); -/* Deliver disc sectors into RAM exactly as a completed CD-ROM DMA would - * (overlay capture, executable-page invalidation, CD DMA log when lba >= 0). - * Word-aligned address and length. Returns 0 when the span leaves RAM. */ -int psx_mod_dma_write_ram(uint32_t address, const void* data, uint32_t bytes, - int lba); -/* Store bytes through the CPU store path (any alignment): for data the - * original code produces with CPU stores, e.g. a decompressor's output. */ -int psx_mod_host_write_ram(uint32_t address, const void* data, uint32_t bytes); -/* PsyQ SpuWrite by DMA, completed synchronously: transfer address, DMA-write - * transfer mode, the words through the SPU's DMA write path (address - * advance and sample-IRQ checks), then transfer mode stop when stop_after. - * spu_address 8-aligned, guest_source/bytes word aligned, at most 512 KiB; - * the transfer address wraps at the end of SPU RAM as on hardware. - * Library bookkeeping (transfer callbacks, busy flags) stays the caller's. */ -int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, - uint32_t bytes, int stop_after); -/* PsyQ LoadImage completed synchronously: texture-cache flush, GP0 A0h - * rectangle copy of w*h 16-bit pixels from guest RAM (provenance attributed - * per word), then GP1(04h) DMA direction CPU->GP0 for uploads the library - * would DMA (16 words or more). Caller drains earlier GPU work first. */ -int psx_mod_psyq_load_image(uint32_t x, uint32_t y, uint32_t w, uint32_t h, - uint32_t guest_source); - /* * Allocate opt-in enhancement memory from Expansion 1. Until the first * allocation, the region remains hardware-faithful open bus. The returned @@ -264,12 +196,8 @@ void psx_mod_set_native_wide_nclip_previous_site(uint32_t address, uint32_t expe void psx_mod_tag_hud_primitive(uint32_t primitive, int edge); /* Packet-guarded screen-space anchor: -1 left, +1 right, 0 stays centred. * Unlike the legacy role tag above, zero explicitly protects centred text - * from automatic layout transforms. Native-wide translates the group; - * projection-and-stretch squashes around the selected screen anchor. This - * works across separate DMA lists and is inert at native 4:3. Title code - * owns classification and must tag each completed packet. Pass the word - * immediately before the GP0 colour command; for a compound E1+SPRT packet - * this is its E1 word (P_TAG+4). */ + * from automatic layout transforms. Word immediately before the GP0 colour + * command; for a compound E1+SPRT packet this is its E1 word (P_TAG+4). */ void psx_mod_anchor_hud_primitive(uint32_t primitive, int edge); /* Screen-space masks identified by their title's producer, P_TAG addresses. * Flat panels extend only their exterior boundaries. Radial flat/Gouraud @@ -341,14 +269,14 @@ void psx_mod_set_world_scene_predicate(PSXModWorldScenePredicate predicate); * player left it in the launcher (or the manifest default when untouched). * Writes a NUL-terminated string into `out` and returns 1; returns 0 with * out[0] = '\0' when the plan is not committed, the ids do not resolve, or the - * value does not fit — the caller then applies its own default rather than + * value does not fit — the caller then applies its own default rather than * treating an empty string as a selection. * * Why this exists: the manifest schema already carries typed, validated, * launcher-rendered, persisted options ([[option]] boolean/choice/integer), but * an activation callback takes no arguments and had no way to read them, so a * trusted plugin could only ever be an on/off switch. A parameterised feature - * then had to be modelled as one feature per value — and `constraint` only + * then had to be modelled as one feature per value — and `constraint` only * expresses ordered_integer WITHIN a feature, so those pseudo-features could * not even be made mutually exclusive. This closes that gap: one feature, one * option, the plugin reads what was chosen. @@ -391,22 +319,6 @@ int psx_mod_current_resource_bytes(const char* resource_id, int psx_mod_append_disc_extent(const char* resource_id, uint64_t byte_offset, uint32_t sector_count, uint32_t* first_lba); -/* Optional CD-DA playlist, built only during activation. Audio has a separate - * TOC/timeline (track 1 is a data placeholder); data/XA LBAs are unchanged. - * Sources are verified immutable raw 2352-byte stereo PCM sectors, or audio - * tracks on the mounted disc. Playback, reports and saves use the native CD - * controller. No conversion, resampling or host clock is involved. */ -typedef struct PSXModCDDATrack { - const char* resource_id; /* non-NULL: verified external raw PCM */ - uint64_t byte_offset; - uint32_t sector_count; - uint32_t disc_track; /* resource_id == NULL: mounted audio track */ -} PSXModCDDATrack; -/* Append the whole group atomically. Both output arrays have count elements; - * failure leaves the previous playlist intact and clears all outputs. */ -int psx_mod_append_cdda_tracks(const PSXModCDDATrack* tracks, uint32_t count, - uint32_t* first_lbas, uint32_t* sector_counts); - /* Display aspects have no framework ceiling: the native-wide surfaces size * themselves from the live width, and each title caps its own view at what * it has validated (fixed ratio, or the adaptive maximum below). Requests @@ -428,15 +340,6 @@ int psx_mod_set_fixed_display_aspect(uint32_t numerator, */ int psx_mod_set_adaptive_display_aspect(uint32_t max_numerator, uint32_t max_denominator); -/* - * HUD size for the auto-UI widescreen HUD ([widescreen] auto_ui_squash): - * 0 keeps it at the display height's scale ("original"), 1 makes it - * proportional (unchanged up to 16:9, shrinking by sqrt((16:9) / aspect) - * beyond, about each widget's anchors). Overrides game.toml - * [widescreen] auto_ui_size for the session; every session start restores the - * title's setting before activation. - */ -int psx_mod_set_widescreen_hud_size(int proportional); /* * Set the wall-clock cadence of simulated guest VBlanks. A value of zero * removes frontend pacing; 60 and higher request that many native guest @@ -537,14 +440,7 @@ uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, uint32_t shown_after_vblanks, uint32_t* alpha_q16, uint32_t max); /* Returns 1 when the pass ran and its image was queued, 0 when it was refused - * or rolled back (state is restored either way). - * A watchdog abort rolls the pass back by longjmp, past every frame between - * the watchdog and psx_mod_render_pass(): plugin callbacks, guest functions - * and function-entry hooks they called. Runtime nesting state (including the - * function-entry context) is restored, but plugin-owned state, held locks and - * C++ destructors in those frames are NOT unwound. A callback run inside a - * pass must keep nothing that needs cleanup (no locks, no RAII objects, no - * partially updated plugin state) across guest code, or tolerate the abort. */ + * or rolled back (state is restored either way). */ int psx_mod_render_pass(struct CPUState* cpu, const PSXModRenderPass* pass, PSXModRenderPassFn fn, void* user); /* @@ -572,108 +468,6 @@ enum { PSX_MOD_RENDER_PASS_BUSY = 6 }; uint32_t psx_mod_render_pass_status(void); -/* Simultaneous stereo capture, independent of temporal interpolation. Each - * eye starts from the same guest state; CPU/RAM/devices/VRAM are restored - * before the other eye and on failure. Publish only after both succeed. - * The caller provides a draw-only callback at a main-thread frame boundary. - * period_vblanks (1..8) describes the game's draw cadence for whole-pair cost - * shedding. No alpha/time phase is used. Returns 1 for a published pair. */ -enum { PSX_MOD_EYE_LEFT = 0, PSX_MOD_EYE_RIGHT = 1 }; -typedef struct PSXModStereoFrame { - uint32_t struct_size; - uint32_t period_vblanks; - uint16_t x, y, w, h; -} PSXModStereoFrame; -typedef int (*PSXModStereoFn)(struct CPUState*, void*, uint32_t eye); -int psx_mod_render_stereo(struct CPUState *cpu, const PSXModStereoFrame *frame, - PSXModStereoFn fn, void *user); -uint32_t psx_mod_render_stereo_status(void); -/* 0 disables stereo output; 1 shows a complete pair side by side. Capture - * itself does not enable presentation. Default 0; applies to this session. */ -int psx_mod_set_stereo_presentation(uint32_t mode); -/* Camera-space addition to RT*V+TR before RTPS/RTPT perspective division. - * Valid only inside a render callback. Replaces (never accumulates) the host - * offset and is restored automatically on completion or watchdog abort. - * Units are the game's GTE camera units; IPD/world-scale calibration is game - * specific. Zero is faithful. Guest TR registers are not modified. */ -int psx_mod_render_view_offset(int32_t x, int32_t y, int32_t z); -/* Rigid camera transform after guest RT*V+TR, before division. Rotation is - * row-major Q12; translation uses camera units. Optional projection supplies - * focal lengths and centre deltas from guest OFX/OFY in Q16 pixel units. - * Identity rotation with projection=0 preserves the architectural path. - * projection=1 REPLACES the guest X/Y projection (guest H, widescreen squash and - * [video] fov_scale do not apply); projection_h_ref != 0 scales the focal lengths - * by (fov-scaled guest H)/ref. Valid only inside a render callback; restored with it. */ -typedef struct PSXModRenderView { - uint32_t struct_size; - int32_t rotation_q12[9], translation[3]; - uint32_t projection; - uint32_t projection_h_ref; /* 0 absolute FOV; otherwise scale focal lengths by guest H/ref */ - int32_t fx_q16, fy_q16, cx_delta_q16, cy_delta_q16; -} PSXModRenderView; -int psx_mod_render_view(const PSXModRenderView *view); -/* Begin locates both views at one predicted time. End submits only a fresh - * complete pair; failed/shed redraws submit zero layers. */ -int psx_mod_openxr_enable(int enabled); -int psx_mod_openxr_begin(uint32_t width, uint32_t height, double units_per_meter); -int psx_mod_openxr_view(uint32_t eye, PSXModRenderView *view); -int psx_mod_openxr_end(int pair_rendered); -/* Frame-local UI surface: the fresh pair's left image is shown to both eyes - * on a head-relative quad. Call after begin, outside the draw transaction. - * Dimensions and distance are meters; zero distance restores projection. - * Requests reset at begin/end and never affect faithful guest rendering. */ -int psx_mod_openxr_quad(double distance_m, double width_m, double height_m); -/* Opt-in native presentation surface for boot, videos and menus. Copies the - * freshly drawn desktop content before host overlays, without guest replay. - * Zero distance disables it. Applications disable it before scene begin and - * re-enable when their scene renderer is inactive. Dimensions are meters; - * height follows the presented content aspect. No retained stereo substitution. */ -int psx_mod_openxr_native_surface(double distance_m, double width_m, - double units_per_meter); -void psx_mod_openxr_recenter(void); -/* Fresh action sample at the offline input boundary, never in an eye replay. - * Positive Y is forward/up in XR. active[] refers only to thumbsticks; - * other actions have independent activity. Unavailable/unfocused actions - * return zero values. Click masks are active-high, unrelated to PSX pad bits. */ -#define PSX_MOD_XR_PRIMARY 1u /* left X / right A */ -#define PSX_MOD_XR_SECONDARY 2u /* left Y / right B */ -#define PSX_MOD_XR_MENU 4u /* Touch left Menu */ -#define PSX_MOD_XR_STICK 8u /* thumbstick click */ -#define PSX_MOD_XR_CLICKS 15u -typedef struct PSXModOpenXRInput { - uint32_t struct_size, focused, active[2], synthetic; - float stick[2][2]; - uint64_t sequence; - float trigger[2], squeeze[2]; /* [0,1] */ - uint32_t trigger_active[2], squeeze_active[2]; - uint32_t buttons[2], buttons_active[2]; /* PSX_MOD_XR_* masks */ -} PSXModOpenXRInput; -int psx_mod_openxr_input(PSXModOpenXRInput *input); - -/* Read-only controller snapshot from the latest located XR frame. Grip and aim - * share the eye poses' predicted time and LOCAL space; no action sync or locate - * occurs here, including during eye replay. Positions are meters; quaternions - * are x,y,z,w. The origin matches the rendered view's recenter basis. Consumers - * must check focus, activity, validity and age before using a cached pose. */ -enum { PSX_MOD_XR_GRIP_POSE = 0, PSX_MOD_XR_AIM_POSE = 1 }; -#define PSX_MOD_XR_ORIENTATION_VALID 1u -#define PSX_MOD_XR_POSITION_VALID 2u -#define PSX_MOD_XR_ORIENTATION_TRACKED 4u -#define PSX_MOD_XR_POSITION_TRACKED 8u -typedef struct PSXModTrackedPose { - uint32_t active, flags; - float position_m[3], orientation_xyzw[4]; -} PSXModTrackedPose; -typedef struct PSXModOpenXRHands { - uint32_t struct_size, focused, synthetic, origin_valid; - uint64_t sequence, predicted_time; - uint32_t age_ms; /* UINT32_MAX when no real frame has been located */ - double origin_position_m[3], origin_orientation_xyzw[4]; - PSXModTrackedPose pose[2][2]; /* [left/right][grip/aim] */ -} PSXModOpenXRHands; -int psx_mod_openxr_hands(PSXModOpenXRHands *hands); - - /* * When the game flips relative to the pass point. PENDING (default, reset at @@ -692,10 +486,6 @@ enum { PSX_MOD_RENDER_PASS_FLIP_SHOWN = 1 }; int psx_mod_set_render_pass_flip(uint32_t mode); -/* A trusted title may opt into presentation-only passes during forward - * netplay. Resimulation and lockstep replay still refuse them. The backend - * restores authoritative CPU VRAM as well as the captured display surface. */ -int psx_mod_set_render_pass_netplay(int enabled); /* * The per-frame plumbing every frame-rate plugin repeats (render_pass_frame.c). @@ -841,86 +631,6 @@ int psx_mod_set_controller_presentation_policy( uint32_t initial_mode, int config_capable); -/* ---- External offline input: ONE ordered resolution per player ---------- - * Two optional, mod-supplied inputs can act on an offline player's pad. The - * runtime resolves them in a fixed order in pad_external_input.h: - * 1. physical/local capture (keyboard, controllers: buttons, sticks, type) - * 2. offline controller source (psx_mod_set_controller_source): buttons = - * source AND physical; sticks/type come from the source, through the - * same mode override / multitap rule / presentation policy as a physical - * pad. A declined/invalid sample delivers neutral, not the last input. - * 3. local mouse policy (psx_mod_set_local_mouse_policy), P1 only: may - * override ONLY the right analog axes of the pad resolved by 1-2. It sees - * the final buttons/analog flag/right stick, so it composes with a - * source (a source never suppresses it) and no capture is taken to be - * discarded. A deflected source right stick, a digital pad, or Start - * held disables it exactly as for a physical pad; a source release, - * no device, or an armed input guard resets (releases) the capture. - * Gating. Neither input reaches the guest under netplay or rollback resim, - * selfcheck replay, headless, or a debug-server input override (which wins and - * is never mixed with external input). The mouse policy is additionally live - * only outside selfcheck input lock/resim, render passes, rewind, the - * savestate menu and the savestate input guard (pad_ext_live). A source is - * still sampled while the input guard is armed but its output is discarded - * (neutral). With no source and no mouse policy registered, pad bytes and - * host event handling are exactly the faithful defaults. - * - * ONE registration rule for both: call only on the main (emulation) thread; - * struct arguments are validated at registration (struct_size) and a bad one - * is rejected (return 0, logged to stderr); results of each callback are - * validated per use and fall back to neutral. These are mod-trusted APIs -- - * arbitrary native code, no caller check -- not launcher settings. A source - * is per player (NULL detaches, with one neutral release frame); the mouse - * policy is one per session (a second registration returns 0). Callbacks run - * on the main thread only: sources once per frame, in the normal offline - * sampler. - * The source's `analog` is the pad capability; the final type still goes - * through the multitap rule, mod mode override and presentation policy. - */ -typedef struct PSXModControllerState { - uint32_t struct_size, buttons, lx, ly, rx, ry, analog; -} PSXModControllerState; -typedef int (*PSXModControllerSource)(PSXModControllerState *state); -int psx_mod_set_controller_source(uint32_t player, PSXModControllerSource source); - -/* Local P1 mouse policy. The runtime delivers ordered events on the SDL owner - * (main) thread, owns relative capture and folds the resulting right-stick - * bytes after native input/presentation (and after any controller source), - * before normal SIO delivery. No SDL type, guest address, gesture, or game - * setting belongs in this interface. */ -enum { - PSX_MOD_MOUSE_HOLD_NONE = 0, - PSX_MOD_MOUSE_HOLD_RIGHT = 1, /* Mouse3 in keybinds.ini */ - PSX_MOD_MOUSE_HOLD_LEFT_ALT = 2, - PSX_MOD_MOUSE_RESET = 0, - PSX_MOD_MOUSE_ACQUIRED = 1, - PSX_MOD_MOUSE_MOTION = 2, - PSX_MOD_MOUSE_HOLD_PRESS = 3, - PSX_MOD_MOUSE_HOLD_RELEASE = 4 -}; -typedef struct PSXModMouseEvent { - uint32_t struct_size; - uint32_t type; - uint64_t time_ns; - double dx, dy; -} PSXModMouseEvent; -typedef struct PSXModMouseOutput { - uint32_t struct_size; - uint32_t override_right; - uint32_t rx, ry; -} PSXModMouseOutput; -typedef struct PSXModMousePolicy { - uint32_t struct_size; - uint32_t hold_control; - /* Re-read persistent guest context, including pause/menus, on every event - * and local sample. Must not infer it from a host Start toggle. */ - int (*eligible)(uint32_t native_buttons); - void (*event)(const PSXModMouseEvent* event); - /* A query: must not consume motion, change the event anchor or deadlines. */ - void (*sample)(uint64_t now_ns, PSXModMouseOutput* output); -} PSXModMousePolicy; -int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy); - /* * Register a C plugin before main() on the compilers supported by the runtime. * The registry itself uses function-local initialization, so constructor order