From 577f6173b9756c70a1eb67c083102fd7f54e3ca1 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 6 Aug 2026 21:00:58 -0700 Subject: [PATCH] fix(mods): reapply executable patches after state load --- runtime/include/mod_runtime.h | 4 ++ runtime/src/main.cpp | 1 + runtime/src/mod_runtime.cpp | 110 +++++++++++++++++++++++------ runtime/tests/test_mod_runtime.cpp | 68 ++++++++++++++++++ 4 files changed, 163 insertions(+), 20 deletions(-) diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index b62056ea..503b3b1f 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -35,6 +35,10 @@ extern "C" { /* Called before a guest dispatch. Applies the complete main-EXE plan * transactionally on the first dispatch to the configured entry point. */ void mod_runtime_on_dispatch(uint32_t target); +/* A full-machine savestate restores guest RAM after the initial entry-point + * application. Reapply the already-validated main-EXE plan so the current + * enabled mod selection remains authoritative after the restore. */ +void mod_runtime_on_savestate_loaded(void); /* Invokes activation callbacks for the committed plan. Call after the final * launcher commit and before renderer/window initialization. */ void mod_runtime_activate_plugins(void); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 041fc864..798a0dca 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -409,6 +409,7 @@ static void present_session_reset(void) { * longjmp). Clears present latches and forces the next vblank to show the * restored VRAM — including a blank if display was disabled in the snapshot. */ extern "C" void psx_frontend_on_savestate_loaded(void) { + mod_runtime_on_savestate_loaded(); s_disabled_frame_presented = false; s_force_present_after_load = true; smooth_60_reset(); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 80c97512..dceec2d2 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -153,6 +153,66 @@ void set_error(const std::string& error) { state().error = error; } +void apply_main_write(const ModResolution::Write& write) { + if (write.fields.empty()) { + for (size_t i = 0; i < write.replacement.size(); ++i) + psx_write_byte((uint32_t)write.location + (uint32_t)i, + write.replacement[i]); + dirty_ram_mark_executable_range( + (uint32_t)write.location & 0x1FFFFFFFu, + (uint32_t)write.replacement.size()); + return; + } + for (const ModResolution::Write::Field& field : write.fields) { + for (size_t i = 0; i < field.replacement.size(); ++i) + psx_write_byte( + (uint32_t)write.location + + (uint32_t)field.offset + (uint32_t)i, + field.replacement[i]); + dirty_ram_mark_executable_range( + ((uint32_t)write.location + + (uint32_t)field.offset) & 0x1FFFFFFFu, + (uint32_t)field.replacement.size()); + } +} + +bool restored_main_matches_plan(const RuntimeMods& s, uint32_t& failed_at) { + std::map desired; + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + if (write.fields.empty()) { + for (size_t i = 0; i < write.replacement.size(); ++i) + desired[(uint32_t)write.location + (uint32_t)i] = + write.replacement[i]; + } else { + for (const ModResolution::Write::Field& field : write.fields) { + for (size_t i = 0; i < field.replacement.size(); ++i) + desired[ + (uint32_t)write.location + + (uint32_t)field.offset + (uint32_t)i] = + field.replacement[i]; + } + } + } + + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + for (size_t i = 0; i < write.expected.size(); ++i) { + const uint32_t address = + (uint32_t)write.location + (uint32_t)i; + const uint8_t observed = psx_read_byte(address); + if (observed == write.expected[i]) continue; + const auto replacement = desired.find(address); + if (replacement != desired.end() && + observed == replacement->second) + continue; + failed_at = address; + return false; + } + } + return true; +} + bool sha256_file(const std::filesystem::path& path, std::string& out, std::string* error) { out.clear(); @@ -1066,26 +1126,7 @@ extern "C" void mod_runtime_on_dispatch(uint32_t target) { } for (const ModResolution::Write& write : s.plan.writes) { if (write.target != ModPatchTarget::MainExe) continue; - if (write.fields.empty()) { - for (size_t i = 0; i < write.replacement.size(); ++i) - psx_write_byte((uint32_t)write.location + (uint32_t)i, - write.replacement[i]); - dirty_ram_mark_executable_range( - (uint32_t)write.location & 0x1FFFFFFFu, - (uint32_t)write.replacement.size()); - } else { - for (const ModResolution::Write::Field& field : write.fields) { - for (size_t i = 0; i < field.replacement.size(); ++i) - psx_write_byte( - (uint32_t)write.location + - (uint32_t)field.offset + (uint32_t)i, - field.replacement[i]); - dirty_ram_mark_executable_range( - ((uint32_t)write.location + - (uint32_t)field.offset) & 0x1FFFFFFFu, - (uint32_t)field.replacement.size()); - } - } + apply_main_write(write); } s.main_applied = true; if (!s.plan.writes.empty()) @@ -1093,6 +1134,35 @@ extern "C" void mod_runtime_on_dispatch(uint32_t target) { s.plan.fingerprint.c_str()); } +extern "C" void mod_runtime_on_savestate_loaded(void) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return; + + if (!s.main_applied) { + uint32_t failed_at = 0; + if (!restored_main_matches_plan(s, failed_at)) { + std::fprintf(stderr, + "psxrecomp: mod plan %s rejected after savestate restore at " + "0x%08X (expected-byte guard failed)\n", + s.plan.fingerprint.c_str(), (unsigned)failed_at); + return; + } + } + + bool applied = false; + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + apply_main_write(write); + applied = true; + } + s.main_applied = true; + if (applied) + std::fprintf(stdout, + "psxrecomp: reapplied mod plan %s after savestate restore\n", + s.plan.fingerprint.c_str()); +} + extern "C" void mod_runtime_enable_disc_patches(void) { PSXRecompV4::state().disc_enabled = true; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 377b1865..b9e608d3 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -25,6 +25,39 @@ extern "C" void psx_write_byte(uint32_t address, uint8_t value) { ram[address & 0x1fffffu] = value; } +extern "C" uint16_t psx_read_half(uint32_t address) { + const uint32_t offset = address & 0x1fffffu; + return (uint16_t)(ram[offset] | ((uint16_t)ram[offset + 1] << 8)); +} + +extern "C" void psx_write_half(uint32_t address, uint16_t value) { + const uint32_t offset = address & 0x1fffffu; + ram[offset] = (uint8_t)value; + ram[offset + 1] = (uint8_t)(value >> 8); +} + +extern "C" uint32_t psx_read_word(uint32_t address) { + const uint32_t offset = address & 0x1fffffu; + return (uint32_t)ram[offset] | + ((uint32_t)ram[offset + 1] << 8) | + ((uint32_t)ram[offset + 2] << 16) | + ((uint32_t)ram[offset + 3] << 24); +} + +extern "C" void psx_write_word(uint32_t address, uint32_t value) { + const uint32_t offset = address & 0x1fffffu; + ram[offset] = (uint8_t)value; + ram[offset + 1] = (uint8_t)(value >> 8); + ram[offset + 2] = (uint8_t)(value >> 16); + ram[offset + 3] = (uint8_t)(value >> 24); +} + +extern "C" uint32_t psx_mod_memory_alloc(uint32_t, uint32_t) { return 0; } +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 dirty_ram_mark_executable_range(uint32_t, uint32_t) {} extern "C" int fntrace_is_game_started(void) { return 1; } @@ -292,6 +325,21 @@ int main() { "adjacent sparse fields must compose while preserving guard-only " "bytes"); + /* A full-machine savestate replaces main RAM after the entry-point plan + * has already applied. Loading a stock checkpoint must not silently turn + * the currently enabled main-EXE features back off. */ + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 1; ram[0x1103] = 0; + ram[0x1200] = 2; ram[0x1201] = 0; + ram[0x1202] = 1; ram[0x1203] = 0x32; + mod_runtime_on_savestate_loaded(); + check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4 && + ram[0x1100] == 42 && ram[0x1102] == 43 && + ram[0x1200] == 1 && ram[0x1201] == 0x42 && + ram[0x1202] == 0 && ram[0x1203] == 0x32, + "savestate restore must reapply the complete enabled main plan"); + std::array sector{}; sector[10] = 0xaa; mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size()); @@ -374,6 +422,26 @@ int main() { "a failed sparse guard-only byte must leave the complete main plan " "untouched"); + /* Loading can be requested while the boot executable is still running, + * before the configured game entry point has dispatched. The restored + * checkpoint itself supplies the bytes used to validate and apply the + * plan in that case. */ + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 1; ram[0x1103] = 0; + ram[0x1200] = 2; ram[0x1201] = 0; + ram[0x1202] = 1; ram[0x1203] = 0x32; + mod_runtime_on_savestate_loaded(); + check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4 && + ram[0x1100] == 42 && ram[0x1102] == 43 && + ram[0x1200] == 1 && ram[0x1201] == 0x42 && + ram[0x1202] == 0 && ram[0x1203] == 0x32, + "pre-entry savestate restore must validate and apply the main plan"); + mod_runtime_enable_disc_patches(); std::array bad_sparse_disc{}; bad_sparse_disc[15] = 2;