Merge pull request #767 from RetroPortingToolKit/integrate/marios-hat-master-20261024

Support trusted mod PCM on the guest audio clock and prompt snapshots
This commit is contained in:
Matt Stanley
2026-10-25 01:21:05 -07:00
committed by GitHub
14 changed files with 215 additions and 7 deletions
+9 -2
View File
@@ -17,6 +17,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;
typedef void (*PSXModFunctionEntryCallback)(struct CPUState* cpu,
uint32_t address);
@@ -34,6 +40,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,
@@ -262,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.
+2
View File
@@ -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
+7
View File
@@ -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,
+4
View File
@@ -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 {
+3
View File
@@ -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);
+16
View File
@@ -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
+22 -2
View File
@@ -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<ModFunctionEntryHook> 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<std::string> mod_registered_plugin_ids() {
std::vector<std::string> 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 &&
+19
View File
@@ -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<ActiveFunctionEntryHook>& active_function_entry_hooks() {
}
unsigned function_entry_depth;
struct ActivePCMHook {
PSXModPCMCallback callback;
const ModResolution::Plugin* plugin;
};
std::vector<ActivePCMHook> 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<ActiveFunctionEntryHook>& active_guest_functions() {
static std::vector<ActiveFunctionEntryHook> 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
+19 -3
View File
@@ -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;
+7
View File
@@ -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
+26
View File
@@ -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();
+17
View File
@@ -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,
@@ -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;
}
+41
View File
@@ -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;