diff --git a/overlay_toolchain/include/mod_plugins.h b/overlay_toolchain/include/mod_plugins.h index 3c3422f4..85f0b47f 100644 --- a/overlay_toolchain/include/mod_plugins.h +++ b/overlay_toolchain/include/mod_plugins.h @@ -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. 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/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/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/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/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/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/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_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(); 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; } 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;