From 5ab7a053954b3992e252ea5f7a0ffc3bfa617f00 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sat, 29 Aug 2026 18:56:46 +0300 Subject: [PATCH 1/8] tools: probe falls back to PSX.EXE boot when SYSTEM.CNF is absent --- tools/new_project_layout/probe_disc.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tools/new_project_layout/probe_disc.py b/tools/new_project_layout/probe_disc.py index 0cc10fc6d..7fee01e13 100755 --- a/tools/new_project_layout/probe_disc.py +++ b/tools/new_project_layout/probe_disc.py @@ -388,12 +388,24 @@ def read_user_iso(d: bytes, lba: int) -> bytes: entries = parse_root_entries(bytes(root[:root_size])) if "SYSTEM.CNF" not in entries: - raise SystemExit( - f"SYSTEM.CNF missing on disc (found {sorted(entries)[:24]})" + # Very early titles (e.g. King's Field, Dec 1994) ship no SYSTEM.CNF; + # the BIOS falls back to booting PSX.EXE from the root directory. + psx_exe = next((k for k in entries if k.upper() == "PSX.EXE"), None) + if psx_exe is None: + raise SystemExit( + f"SYSTEM.CNF missing on disc and no PSX.EXE fallback " + f"(found {sorted(entries)[:24]})" + ) + boot_token = psx_exe + warnings.append( + "SYSTEM.CNF missing; using the BIOS PSX.EXE fallback boot path. " + "No serial is recoverable from the filesystem — set game_id " + "manually in catalog_identity.json / game.toml." ) - extent, fsize = entries["SYSTEM.CNF"] - cnf = read_file(read_user, data, extent, fsize) - boot_token = parse_system_cnf(cnf) + else: + extent, fsize = entries["SYSTEM.CNF"] + cnf = read_file(read_user, data, extent, fsize) + boot_token = parse_system_cnf(cnf) serial, boot_exe = normalize_serial(boot_token) disc_boot = boot_token From 54fe30bf431009bfda410893235f87f76ecf3fa6 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sun, 30 Aug 2026 00:13:40 +0300 Subject: [PATCH 2/8] runtime: apply present cadence on first video-standard observation (fast-boot PAL vsync+pacer double-block) --- runtime/src/main.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 75bb8121c..b78a58fb0 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2802,13 +2802,18 @@ static void present_track_video_standard(void) { static int s_last_pal = -1; const int pal = present_video_standard_is_pal(); if (pal == s_last_pal) return; - if (s_last_pal >= 0) { - apply_present_cadence(); - std::printf("psxrecomp: video standard now %s; present cadence %s (%.4f ms/frame)\n", - pal ? "PAL (50 Hz)" : "NTSC (59.94 Hz)", - present_vsync_owns_cadence() ? "driver vsync" : "wall-clock pacer", - present_effective_frame_period_ms()); - } + /* Apply on the FIRST observation too: with fast boot a PAL title is + * already in PAL mode at the first tracked present, so there is no + * later transition to re-apply cadence — the init-time swap interval + * (chosen while the GPU still sat in its NTSC default) would stay + * vsync-on while the live PAL check enables the 20 ms wall pacer, + * and the two waits stack to ~46 FPS (Kula World, wave-2 playtest). */ + apply_present_cadence(); + std::printf("psxrecomp: video standard %s%s; present cadence %s (%.4f ms/frame)\n", + s_last_pal >= 0 ? "now " : "", + pal ? "PAL (50 Hz)" : "NTSC (59.94 Hz)", + present_vsync_owns_cadence() ? "driver vsync" : "wall-clock pacer", + present_effective_frame_period_ms()); s_last_pal = pal; } From fff63301d75e0d7d0c2e5d3c608e49f200db137f Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sun, 30 Aug 2026 13:55:11 +0300 Subject: [PATCH 3/8] recompiler: sync GTE read-helper classification with runtime accessor semantics; cosim: per-instance cwd --- recompiler/include/gte_register_classification.h | 16 ++++++++++++---- tools/cosim.py | 9 ++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/recompiler/include/gte_register_classification.h b/recompiler/include/gte_register_classification.h index b3dda21c0..76485e0d7 100644 --- a/recompiler/include/gte_register_classification.h +++ b/recompiler/include/gte_register_classification.h @@ -8,7 +8,12 @@ namespace PSXRecompGTERegisters { constexpr bool data_read_needs_helper(uint8_t reg) { - return (reg >= 8 && reg <= 11) || reg == 15 || reg == 23 || + /* Must cover every reg gte_read_data() treats specially (gte.cpp): + * 1,3,5,8-11 sign-extend; 7,16-19 mask to 16 bits; 15 mirrors 14; + * 28/29 pack IRGB; 31 computes LZCR. 23 kept from the legacy set. */ + return reg == 1 || reg == 3 || reg == 5 || reg == 7 || + (reg >= 8 && reg <= 11) || reg == 15 || + (reg >= 16 && reg <= 19) || reg == 23 || reg == 28 || reg == 29 || reg == 31; } @@ -19,7 +24,10 @@ constexpr bool data_write_needs_helper(uint8_t reg) { } constexpr bool ctrl_read_needs_helper(uint8_t reg) { - return reg == 26 || reg == 27 || reg == 29 || reg == 30 || reg == 31; + /* Must cover every reg gte_read_ctrl() sign-extends (gte.cpp): + * 4, 12, 20, 26, 27, 29, 30. 31 kept from the legacy set. */ + return reg == 4 || reg == 12 || reg == 20 || reg == 26 || + reg == 27 || reg == 29 || reg == 30 || reg == 31; } constexpr bool ctrl_write_needs_helper(uint8_t reg) { @@ -37,11 +45,11 @@ constexpr uint32_t helper_mask(bool (*predicate)(uint8_t)) { /* Independent architectural expectations. These deliberately do not derive * from one another: changing a predicate requires an explicit review of the * register mask, rather than letting both emitter tests agree on a bad table. */ -static_assert(helper_mask(data_read_needs_helper) == 0xB0808F00u, +static_assert(helper_mask(data_read_needs_helper) == 0xB08F8FAAu, "GTE data-read helper set changed"); static_assert(helper_mask(data_write_needs_helper) == 0xF08FFFAAu, "GTE data-write helper set changed"); -static_assert(helper_mask(ctrl_read_needs_helper) == 0xEC000000u, +static_assert(helper_mask(ctrl_read_needs_helper) == 0xEC101010u, "GTE control-read helper set changed"); static_assert(helper_mask(ctrl_write_needs_helper) == 0xEC101010u, "GTE control-write helper set changed"); diff --git a/tools/cosim.py b/tools/cosim.py index 882e92e59..d2753ec98 100644 --- a/tools/cosim.py +++ b/tools/cosim.py @@ -42,6 +42,13 @@ def tail_file(path, max_bytes=8192): def launch(mode, port, stride, start_cycle): os.makedirs(LOGDIR, exist_ok=True) + # Two instances sharing one cwd race on mods/state.toml publish (temp-file + # rename collision, ENOENT). COSIM_CWD_B gives the second instance its own + # runtime dir when set. + cwd = CWD + cwd_b = os.environ.get("COSIM_CWD_B", "") + if cwd_b and port != 4600 and str(port).endswith("1"): + cwd = cwd_b env = dict(os.environ) env["PSX_HEADLESS"] = "1" env["PSX_COSIM_PORT"] = str(port) @@ -57,7 +64,7 @@ def launch(mode, port, stride, start_cycle): log_path = os.path.join(LOGDIR, f"cosim_{mode}_{port}_{os.getpid()}.log") log_file = open(log_path, "wb") p = subprocess.Popen([EXE, "--headless", "--no-launcher", "--game", GAME], - cwd=CWD, env=env, + cwd=cwd, env=env, stdout=log_file, stderr=subprocess.STDOUT, creationflags=0x00000200) p._cosim_log_path = log_path From 77112830527a4c4409fcf5aebca9c34c06dca9e5 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sun, 30 Aug 2026 15:46:49 +0300 Subject: [PATCH 4/8] cosim: key checkpoints on retired-instruction count, not guest cycles The cycle-keyed design hashed+parked at the first cosim_instr boundary AFTER the stride cycle was crossed; the two backends can reach that crossing at different retirement positions, so the oracle compared different architectural states (observed as a +5-cycle park skew and phantom CPU-register divergences mid-CPS). Checkpoints are now taken inside cosim_instr, keyed on the retirement count itself (a branch and its delay slot retire as one event in both backends). Checkpoint k = state after k*stride retirements in BOTH instances by construction. The guest-cycle clock stays inside the state hash, so real cycle-accounting drift is still detected -- now pinned to an exact instruction window instead of corrupting the alignment. cosim.py: stride/--max/--start-cycle now in retirement units; the cycle skew warning is now reported as a real timing divergence. launch() gives instance B its own exe (the mods root is exe-dir-relative, so a shared exe races on mods/state.toml publish regardless of cwd separation) and refuses a stale COSIM_CWD_B exe. Gates on CMR2 (SLUS-01222): compiled/compiled and interp/interp both clean over 100M retirements with identical cross-pair chains; injected 1-bit RAM fault detected at the next checkpoint. --- runtime/src/cosim.c | 77 +++++++++++++++++++++++---------------------- tools/cosim.py | 47 ++++++++++++++++++++------- 2 files changed, 74 insertions(+), 50 deletions(-) diff --git a/runtime/src/cosim.c b/runtime/src/cosim.c index 02c199f7f..306d1bf75 100644 --- a/runtime/src/cosim.c +++ b/runtime/src/cosim.c @@ -48,11 +48,10 @@ typedef struct { uint64_t cp; uint32_t pc; uint64_t hash; uint32_t istat, imask; static Entry g_ring[RING_N]; static uint64_t g_cp = 0; /* checkpoints crossed so far */ static uint64_t g_chain = 1469598103934665603ULL; /* cumulative FNV over checkpoints */ -static uint64_t g_stride = 4096; /* guest cycles per checkpoint (coordinator sets) */ -static uint64_t g_next_cp = 0; /* next cycle at which to checkpoint */ +static uint64_t g_stride = 4096; /* retired instructions per checkpoint */ +static uint64_t g_next_cp = 0; /* icount at which to take the next checkpoint */ static uint32_t g_last_leader_pc = 0; /* set by cosim_block, reported at checkpoints */ -static uint64_t g_pending_first_cycle = 0; -static uint64_t g_pending_count = 0; +static uint64_t g_icount = 0; /* retirement events (cosim_instr calls) */ /* lockstep control (written by TCP thread, read by guest thread). * The guest parks at EVERY checkpoint boundary (a deterministic guest cycle = multiple @@ -77,25 +76,29 @@ void cosim_block(uint32_t pc) { g_last_leader_pc = pc; } uint32_t cosim_last_block(void) { return g_last_leader_pc; } uint32_t cosim_cycles_to_next_checkpoint(void) { - uint64_t now = psx_cycle_count; - if (now >= g_next_cp) return 0; - uint64_t d = g_next_cp - now; - return d > 0xFFFFFFFFULL ? 0xFFFFFFFFu : (uint32_t)d; + return 0; /* checkpoints are icount-keyed; no cycle sub-step cap needed */ } -/* Cycle-keyed checkpoint — called from psx_advance_cycles (both backends, identical - * per-instruction charges). Folds a full-state hash into the chain at each guest-cycle - * stride and parks at the coordinator's stop cycle. This is the alignment clock. */ -static void cosim_record_checkpoint(uint64_t cycle, uint32_t pc) { +/* Instruction-count-keyed checkpoint — taken inside cosim_instr, which both + * backends call exactly once per retirement event (a branch and its delay slot + * retire as ONE event in both the dirty-RAM interp and the emitted code). That + * makes checkpoint k "the state after k*stride retirements" by construction, so + * the two instances always hash the SAME architectural position. The guest-cycle + * clock is part of the hash (cosim_state.c), so any cycle-accounting drift shows + * up as a chain divergence AT an exact instruction instead of silently skewing + * where the two instances park (the old cycle-keyed design parked at the first + * instruction boundary after the stride cycle, which the two backends could + * reach at different retirement positions — phantom divergences). */ +static void cosim_record_checkpoint(uint64_t icount, uint32_t pc) { uint64_t h = cosim_state_hash(NULL); uint64_t cp = ++g_cp; - g_chain = fold(fold(g_chain, cycle), h); + g_chain = fold(fold(g_chain, icount), h); Entry *e = &g_ring[cp & (RING_N - 1u)]; e->cp = cp; e->pc = pc; e->hash = h; - e->istat = i_stat; e->imask = i_mask; e->cycle = cycle; + e->istat = i_stat; e->imask = i_mask; e->cycle = psx_cycle_count; /* deterministic park: consume one checkpoint of budget, else block for `step`. - * The guest ALWAYS stops here (a fixed cycle boundary), never at a wall-time point. */ + * The guest ALWAYS stops here (a fixed icount boundary), never at a wall-time point. */ if (g_run_budget > 0) { g_run_budget--; return; } g_parked = 1; while (g_run_budget <= 0) { @@ -107,29 +110,24 @@ static void cosim_record_checkpoint(uint64_t cycle, uint32_t pc) { } void cosim_tick(void) { - uint64_t now = psx_cycle_count; - if (now < g_next_cp) return; - - if (g_cp == 0 && now == 0 && g_next_cp == 0) { - cosim_record_checkpoint(0, g_last_leader_pc); - g_next_cp = g_stride ? g_stride : 1; - return; - } - - while (now >= g_next_cp) { - if (g_pending_count == 0) g_pending_first_cycle = g_next_cp; - g_pending_count++; - g_next_cp += g_stride ? g_stride : 1; - } + /* Retained for the psx_advance_cycles call sites; checkpointing moved to + * the icount key in cosim_instr (see cosim_record_checkpoint comment). */ } void cosim_instr(uint32_t pc) { g_last_leader_pc = pc; - while (g_pending_count > 0) { - uint64_t cycle = g_pending_first_cycle; - g_pending_first_cycle += g_stride ? g_stride : 1; - g_pending_count--; - cosim_record_checkpoint(cycle, pc); + g_icount++; + if (g_cp == 0 && g_next_cp == 0) { + /* Initial park at the very first retirement: the coordinator gains + * control before the guest free-runs (unless PSX_COSIM_START_CYCLE + * set a free-run target, which makes g_next_cp nonzero). */ + cosim_record_checkpoint(g_icount, pc); + g_next_cp = g_icount + (g_stride ? g_stride : 1); + return; + } + if (g_icount >= g_next_cp) { + cosim_record_checkpoint(g_icount, pc); + g_next_cp = g_icount + (g_stride ? g_stride : 1); } } @@ -200,8 +198,9 @@ static void handle_line(sock_t s, char *line) { if (sscanf(line, "%31s", cmd) != 1) { send_line(s, "err empty\n"); return; } if (!strcmp(cmd, "status")) { - snprintf(out, sizeof out, "cp %llu cycle %llu chain %016llx stride %llu parked %d\n", + snprintf(out, sizeof out, "cp %llu cycle %llu icnt %llu chain %016llx stride %llu parked %d\n", (unsigned long long)g_cp, (unsigned long long)psx_cycle_count, + (unsigned long long)g_icount, (unsigned long long)g_chain, (unsigned long long)g_stride, g_parked); send_line(s, out); return; } @@ -229,9 +228,10 @@ static void handle_line(sock_t s, char *line) { while (g_run_budget > 0 && spins < 1200000) { COSIM_SLEEP(1); spins++; } /* small settle so g_parked/g_chain reflect the checkpoint just recorded */ int s2 = 0; while (!g_parked && g_run_budget <= 0 && s2 < 2000) { COSIM_SLEEP(1); s2++; } - snprintf(out, sizeof out, "%s cp %llu cycle %llu chain %016llx\n", + snprintf(out, sizeof out, "%s cp %llu cycle %llu icnt %llu chain %016llx\n", g_parked ? "parked" : "running", (unsigned long long)g_cp, (unsigned long long)psx_cycle_count, + (unsigned long long)g_icount, (unsigned long long)g_chain); send_line(s, out); return; } @@ -369,8 +369,9 @@ void cosim_init(void) { unsigned short port = 4600; const char *e = getenv("PSX_COSIM_PORT"); if (e && *e) port = (unsigned short)atoi(e); - /* Stride fixed at launch (env) so the checkpoint cycle boundaries are identical in - * both processes before either runs a single instruction — no set-stride race. */ + /* Stride fixed at launch (env) so the checkpoint icount boundaries are identical + * in both processes before either runs a single instruction — no set-stride race. + * Both stride and start are in RETIRED INSTRUCTIONS (icount), not guest cycles. */ const char *st = getenv("PSX_COSIM_STRIDE"); if (st && *st) { unsigned long long v = strtoull(st, 0, 10); if (v) g_stride = v; } const char *sc = getenv("PSX_COSIM_START_CYCLE"); diff --git a/tools/cosim.py b/tools/cosim.py index d2753ec98..3f3a20d94 100644 --- a/tools/cosim.py +++ b/tools/cosim.py @@ -2,12 +2,16 @@ """cosim.py — first-divergence co-simulation coordinator. See COSIM_ORACLE.md. Launches two clean psx-cosim instances (each its own complete deterministic PSX), -advances BOTH to the same guest-cycle checkpoints, and compares their full-state chain -hashes. The first checkpoint whose chains differ brackets the first divergence; the +advances BOTH to the same retired-instruction-count (icount) checkpoints, and compares +their full-state chain hashes. Checkpoints are icount-keyed (stride / --max / +--start-cycle are all in retired instructions, where a branch+delay-slot retires as +ONE event), so both sides always hash the same architectural position; the guest-cycle +clock is inside the hash, so cycle-accounting drift is itself a detectable divergence. +The first checkpoint whose chains differ brackets the first divergence; the per-subsystem `sub` hashes + the ring `window` say WHAT and WHERE. The two instances are NOT interleaved on one timeline — they are two independent -deterministic runs, sampled at matched guest cycles. Validity rests ENTIRELY on +deterministic runs, sampled at matched icounts. Validity rests ENTIRELY on determinism, which is why you MUST pass the gates first: # GATE 1 — determinism/hashing: two of the SAME backend must NEVER diverge. @@ -42,13 +46,27 @@ def tail_file(path, max_bytes=8192): def launch(mode, port, stride, start_cycle): os.makedirs(LOGDIR, exist_ok=True) - # Two instances sharing one cwd race on mods/state.toml publish (temp-file - # rename collision, ENOENT). COSIM_CWD_B gives the second instance its own - # runtime dir when set. + # Two instances sharing one runtime dir race on mods/state.toml publish + # (temp-file rename collision, ENOENT). The mods root is EXE-DIR-relative + # (main.cpp: exe_dir_from_argv(argv[0]) / "mods"), NOT cwd-relative — so + # instance B needs its OWN exe copy, not just its own cwd. COSIM_CWD_B + # names B's runtime dir; B runs the exe found there (override: COSIM_EXE_B). cwd = CWD + exe = EXE cwd_b = os.environ.get("COSIM_CWD_B", "") if cwd_b and port != 4600 and str(port).endswith("1"): cwd = cwd_b + exe_b = os.environ.get("COSIM_EXE_B", + os.path.join(cwd_b, os.path.basename(EXE))) + if not os.path.isfile(exe_b): + raise RuntimeError( + f"instance B exe not found: {exe_b} — copy the freshly built " + f"exe into COSIM_CWD_B (its mods/ root is exe-dir-relative)") + if os.path.getmtime(exe_b) < os.path.getmtime(EXE) - 1: + raise RuntimeError( + f"instance B exe is STALE: {exe_b} is older than {EXE} — " + f"copy the freshly built exe into COSIM_CWD_B") + exe = exe_b env = dict(os.environ) env["PSX_HEADLESS"] = "1" env["PSX_COSIM_PORT"] = str(port) @@ -63,7 +81,7 @@ def launch(mode, port, stride, start_cycle): env["PSX_FORCE_INTERP"] = "1" log_path = os.path.join(LOGDIR, f"cosim_{mode}_{port}_{os.getpid()}.log") log_file = open(log_path, "wb") - p = subprocess.Popen([EXE, "--headless", "--no-launcher", "--game", GAME], + p = subprocess.Popen([exe, "--headless", "--no-launcher", "--game", GAME], cwd=cwd, env=env, stdout=log_file, stderr=subprocess.STDOUT, creationflags=0x00000200) @@ -157,13 +175,15 @@ def main(): ap.add_argument("--a", default="compiled", choices=["compiled", "interp"]) ap.add_argument("--b", default="interp", choices=["compiled", "interp"]) ap.add_argument("--stride", type=int, default=65536) - ap.add_argument("--max", type=int, default=1_500_000_000) # ~2650 frames + ap.add_argument("--max", type=int, default=1_500_000_000, + help="stop after this many retired instructions (icount units)") ap.add_argument("--porta", type=int, default=4600) ap.add_argument("--portb", type=int, default=4601) ap.add_argument("--inject-at", type=int, default=0) ap.add_argument("--inject", default="") # e.g. ram:100000:1 or reg:2:1 ap.add_argument("--start-cycle", type=int, default=0, - help="free-run to this absolute guest cycle before checkpointing") + help="free-run to this absolute retired-instruction count " + "(icount) before checkpointing") ap.add_argument("--cpudiff-at-cp", type=int, default=0, help="step both to this checkpoint and field-diff the CPU dump") args = ap.parse_args() @@ -266,9 +286,12 @@ def main(): f" A: {ra}\n B: {rb}", flush=True); return cyc_a, cyc_b = ra.get("cycle"), rb.get("cycle") if cyc_a != cyc_b: - print(f"[WARN] cycle skew A={cyc_a} B={cyc_b} at cp {ra.get('cp')} — " - f"the two runs are NOT parking at the same cycle (harness " - f"nondeterminism, not a guest divergence). Investigate before trusting.", + print(f"[TIMING] cycle skew A={cyc_a} B={cyc_b} at cp {ra.get('cp')} " + f"icnt A={ra.get('icnt')} B={rb.get('icnt')} — checkpoints are " + f"icount-keyed, so BOTH sides hashed the state after the same " + f"retired-instruction count; a cycle difference here is a REAL " + f"cycle-accounting divergence between the backends (the clock is " + f"in the chain hash, so the chain should differ too).", flush=True) if ca != cb: print(f"\n*** FIRST DIVERGENCE at checkpoint cp={ra.get('cp')} " From e01e2f29abd4d3a9bcbcccf716c6ed20418bf20c Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sun, 30 Aug 2026 16:43:24 +0300 Subject: [PATCH 5/8] codegen: stale-static guard on inter-piece host transfers A game that overlays its own text at runtime (CMR2 streams transform- loop variants with patched projection constants over the boot EXE) kept executing the stale static translation through inter-piece host transfers: dispatch validates an entry's emitted ranges, but split function pieces chain via direct host calls (fallthrough to split piece / next function, legacy non-CPS split branch/jump/continuation transfers, non-CPS direct jal) with no revalidation. A clean entry piece falls through natively into a stale sibling whose live bytes an overlay replaced -- the CMR2 attract/gameplay wedge corruption (first divergence: one ADDI immediate, screen-center 160 static vs 256 live, at 0x800199D4, retirement 3,203,596,289, frame ~11963). Emit at every such transfer: if (!psx_game_text_native_ok(T)) { cpu->pc = T; return; } publishing the PC and unwinding to the trampoline, whose dispatch takes the sanctioned dirty-RAM-interpreter fallback over live bytes. The non-CPS direct jal falls back to call_by_address instead. dirty_ram_text_native_ok_ranges_from gains a page-clean fast path (skip the memcmp when no touched page is guard-modified or runtime- dirty) so the guards are near-free on pristine pages. cosim.py: print full A/B cpu dumps in cpudiff mode (values, not just diffs). --- recompiler/src/code_generator.cpp | 45 +++++++++++++++++++++++++++++-- runtime/src/memory.c | 19 +++++++++++++ tools/cosim.py | 7 +++-- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 94b3507e1..4194abee7 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -101,6 +101,33 @@ CodeGenerator::CodeGenerator(const PS1Executable& exe, const CodeGenConfig& conf { const char* e = std::getenv("PSX_CPS"); cps_enabled_ = (e == nullptr || e[0] != '0'); } } +/* Inter-piece host transfers (fallthrough into a split piece / the next + * function, and the legacy non-CPS split-target transfers) hand execution to + * another compiled dispatch entry WITHOUT passing the dispatcher's + * stale-static validation. A game that overlays its own text at runtime + * (CMR2 streams transform-loop variants over its boot EXE) keeps executing + * the stale static translation of the target piece through such an edge. + * The guard revalidates the target entry's emitted ranges; on mismatch it + * publishes the PC and unwinds to the trampoline, whose dispatch takes the + * sanctioned dirty-RAM-interpreter fallback over the live bytes. */ +static std::string emit_stale_static_guard(uint32_t target, const std::string& indent) { + return fmt::format( + "{0}if (!psx_game_text_native_ok(0x{1:08X}u)) {{ cpu->pc = 0x{1:08X}u; return; }} /* stale-static guard */\n", + indent, target); +} + +/* Same guard when only the emitted function NAME is at hand (the + * fallthrough-to-next-function edges). Game functions are named func_%08X; + * anything else gets no guard (identical to the pre-guard emission). */ +static std::string emit_stale_static_guard_named(const std::string& name, + const std::string& indent) { + if (name.rfind("func_", 0) != 0) return ""; + char* end = nullptr; + unsigned long v = strtoul(name.c_str() + 5, &end, 16); + if (!end || *end != '\0' || v == 0) return ""; + return emit_stale_static_guard((uint32_t)v, indent); +} + uint32_t CodeGenerator::partial_block_cycle_count(uint32_t addr, const ControlFlowGraph& cfg) const { if (cfg.blocks.count(addr)) { @@ -2154,6 +2181,7 @@ std::string CodeGenerator::translate_basic_block( << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS taken: split */\n", branch_target); } else if (known_functions_.count(branch_target)) { ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); + ss << emit_stale_static_guard(branch_target, config_.indent + config_.indent); ss << config_.indent << config_.indent << fmt::format("func_{:08X}(cpu); return; /* taken: split piece */\n", branch_target); } else { @@ -2172,6 +2200,7 @@ std::string CodeGenerator::translate_basic_block( << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS not taken: split */\n", fall_through_addr); } else if (known_functions_.count(fall_through_addr)) { ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); + ss << emit_stale_static_guard(fall_through_addr, config_.indent + config_.indent); ss << config_.indent << config_.indent << fmt::format("func_{:08X}(cpu); return; /* not taken: split piece */\n", fall_through_addr); } else { @@ -2197,6 +2226,7 @@ std::string CodeGenerator::translate_basic_block( } else if (block.exit_instr.target != 0 && known_functions_.count(block.exit_instr.target)) { // Jump target is out-of-function and is a known function start ss << emit_interrupt_check(block.exit_instr.target, config_.indent); + ss << emit_stale_static_guard(block.exit_instr.target, config_.indent); ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* j to split piece */\n", block.exit_instr.target); @@ -2368,8 +2398,13 @@ std::string CodeGenerator::translate_basic_block( ss << config_.indent << "{ uint32_t _csp = cpu->gpr[29];\n"; ss << emit_interrupt_check(target, config_.indent); if (known_functions_.count(target) > 0) { - ss << config_.indent << fmt::format("func_{:08X}(cpu); /* jal */\n", target); - ss << config_.indent << fmt::format("if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return; }}\n", addr + 8); + ss << config_.indent << fmt::format( + "if (psx_game_text_native_ok(0x{0:08X}u)) {{ func_{0:08X}(cpu); /* jal */\n", target); + ss << config_.indent << fmt::format( + "if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return;\n", addr + 8); + ss << config_.indent << fmt::format( + "}} else {{ call_by_address(cpu, 0x{:08X}u); /* jal: stale-static guard */\n", target); + ss << config_.indent << "if (g_psx_call_bail) return; (void)_csp; } }\n"; } else { ss << config_.indent << fmt::format("call_by_address(cpu, 0x{:08X}u); /* external jal */\n", target); /* psx_dispatch_call validated the (ra, sp) contract; @@ -2383,6 +2418,7 @@ std::string CodeGenerator::translate_basic_block( // Split-function: JAL continuation is outside this function piece. // Tail-call to the continuation piece (at exit_addr + 8, past delay slot). if (known_functions_.count(cont_addr)) { + ss << emit_stale_static_guard(cont_addr, config_.indent); ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* jal cont: split piece */\n", cont_addr); } else { @@ -2424,6 +2460,7 @@ std::string CodeGenerator::translate_basic_block( } else { // Split-function: JALR continuation is outside this function piece. if (known_functions_.count(cont_addr)) { + ss << emit_stale_static_guard(cont_addr, config_.indent); ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* jalr cont: split piece */\n", cont_addr); } else { @@ -2449,6 +2486,7 @@ std::string CodeGenerator::translate_basic_block( } else if (block.exit_instr.type == ControlFlowType::None) { uint32_t next_addr = block.end_addr + 4; if (known_functions_.count(next_addr) > 0) { + ss << emit_stale_static_guard(next_addr, config_.indent); ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* fallthrough to split piece */\n", next_addr); @@ -2787,6 +2825,7 @@ GeneratedFunction CodeGenerator::generate_function( const BasicBlock& last = cfg.blocks.at(cfg.block_order.back()); bool is_reachable = last.is_entry || !last.predecessors.empty(); if (is_reachable) { + body_ss << emit_stale_static_guard_named(fallthrough_name, " "); body_ss << fmt::format(" {}(cpu); /* fallthrough to next function */\n", fallthrough_name); } @@ -3035,6 +3074,7 @@ std::vector CodeGenerator::generate_alias_group( last_block.exit_instr.type == ControlFlowType::Jump) && last_block.successors.empty()); if (needs_fallthrough) { + body << emit_stale_static_guard_named(fallthrough_name, " "); body << fmt::format(" {}(cpu); /* fallthrough to next function */\n", fallthrough_name); } @@ -3224,6 +3264,7 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "extern void cosim_block(uint32_t block_leader_phys);\n"; ss << "extern void cosim_instr(uint32_t pc);\n"; ss << "#endif\n"; + ss << "extern int psx_game_text_native_ok(uint32_t addr); /* stale-static guard (dispatch shard) */\n"; ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; ss << "extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n"; diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 124a6c37c..fb765dddc 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -553,6 +553,25 @@ int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, phys = at; } any = 1; + /* Page-clean fast path: every page this range touches is neither + * guard-modified nor runtime-dirty, so its bytes still equal the + * reference image — skip the memcmp. This keeps the emitted + * stale-static guards on inter-piece host transfers near-free on + * the (overwhelmingly common) pristine pages. */ + { + uint32_t p0 = phys >> DIRTY_RAM_PAGE_SHIFT; + uint32_t p1 = (phys + len - 1u) >> DIRTY_RAM_PAGE_SHIFT; + uint32_t p; + int clean = 1; + for (p = p0; p <= p1; p++) { + if ((text_modified_bitmap[p >> 5] & (1u << (p & 31u))) || + dirty_ram_is_dirty(p << DIRTY_RAM_PAGE_SHIFT)) { + clean = 0; + break; + } + } + if (clean) continue; + } if (memcmp(ram + phys, text_ref_image + (phys - text_ref_lo), len) != 0) { uint32_t off = 0; const uint8_t *live = ram + phys; diff --git a/tools/cosim.py b/tools/cosim.py index 3f3a20d94..74bd17db2 100644 --- a/tools/cosim.py +++ b/tools/cosim.py @@ -200,8 +200,11 @@ def main(): for _ in range(max(0, n)): cmd(sa, "step 1", timeout=1200) cmd(sb, "step 1", timeout=1200) - da = parse_cpu(cmd(sa, "cpu")) - db = parse_cpu(cmd(sb, "cpu")) + ra = cmd(sa, "cpu"); rb = cmd(sb, "cpu") + da = parse_cpu(ra) + db = parse_cpu(rb) + print(f"A cpu dump: {ra}", flush=True) + print(f"B cpu dump: {rb}", flush=True) print(f"CPU field-diff at cp {args.cpudiff_at_cp} (A={args.a} B={args.b}):", flush=True) diffs = [k for k in da if da.get(k) != db.get(k)] if not diffs: From 82fd305628b3aaa37b5296cc1e4f922032a0baf2 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sun, 30 Aug 2026 17:26:38 +0300 Subject: [PATCH 6/8] =?UTF-8?q?runtime:=20validate=20stale-static=20ranges?= =?UTF-8?q?=20in=20full=20=E2=80=94=20drop=20the=20exec=5Fpc=20clip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dirty_ram_text_native_ok_ranges_from clipped validation to [exec_pc, end), assuming a continuation never fetches bytes behind its resume PC. False for any entry with a backward edge: a post-call continuation re-enters mid-function and loops back into the clipped region, executing stale static code. CMR2's overlay engine carries a second copy of the vertex-transform kernel at 0x80016B34 that forked exactly this way (r16 +0xA0 static vs +0x100 live, the same 0x60 delta as the first locus) after the inter-piece transfer guards closed the fallthrough edge. Validation now covers every emitted range of the entry regardless of resume PC. A genuinely patched prologue blocks the whole entry to the dirty-RAM interpreter -- correct, merely slower. --- runtime/src/memory.c | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/runtime/src/memory.c b/runtime/src/memory.c index fb765dddc..82468fb38 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -538,6 +538,14 @@ int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, g_text_native_blocked++; return 0; } + /* NOTE (2026-08-30): ranges are validated IN FULL regardless of exec_pc. + * The former [exec_pc, end) clip assumed a continuation "never fetches + * the patched bytes" behind its resume PC — false for any entry with a + * backward edge: a post-call continuation re-enters mid-function and + * loops back into the clipped-away region, executing stale static code + * (CMR2 overlay corruption, second locus 0x80016B34). A genuinely + * patched prologue now blocks the whole entry to the interpreter — + * correct, merely slower. */ int any = 0; for (uint32_t i = 0; i < count; i++) { uint32_t phys = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; @@ -547,11 +555,6 @@ int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, g_text_native_blocked++; return 0; } - if (phys + len <= at) continue; - if (phys < at) { - len -= at - phys; - phys = at; - } any = 1; /* Page-clean fast path: every page this range touches is neither * guard-modified nor runtime-dirty, so its bytes still equal the From 4549dc9c25afc4fa323c9da16791ec3ac3f8b46f Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Thu, 10 Sep 2026 15:57:06 +0300 Subject: [PATCH 7/8] runtime: port freeze heartbeat stack capture to POSIX Linux/macOS builds kept no freeze observability: freeze_heartbeat_start was a Windows-only no-op (Linux smoke finding 5, 2026-08-29). This ports the heartbeat thread to pthreads and the main-thread stack capture to a signal-based walker. POSIX design: heartbeat thread = pthread + nanosleep. Stack capture = SIGUSR2 delivered to the main thread; the handler runs on the main thread, so backtrace() walks the wedged stack directly into a preallocated static buffer; the heartbeat thread polls a sig_atomic flag with a 64ms bound, then symbolizes via dladdr (symbol, module, RVA for offline addr2line). Barrier before the capture flag; uninterruptible wedges return an empty array. Self-capture guard matches the Windows rule (fatal path never walks itself). Dump mutex and atomic rename were already dual-path. Windows path unchanged; compiles clean on MinGW (object check). POSIX path compiles on glibc targets only (execinfo/dladdr); Linux build and gate validation deferred to users and Linux CI per owner decision 2026-09-10. --- runtime/src/freeze_heartbeat.c | 134 ++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/runtime/src/freeze_heartbeat.c b/runtime/src/freeze_heartbeat.c index 52c8e86b6..6ec0d76d1 100644 --- a/runtime/src/freeze_heartbeat.c +++ b/runtime/src/freeze_heartbeat.c @@ -14,6 +14,14 @@ #ifdef _WIN32 #include #include +#else +/* POSIX (Linux/macOS): pthread heartbeat thread + signal-based main-thread + * stack capture. Best effort by design — see hb_sig_stack_handler. */ +#include +#include +#include +#include +#include #endif /* State accessors. All defined in other compilation units; declared here @@ -81,6 +89,20 @@ static HANDLE s_thread = NULL; static HANDLE s_main_thread = NULL; /* DuplicateHandle of main thread */ static DWORD s_main_thread_id = 0; static int s_sym_initialized = 0; +#else +static pthread_t s_thread; +static pthread_t s_main_pthread; /* main-thread identity, captured at start */ +static int s_sig_installed = 0; +/* Signal-capture mailbox: the handler runs ON the main thread, fills the + * preallocated frame buffer, and raises the flag. The heartbeat thread + * polls the flag with a bounded timeout (a main thread wedged inside an + * uninterruptible syscall keeps the signal pending — capture returns + * empty, which is the honest result). */ +static volatile sig_atomic_t s_sig_captured = 0; +static volatile int s_sig_depth = 0; +#define HB_SIG SIGUSR2 +#define HB_MAX_STACK_FRAMES 64 +static void *s_sig_frames[HB_MAX_STACK_FRAMES]; #endif #define HB_FILE "psx_freeze_heartbeat.json" @@ -308,6 +330,77 @@ static void freeze_dump_main_stack_samples_json(FILE *f, int n) { } fputc(']', f); } +#else /* POSIX stack capture */ + +static void hb_sig_stack_handler(int sig) { + (void)sig; + /* Runs ON the main thread at delivery, so backtrace() walks the main + * thread's stack above the interrupted point — the same snapshot the + * Windows walker gets from SuspendThread+GetThreadContext. backtrace() + * is not officially async-signal-safe (glibc unwinds in place); + * documented best effort: no allocation, preallocated static buffer, + * bounded depth, single flag raise. */ + s_sig_depth = backtrace((void **)s_sig_frames, HB_MAX_STACK_FRAMES); + __sync_synchronize(); /* frames visible before the capture flag */ + s_sig_captured = 1; +} + +static int hb_sig_capture_main_stack(int timeout_ms) { + /* Never signal self: a capture running on the main thread (fatal path) + * must not walk the main thread — same rule as the Windows walker. */ + if (pthread_equal(pthread_self(), s_main_pthread)) return 0; + if (!s_sig_installed) return 0; + s_sig_captured = 0; + s_sig_depth = 0; + if (pthread_kill(s_main_pthread, HB_SIG) != 0) return 0; + for (int i = 0; i < timeout_ms; i++) { + if (s_sig_captured) { + __sync_synchronize(); /* acquire: frames filled before the flag */ + return s_sig_depth > 0; + } + usleep(1000); + } + return 0; /* signal still pending (uninterruptible wedge) — empty result */ +} + +static void freeze_dump_main_stack_json(FILE *f) { + if (!f) return; + int depth = hb_sig_capture_main_stack(64); + fputc('[', f); + int first = 1; + for (int i = 0; i < depth; i++) { + void *addr = s_sig_frames[i]; + if (!addr) break; + Dl_info info; + memset(&info, 0, sizeof(info)); + int got = dladdr(addr, &info); + fprintf(f, "%s{\"depth\":%d,\"addr\":\"0x%016llX\"", + first ? "" : ",", i, (unsigned long long)(uintptr_t)addr); + if (got && info.dli_sname) { + fprintf(f, ",\"symbol\":\"%s\"", info.dli_sname); + } + if (got && info.dli_fname) { + fprintf(f, ",\"module\":\"%s\",\"rva\":\"0x%llX\"", + info.dli_fname, + (unsigned long long)((uintptr_t)addr - (uintptr_t)info.dli_fbase)); + } + fputc('}', f); + first = 0; + } + fputc(']', f); +} + +static void freeze_dump_main_stack_samples_json(FILE *f, int n) { + if (!f) return; + if (pthread_equal(pthread_self(), s_main_pthread)) { fputs("[]", f); return; } + fputc('[', f); + for (int i = 0; i < n; i++) { + if (i) fputc(',', f); + freeze_dump_main_stack_json(f); /* one signal/capture snapshot */ + usleep(2000); + } + fputc(']', f); +} #endif /* _WIN32 */ /* One dump at a time. The watchdog (heartbeat thread) and psx_fatal_halt @@ -676,7 +769,19 @@ static int freeze_dump_write(long long wall, uint64_t frame, uint64_t cyc, } fputs("\n", f); #else - fputs(" \"main_stack\":[]\n", f); + fputs(" \"main_stack\":", f); + if (wedge_kind == 1) { + freeze_dump_main_stack_json(f); + } else { + fputs("[]", f); + } + fputs(",\n \"main_stack_samples\":", f); + if (wedge_kind == 2 || wedge_kind == 3 || wedge_kind == 5) { + freeze_dump_main_stack_samples_json(f, 8); + } else { + fputs("[]", f); + } + fputs("\n", f); #endif { @@ -1095,6 +1200,17 @@ static DWORD WINAPI heartbeat_thread(LPVOID arg) { Sleep(HB_INTERVAL_MS); } } +#else +static void *heartbeat_thread(void *arg) { + (void)arg; + for (;;) { + heartbeat_write(); + struct timespec ts = { HB_INTERVAL_MS / 1000, + (HB_INTERVAL_MS % 1000) * 1000000L }; + nanosleep(&ts, NULL); + } + return NULL; +} #endif void freeze_heartbeat_start(const char *backend_label) { @@ -1124,5 +1240,21 @@ void freeze_heartbeat_start(const char *backend_label) { } s_thread = CreateThread(NULL, 0, heartbeat_thread, NULL, 0, NULL); if (s_thread) s_started = 1; +#else + /* Install the stack-capture handler and pin the main-thread identity. + * This function is called from the main thread at boot (same call-site + * contract as the Windows handle duplication); s_main_pthread is what + * hb_sig_capture_main_stack compares against. */ + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = hb_sig_stack_handler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; + if (sigaction(HB_SIG, &sa, NULL) == 0) s_sig_installed = 1; + s_main_pthread = pthread_self(); + if (pthread_create(&s_thread, NULL, heartbeat_thread, NULL) == 0) { + pthread_detach(s_thread); + s_started = 1; + } #endif } From b053ed00c47408b9dacbebad37629eaaf25e866c Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Fri, 11 Sep 2026 01:20:17 +0300 Subject: [PATCH 8/8] runtime: capture the POSIX freeze stack from a saved context, not in the handler Cubic review finding (P2, PR #31): calling backtrace() from the SIGUSR2 handler can deadlock when the interrupted code holds a loader or allocator lock. The handler now only copies the interrupted ucontext_t and raises a flag (async-signal-safe: no unwinding, allocation, or locking); the heartbeat thread polls with the existing 64ms bound and walks the saved PC/FP/SP itself, rejecting frame pointers outside the main-thread stack bounds recorded at start. Linux and macOS x86-64 use the FP chain; other POSIX targets emit the interrupted PC. _GNU_SOURCE precedes the system headers for pthread_getattr_np. Windows path unchanged and object-compiled on MinGW. --- runtime/src/freeze_heartbeat.c | 139 ++++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 36 deletions(-) diff --git a/runtime/src/freeze_heartbeat.c b/runtime/src/freeze_heartbeat.c index 6ec0d76d1..a8392d06d 100644 --- a/runtime/src/freeze_heartbeat.c +++ b/runtime/src/freeze_heartbeat.c @@ -1,5 +1,11 @@ /* freeze_heartbeat.c — see header for rationale. */ +/* pthread_getattr_np (glibc) and the ucontext register accessors need the GNU + * source contract; define it before any system header on POSIX targets. */ +#if !defined(_WIN32) && !defined(_GNU_SOURCE) +#define _GNU_SOURCE 1 +#endif + #include "freeze_heartbeat.h" #include "freeze_dump_policy.h" #include "debug_server.h" @@ -16,11 +22,12 @@ #include #else /* POSIX (Linux/macOS): pthread heartbeat thread + signal-based main-thread - * stack capture. Best effort by design — see hb_sig_stack_handler. */ + * stack capture. The handler copies the interrupted context only — it is + * async-signal-safe by construction; the heartbeat thread walks it after. */ #include #include #include -#include +#include #include #endif @@ -93,16 +100,23 @@ static int s_sym_initialized = 0; static pthread_t s_thread; static pthread_t s_main_pthread; /* main-thread identity, captured at start */ static int s_sig_installed = 0; -/* Signal-capture mailbox: the handler runs ON the main thread, fills the - * preallocated frame buffer, and raises the flag. The heartbeat thread - * polls the flag with a bounded timeout (a main thread wedged inside an - * uninterruptible syscall keeps the signal pending — capture returns - * empty, which is the honest result). */ +/* Signal-capture mailbox. The handler is async-signal-safe by construction: + * it copies the interrupted ucontext_t and raises a flag. It never unwinds, + * allocates, or takes a lock. (Cubic review finding 2026-09-10: calling + * backtrace() from the handler can deadlock against a loader or allocator + * lock held by the interrupted code.) The heartbeat thread polls the flag + * with a bounded timeout — a main thread wedged inside an uninterruptible + * syscall leaves the signal pending, so the capture reports empty, which is + * the honest result — and then walks the saved context from its own thread. */ static volatile sig_atomic_t s_sig_captured = 0; -static volatile int s_sig_depth = 0; +static ucontext_t s_sig_uctx; #define HB_SIG SIGUSR2 #define HB_MAX_STACK_FRAMES 64 -static void *s_sig_frames[HB_MAX_STACK_FRAMES]; +/* Main-thread stack bounds, recorded at start when the platform reports + * them. A frame pointer outside the stack is rejected, so a corrupt value + * cannot become an unbounded walk or a faulting read. */ +static uintptr_t s_stack_lo = 0; +static uintptr_t s_stack_hi = 0; #endif #define HB_FILE "psx_freeze_heartbeat.json" @@ -332,60 +346,97 @@ static void freeze_dump_main_stack_samples_json(FILE *f, int n) { } #else /* POSIX stack capture */ -static void hb_sig_stack_handler(int sig) { - (void)sig; - /* Runs ON the main thread at delivery, so backtrace() walks the main - * thread's stack above the interrupted point — the same snapshot the - * Windows walker gets from SuspendThread+GetThreadContext. backtrace() - * is not officially async-signal-safe (glibc unwinds in place); - * documented best effort: no allocation, preallocated static buffer, - * bounded depth, single flag raise. */ - s_sig_depth = backtrace((void **)s_sig_frames, HB_MAX_STACK_FRAMES); - __sync_synchronize(); /* frames visible before the capture flag */ - s_sig_captured = 1; +static void hb_sig_stack_handler(int sig, siginfo_t *si, void *uctx) { + (void)sig; (void)si; + /* Async-signal-safe: copy the interrupted context and raise the flag. + * No unwinding, allocation, or locking — see the mailbox comment. */ + if (uctx) { + s_sig_uctx = *(const ucontext_t *)uctx; + __sync_synchronize(); /* context visible before the capture flag */ + s_sig_captured = 1; + } } -static int hb_sig_capture_main_stack(int timeout_ms) { +static int hb_sig_capture_main_context(int timeout_ms) { /* Never signal self: a capture running on the main thread (fatal path) * must not walk the main thread — same rule as the Windows walker. */ if (pthread_equal(pthread_self(), s_main_pthread)) return 0; if (!s_sig_installed) return 0; s_sig_captured = 0; - s_sig_depth = 0; if (pthread_kill(s_main_pthread, HB_SIG) != 0) return 0; for (int i = 0; i < timeout_ms; i++) { if (s_sig_captured) { - __sync_synchronize(); /* acquire: frames filled before the flag */ - return s_sig_depth > 0; + __sync_synchronize(); /* acquire: context copied before the flag */ + return 1; } usleep(1000); } return 0; /* signal still pending (uninterruptible wedge) — empty result */ } +/* Extract the interrupted PC/FP/SP from the saved context. Linux and macOS + * x86-64 supply all three; other POSIX targets emit the frame pointer path + * as unavailable and the walk returns the single interrupted PC. */ +static int hb_uctx_regs(uintptr_t *pc, uintptr_t *fp, uintptr_t *sp) { + *pc = 0; *fp = 0; *sp = 0; +#if defined(__linux__) && defined(__x86_64__) + *pc = (uintptr_t)s_sig_uctx.uc_mcontext.gregs[REG_RIP]; + *fp = (uintptr_t)s_sig_uctx.uc_mcontext.gregs[REG_RBP]; + *sp = (uintptr_t)s_sig_uctx.uc_mcontext.gregs[REG_RSP]; +#elif defined(__APPLE__) && defined(__x86_64__) + *pc = (uintptr_t)s_sig_uctx.uc_mcontext->__ss.__rip; + *fp = (uintptr_t)s_sig_uctx.uc_mcontext->__ss.__rbp; + *sp = (uintptr_t)s_sig_uctx.uc_mcontext->__ss.__rsp; +#else + (void)sp; +#endif + return (*pc != 0); +} + +/* Frame-pointer walk over the saved context, run on the heartbeat thread. + * Bounded twice over: at most HB_MAX_STACK_FRAMES frames, and every candidate + * frame pointer must lie inside the recorded main-thread stack and advance + * toward the stack base. Without stack bounds (or without frame pointers) the + * walk stops after the interrupted PC. */ +static int hb_walk_saved_frames(uintptr_t *out, int max) { + uintptr_t pc = 0, fp = 0, sp = 0; + if (!hb_uctx_regs(&pc, &fp, &sp)) return 0; + (void)sp; + int n = 0; + out[n++] = pc; + while (n < max && fp && s_stack_lo && s_stack_hi) { + if (fp < s_stack_lo || fp + 16 > s_stack_hi) break; + uintptr_t next_fp = *(const uintptr_t *)fp; + uintptr_t ret = *(const uintptr_t *)(fp + sizeof(uintptr_t)); + if (!ret) break; + out[n++] = ret; + if (next_fp <= fp) break; /* must advance toward the stack base */ + fp = next_fp; + } + return n; +} + static void freeze_dump_main_stack_json(FILE *f) { if (!f) return; - int depth = hb_sig_capture_main_stack(64); + if (!hb_sig_capture_main_context(64)) { fputs("[]", f); return; } + uintptr_t frames[HB_MAX_STACK_FRAMES]; + int depth = hb_walk_saved_frames(frames, HB_MAX_STACK_FRAMES); fputc('[', f); - int first = 1; for (int i = 0; i < depth; i++) { - void *addr = s_sig_frames[i]; - if (!addr) break; Dl_info info; memset(&info, 0, sizeof(info)); - int got = dladdr(addr, &info); + int got = dladdr((void *)frames[i], &info); fprintf(f, "%s{\"depth\":%d,\"addr\":\"0x%016llX\"", - first ? "" : ",", i, (unsigned long long)(uintptr_t)addr); + i ? "," : "", i, (unsigned long long)frames[i]); if (got && info.dli_sname) { fprintf(f, ",\"symbol\":\"%s\"", info.dli_sname); } if (got && info.dli_fname) { fprintf(f, ",\"module\":\"%s\",\"rva\":\"0x%llX\"", info.dli_fname, - (unsigned long long)((uintptr_t)addr - (uintptr_t)info.dli_fbase)); + (unsigned long long)(frames[i] - (uintptr_t)info.dli_fbase)); } fputc('}', f); - first = 0; } fputc(']', f); } @@ -1241,16 +1292,32 @@ void freeze_heartbeat_start(const char *backend_label) { s_thread = CreateThread(NULL, 0, heartbeat_thread, NULL, 0, NULL); if (s_thread) s_started = 1; #else - /* Install the stack-capture handler and pin the main-thread identity. + /* Install the context-capture handler and pin the main-thread identity. * This function is called from the main thread at boot (same call-site * contract as the Windows handle duplication); s_main_pthread is what - * hb_sig_capture_main_stack compares against. */ + * hb_sig_capture_main_context compares against. */ struct sigaction sa; memset(&sa, 0, sizeof(sa)); - sa.sa_handler = hb_sig_stack_handler; + sa.sa_sigaction = hb_sig_stack_handler; /* SA_SIGINFO form: gets ucontext */ + sa.sa_flags = SA_RESTART | SA_SIGINFO; sigemptyset(&sa.sa_mask); - sa.sa_flags = SA_RESTART; if (sigaction(HB_SIG, &sa, NULL) == 0) s_sig_installed = 1; +#if defined(__linux__) + { + /* Stack bounds make the saved-context walk safe (rejecting any frame + * pointer outside the main thread's stack). */ + pthread_attr_t attr; + if (pthread_getattr_np(pthread_self(), &attr) == 0) { + void *base = NULL; + size_t size = 0; + if (pthread_attr_getstack(&attr, &base, &size) == 0 && base) { + s_stack_lo = (uintptr_t)base; + s_stack_hi = (uintptr_t)base + size; + } + pthread_attr_destroy(&attr); + } + } +#endif s_main_pthread = pthread_self(); if (pthread_create(&s_thread, NULL, heartbeat_thread, NULL) == 0) { pthread_detach(s_thread);