From 18272616a165becc69fff26a883201ebe05d3a23 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:53:46 -0700 Subject: [PATCH] savestate: dynamic section count; refuse cross-mod-config loads; tex_pack section The file header's section count was HARDCODED to 16, so ANY appended section made the loader stop early and poisoned every later section - the long-standing "adding a section breaks a later one" trap. BsOut now counts sections and backpatches the header (file via fseek, memory in place). New BS_SEC_MODSET rides FIRST: the resolved mod-plan fingerprint. A load whose fingerprint differs from the running plan is refused before any state is applied, with a message naming both plans - previously such loads half-applied and died at a null PC. Old saves without the section still load (best effort), matching the skip-unknown policy. BS_SEC_TEXPACK (dormant since the count trap) is enabled: tracked-upload rects+hashes persist, pixels rebuilt from restored VRAM and hash-verified, so HD texture substitution survives savestate loads. Full save->load->run roundtrip verified live. Co-Authored-By: Claude Fable 5 --- runtime/include/boot_state.h | 5 +++ runtime/include/mod_plugins.h | 5 +++ runtime/src/boot_state.c | 79 ++++++++++++++++++++++++++++++++++- runtime/src/mod_runtime.cpp | 4 ++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 43e7f76f..c82e2a00 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -102,6 +102,11 @@ enum { from identical baselines). Optional on load for old blobs (left untouched when absent). */ BS_SEC_TEXPACK = 0x11, /* HD texture pack upload tracker (rects+hashes) */ + BS_SEC_MODSET = 0x12, /* resolved mod-plan fingerprint (load guard): + * loading a state into a session whose enabled mod + * set differs poisons the machine (null-PC spin was + * observed live) -- refuse BEFORE any apply instead. + * Written FIRST so the reject precedes mutation. */ }; /* Save a COMPLETE snapshot at game handoff. Returns 1 on success. */ diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 878ebb15..787928f7 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -181,6 +181,11 @@ int psx_mod_set_disc_speed(uint32_t divisor, */ int psx_mod_set_main_ram_8mb(int enabled); +/* Fingerprint of the resolved mod plan for this session ("" when no mods + * runtime is active). Savestates embed it (BS_SEC_MODSET) so a load into a + * session with a different enabled mod set refuses cleanly. */ +const char* psx_mod_runtime_fingerprint_cstr(void); + /* Mark a guest main-RAM range unique under 8 MB mode (optional narrowing). */ void psx_ram_register_unique(uint32_t addr, uint32_t len); diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index 44c51f7e..e560693b 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -15,6 +15,8 @@ #include #include #include +#include "tex_pack.h" +#include "mod_plugins.h" #if defined(_WIN32) # include #else @@ -193,6 +195,10 @@ typedef struct BsOut { size_t len; size_t cap; int no_zlib; /* 1 => always raw sections (netplay snap ring) */ + uint32_t sections;/* sections actually written; backpatched into the header. + * The old hardcoded section_count=16 made every appended + * section a poison pill: the loader stopped at 16 and a + * LATER section's bytes read as garbage (the TEXPACK trap). */ } BsOut; static int bs_write(BsOut* o, const void* p, size_t n) { @@ -243,6 +249,7 @@ static int write_section_raw(BsOut* o, uint32_t tag, uint32_t flags, return 0; if (!bs_write(o, hdr, sizeof hdr)) return 0; if (len && !bs_write(o, data, (size_t)len)) return 0; + o->sections++; return 1; } @@ -384,10 +391,20 @@ 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; + /* Backpatched after the last section lands (see below). */ + h.section_count = 0; + size_t hdr_off = o->f ? (size_t)ftell(o->f) : o->len; + o->sections = 0; ok = write_header_le(o, &h); + /* Mod-plan guard rides FIRST: applying it is a pure compare, so a + * mismatched load rejects before any machine state is touched. */ + if (ok) { + const char* fp = psx_mod_runtime_fingerprint_cstr(); + ok = write_section(o, BS_SEC_MODSET, fp, (uint64_t)strlen(fp)); + } + if (ok) ok = write_cpu_section(o, cpu); if (ok) ok = write_section(o, BS_SEC_RAM, memory_get_ram_ptr(), memory_get_ram_bytes()); if (ok) ok = write_section(o, BS_SEC_SPAD, memory_get_scratchpad_ptr(), SPAD_SIZE); @@ -472,6 +489,42 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, free(db); } } + /* HD texture pack tracker: rects+hashes only; pixels rebuild from the + * restored VRAM at apply (see tex_pack_state_apply). Zero bytes when the + * pack is inactive — the section is simply absent. */ + if (ok) { + uint32_t tb = tex_pack_state_bytes(); + if (tb) { + uint8_t* buf = (uint8_t*)malloc(tb); + if (!buf) ok = 0; + else { + tex_pack_state_write(buf); + ok = write_section(o, BS_SEC_TEXPACK, buf, tb); + free(buf); + } + } + } + /* Backpatch the real section count over the header's placeholder. */ + if (ok) { + uint8_t le[4]; + le[0] = (uint8_t)(o->sections & 0xFF); + le[1] = (uint8_t)((o->sections >> 8) & 0xFF); + le[2] = (uint8_t)((o->sections >> 16) & 0xFF); + le[3] = (uint8_t)((o->sections >> 24) & 0xFF); + if (o->f) { + long end_pos = ftell(o->f); + if (end_pos < 0 || + fseek(o->f, (long)hdr_off + 28, SEEK_SET) != 0 || + fwrite(le, 1, 4, o->f) != 4 || + fseek(o->f, end_pos, SEEK_SET) != 0) + ok = 0; + } else { + if (hdr_off + 32 <= o->len) + memcpy(o->data + hdr_off + 28, le, 4); + else + ok = 0; + } + } return ok; } @@ -691,6 +744,30 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, if (!pst_r_u32(&r, &g_psx_icache_tv[i])) return 0; return 1; } + case BS_SEC_MODSET: { + /* Pure compare — no state touched. A state saved under a different + * enabled mod set must not apply: RAM layout / patched code paths + * differ and the machine resumes into garbage (observed as a null-PC + * recovery spin). Section order puts this first, so the reject lands + * before any mutation. */ + const char* live = psx_mod_runtime_fingerprint_cstr(); + size_t live_len = strlen(live); + if (live_len != (size_t)len || (len && memcmp(live, p, (size_t)len) != 0)) { + fprintf(stderr, + "savestate: REFUSED - state's mod set differs from this " + "session's (state %.*s... vs live %.8s...). Relaunch with " + "the matching mods enabled to load it.\n", + (int)(len < 8 ? len : 8), (const char*)p, live); + return 0; + } + return 1; + } + case BS_SEC_TEXPACK: { + /* Applies AFTER BS_SEC_VRAM in stream order; pixels rebuild from the + * freshly restored CPU VRAM mirror and are hash-verified inside. */ + tex_pack_state_apply(p, len, NULL); + return 1; + } default: /* Unknown section: SKIP, never fail. This was `return 0`, which made * every state written by a build with one extra section a poison pill diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index a27c0bb5..8b4b3e12 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1295,6 +1295,10 @@ const std::string& mod_runtime_fingerprint() { return state().plan.fingerprint; } +extern "C" const char* psx_mod_runtime_fingerprint_cstr(void) { + return state().plan.fingerprint.c_str(); +} + const std::filesystem::path& mod_runtime_effective_disc_path() { return state().effective_disc_path; }