From 8d73618f794faae2450f68f425cfa6338d2f0b03 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:27:49 -0700 Subject: [PATCH] Isolate compiled overlay bindings per execution thread --- docs/internal/FAITHFUL_TIMING_PLAN.md | 6 ++ runtime/CMakeLists.txt | 9 ++ runtime/include/cpu_state.h | 24 ++++- runtime/include/overlay_api.h | 5 +- .../include/overlay_dispatch_preamble.c.inc | 27 +++-- runtime/tests/test_overlay_thread_binding.c | 100 ++++++++++++++++++ 6 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 runtime/tests/test_overlay_thread_binding.c diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index e61454f8..c43db068 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,12 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- 2026-10-21 BRIEF-5 prerequisite: compiled-overlay callback tables, pending + cycle charges and host dispatch pointers now bind per executing thread. + The production-shim two-thread test and existing store-PC/shim compilation + checks pass on Windows and macOS. This does not isolate the runtime or move + replay to a worker; Stage 3 acceptance gates have not been run. + - 2026-10-21 BRIEF-4 Stage 3 continuation: temporal GPU transactions now stay on the render context owner. Recorded redraws carry exact PGXP model-word provenance and homogeneous geometry, avoiding a second guest draw and the diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c87d2a9a..638ccdc2 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1979,6 +1979,15 @@ if(BUILD_TESTING) add_test(NAME overlay_store_pc_forwarding_test COMMAND overlay_store_pc_forwarding_test) + find_package(Threads REQUIRED) + add_executable(overlay_thread_binding_test tests/test_overlay_thread_binding.c) + target_include_directories(overlay_thread_binding_test PRIVATE include) + target_compile_definitions(overlay_thread_binding_test PRIVATE + PSX_OVERLAY_DLL_BUILD PSX_NO_DEBUG_TOOLS PSX_ENABLE_BLOCK_CYCLES=1) + target_link_libraries(overlay_thread_binding_test PRIVATE Threads::Threads) + add_test(NAME overlay_thread_binding_test COMMAND overlay_thread_binding_test) + set_tests_properties(overlay_thread_binding_test PROPERTIES TIMEOUT 20) + # frame_fingerprint judge columns are blind to device-write service order. add_executable(frame_fingerprint_test tests/test_frame_fingerprint.c) target_include_directories(frame_fingerprint_test PRIVATE include) diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 1a9e2466..4355071a 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -290,16 +290,30 @@ extern int32_t gte_nclip_exact_sign(int32_t native_mac0, uint32_t pc); * PSX_OVERLAY_DLL_BUILD branch; see overlay_api.h / compile_overlays.py). * ========================================================================== */ #ifdef PSX_OVERLAY_DLL_BUILD -extern int *g_psx_call_bail_p; -extern uint64_t *g_psx_bail_first_p; -extern uint64_t *g_psx_bail_resolved_p; +/* Each executing host thread binds its own overlay callback table. A shard + * can be entered concurrently by live execution and a private replay. */ +#if defined(_MSC_VER) +#define PSX_OVERLAY_THREAD_LOCAL __declspec(thread) +#else +#define PSX_OVERLAY_THREAD_LOCAL __thread +#endif +extern PSX_OVERLAY_THREAD_LOCAL int *g_psx_call_bail_p; +extern PSX_OVERLAY_THREAD_LOCAL uint64_t *g_psx_bail_first_p; +extern PSX_OVERLAY_THREAD_LOCAL uint64_t *g_psx_bail_resolved_p; #define g_psx_call_bail (*g_psx_call_bail_p) #define g_psx_bail_first (*g_psx_bail_first_p) #define g_psx_bail_resolved (*g_psx_bail_resolved_p) /* The store-PC breadcrumb the emitter writes before every guest store is the * runtime's, not the DLL's (ABI v24, overlay_api.h last_store_pc). */ -extern uint32_t *g_psx_last_store_pc_p; -#define g_debug_last_store_pc (*g_psx_last_store_pc_p) +extern PSX_OVERLAY_THREAD_LOCAL uint32_t *g_psx_last_store_pc_p; +/* Generated shards also declare `extern uint32_t g_debug_last_store_pc`. + * Map that declaration to a pointer-returning function, rather than a second + * pointer declaration that would lose its thread-local storage qualifier. + * The inline accessor leaves the store path as a direct TLS pointer load. */ +static inline uint32_t *psx_overlay_last_store_pc(void) { + return g_psx_last_store_pc_p; +} +#define g_debug_last_store_pc (*psx_overlay_last_store_pc()) #else extern int g_psx_call_bail; extern uint64_t g_psx_bail_first; /* contract violations detected */ diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index 36c36680..23e13d35 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -400,7 +400,10 @@ typedef struct OverlayCallbacks { extern "C" { #endif -/* Exported by every overlay DLL. Call once after LoadLibrary. */ +/* Exported by every overlay DLL. Call on each executing thread before guest + * entry. The table, cycle accumulator and host-state pointers are thread-local; + * loading a shard does not bind it on other threads. Flush pending cycles + * before rebinding this thread to a different execution context. */ #ifdef _WIN32 __declspec(dllexport) #else diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index 0473b4bf..13063439 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -2,8 +2,8 @@ #include "ws_cull_edge.h" /* psx_ws_cull_bgez / psx_ws_clip_edge_x math */ /* ---- Overlay dispatch shim (inserted by compile_overlays.py) ----------- */ -static OverlayCallbacks g_cbs; -static uint32_t s_pending_cycles; +static PSX_OVERLAY_THREAD_LOCAL OverlayCallbacks g_cbs; +static PSX_OVERLAY_THREAD_LOCAL uint32_t s_pending_cycles; PSX_OVERLAY_EXPORT void overlay_flush_cycles(void) { uint32_t cycles = s_pending_cycles; @@ -16,27 +16,34 @@ PSX_OVERLAY_EXPORT void overlay_flush_cycles(void) { * branch). They start at local dummies so a pre-init call can't fault. */ static int s_bail_dummy; static uint64_t s_ctr_dummy0, s_ctr_dummy1; -int *g_psx_call_bail_p = &s_bail_dummy; -uint64_t *g_psx_bail_first_p = &s_ctr_dummy0; -uint64_t *g_psx_bail_resolved_p = &s_ctr_dummy1; +static PSX_OVERLAY_THREAD_LOCAL int s_bound_bail_dummy; +static PSX_OVERLAY_THREAD_LOCAL uint64_t s_bound_ctr_dummy0, s_bound_ctr_dummy1; +PSX_OVERLAY_THREAD_LOCAL int *g_psx_call_bail_p = &s_bail_dummy; +PSX_OVERLAY_THREAD_LOCAL uint64_t *g_psx_bail_first_p = &s_ctr_dummy0; +PSX_OVERLAY_THREAD_LOCAL uint64_t *g_psx_bail_resolved_p = &s_ctr_dummy1; /* g_debug_last_store_pc (ABI v24): cpu_state.h maps the emitter's writes to * *g_psx_last_store_pc_p. The runtime reads the value in every build (memory.c * store filters, write attribution), so overlay_init points this at the host's * copy; the local one only catches a store made before init or on a NULL host. */ static uint32_t s_last_store_pc_local; -uint32_t *g_psx_last_store_pc_p = &s_last_store_pc_local; +static PSX_OVERLAY_THREAD_LOCAL uint32_t s_bound_last_store_pc; +PSX_OVERLAY_THREAD_LOCAL uint32_t *g_psx_last_store_pc_p = &s_last_store_pc_local; PSX_OVERLAY_EXPORT int overlay_abi(void) { return PSX_OVERLAY_ABI_VERSION | (PSX_OVERLAY_FLAVOR << 16); } PSX_OVERLAY_EXPORT void overlay_init(const OverlayCallbacks *cbs) { + /* Binding is done on the thread that will execute the shard, before any + * guest call. Never carry a previous context's unpublished cycles into a + * new binding. The host must flush before rebinding a running context. */ + s_pending_cycles = 0; g_cbs = *cbs; - if (g_cbs.call_bail_flag) g_psx_call_bail_p = g_cbs.call_bail_flag; - if (g_cbs.bail_first) g_psx_bail_first_p = g_cbs.bail_first; - if (g_cbs.bail_resolved) g_psx_bail_resolved_p = g_cbs.bail_resolved; + g_psx_call_bail_p = g_cbs.call_bail_flag ? g_cbs.call_bail_flag : &s_bound_bail_dummy; + g_psx_bail_first_p = g_cbs.bail_first ? g_cbs.bail_first : &s_bound_ctr_dummy0; + g_psx_bail_resolved_p = g_cbs.bail_resolved ? g_cbs.bail_resolved : &s_bound_ctr_dummy1; g_psx_last_store_pc_p = g_cbs.last_store_pc ? g_cbs.last_store_pc - : &s_last_store_pc_local; + : &s_bound_last_store_pc; } void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t ra) { diff --git a/runtime/tests/test_overlay_thread_binding.c b/runtime/tests/test_overlay_thread_binding.c new file mode 100644 index 00000000..dda60093 --- /dev/null +++ b/runtime/tests/test_overlay_thread_binding.c @@ -0,0 +1,100 @@ +/* Concurrent calls through the production shard shim must keep the host's + * callbacks, pending cycles and mutable dispatch breadcrumbs separate. */ +#include +#include +#include "overlay_dispatch_preamble.c.inc" +/* Checks and thread creation must still execute in Release builds. */ +#define assert(condition) do { if (!(condition)) abort(); } while (0) +#ifdef _WIN32 +#include +#else +#include +#endif + +static atomic_int phase; +static unsigned live_cycles, replay_cycles; +static int live_bail, replay_bail; +static uint32_t live_pc, replay_pc; +static uint64_t live_first, replay_first, live_resolved, replay_resolved; +static void charge_live(uint32_t cycles) { live_cycles += cycles; } +static void charge_replay(uint32_t cycles) { replay_cycles += cycles; } +static void wait_for(int value) { + while (atomic_load_explicit(&phase, memory_order_acquire) != value) { } +} +static void signal_phase(int value) { + atomic_store_explicit(&phase, value, memory_order_release); +} +static void replay(void) { + OverlayCallbacks cbs = {0}; + cbs.cpu_charge = charge_replay; + cbs.call_bail_flag = &replay_bail; + cbs.last_store_pc = &replay_pc; + cbs.bail_first = &replay_first; + cbs.bail_resolved = &replay_resolved; + overlay_init(&cbs); + psx_cpu_charge(11); + g_debug_last_store_pc = 0x80002000u; + g_psx_call_bail = 2; + g_psx_bail_first = 3; + g_psx_bail_resolved = 4; + signal_phase(1); + wait_for(2); + assert(g_debug_last_store_pc == 0x80002000u); + assert(g_psx_call_bail == 2); + overlay_flush_cycles(); + assert(replay_cycles == 11); + /* Rebinding with missing pointers must not retain the former host's + * state, and fallback words must belong to this thread too. */ + cbs.call_bail_flag = 0; + cbs.last_store_pc = 0; + cbs.bail_first = 0; + cbs.bail_resolved = 0; + overlay_init(&cbs); + g_psx_call_bail = 99; + g_debug_last_store_pc = 99; + signal_phase(3); +} +#ifdef _WIN32 +static DWORD WINAPI replay_entry(LPVOID arg) { (void)arg; replay(); return 0; } +#else +static void *replay_entry(void *arg) { (void)arg; replay(); return 0; } +#endif +int main(void) { + OverlayCallbacks cbs = {0}; + cbs.cpu_charge = charge_live; + cbs.call_bail_flag = &live_bail; + cbs.last_store_pc = &live_pc; + cbs.bail_first = &live_first; + cbs.bail_resolved = &live_resolved; + overlay_init(&cbs); +#ifdef _WIN32 + HANDLE thread = CreateThread(0, 0, replay_entry, 0, 0, 0); + assert(thread); +#else + pthread_t thread; + assert(pthread_create(&thread, 0, replay_entry, 0) == 0); +#endif + wait_for(1); + psx_cpu_charge(7); + g_debug_last_store_pc = 0x80001000u; + g_psx_call_bail = 5; + g_psx_bail_first = 6; + g_psx_bail_resolved = 7; + overlay_flush_cycles(); + assert(live_cycles == 7); + signal_phase(2); + wait_for(3); +#ifdef _WIN32 + assert(WaitForSingleObject(thread, 10000) == WAIT_OBJECT_0); + CloseHandle(thread); +#else + assert(pthread_join(thread, 0) == 0); +#endif + assert(live_bail == 5 && replay_bail == 2); + assert(live_pc == 0x80001000u && replay_pc == 0x80002000u); + assert(live_first == 6 && replay_first == 3); + assert(live_resolved == 7 && replay_resolved == 4); + assert(g_debug_last_store_pc == live_pc); + assert(g_psx_call_bail == live_bail); + return 0; +}