From 76a53d909069115c1d70bfdf2d8847741c15158c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 24 Jul 2026 23:38:38 -0700 Subject: [PATCH] Invalidate per-PC code caches when a savestate replaces RAM Several consumers cache a classification of the instruction at a given PC and key that cache on g_dirty_ram_code_gen, per the contract stated where the counter is defined (runtime/src/memory.c): "Consumers that cache per-PC classifications of RAM instructions compare against this and re-derive after any code change - a cached kind must never survive an overlay reload." Today that counter is bumped from exactly two places, both on the instrumented store path: a page's clean->dirty transition, and dirty_ram_mark_executable_range(). A savestate restore reaches neither. BS_SEC_RAM applies a raw memcpy over all 2 MB, so code identity at any address can change completely while no page makes a clean->dirty transition and no executable range is marked. Every cache keyed on the generation therefore keeps serving verdicts derived from the PREVIOUS contents. The overlay loader's negative "no native owner" cache is the damaging one, because overlay_loader_dispatch() consults it before rediscovery, so one stale entry can pin an address to the wrong implementation for the rest of the run. Note the same restore already invalidates psx_kernel_bless_note_range(), so the path is aware it must invalidate downstream caches - it only ever did one of them. Fix: add dirty_ram_invalidate_code_caches() and call it after the BS_SEC_RAM memcpy. dirty_ram_set_bitmap_words() (the BS_SEC_DIRTY apply path) calls it too, since a wholesale bitmap replacement changes which addresses are RAM-resident code by the same argument. Bumping the generation is sufficient by construction: each cache stores the generation it was derived under and re-derives on mismatch, so this fixes every consumer at once instead of patching them individually. Scope, stated honestly: this closes a real latent correctness gap in the load path and is worth having on its own. It is NOT a fix for the separate, still-open bug where loading a gameplay state into a process that never entered that stage leaves the player unable to move - that was measured and the overlay cache is demonstrably NOT its cause (the dirty bitmap is identical between a working and a broken instance, and covers only the kernel window, so the player's code is static-dispatched in both). No behaviour change for a process that never loads a state. Co-Authored-By: Claude Opus 5 (1M context) --- runtime/src/boot_state.c | 15 +++++++++++++++ runtime/src/memory.c | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index b4acb4175..ddb399586 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -19,6 +19,7 @@ extern uint8_t* memory_get_scratchpad_ptr(void); extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); extern uint32_t dirty_ram_get_bitmap_word_count(void); extern void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count); +extern void dirty_ram_invalidate_code_caches(void); extern uint32_t i_stat; extern uint32_t i_mask; extern uint64_t psx_cycle_count; @@ -277,6 +278,20 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, { extern void psx_kernel_bless_note_range(uint32_t phys, uint32_t l); psx_kernel_bless_note_range(0, RAM_SIZE); + /* Replacing all of RAM can change code identity at any address + * without any page making a clean->dirty transition, so caches + * that key on the RAM code generation would keep serving verdicts + * derived from the PREVIOUS contents. The overlay loader's + * negative cache is the damaging one: it short-circuits dispatch + * before rediscovery, so a stale "no native owner" entry pins an + * address to the wrong implementation for the rest of the run. + * Symptom when this was missing: loading a gameplay state into a + * process that had not itself entered that stage restored RAM + * perfectly and kept running at 60fps, but the player stood + * frozen -- his overlay-resident movement code was never selected + * -- while the rest of the scene animated normally. */ + extern void dirty_ram_invalidate_code_caches(void); + dirty_ram_invalidate_code_caches(); } return 1; case BS_SEC_SPAD: diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 23796bb58..b28b8d525 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -560,10 +560,36 @@ uint32_t dirty_ram_get_bitmap_word_count(void) { return DIRTY_RAM_BITMAP_WORDS; } +void dirty_ram_invalidate_code_caches(void); + void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count) { if (count > DIRTY_RAM_BITMAP_WORDS) count = DIRTY_RAM_BITMAP_WORDS; for (uint32_t i = 0; i < count; i++) dirty_ram_bitmap[i] = words[i]; + /* Wholesale bitmap replacement changes which addresses are RAM-resident + * code, so every cached per-PC classification is now potentially wrong. + * The per-page mark path bumps the generation on its clean->dirty edge; + * this path bypasses it, so bump explicitly. */ + dirty_ram_invalidate_code_caches(); +} + +/* Invalidate every consumer that caches a per-PC classification of RAM + * instructions. Bumping the generation is sufficient by construction: each + * cache stores the generation it was derived under and re-derives on + * mismatch (the interpreter's widescreen site caches, and the overlay + * loader's negative "no native owner" cache, which otherwise short-circuits + * dispatch before it would ever re-discover the correct overlay). + * + * This exists for bulk RAM writes that are NOT ordinary guest stores -- + * savestate restore above all. A savestate replaces all 2 MB at once, so + * code identity can change completely while no page ever makes a + * clean->dirty transition and no executable range is marked. Without this, + * a loaded state keeps executing whatever the host already believed was + * resident: observed as a restored player standing frozen while the rest of + * the scene animated, because the overlay-resident movement code was never + * re-selected. */ +void dirty_ram_invalidate_code_caches(void) { + g_dirty_ram_code_gen++; } /* ---- Inc3: watched overlay pages + per-page generation counters ---------