Merge pull request #646 from RetroPortingToolKit/fix/freeze-spin-whole-window

freeze_heartbeat: no spin_freeze false positives on statically compiled code
This commit is contained in:
Matt Stanley
2026-10-19 13:07:39 -07:00
committed by GitHub
4 changed files with 214 additions and 41 deletions
+56
View File
@@ -89,3 +89,59 @@ int freeze_dump_format_path(char *out, size_t cap, const char *backend,
backend, wall, sequence);
return n > 0 && (size_t)n < cap;
}
int freeze_distinct_add(FreezeDistinct *set, uint32_t value) {
if (set->count > FREEZE_WEDGE_SPIN_MAX_FNS) return 1;
for (uint32_t i = 0; i < set->count; i++)
if (set->values[i] == value) return 0;
set->values[set->count++] = value;
return set->count > FREEZE_WEDGE_SPIN_MAX_FNS;
}
uint32_t freeze_window_fn_diversity(uint64_t lo, uint64_t hi, uint64_t oldest,
int (*get)(uint64_t seq, uint32_t *fn,
int *in_exception)) {
if (lo < oldest) lo = oldest;
FreezeDistinct set = {{0}, 0};
int any = 0;
for (uint64_t seq = lo; seq < hi; seq++) {
uint32_t fn = 0;
int in_exception = 0;
if (!get(seq, &fn, &in_exception) || in_exception) continue;
any = 1;
if (freeze_distinct_add(&set, fn)) break;
}
return any ? set.count : FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN;
}
uint32_t freeze_wedge_classify(const FreezeWedgeSample *window, uint32_t count,
uint32_t fn_diversity,
int (*mdec_recently_active)(uint32_t frames)) {
if (!window || count < 2u) return 0;
const FreezeWedgeSample *oldest = &window[0];
const FreezeWedgeSample *newest = &window[count - 1u];
uint64_t frame_delta = (newest->frame_count >= oldest->frame_count)
? (newest->frame_count - oldest->frame_count) : 0;
uint64_t excre_delta = (newest->exc_reentry >= oldest->exc_reentry)
? (newest->exc_reentry - oldest->exc_reentry) : 0;
if (frame_delta == 0) return FREEZE_WEDGE_HARD;
if (excre_delta / frame_delta > FREEZE_WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD)
return FREEZE_WEDGE_REENTRY_STORM;
if (frame_delta < FREEZE_WEDGE_SLOW_FRAMES_MAX_DELTA) return FREEZE_WEDGE_SLOW_FRAMES;
for (uint32_t i = 1; i < count; i++) {
if (window[i].current_func != oldest->current_func ||
window[i].last_store_pc != oldest->last_store_pc ||
window[i].dirty_ram_insns != oldest->dirty_ram_insns)
return 0;
}
if (fn_diversity != FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN &&
fn_diversity > FREEZE_WEDGE_SPIN_MAX_FNS)
return 0;
/* During an FMV the guest parks in BIOS MDEC code with every pinned
* signal legitimately still while frames advance. */
if (mdec_recently_active &&
mdec_recently_active((uint32_t)(frame_delta > 0xFFFFFFFEu ? 0xFFFFFFFEu : frame_delta) + 1u))
return 0;
return FREEZE_WEDGE_SPIN;
}
+56
View File
@@ -39,6 +39,62 @@ int freeze_dump_policy_observe(FreezeDumpPolicy *policy, uint32_t wedge_kind,
* bounded number of failed attempts before the policy consumes that slot. */
void freeze_dump_policy_record_result(FreezeDumpPolicy *policy, int written);
/* Wedge kinds reported by freeze_wedge_classify (0 = healthy). */
#define FREEZE_WEDGE_HARD 1u /* frame_count did not advance */
#define FREEZE_WEDGE_REENTRY_STORM 2u /* exception re-entries per frame over threshold */
#define FREEZE_WEDGE_SLOW_FRAMES 3u /* frames advanced below the slow-frames floor */
#define FREEZE_WEDGE_SPIN 5u /* guest pinned in one loop while frames advance */
#define FREEZE_WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD 20000u /* ~10x chronic 2K/frame */
#define FREEZE_WEDGE_SLOW_FRAMES_MAX_DELTA 10u /* <5 fps avg over the 2s window */
/* One heartbeat sample, as far as wedge classification needs it. */
typedef struct FreezeWedgeSample {
uint64_t frame_count;
uint64_t exc_reentry;
uint64_t dirty_ram_insns;
uint32_t current_func;
uint32_t last_store_pc;
} FreezeWedgeSample;
/* A spinning guest enters only its wait loop plus whatever the interrupt
* handlers last stamped; a running game loop enters dozens of functions per
* window (LoD title/field/battle >= 13 per 120 frames, measured 2026-10-12). */
#define FREEZE_WEDGE_SPIN_MAX_FNS 8u
#define FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN UINT32_MAX
/* Distinct-value counter that stops counting past its capacity. */
typedef struct FreezeDistinct {
uint32_t values[FREEZE_WEDGE_SPIN_MAX_FNS + 1u];
uint32_t count;
} FreezeDistinct;
/* Add one value; returns nonzero once more than FREEZE_WEDGE_SPIN_MAX_FNS
* distinct values have been seen (the caller can stop scanning). */
int freeze_distinct_add(FreezeDistinct *set, uint32_t value);
/* Distinct functions entered outside exceptions over profile samples
* [lo, hi), clamped to oldest (the ring's oldest held sample). get() returns
* 0 for an evicted/unavailable sample. Stops counting past
* FREEZE_WEDGE_SPIN_MAX_FNS; FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN when no
* non-exception sample covers the window. */
uint32_t freeze_window_fn_diversity(uint64_t lo, uint64_t hi, uint64_t oldest,
int (*get)(uint64_t seq, uint32_t *fn,
int *in_exception));
/* Classify a window of consecutive samples, oldest first. A spin requires the
* executing function, last store PC and dirty-RAM instruction count to hold
* one value at EVERY sample, AND (when known) at most FREEZE_WEDGE_SPIN_MAX_FNS
* distinct guest functions entered outside exceptions over the window.
* Statically compiled code stamps neither current_func nor dirty_ram_insns, so
* the pinned signature alone also matches a healthy static main loop; the
* function-entry stamp is maintained by all generated code. fn_diversity is
* FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN when no profile samples cover the window.
* mdec_recently_active (may be NULL) vetoes a spin while FMV decoding parks
* the guest in BIOS MDEC code. */
uint32_t freeze_wedge_classify(const FreezeWedgeSample *window, uint32_t count,
uint32_t fn_diversity,
int (*mdec_recently_active)(uint32_t frames));
/* Format a unique dump path. The sequence prevents same-second truncation. */
int freeze_dump_format_path(char *out, size_t cap, const char *backend,
long long wall, uint32_t sequence);
+40 -39
View File
@@ -4,6 +4,7 @@
#include "psx_memory.h"
#include "freeze_heartbeat.h"
#include "freeze_dump_policy.h"
#include "guest_cycle_profile.h"
#include "debug_server.h"
#include "crash_trace.h" /* g_psx_fatal_reason */
#include "cpu_state.h" /* g_psx_bail_* call-contract counters */
@@ -125,8 +126,7 @@ static int s_sym_initialized = 0;
* activity (boot, FMV decode, save load) doesn't trip it; short enough
* that real wedges dump within seconds. */
#define WEDGE_WINDOW_TICKS 20u
#define WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD 20000u /* ~10x chronic 2K/frame */
#define WEDGE_SLOW_FRAMES_MAX_DELTA 10u /* <5 fps avg over the 2s window */
/* Thresholds and the classifier live in freeze_dump_policy.{h,c} (unit-tested). */
/* Per-ring caps for auto-dump. Newest-first window. The old 4-16K caps
* spanned well under a second of activity — too short to cross a
@@ -163,6 +163,7 @@ typedef struct {
uint64_t tcp_stall_ms;
uint16_t t1_count; /* Timer1/RCnt1 counter — must advance if the wait is to complete */
uint64_t t1_irq_fired; /* Timer1 IRQ-fire count — flat == RCnt IRQ never fires */
uint64_t gcp_seq; /* guest_cycle_profile_total() at this tick */
} HbRingEntry;
static HbRingEntry s_ring[RING_CAP];
static uint32_t s_ring_head = 0;
@@ -755,6 +756,14 @@ void freeze_heartbeat_fatal_dump(const char *reason) {
sio_get_mc_max_state(), sio_get_tx_writes());
}
/* Adapter from the guest-cycle profile ring to freeze_window_fn_diversity. */
static int gcp_sample_fn(uint64_t seq, uint32_t *fn, int *in_exception) {
GcpSample s;
if (!guest_cycle_profile_get(seq, &s)) return 0;
*fn = s.fn;
*in_exception = (s.flags & GCP_FLAG_EXCEPTION) != 0;
return 1;
}
static void heartbeat_write(void) {
uint64_t cyc = psx_get_cycle_count();
uint64_t frame = s_frame_count;
@@ -805,6 +814,7 @@ static void heartbeat_write(void) {
{ uint16_t _t1c = 0; uint64_t _t1f = 0;
timers_get_debug(1, &_t1c, NULL, NULL, &_t1f);
re->t1_count = _t1c; re->t1_irq_fired = _t1f; }
re->gcp_seq = guest_cycle_profile_total();
s_ring_head = (s_ring_head + 1) % RING_CAP;
if (s_ring_count < RING_CAP) s_ring_count++;
@@ -823,43 +833,34 @@ static void heartbeat_write(void) {
s_ring_count = 0;
s_last_wedge_kind = 0;
} else if (s_ring_count >= WEDGE_WINDOW_TICKS) {
/* The just-pushed tick is at (s_ring_head - 1). The oldest in
* our window is WEDGE_WINDOW_TICKS - 1 ticks before it. */
uint32_t newest_idx = (s_ring_head + RING_CAP - 1u) % RING_CAP;
uint32_t oldest_idx = (s_ring_head + RING_CAP - WEDGE_WINDOW_TICKS) % RING_CAP;
uint64_t newest_frame = s_ring[newest_idx].frame_count;
uint64_t oldest_frame = s_ring[oldest_idx].frame_count;
uint64_t newest_excre = s_ring[newest_idx].exc_reentry;
uint64_t oldest_excre = s_ring[oldest_idx].exc_reentry;
uint64_t frame_delta = (newest_frame >= oldest_frame)
? (newest_frame - oldest_frame) : 0;
uint64_t excre_delta = (newest_excre >= oldest_excre)
? (newest_excre - oldest_excre) : 0;
/* Logical-hang (kind D) signature: the executing function, the last
* store PC, and the retired dirty-RAM instruction count are all
* unchanged across the whole window. Requiring all three pinned makes
* this specific to a guest spin loop — a legitimate long native
* compute would still move last_store_pc, and any interpreted/overlay
* work would advance dirty_insns. Checked only when frames are
* advancing healthily (kinds 1/2/3 take precedence below). */
int logic_pinned =
(s_ring[newest_idx].current_func == s_ring[oldest_idx].current_func) &&
(s_ring[newest_idx].last_store_pc == s_ring[oldest_idx].last_store_pc) &&
(s_ring[newest_idx].dirty_ram_insns == s_ring[oldest_idx].dirty_ram_insns);
if (frame_delta == 0)
wedge_kind = 1;
else if (excre_delta / frame_delta > WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD)
wedge_kind = 2;
else if (frame_delta < WEDGE_SLOW_FRAMES_MAX_DELTA)
wedge_kind = 3;
else if (logic_pinned && !mdec_recently_active((uint32_t)frame_delta + 1u))
wedge_kind = 5; /* spin freeze: game wedged while frames advance */
/* Wave-5 F9: during an FMV the guest parks in BIOS MDEC code with the dirty-RAM
* counters legitimately still while frames advance -- exactly the spin signature.
* Every boot FMV wrote a 47-76 MB dump (1.8 GB per kit in one session). MDEC
* activity inside the window rules the spin classification out. */
/* The just-pushed tick is at (s_ring_head - 1); the window is the
* WEDGE_WINDOW_TICKS samples ending there, passed oldest first. A
* spin must hold at every sample, not just the window ends: static
* code leaves dirty_ram_insns flat, and a per-frame main loop often
* stores at the same PC when two ticks happen to align. */
FreezeWedgeSample window[WEDGE_WINDOW_TICKS];
for (uint32_t i = 0; i < WEDGE_WINDOW_TICKS; i++) {
const HbRingEntry *src =
&s_ring[(s_ring_head + RING_CAP - WEDGE_WINDOW_TICKS + i) % RING_CAP];
window[i].frame_count = src->frame_count;
window[i].exc_reentry = src->exc_reentry;
window[i].dirty_ram_insns = src->dirty_ram_insns;
window[i].current_func = src->current_func;
window[i].last_store_pc = src->last_store_pc;
}
wedge_kind = freeze_wedge_classify(window, WEDGE_WINDOW_TICKS,
FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN,
mdec_recently_active);
if (wedge_kind == FREEZE_WEDGE_SPIN) {
/* Only a pinned window pays for the profile scan. */
uint64_t lo = s_ring[(s_ring_head + RING_CAP - WEDGE_WINDOW_TICKS) % RING_CAP].gcp_seq;
uint64_t hi = s_ring[(s_ring_head + RING_CAP - 1u) % RING_CAP].gcp_seq;
wedge_kind = freeze_wedge_classify(window, WEDGE_WINDOW_TICKS,
freeze_window_fn_diversity(
lo, hi, guest_cycle_profile_oldest(),
gcp_sample_fn),
mdec_recently_active);
}
}
if (!s_wedge_classification_paused &&
+62 -2
View File
@@ -23,6 +23,29 @@ static void observe_healthy(FreezeDumpPolicy *policy, uint32_t ticks) {
for (uint32_t i = 0; i < ticks; i++) EXPECT_EQ(observe(policy, 0), 0);
}
#define UNK FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN
static int mdec_idle(uint32_t frames) { (void)frames; return 0; }
static int mdec_busy(uint32_t frames) { (void)frames; return 1; }
/* 20 samples, frames advancing 9 per tick, guest pinned in one kernel loop. */
static void spin_window(FreezeWedgeSample *w, uint32_t n) {
for (uint32_t i = 0; i < n; i++) {
w[i].frame_count = 1746u + 9u * i;
w[i].exc_reentry = 2000u + i;
w[i].dirty_ram_insns = 908846u;
w[i].current_func = 0x00000F40u;
w[i].last_store_pc = 0x8003A230u;
}
}
static int sample_profile(uint64_t seq, uint32_t *fn, int *exception) {
if (seq >= 12u) return 0;
*fn = (uint32_t)seq;
*exception = seq == 2u;
return 1;
}
int main(void) {
FreezeDumpPolicy policy = {0};
@@ -114,8 +137,45 @@ int main(void) {
EXPECT_EQ(strcmp(path0, path1) != 0, 1);
EXPECT_EQ(freeze_dump_format_path(path0, 4, "psx-runtime", 1234, 0), 0);
if (failures != 0) {
fprintf(stderr, "freeze dump policy: %d failure(s)\n", failures);
/* Wedge classification over the whole window. */
FreezeWedgeSample w[20];
spin_window(w, 20);
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), FREEZE_WEDGE_SPIN);
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, NULL), FREEZE_WEDGE_SPIN);
/* A busy native window is healthy, while a small wait-loop remains detectable. */
EXPECT_EQ(freeze_wedge_classify(w, 20, 9u, mdec_idle), 0);
EXPECT_EQ(freeze_wedge_classify(w, 20, 8u, mdec_idle), FREEZE_WEDGE_SPIN);
EXPECT_EQ(freeze_window_fn_diversity(0, 12, 0, sample_profile), 9u);
EXPECT_EQ(freeze_window_fn_diversity(0, 8, 5, sample_profile), 3u);
EXPECT_EQ(freeze_window_fn_diversity(2, 3, 0, sample_profile), UNK);
EXPECT_EQ(freeze_window_fn_diversity(20, 30, 0, sample_profile), UNK);
/* FMV decode parks the guest the same way; MDEC activity vetoes it. */
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_busy), 0);
/* Static-code main loop (LoD 2026-10-12 dump): the window ends agree but
* intermediate ticks store elsewhere. Not a spin. */
w[3].last_store_pc = 0x800461F0u;
w[11].last_store_pc = 0x800FBD44u;
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), 0);
spin_window(w, 20);
w[7].current_func = 0x1FC02918u;
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), 0);
spin_window(w, 20);
w[19].dirty_ram_insns += 4u;
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), 0);
/* Frame-progress kinds take precedence over the spin test. */
spin_window(w, 20);
for (uint32_t i = 0; i < 20; i++) w[i].frame_count = 500u;
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_busy), FREEZE_WEDGE_HARD);
for (uint32_t i = 0; i < 20; i++) w[i].frame_count = 500u + (i == 19 ? 5u : 0u);
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), FREEZE_WEDGE_SLOW_FRAMES);
spin_window(w, 20);
w[19].exc_reentry = w[0].exc_reentry +
(uint64_t)(FREEZE_WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD + 1u) * 171u;
EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), FREEZE_WEDGE_REENTRY_STORM);
EXPECT_EQ(freeze_wedge_classify(w, 1, UNK, mdec_idle), 0);
EXPECT_EQ(freeze_wedge_classify(NULL, 20, UNK, mdec_idle), 0);
if (failures != 0) { fprintf(stderr, "freeze dump policy: %d failure(s)\n", failures);
return 1;
}
puts("freeze dump policy: all checks passed");