diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index d39d56e1..603b982d 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -30,6 +30,15 @@ if(BUILD_TESTING) target_include_directories(mod_texture_banks_test PRIVATE include) add_test(NAME mod_texture_banks_test COMMAND mod_texture_banks_test) if(NOT MSVC) + add_executable(host_snapshot_guard_test tests/test_host_snapshot_guard.c) + add_executable(fntrace_restore_handoff_test + tests/test_fntrace_restore_handoff.c src/fntrace.c) + foreach(test_target host_snapshot_guard_test fntrace_restore_handoff_test) + target_include_directories(${test_target} PRIVATE include src) + target_compile_options(${test_target} PRIVATE -O2 -flto -ffunction-sections -fdata-sections) + target_link_options(${test_target} PRIVATE -flto -Wl,--gc-sections) + add_test(NAME ${test_target} COMMAND ${test_target}) + endforeach() add_executable(ws_world_scene_test tests/test_ws_world_scene.c) target_include_directories(ws_world_scene_test PRIVATE include src) target_compile_options(ws_world_scene_test PRIVATE -O2 -flto -ffunction-sections -fdata-sections) diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 3ee203e1..6a74c689 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -84,6 +84,7 @@ typedef struct { * never allowed) -> normal boot + recapture. */ enum { + BS_SEC_HANDOFF = 0x12, /* game-start latch; optional for legacy snapshots */ BS_SEC_MODMEM = 0x11, /* allocated opt-in CPU/GPU enhancement arenas */ BS_SEC_CPU = 0x01, /* CPUState: gpr/pc/hi/lo/cop0/gte_data/gte_ctrl */ BS_SEC_RAM = 0x02, /* 2 MB main RAM */ diff --git a/runtime/include/fntrace.h b/runtime/include/fntrace.h index 35867c05..1e18bffd 100644 --- a/runtime/include/fntrace.h +++ b/runtime/include/fntrace.h @@ -89,6 +89,9 @@ void fntrace_record(CPUState* cpu, uint32_t target); * First dispatch into [lo, hi) calls cdrom_notify_game_started(). */ void fntrace_set_game_range(uint32_t lo, uint32_t hi); int fntrace_is_game_started(void); +/* Restore the handoff latch without repeating boot-only RAM/CD mutations. + * -1 infers legacy snapshots from the registered entry's live bytes. */ +void fntrace_restore_game_started(int started); /* One-shot game-start handoff. Idempotent — safe to call from any path * (compiled dispatch, dirty-RAM interpreter, generated entry function). * Performs dirty-image baseline clear, low-boot scratch clear, CD speed diff --git a/runtime/include/interrupts.h b/runtime/include/interrupts.h index 9dd87ce0..2bac2588 100644 --- a/runtime/include/interrupts.h +++ b/runtime/include/interrupts.h @@ -8,6 +8,12 @@ extern "C" { #endif struct CPUState; +/* A host adapter that preserves registers outside guest RAM is not a + * serializable continuation. Defer snapshots until it returns; scheduler + * escapes discard the scope with the abandoned host stack. */ +void psx_snapshot_host_call_begin(void); +void psx_snapshot_host_call_end(void); +void psx_snapshot_host_call_reset(void); /* IRQ bit positions in I_STAT/I_MASK */ #define IRQ_VBLANK 0 diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index b4860c92..fb4ce010 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -454,9 +454,11 @@ bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver res void mod_clear_builtin_resolvers_for_tests(); bool mod_register_activation_plugin(const std::string& id, void (*callback)(void)); bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); +bool mod_register_savestate_plugin(const std::string& id, void (*callback)(void)); bool mod_plugin_registered(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); +void mod_invoke_savestate_plugin(const std::string& id); void mod_clear_plugins_for_tests(); } // namespace PSXRecompV4 diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 30887ac5..a37b420c 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -21,6 +21,10 @@ int psx_mod_register_activation_plugin(const char* id, PSXModActivationCallback callback); int psx_mod_register_vblank_plugin(const char* id, PSXModVBlankCallback 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, + PSXModActivationCallback callback); int psx_mod_register_function_entry_plugin( const char* id, uint32_t address, PSXModFunctionEntryCallback callback); /* Called only from generated functions explicitly listed by the game config. */ diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index 0a644f4d..700945e5 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -7,6 +7,7 @@ #include "gpu_vram_dirty.h" #include "cpu_state.h" /* gte_canonicalize_cpu_state after CPU wire restore */ #include "interrupts.h" +#include "fntrace.h" #include "psx_cycles.h" #include "psx_icache.h" /* g_psx_icache_tv — fetch-cost tags in BS_SEC_ICACHE */ #include "pst_wire.h" @@ -372,11 +373,18 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER; - h.section_count = 16 + (psx_mod_memory_snapshot_bytes() ? 1u : 0u); + h.section_count = 17 + (psx_mod_memory_snapshot_bytes() ? 1u : 0u); ok = write_header_le(o, &h); if (ok) ok = write_cpu_section(o, cpu); + if (ok) { + uint8_t phase[4]; + PstW w; + pst_w_init(&w, phase, sizeof phase); + ok = pst_w_u32(&w, (uint32_t)fntrace_is_game_started()) && + write_section(o, BS_SEC_HANDOFF, phase, sizeof phase); + } if (ok) ok = write_section(o, BS_SEC_RAM, memory_get_ram_ptr(), RAM_SIZE); if (ok) ok = write_section(o, BS_SEC_SPAD, memory_get_scratchpad_ptr(), SPAD_SIZE); if (ok && psx_mod_memory_snapshot_bytes()) @@ -816,6 +824,7 @@ int boot_state_load_buffer(const uint8_t* file, size_t file_len, (1u< 1) ok = 0; + else game_started = (int)phase; + } + else if (!apply_section(tag, apply_ptr, apply_len, cpu, entry_pc)) ok = 0; else if (tag < 32) seen |= (1u << tag); { double dt = boot_state_mono_ms() - t_sec; @@ -909,6 +925,11 @@ int boot_state_load_buffer(const uint8_t* file, size_t file_len, if (!ok || (seen & required) != required) return 0; + /* Re-running the normal handoff after a cold gameplay restore clears the + * restored low kernel scratch and dirtiness baseline, then captures a + * bogus boot state. CD speed is already in the CD-ROM section. */ + fntrace_restore_game_started(game_started); + /* RAM was memcpy'd; force overlay revalidation before resume. */ overlay_watch_invalidate_after_ram_restore(); diff --git a/runtime/src/fntrace.c b/runtime/src/fntrace.c index b767ee6e..0f27f1d4 100644 --- a/runtime/src/fntrace.c +++ b/runtime/src/fntrace.c @@ -47,6 +47,15 @@ void fntrace_set_game_range(uint32_t lo, uint32_t hi) { int fntrace_is_game_started(void) { return s_game_started; } +void fntrace_restore_game_started(int started) { + if (started < 0) { + extern int dirty_ram_text_image_matches(uint32_t phys, uint32_t len); + started = s_game_entry_phys != 0 && + dirty_ram_text_image_matches(s_game_entry_phys, 32u); + } + s_game_started = started != 0; +} + /* Centralised game-start transition. Idempotent — safe to call from both * the dispatcher (fntrace_record) and the generated entry-point function. * Performs the complete handoff side effects: dirty-image baseline clear, diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index e3e6c143..08cf108f 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -632,6 +632,13 @@ int psx_irq_resume_context_snapshot_site(void) return g_cosim_dirty_pump_site; } +static unsigned s_snapshot_host_call_depth; +void psx_snapshot_host_call_begin(void) { ++s_snapshot_host_call_depth; } +void psx_snapshot_host_call_end(void) { + if (s_snapshot_host_call_depth) --s_snapshot_host_call_depth; +} +void psx_snapshot_host_call_reset(void) { s_snapshot_host_call_depth = 0; } + uint32_t psx_irq_resume_context_snapshot_pc(void) { return g_dirty_safe_resume_pc; @@ -639,6 +646,7 @@ uint32_t psx_irq_resume_context_snapshot_pc(void) int psx_irq_resume_context_snapshot_safe_at(uint32_t resume_pc) { + if (s_snapshot_host_call_depth) return 0; if (g_cosim_dirty_pump_site == 0) return 1; diff --git a/runtime/src/memory.c b/runtime/src/memory.c index dbb05188..8dcd1e80 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -522,6 +522,16 @@ void dirty_ram_register_text_image(uint32_t phys_lo, const uint8_t *bytes, int dirty_ram_text_image_registered(void) { return text_ref_image != NULL; } +/* Cold legacy restores have no serialized handoff latch. Check live bytes + * directly: the page-clean shortcut is not evidence of a loaded game after + * RAM/dirty-bit replacement, and a warm session's latch is not evidence either. */ +int dirty_ram_text_image_matches(uint32_t phys, uint32_t len) { + phys &= 0x1FFFFFFFu; + return text_ref_image && len && phys >= text_ref_lo && phys < text_ref_hi && + len <= text_ref_hi - phys && + memcmp(ram + phys, text_ref_image + phys - text_ref_lo, len) == 0; +} + static inline void text_guard_note_write(uint32_t phys, uint32_t val, int size) { if (!text_ref_image) return; if (phys < text_ref_lo || phys + (uint32_t)size > text_ref_hi) return; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 150a5a3d..cae42981 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -32,6 +32,7 @@ std::map& builtin_resolvers() { } struct RegisteredPlugin { + void (*savestate)(void) = nullptr; PSXModActivationCallback activation = nullptr; PSXModVBlankCallback vblank = nullptr; }; @@ -1452,7 +1453,21 @@ bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) { 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.vblank); + (found->second.activation || found->second.vblank || found->second.savestate); +} + +bool mod_register_savestate_plugin(const std::string& id, void (*callback)(void)) { + if (!valid_id(id) || !callback) return false; + RegisteredPlugin& plugin = registered_plugins()[id]; + if (plugin.savestate) return false; + plugin.savestate = callback; + return true; +} + +void mod_invoke_savestate_plugin(const std::string& id) { + const auto found = registered_plugins().find(id); + if (found != registered_plugins().end() && found->second.savestate) + found->second.savestate(); } void mod_invoke_activation_plugin(const std::string& id) { @@ -3663,3 +3678,9 @@ extern "C" int psx_mod_register_activation_plugin( return id && PSXRecompV4::mod_register_activation_plugin(id, callback) ? 1 : 0; } + +extern "C" int psx_mod_register_savestate_plugin( + const char* id, PSXModActivationCallback callback) { + return id && + PSXRecompV4::mod_register_savestate_plugin(id, callback) ? 1 : 0; +} diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 92e4ee97..a241030e 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1287,6 +1287,11 @@ extern "C" void mod_runtime_on_savestate_loaded(void) { applied = true; } s.main_applied = true; + for (const ModResolution::Plugin& plugin : s.plan.plugins) { + s.current_plugin = &plugin; + mod_invoke_savestate_plugin(plugin.id); + s.current_plugin = nullptr; + } if (applied) std::fprintf(stdout, "psxrecomp: reapplied mod plan %s after savestate restore\n", diff --git a/runtime/src/traps.c b/runtime/src/traps.c index d0145a19..9d246b0a 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -5,6 +5,7 @@ */ #include "cpu_state.h" +#include "interrupts.h" #include "psx_bss.h" #include "psx_runtime.h" /* fix B: psx_exc_escape_reason_t + g_exc_escape_reason */ #include "debug_server.h" @@ -810,6 +811,7 @@ void psx_scheduler_run(CPUState* cpu) g_sched_escape.reason = PSX_RUN_CONTINUE; for (;;) { if (setjmp(g_scheduler_jmpbuf) != 0) { + psx_snapshot_host_call_reset(); /* A structured escape unwound the native stack to here. Reset the * invariants the skipped psx_dispatch_impl frames would otherwise * own. No exception_jmpbuf frame is ever skipped (switch fires only diff --git a/runtime/tests/test_fntrace_restore_handoff.c b/runtime/tests/test_fntrace_restore_handoff.c new file mode 100644 index 00000000..3d7ccfe2 --- /dev/null +++ b/runtime/tests/test_fntrace_restore_handoff.c @@ -0,0 +1,37 @@ +#include "fntrace.h" +#include +#include + +static int effects, image_matches; +void dirty_ram_clear_image_baseline(void) { ++effects; } +void memory_clear_low_boot_scratch(void) { ++effects; } +void cdrom_notify_game_started(void) { ++effects; } +void boot_state_trigger_capture(const CPUState* cpu) { (void)cpu; ++effects; } +int dirty_ram_text_image_matches(uint32_t phys, uint32_t len) { + return image_matches && phys == 0x6B58Cu && len == 32u; +} +static void check(int ok, const char* message) { + if (!ok) { fprintf(stderr, "FAIL: %s\n", message); exit(1); } +} +int main(void) { + CPUState cpu = {0}; + fntrace_set_game_range(0x8006B58Cu, 0); + fntrace_restore_game_started(1); + fntrace_mark_game_started(&cpu); + check(fntrace_is_game_started() && !effects, "cold game restore skips destructive handoff"); + fntrace_restore_game_started(0); + check(!fntrace_is_game_started() && !effects, "pre-game restore resets warm latch"); + fntrace_mark_game_started(&cpu); + check(effects == 4, "normal boot retains all handoff effects"); + fntrace_mark_game_started(&cpu); + check(effects == 4, "handoff remains one-shot"); + image_matches = 1; + fntrace_set_game_range(0x8006B58Cu, 0); + fntrace_restore_game_started(-1); + check(fntrace_is_game_started() && effects == 4, "legacy game restore infers loaded entry"); + image_matches = 0; + fntrace_restore_game_started(-1); + check(!fntrace_is_game_started(), "legacy BIOS restore does not inherit warm game latch"); + puts("restored handoff checks passed"); + return 0; +} diff --git a/runtime/tests/test_host_snapshot_guard.c b/runtime/tests/test_host_snapshot_guard.c new file mode 100644 index 00000000..3adee300 --- /dev/null +++ b/runtime/tests/test_host_snapshot_guard.c @@ -0,0 +1,36 @@ +#include "interrupts.c" + +int g_cosim_dirty_pump_site; +int g_call_unit_depth; +int g_psx_dispatch_depth; +uint32_t g_dirty_safe_resume_pc; + +static void check(int ok, const char* message) { + if (!ok) { fprintf(stderr, "FAIL: %s\n", message); exit(1); } +} + +int main(void) { + uint32_t pc = 0x80025070u; + check(psx_irq_resume_context_snapshot_safe_at(pc), "native boundary admitted"); + psx_snapshot_host_call_begin(); + psx_snapshot_host_call_begin(); + check(!psx_irq_resume_context_snapshot_safe_at(pc), "nested host adapter deferred"); + psx_snapshot_host_call_end(); + check(!psx_irq_resume_context_snapshot_safe_at(pc), "outer adapter still active"); + psx_snapshot_host_call_end(); + check(psx_irq_resume_context_snapshot_safe_at(pc), "register restore admits snapshot"); + g_cosim_dirty_pump_site = 1; + g_dirty_safe_resume_pc = pc; + check(psx_irq_resume_context_snapshot_safe_at(pc), "retired interpreter boundary admitted"); + psx_snapshot_host_call_begin(); + check(!psx_irq_resume_context_snapshot_safe_at(pc), "interpreter adapter deferred"); + psx_snapshot_host_call_reset(); /* scheduler longjmp abandoned the adapter */ + check(psx_irq_resume_context_snapshot_safe_at(pc), "scheduler escape releases abandoned scope"); + psx_snapshot_host_call_end(); /* no underflow */ + check(psx_irq_resume_context_snapshot_safe_at(pc), "unmatched end stays safe"); + check(!psx_irq_resume_context_snapshot_safe_at(pc + 4), "existing resume-PC guard retained"); + g_call_unit_depth = 1; + check(!psx_irq_resume_context_snapshot_safe_at(pc), "existing call-unit guard retained"); + puts("host snapshot continuation checks passed"); + return 0; +} diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 9b66052f..6264b069 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -20,6 +20,7 @@ static std::array ram; static int failures; static int activation_calls; static int plugin_calls; +static int restore_calls; extern "C" uint8_t psx_read_byte(uint32_t address) { return ram[address & 0x1fffffu]; @@ -61,6 +62,9 @@ extern "C" uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t, uint32_t) { return 0; } extern "C" int psx_ws_x_margin(void) { return 0; } +extern "C" void gpu_ws_tag_hud_primitive(uint32_t, int) {} +extern "C" void gpu_ws_tag_world_primitive(uint32_t, int) {} +extern "C" void gpu_ws_set_adaptive_backdrop_preload(int) {} /* Stand-in for the GPU's display geometry. psx_mod_display_width/height must * report exactly what the presenter reports -- a plugin drawing an overlay @@ -82,6 +86,11 @@ static void test_activation_plugin(void) { activation_calls++; } +static void test_restore_plugin(void) { + restore_calls++; + if (ram[0x1000] != 0xa1) failures++; /* main plan must be reapplied first */ +} + static void check(bool value, const char* message) { if (!value) { std::cerr << "FAIL: " << message << "\n"; @@ -308,6 +317,10 @@ int main() { check(PSXRecompV4::mod_register_vblank_plugin( "runtime.test-vblank", test_vblank_plugin), "runtime test plugin must register"); + check(psx_mod_register_savestate_plugin("runtime.test-vblank", test_restore_plugin), + "restore callback must register through the C API"); + check(!psx_mod_register_savestate_plugin("runtime.test-vblank", test_restore_plugin), + "duplicate restore callback must be rejected"); check(PSXRecompV4::mod_runtime_initialize( root, "SLUS-RUNTIME", 0x80002000, {}, &error), error.c_str()); @@ -347,6 +360,8 @@ int main() { ram[0x1200] = 2; ram[0x1201] = 0; ram[0x1202] = 1; ram[0x1203] = 0x32; mod_runtime_on_savestate_loaded(); + check(restore_calls == 1 && activation_calls == 1 && plugin_calls == 1, + "restore must rebind hooks without activation or a gameplay VBlank"); check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4 && ram[0x1100] == 42 && ram[0x1102] == 43 && ram[0x1200] == 1 && ram[0x1201] == 0x42 &&