Preserve restored handoff and snapshot host-call continuations

This commit is contained in:
Matthew Stanley
2026-10-04 17:51:23 -07:00
parent c68bf6e085
commit 6426b25f3d
16 changed files with 192 additions and 3 deletions
+9
View File
@@ -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)
+1
View File
@@ -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 */
+3
View File
@@ -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
+6
View File
@@ -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
+2
View File
@@ -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
+4
View File
@@ -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. */
+23 -2
View File
@@ -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<<BS_SEC_SIO)|(1u<<BS_SEC_MDEC)|(1u<<BS_SEC_DIRTY)|
(psx_mod_memory_snapshot_bytes() ? (1u<<BS_SEC_MODMEM) : 0u);
uint32_t seen = 0;
int game_started = -1;
int ok = 1;
const double t0 = boot_state_mono_ms();
double inflate_ms = 0.0;
@@ -894,7 +903,14 @@ int boot_state_load_buffer(const uint8_t* file, size_t file_len,
}
t_sec = boot_state_mono_ms();
if (!apply_section(tag, apply_ptr, apply_len, cpu, entry_pc)) ok = 0;
if (tag == BS_SEC_HANDOFF) {
PstR r;
uint32_t phase = 0;
pst_r_init(&r, apply_ptr, apply_len);
if (apply_len != 4 || !pst_r_u32(&r, &phase) || phase > 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();
+9
View File
@@ -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,
+8
View File
@@ -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;
+10
View File
@@ -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;
+22 -1
View File
@@ -32,6 +32,7 @@ std::map<std::string, ModBuiltinResolver>& 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;
}
+5
View File
@@ -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",
+2
View File
@@ -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
@@ -0,0 +1,37 @@
#include "fntrace.h"
#include <stdio.h>
#include <stdlib.h>
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;
}
+36
View File
@@ -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;
}
+15
View File
@@ -20,6 +20,7 @@ static std::array<uint8_t, 2 * 1024 * 1024> 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 &&