Files
psxrecomp/runtime/src/overlay_capture.c
T
Shokunin 752f8ff2e7 runtime: record the segment of each interpreted dispatch (capture schema v3)
docs/SEGMENT_AWARE_CODE.md §5.7. Overlay bytes are segment-free, but a PC
is not: a KUSEG, KSEG0 or KSEG1 entry of the same word links, traps, fetches
and stamps store PCs in its own segment, so each needs a shard compiled for
that segment. Capture kept only the physical word, so every shard was
compiled for KSEG0 and #417's gate interpreted the other segments forever.

dirty_ram_interp.h adds one sibling bitmap per segment that maps RAM
(g_dirty_ram_dispatch_seg_bitmap[3]; 768 KiB for the 8 MiB backing).
dirty_ram_dispatch_inner sets the bit for the full PC next to the existing
any-segment bit, once per interpreted dispatch. A PC in 0x20000000-0x7FFFFFFF
or KSEG2 addresses no RAM in Beetle and gets no bit.
dirty_ram_dispatch_evidence_clear() drops both together at every site that
dropped dispatch evidence: boot reset, a store into an executed page, DMA
preservation and the autocapture epoch. Snapshot jobs copy the siblings.

Capture schema v3 adds, per region,
  "dispatch_entry_segments": {"kuseg": [...], "kseg0": [...], "kseg1": [...]}
with each PC spelled in its segment. dispatch_entry_pcs keeps its v2 meaning
(dispatched in any segment) and KSEG0 spelling, so existing readers are
unaffected. A record without the field is KSEG0 only.

Guest behaviour is unchanged: the bitmaps are host-only capture evidence and
are not saved in savestates. overlay_capture_retry_test checks the v3 record
through both writers (the background epoch and the synchronous final
capture), including that a cleared epoch's segment does not outlive it.
psx_ram_runtime_map_stubs.c defines the new bitmap, which memory.c's write
paths now reference, so psx_ram_runtime_map_test still links on GNU
toolchains. The disabled interpreter_perf_guards check follows the evidence
clear (it would otherwise contradict overlay_segment_gate's wiring check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 02:43:45 -07:00

1552 lines
63 KiB
C

#include "overlay_capture.h"
#include "overlay_loader.h"
#include "dirty_ram_interp.h"
#include "code_provider.h"
#include "crc32.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <ctype.h>
#include <time.h>
#include "psx_sdl.h"
#ifdef _WIN32
#include <windows.h>
#include <io.h>
#include <process.h>
#else
#include <unistd.h>
#endif
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <windows.h>
# include <direct.h>
# include <io.h>
# define CAPTURE_MKDIR(path) _mkdir(path)
# define CAPTURE_FSYNC(file) _commit(_fileno(file))
# define CAPTURE_PID() ((unsigned long)GetCurrentProcessId())
#else
# include <sys/stat.h>
# include <unistd.h>
# define CAPTURE_MKDIR(path) mkdir((path), 0755)
# define CAPTURE_FSYNC(file) fsync(fileno(file))
# define CAPTURE_PID() ((unsigned long)getpid())
#endif
/* ---- Capture set --------------------------------------------------------
* Tracks which physical addresses have received CD DMA loads since game
* handoff. Used for dedup (state machine) and A-layer compilation queue.
* The JSON output uses dirty_ram regions, not individual DMA blocks.
* -------------------------------------------------------------------------*/
typedef enum {
OV_QUEUED = 0, /* captured, not yet compiled */
OV_COMPILING = 1, /* background thread working on it */
OV_COMPILED = 2, /* DLL written, dispatch table patched */
} OvState;
typedef struct {
uint32_t load_addr; /* physical RAM address */
uint32_t size; /* byte count of this DMA block */
uint8_t *bytes; /* unpatched copy taken at DMA time */
OvState state;
} OvEntry;
#define MAX_OVERLAYS 4096
static OvEntry s_entries[MAX_OVERLAYS];
static int s_count = 0;
static char s_capture_path[768];
static int s_active = 0;
static int s_enabled = 0; /* config gate; off unless overlay cache enabled */
static SDL_mutex *s_commit_mutex;
static SDL_atomic_t s_snapshot_seq;
static uint64_t s_latest_commit_seq;
static int s_history_enabled = 0;
static char s_history_addendum[600];
static char s_history_persist_dir[600];
static char s_history_game_id[64];
static char s_history_session[64];
static uint32_t s_history_sequence;
static uint64_t s_history_last_sig;
static int s_history_written;
static SDL_atomic_t s_autocap_write_state; /* 0 idle, 1 writing, 2 complete */
static uint64_t capture_next_sequence(void)
{
/* Never contend with the writer's long-held file commit mutex here: this
* runs at a pre-write boundary on the emulation thread. */
return (uint32_t)SDL_AtomicAdd(&s_snapshot_seq, 1) + 1u;
}
static int preserve_snapshot_async(uint32_t scope_lo, uint32_t scope_hi);
static int preserve_writer_start(void);
static int capture_process_id(void)
{
#ifdef _WIN32
return _getpid();
#else
return (int)getpid();
#endif
}
static void sanitize_filename(char *dst, size_t dst_size, const char *src)
{
size_t out = 0;
if (!dst_size) return;
while (src && *src && out + 1 < dst_size) {
unsigned char c = (unsigned char)*src++;
dst[out++] = (char)(isalnum(c) || c == '-' || c == '_' ? c : '_');
}
dst[out] = '\0';
}
static int sync_file(FILE *f)
{
if (fflush(f) != 0) return 0;
#ifdef _WIN32
return _commit(_fileno(f)) == 0;
#else
return fsync(fileno(f)) == 0;
#endif
}
static int atomic_replace_file(const char *tmp_path, const char *path)
{
#ifdef _WIN32
return MoveFileExA(tmp_path, path,
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
#else
return rename(tmp_path, path) == 0;
#endif
}
static uint64_t capture_file_sig(const char *path)
{
FILE *f = fopen(path, "rb");
uint64_t h = 1469598103934665603ull;
unsigned char buf[65536];
size_t n, i;
if (!f) return 0;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0)
for (i = 0; i < n; i++) { h ^= buf[i]; h *= 1099511628211ull; }
if (ferror(f)) h = 0;
fclose(f);
return h;
}
/* Directory portion of `path` (no trailing separator); "." when `path` has
* no separator. Colocates durable-history bookkeeping (the addendum.jsonl
* and optional persist_dir copies) with whichever canonical capture manifest
* path is currently configured, whether that came from
* overlay_capture_set_out_dir or the game-specific overlay_capture_set_path. */
static void capture_path_dirname(char *dst, size_t dst_size, const char *path)
{
const char *cut = strrchr(path, '/');
#ifdef _WIN32
const char *bslash = strrchr(path, '\\');
if (bslash && (!cut || bslash > cut)) cut = bslash;
#endif
if (!dst_size) return;
if (cut) {
size_t len = (size_t)(cut - path);
if (len >= dst_size) len = dst_size - 1;
memcpy(dst, path, len);
dst[len] = '\0';
} else {
snprintf(dst, dst_size, ".");
}
}
/* ---- Base64 encoder ----------------------------------------------------- */
static const char k_b64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static void write_b64(FILE *f, const uint8_t *data, size_t len)
{
size_t i;
for (i = 0; i < len; i += 3) {
uint32_t b = (uint32_t)data[i] << 16;
if (i + 1 < len) b |= (uint32_t)data[i + 1] << 8;
if (i + 2 < len) b |= (uint32_t)data[i + 2];
fputc(k_b64[(b >> 18) & 0x3F], f);
fputc(k_b64[(b >> 12) & 0x3F], f);
fputc(i + 1 < len ? k_b64[(b >> 6) & 0x3F] : '=', f);
fputc(i + 2 < len ? k_b64[(b ) & 0x3F] : '=', f);
}
}
/* ---- Public API --------------------------------------------------------- */
void overlay_capture_set_out_dir(const char *out_dir)
{
snprintf(s_capture_path, sizeof(s_capture_path),
"%s/overlay_captures.json", out_dir ? out_dir : ".");
if (!s_commit_mutex) s_commit_mutex = SDL_CreateMutex();
(void)preserve_writer_start();
}
int overlay_capture_set_path(const char *path)
{
const char *resolved = (path && path[0]) ? path : "overlay_captures.json";
if (strlen(resolved) >= sizeof(s_capture_path)) {
fprintf(stderr, "psxrecomp: overlay capture path is too long (%zu bytes)\n",
strlen(resolved));
return 0;
}
snprintf(s_capture_path, sizeof(s_capture_path), "%s", resolved);
if (!s_commit_mutex) s_commit_mutex = SDL_CreateMutex();
(void)preserve_writer_start();
return 1;
}
void overlay_capture_init(const char *out_dir)
{
overlay_capture_set_out_dir(out_dir);
}
void overlay_capture_set_enabled(int enabled)
{
s_enabled = enabled ? 1 : 0;
}
void overlay_capture_configure_history(int enabled, const char *persist_dir,
const char *game_id)
{
time_t now;
struct tm tm_now;
char stamp[32];
s_history_enabled = enabled ? 1 : 0;
s_history_persist_dir[0] = '\0';
s_history_sequence = 0;
s_history_last_sig = 0;
s_history_written = 0;
sanitize_filename(s_history_game_id, sizeof(s_history_game_id),
game_id && game_id[0] ? game_id : "UNKNOWN");
if (persist_dir && persist_dir[0]) {
snprintf(s_history_persist_dir, sizeof(s_history_persist_dir), "%s",
persist_dir);
}
{
char dir[512];
capture_path_dirname(dir, sizeof(dir),
s_capture_path[0] ? s_capture_path
: "overlay_captures.json");
snprintf(s_history_addendum, sizeof(s_history_addendum),
"%s/overlay_captures.addendum.jsonl", dir);
}
now = time(NULL);
#ifdef _WIN32
gmtime_s(&tm_now, &now);
#else
gmtime_r(&now, &tm_now);
#endif
strftime(stamp, sizeof(stamp), "%Y%m%dT%H%M%SZ", &tm_now);
snprintf(s_history_session, sizeof(s_history_session), "%s-p%d",
stamp, capture_process_id());
if (s_history_enabled) {
fprintf(stdout,
"psxrecomp: durable overlay capture history enabled (%s%s%s)\n",
s_history_addendum,
s_history_persist_dir[0] ? "; immutable dir=" : "",
s_history_persist_dir[0] ? s_history_persist_dir : "");
}
}
void overlay_capture_on_dma(uint32_t load_addr, uint32_t size,
const uint8_t *bytes)
{
int i;
OvEntry *e;
extern int fntrace_is_game_started(void);
if (!s_enabled) return; /* overlay cache disabled in config */
if (size == 0) return;
/* Auto-activate on the first post-game-handoff DMA. */
if (!s_active) {
if (!fntrace_is_game_started()) return;
s_active = 1;
}
/* This table reconstructs only the CURRENT DMA image for the legacy CRC
* helper. Executed historical variants live in the additive snapshot
* journal; retaining every DMA payload here caused unbounded session heap
* growth and made A->B->A report stale B bytes. */
for (i = 0; i < s_count; i++) {
if (s_entries[i].load_addr == load_addr) {
if (s_entries[i].size == size && s_entries[i].bytes &&
memcmp(s_entries[i].bytes, bytes, size) == 0)
return;
uint8_t *replacement = (uint8_t *)malloc(size);
if (!replacement) return;
memcpy(replacement, bytes, size);
free(s_entries[i].bytes);
s_entries[i].bytes = replacement;
s_entries[i].size = size;
s_entries[i].state = OV_QUEUED;
overlay_loader_check_cache(load_addr, size, bytes);
return;
}
}
if (s_count >= MAX_OVERLAYS) return;
e = &s_entries[s_count++];
e->load_addr = load_addr;
e->size = size;
e->state = OV_QUEUED;
e->bytes = (uint8_t *)malloc(size);
if (!e->bytes) { s_count--; return; }
memcpy(e->bytes, bytes, size);
/* A-1: check if a compiled DLL exists in cache for these bytes. */
overlay_loader_check_cache(load_addr, size, bytes);
/* A-2: push onto compilation queue (to be added in A-2). */
}
/* ---- JSON output — assembled dirty_ram regions -------------------------- */
/* Emit every dirty-page run within [win_lo_page, win_hi_page) as a capture
* region. Runs are clamped to the window so region_start keys are stable
* across window boundaries: the kernel window must never merge with
* boot-text pages (both can be dirty and adjacent — kernel via CPU stores,
* boot-text via overlay CD DMA), and a boot-text run must never merge
* across the overlay-region floor (existing [FLOOR,...) DLL keys would
* shift). Dirty boot-text runs ARE captured — post-baseline they are live
* overlay code (see dirty_ram_interp.h window model). */
static void write_json_window(FILE *f, uint32_t win_lo_page,
uint32_t win_hi_page, int *first_region,
const uint32_t *bitmap,
const uint32_t *dispatch_pc_bitmap,
const uint32_t *dispatch_seg_bitmap,
const uint32_t *exec_pc_bitmap,
const uint8_t *ram_base)
{
uint32_t page_sz = 4096u;
uint32_t page, run_start;
int in_run;
in_run = 0;
run_start = 0;
for (page = win_lo_page; page <= win_hi_page; page++) {
int dirty = 0;
if (page < win_hi_page) {
uint32_t word = bitmap[page >> 5];
dirty = (word >> (page & 31u)) & 1u;
}
if (dirty && !in_run) {
in_run = 1;
run_start = page;
} else if (!dirty && in_run) {
uint32_t phys = run_start * page_sz;
uint32_t size = (page - run_start) * page_sz;
/* Carry one coherent guard instruction beyond a dirty-page run.
* A MIPS branch/jump at the final word of the run (...FFC) always
* executes its delay slot in the next page (...000). Supplying
* that word lets overlay codegen emit exact semantics and lets its
* range manifest hash/watch the slot. Keep the established region
* start. A four-byte overlap across an artificial kernel/boot or
* boot/overlay capture-window boundary is required: those windows
* stabilize region keys, but they are not MIPS execution barriers.
* Adjacent dirty pages were already folded into this run. */
const uint32_t ram_size = psx_ram_live_bytes();
uint32_t guard_bytes = 0u;
if (phys <= ram_size && size <= ram_size - phys &&
phys + size <= ram_size - 4u) {
size += 4u;
guard_bytes = 4u;
}
uint32_t virt = PSX_OVERLAY_CODE_SEGMENT | psx_ram_canonical_offset(phys);
in_run = 0;
/* Seeds: only per-PC interpreter hits — execution-verified. */
int nexec = 0;
int ndisp = 0;
for (uint32_t ep = phys; ep < phys + size; ep += 4u) {
uint32_t wi = ep >> 2;
if ((dispatch_pc_bitmap[wi >> 5] >> (wi & 31u)) & 1u)
ndisp++;
if ((exec_pc_bitmap[wi >> 5] >> (wi & 31u)) & 1u)
nexec++;
}
/* Skip pure-data regions — nothing to compile. */
if (ndisp == 0 && nexec == 0) {
continue;
}
/* Execution-time capture (§2.2 fix): snapshot the overlay region
* from LIVE RAM, i.e. the bytes as they ACTUALLY EXECUTE — after the
* game's load-time fixups/relocation. The old code assembled
* "unpatched" bytes from DMA-time blocks, which omitted relocated
* jump tables and other fixed-up data: the recompiler then could not
* resolve `jr`-jump-tables (their address tables were wrong), fell
* back to call_by_address, and native diverged from the interpreter
* (the village→overworld blue screen). Live RAM is the faithful
* image. (Capture at a COHERENT moment — one overlay freshly loaded,
* via overlay_capture_dump — to avoid merging overlay generations.) */
if (!*first_region) fprintf(f, ",\n");
*first_region = 0;
fprintf(f, " {\n");
fprintf(f, " \"schema\": \"psxrecomp overlay capture v3\",\n");
fprintf(f, " \"load_addr\": \"0x%08X\",\n", virt);
fprintf(f, " \"size\": %u,\n", size);
/* How many of the trailing bytes above are the delay-slot guard,
* i.e. NOT part of the dirty-page run. The consumer must keep
* them READABLE (a branch at the run's last word emits its slot
* from here) while never discovering them as code: the word after
* a guard word is not in this region at all. Stated explicitly so
* the recompiler is TOLD rather than inferring it from `size`
* (tools/compile_overlays.py capture_guard_bytes ->
* recompiler/include/ps1_exe_parser.h exe_tag). */
fprintf(f, " \"guard_bytes\": %u,\n", guard_bytes);
fprintf(f, " \"bytes_b64\": \"");
write_b64(f, ram_base + phys, size);
fprintf(f, "\",\n");
fprintf(f, " \"executed_pcs\": [");
int emitted_exec = 0;
for (uint32_t ep = phys; ep < phys + size; ep += 4u) {
uint32_t wi = ep >> 2;
if (!((exec_pc_bitmap[wi >> 5] >> (wi & 31u)) & 1u))
continue;
uint32_t seed_virt = PSX_OVERLAY_CODE_SEGMENT | ep;
if (emitted_exec++) fprintf(f, ", ");
fprintf(f, "\"0x%08X\"", seed_virt);
}
fprintf(f, "],\n");
fprintf(f, " \"dispatch_entry_pcs\": [");
int emitted_dispatch = 0;
for (uint32_t ep = phys; ep < phys + size; ep += 4u) {
uint32_t wi = ep >> 2;
if (!((dispatch_pc_bitmap[wi >> 5] >> (wi & 31u)) & 1u))
continue;
uint32_t seed_virt = PSX_OVERLAY_CODE_SEGMENT | ep;
if (emitted_dispatch++) fprintf(f, ", ");
fprintf(f, "\"0x%08X\"", seed_virt);
}
fprintf(f, "],\n");
/* Schema v3 (docs/SEGMENT_AWARE_CODE.md §5.7): the segments each
* dispatch entry above entered through, each PC spelled in its
* segment. dispatch_entry_pcs keeps its v2 meaning and spelling
* (entered in any segment, written at KSEG0); a reader of a v2
* capture, which has no such field, takes every entry as KSEG0. */
fprintf(f, " \"dispatch_entry_segments\": {");
for (int seg = 0; seg < PSX_CODE_SEGMENT_COUNT; seg++) {
const uint32_t *seg_bitmap =
dispatch_seg_bitmap + (size_t)seg * DIRTY_RAM_EXEC_BITMAP_WORDS;
int emitted_seg = 0;
fprintf(f, "%s\"%s\": [", seg ? ", " : "",
psx_code_segment_name(seg));
for (uint32_t ep = phys; ep < phys + size; ep += 4u) {
uint32_t wi = ep >> 2;
if (!((dispatch_pc_bitmap[wi >> 5] >> (wi & 31u)) & 1u) ||
!((seg_bitmap[wi >> 5] >> (wi & 31u)) & 1u))
continue;
if (emitted_seg++) fprintf(f, ", ");
fprintf(f, "\"0x%08X\"", psx_code_segment_base(seg) | ep);
}
fprintf(f, "]");
}
fprintf(f, "},\n");
fprintf(f, " \"function_entry_pcs\": [],\n");
fprintf(f, " \"seeds\": [");
emitted_dispatch = 0;
for (uint32_t ep = phys; ep < phys + size; ep += 4u) {
uint32_t wi = ep >> 2;
if (!((dispatch_pc_bitmap[wi >> 5] >> (wi & 31u)) & 1u))
continue;
uint32_t seed_virt = PSX_OVERLAY_CODE_SEGMENT | ep;
if (emitted_dispatch++) fprintf(f, ", ");
fprintf(f, "\"0x%08X\"", seed_virt);
}
fprintf(f, "]\n");
fprintf(f, " }");
}
}
}
static int write_json_snapshot(const char *path, uint32_t bw,
const uint32_t *bitmap,
const uint32_t *dispatch_pc_bitmap,
const uint32_t *dispatch_seg_bitmap,
const uint32_t *exec_pc_bitmap,
const uint8_t *ram_base)
{
FILE *f;
uint32_t page_sz;
int first_region;
page_sz = 4096u;
f = fopen(path, "wb");
if (!f) return 0;
fprintf(f, "[\n");
first_region = 1;
/* Kernel window, then dirty boot-text, then the overlay region (see
* dirty_ram_interp.h for the window model). The boot-text window
* [KERNEL_WINDOW_END, FLOOR) emits only genuinely-overlaid runs: the
* game-start baseline cleared the EXE-load false positives, so a dirty
* page there is runtime overlay code by definition. Windows stay
* separate so runs clamp at the boundaries and region_start keys match
* try_load_region's clamped walkback (existing overlay-region DLL keys
* are unchanged). */
write_json_window(f, 0u,
DIRTY_RAM_KERNEL_WINDOW_END / page_sz, &first_region,
bitmap, dispatch_pc_bitmap, dispatch_seg_bitmap,
exec_pc_bitmap, ram_base);
write_json_window(f, DIRTY_RAM_KERNEL_WINDOW_END / page_sz,
OVERLAY_REGION_FLOOR / page_sz, &first_region,
bitmap, dispatch_pc_bitmap, dispatch_seg_bitmap,
exec_pc_bitmap, ram_base);
write_json_window(f, OVERLAY_REGION_FLOOR / page_sz, bw * 32u,
&first_region, bitmap, dispatch_pc_bitmap,
dispatch_seg_bitmap, exec_pc_bitmap, ram_base);
fprintf(f, "\n]\n");
{
int ok = !ferror(f) && sync_file(f);
if (fclose(f) != 0) ok = 0;
return ok;
}
}
static int capture_copy_file(const char *src, const char *dst)
{
FILE *in = fopen(src, "rb");
FILE *out;
unsigned char buf[65536];
size_t n;
int ok = 1;
if (!in) return 0;
out = fopen(dst, "wb");
if (!out) { fclose(in); return 0; }
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
if (fwrite(buf, 1, n, out) != n) { ok = 0; break; }
}
if (ferror(in)) ok = 0;
if (ok && (fflush(out) != 0 || CAPTURE_FSYNC(out) != 0)) ok = 0;
if (fclose(out) != 0) ok = 0;
fclose(in);
if (!ok) remove(dst);
return ok;
}
static int capture_files_equal(const char *a, const char *b)
{
FILE *fa = fopen(a, "rb"), *fb = fopen(b, "rb");
unsigned char ba[65536], bb[65536];
int equal = 1;
if (!fa || !fb) { if (fa) fclose(fa); if (fb) fclose(fb); return 0; }
for (;;) {
size_t na = fread(ba, 1, sizeof(ba), fa);
size_t nb = fread(bb, 1, sizeof(bb), fb);
if (na != nb || (na && memcmp(ba, bb, na) != 0)) { equal = 0; break; }
if (na == 0) break;
}
if (ferror(fa) || ferror(fb)) equal = 0;
fclose(fa); fclose(fb);
return equal;
}
static int copy_file_atomic(const char *src_path, const char *dst_path)
{
char tmp_path[700];
unsigned char buf[65536];
FILE *src, *dst;
size_t n;
int ok = 1;
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp-p%d", dst_path,
capture_process_id());
src = fopen(src_path, "rb");
if (!src) return 0;
dst = fopen(tmp_path, "wb");
if (!dst) { fclose(src); return 0; }
while ((n = fread(buf, 1, sizeof(buf), src)) > 0) {
if (fwrite(buf, 1, n, dst) != n) { ok = 0; break; }
}
if (ferror(src)) ok = 0;
fclose(src);
if (ok) ok = sync_file(dst);
if (fclose(dst) != 0) ok = 0;
if (!ok || !atomic_replace_file(tmp_path, dst_path)) {
remove(tmp_path);
return 0;
}
return 1;
}
/* Append the canonical JSON array as one minified JSONL record. Whitespace is
* stripped only outside strings, so bytes_b64 remains byte-for-byte intact.
* A crash can damage only the final line; every earlier launch remains a valid
* independently parseable record. */
static int append_history_record(const char *snapshot_path, const char *reason,
uint64_t sig, uint32_t sequence)
{
FILE *src = fopen(snapshot_path, "rb");
FILE *dst, *tail;
int c, in_string = 0, escape = 0, ok = 1;
int needs_separator = 0;
if (!src) return 0;
tail = fopen(s_history_addendum, "rb");
if (tail) {
if (fseek(tail, -1, SEEK_END) == 0) {
c = fgetc(tail);
needs_separator = c != '\n';
}
fclose(tail);
}
dst = fopen(s_history_addendum, "ab");
if (!dst) { fclose(src); return 0; }
/* Quarantine an incomplete final record from a previous hard kill. */
if (needs_separator && fputc('\n', dst) == EOF) ok = 0;
if (ok && fprintf(dst,
"{\"schema\":\"psxrecomp overlay capture addendum v1\","
"\"game\":\"%s\",\"session\":\"%s\",\"sequence\":%u,"
"\"reason\":\"%s\",\"fnv64\":\"%016llX\",\"captures\":",
s_history_game_id, s_history_session, sequence,
reason ? reason : "snapshot", (unsigned long long)sig) < 0) ok = 0;
while (ok && (c = fgetc(src)) != EOF) {
if (in_string) {
if (fputc(c, dst) == EOF) { ok = 0; break; }
if (escape) escape = 0;
else if (c == '\\') escape = 1;
else if (c == '"') in_string = 0;
} else {
if (c == '"') {
in_string = 1;
if (fputc(c, dst) == EOF) { ok = 0; break; }
} else if (!isspace((unsigned char)c)) {
if (fputc(c, dst) == EOF) { ok = 0; break; }
}
}
}
if (ferror(src) || in_string) ok = 0;
fclose(src);
if (ok && fprintf(dst, "}\n") < 0) ok = 0;
if (ok) ok = sync_file(dst);
if (fclose(dst) != 0) ok = 0;
return ok;
}
static int write_json_string(FILE *dst, const char *value)
{
const unsigned char *p = (const unsigned char *)(value ? value : "");
if (fputc('"', dst) == EOF) return 0;
for (; *p; p++) {
if (*p == '"' || *p == '\\') {
if (fputc('\\', dst) == EOF || fputc(*p, dst) == EOF) return 0;
} else if (*p < 0x20) {
if (fprintf(dst, "\\u%04X", (unsigned)*p) < 0) return 0;
} else if (fputc(*p, dst) == EOF) {
return 0;
}
}
return fputc('"', dst) != EOF;
}
/* Dev persistence already owns an atomically published immutable full JSON
* snapshot. Append only a durable reference to it instead of embedding the
* same multi-megabyte capture array again every autocap interval. Production
* configs without persist_dir continue to use the self-contained v1 record. */
static int append_history_reference(const char *persist_path, const char *reason,
uint64_t sig, uint32_t sequence)
{
FILE *dst, *tail;
int c, needs_separator = 0, ok = 1;
tail = fopen(s_history_addendum, "rb");
if (tail) {
if (fseek(tail, -1, SEEK_END) == 0) {
c = fgetc(tail);
needs_separator = c != '\n';
}
fclose(tail);
}
dst = fopen(s_history_addendum, "ab");
if (!dst) return 0;
if (needs_separator && fputc('\n', dst) == EOF) ok = 0;
if (ok && fprintf(dst,
"{\"schema\":\"psxrecomp overlay capture addendum v2\","
"\"game\":\"%s\",\"session\":\"%s\",\"sequence\":%u,"
"\"reason\":\"%s\",\"fnv64\":\"%016llX\",\"snapshot\":",
s_history_game_id, s_history_session, sequence,
reason ? reason : "snapshot", (unsigned long long)sig) < 0) ok = 0;
if (ok) ok = write_json_string(dst, persist_path);
if (ok && fputs("}\n", dst) == EOF) ok = 0;
if (ok) ok = sync_file(dst);
if (fclose(dst) != 0) ok = 0;
return ok;
}
static void persist_history_snapshot(const char *snapshot_path,
const char *reason, uint64_t sig)
{
char persist_path[800];
uint32_t sequence;
int addendum_saved;
int persist_saved = s_history_persist_dir[0] ? 0 : 1;
if (!s_history_enabled || !sig) return;
if (s_history_written && sig == s_history_last_sig) return;
sequence = ++s_history_sequence;
if (s_history_persist_dir[0]) {
snprintf(persist_path, sizeof(persist_path),
"%s/%s_%s_%04u_%016llX.json",
s_history_persist_dir, s_history_game_id, s_history_session,
sequence, (unsigned long long)sig);
if (copy_file_atomic(snapshot_path, persist_path)) persist_saved = 1;
else fprintf(stderr,
"psxrecomp: failed to persist overlay capture snapshot %s\n",
persist_path);
}
if (s_history_persist_dir[0] && persist_saved)
addendum_saved = append_history_reference(
persist_path, reason, sig, sequence);
else
addendum_saved = append_history_record(
snapshot_path, reason, sig, sequence);
if (!addendum_saved)
fprintf(stderr, "psxrecomp: failed to append overlay capture history %s\n",
s_history_addendum);
/* Retry an unchanged snapshot if either configured durable sink failed.
* Repeated addendum records or immutable files are harmless: vault merges
* are content-keyed and idempotent. */
if (addendum_saved && persist_saved) {
s_history_last_sig = sig;
s_history_written = 1;
}
}
/* Persist one coherent snapshot for three deliberately different roles:
* - capture_path is the legacy/latest manifest consumed by existing tools;
* - capture_path.d/<content-hash>.json is immutable additive history used
* by compile_overlays.py / the coverage vault;
* - (opt-in) overlay_captures.addendum.jsonl (+ persist_dir) is a durable
* session-tagged history log for auditing/regeneration across restarts.
* compile_overlays.py unions the first two. Different byte variants at the
* same load address therefore survive future captures, rebuilds, and
* regenerations. */
static uint64_t capture_commit_temp(const char *temp_path, const char *reason,
uint64_t sequence)
{
char contribution_dir[900];
char contribution[1024];
char contribution_tmp[1040];
FILE *probe;
uint64_t sig = capture_file_sig(temp_path);
int history_ok = 0;
int base_ok = 1;
if (!sig) { remove(temp_path); return 0; }
if (s_commit_mutex) SDL_LockMutex(s_commit_mutex);
snprintf(contribution_dir, sizeof(contribution_dir), "%s.d", s_capture_path);
(void)CAPTURE_MKDIR(contribution_dir); /* already-exists is success */
for (unsigned collision = 0;; collision++) {
if (collision == 0)
snprintf(contribution, sizeof(contribution), "%s/%016llX.json",
contribution_dir, (unsigned long long)sig);
else
snprintf(contribution, sizeof(contribution), "%s/%016llX-%u.json",
contribution_dir, (unsigned long long)sig, collision);
probe = fopen(contribution, "rb");
if (!probe) break;
fclose(probe);
if (capture_files_equal(temp_path, contribution)) break;
}
probe = fopen(contribution, "rb");
if (probe) {
fclose(probe); /* exact snapshot already retained */
history_ok = 1;
} else {
snprintf(contribution_tmp, sizeof(contribution_tmp), "%s.%lu.%llu.tmp",
contribution, CAPTURE_PID(), (unsigned long long)sequence);
if (capture_copy_file(temp_path, contribution_tmp) &&
atomic_replace_file(contribution_tmp, contribution)) {
history_ok = 1;
} else {
remove(contribution_tmp);
fprintf(stderr,
"psxrecomp: ERROR: additive capture history write failed: %s\n",
contribution);
}
}
/* Snapshots may be formatted concurrently, so only the newest snapshot is
* allowed to replace the compatibility/latest path. Immutable history is
* authoritative for additivity and is never discarded by this ordering. */
if (sequence >= s_latest_commit_seq) {
base_ok = atomic_replace_file(temp_path, s_capture_path);
if (base_ok) s_latest_commit_seq = sequence;
else {
remove(temp_path);
fprintf(stderr, "psxrecomp: ERROR: latest capture write failed: %s\n",
s_capture_path);
}
} else {
remove(temp_path);
}
if (s_commit_mutex) SDL_UnlockMutex(s_commit_mutex);
/* A durable immutable contribution is sufficient to advance the evidence
* epoch. A failed latest-copy update is noisy but cannot lose that history.
* Persist history from the immutable contribution file (not the racy
* "latest" path, which another writer can replace at any time), and
* outside the commit lock since durable-history I/O (an optional
* persist_dir copy plus a JSONL append) can be comparatively slow. */
if (history_ok) persist_history_snapshot(contribution, reason, sig);
return history_ok ? sig : 0;
}
static uint64_t write_and_commit_snapshot(uint32_t bw,
const uint32_t *bitmap,
const uint32_t *dispatch_pc_bitmap,
const uint32_t *dispatch_seg_bitmap,
const uint32_t *exec_pc_bitmap,
const uint8_t *ram_base,
const char *reason,
uint64_t sequence)
{
char temp_path[840];
snprintf(temp_path, sizeof(temp_path), "%s.%lu.%llu.tmp", s_capture_path,
CAPTURE_PID(), (unsigned long long)sequence);
if (!write_json_snapshot(temp_path, bw, bitmap, dispatch_pc_bitmap,
dispatch_seg_bitmap, exec_pc_bitmap, ram_base))
return 0;
return capture_commit_temp(temp_path, reason, sequence);
}
static void capture_executed_pages(uint32_t *bitmap, uint32_t bw,
const uint32_t *exec_pc_bitmap,
uint32_t scope_lo, uint32_t scope_hi,
int include_halo)
{
const uint32_t ram_size = psx_ram_live_bytes();
memset(bitmap, 0, (size_t)bw * sizeof(uint32_t));
if (scope_hi > ram_size) scope_hi = ram_size;
if (scope_lo >= scope_hi) return;
uint32_t first_page = scope_lo >> 12;
uint32_t last_page = (scope_hi - 1u) >> 12;
uint32_t scan_lo = include_halo && first_page ? first_page - 1u : first_page;
uint32_t scan_hi = include_halo && last_page + 1u < ram_size / 4096u
? last_page + 1u : last_page;
for (uint32_t page = scan_lo; page <= scan_hi; page++) {
uint32_t first_exec_word = page * (4096u / 4u);
int observed = 0;
for (uint32_t b = 0; b < 4096u / 4u / 32u; b++) {
if (exec_pc_bitmap[(first_exec_word >> 5) + b]) {
observed = 1;
break;
}
}
if (observed && (page >> 5) < bw)
bitmap[page >> 5] |= 1u << (page & 31u);
}
}
static uint64_t overlay_capture_write_current(const char *reason,
uint32_t scope_lo,
uint32_t scope_hi,
int include_halo)
{
extern uint8_t *memory_get_ram_ptr(void);
uint32_t bw = dirty_ram_get_bitmap_word_count();
uint32_t *bitmap;
uint64_t sig;
if (!s_active) return 0;
bitmap = (uint32_t *)malloc((size_t)bw * sizeof(uint32_t));
if (!bitmap) return 0;
capture_executed_pages(bitmap, bw, g_dirty_ram_exec_pc_bitmap,
scope_lo, scope_hi, include_halo);
sig = write_and_commit_snapshot(bw, bitmap,
g_dirty_ram_dispatch_pc_bitmap,
&g_dirty_ram_dispatch_seg_bitmap[0][0],
g_dirty_ram_exec_pc_bitmap,
memory_get_ram_ptr(),
reason,
capture_next_sequence());
free(bitmap);
return sig;
}
void overlay_capture_write_json(void)
{
/* Sticky dirty pages describe everything written since boot. They do not
* describe a coherent code variant: mutable data between two code pages
* can otherwise merge them into a giant region that changes every run.
* Final/manual captures use the same executed-page scope as autocapture. */
(void)overlay_capture_write_current("shutdown-or-manual",
0, psx_ram_live_bytes(), 0);
}
void overlay_capture_before_dma(uint32_t load_addr, uint32_t size)
{
if (!s_enabled || !s_active || size == 0) return;
uint32_t lo = psx_ram_canonical_offset(load_addr);
uint32_t hi = lo + size;
if (lo >= psx_ram_live_bytes()) return;
if (hi > psx_ram_live_bytes() || hi < lo) hi = psx_ram_live_bytes();
/* Snapshot complete pages touched by this DMA. Page scope prevents sticky
* dirty runs from turning a small sector replacement into a multi-megabyte
* journal, while whole-page evidence catches a write to one half of a page
* before it can hybridize code executed in the other half. */
const uint32_t page_shift = 12u;
uint32_t first_page = lo >> page_shift;
uint32_t last_page = (hi - 1u) >> page_shift;
uint32_t evidence_lo = first_page << page_shift;
uint32_t evidence_hi = (last_page + 1u) << page_shift;
/* Preserve an outgoing variant only when code in the affected/mergeable
* region was interpreted. Clear only that region after the RAM+bitmap copy
* is queued, leaving unrelated live variants in the current epoch. */
int observed = 0;
uint32_t first_word = evidence_lo >> 2;
uint32_t last_word = evidence_hi >> 2;
for (uint32_t wi = first_word; wi < last_word; wi++) {
if ((g_dirty_ram_exec_pc_bitmap[wi >> 5] >> (wi & 31u)) & 1u) {
observed = 1;
break;
}
}
if (!observed) return;
if (!preserve_snapshot_async(evidence_lo, evidence_hi)) {
/* Allocation/thread startup failure is rare and already exceptional.
* Fall back to a synchronous durable commit rather than let the RAM
* overwrite bind old evidence to new bytes. If storage itself fails,
* report the unavoidable loss but still clear the stale association. */
if (!overlay_capture_write_current("preserve-sync-fallback",
evidence_lo, evidence_hi, 1))
fprintf(stderr,
"psxrecomp: ERROR: could not preserve outgoing overlay evidence; discarding stale epoch\n");
}
uint32_t first_bitmap_word = first_word >> 5;
uint32_t bitmap_words = (last_word - first_word) >> 5;
memset(&g_dirty_ram_exec_pc_bitmap[first_bitmap_word], 0,
(size_t)bitmap_words * sizeof(uint32_t));
dirty_ram_dispatch_evidence_clear(first_bitmap_word, bitmap_words);
for (uint32_t page = first_page; page <= last_page; page++)
g_dirty_ram_exec_page_bitmap[page >> 5] &= ~(1u << (page & 31u));
}
int overlay_capture_count(void)
{
return s_count;
}
/* ---- Variant-capture automation (step 2.8) -------------------------------
* Trigger = sustained dirty-RAM-interp pressure inside a capture window
* (g_dirty_window_dispatches), sampled every AUTOCAP_CHECK_FRAMES vblanks,
* gated on NOT loading (cdrom_load_in_progress — captures must be taken at
* a coherent moment, never mid-load) plus a cooldown and a session cap.
* On fire: write overlay_captures.json and kick the configured background
* compile (autocompile.c). Dedup is the tool's job — compile_overlays.py
* skips image CRCs already in the cache — and the loop self-limits: once
* the hot code goes native the pressure signal drops to zero and no more
* captures fire. */
/* Cadence/threshold retune 2026-07-06 (measured, Tomba2 attract): a single
* interp dispatch can chain ~129K insns, so DISPATCH count under-reports
* pressure by orders of magnitude — a scene burning >50% of wall time in the
* interpreter produced ~250 dispatches per old 10s window, hovering at the
* old 256 gate (triggers effectively never fired mid-scene). Pressure is now
* EITHER signal: dispatch count (many short stubs) OR interpreted-insn count
* (few long chains). ~250K insns/window ≈ interp >~2.5% of wall — worth a
* capture. Faster cadence + short cooldown are safe: fires are still gated
* on a coherent moment (not loading, provider idle) and the loop self-limits
* as coverage converges (pressure → 0). */
/* There is deliberately NO lifetime trigger cap. A 64-fire "session backstop"
* shipped 2026-07-06 and was exhausted ~4h into an attract soak, silently
* freezing coverage growth with interp still ~50% of wall in 2D scenes —
* convergence IS many fires over a long session. The runaway case a cap was
* papering over (pressure that never converts: excluded PCs, failing shards
* — fires every cooldown forever, spawning futile provider runs) is handled
* by FUTILITY BACKOFF instead: a fire is skipped when the capture manifest
* is byte-identical to the last compile request AND that request registered
* no new candidates; skips retry on exponential backoff and any change in
* either signal resets to normal cadence. Concurrency is already bounded
* (cp->busy defers fires while a compile is in flight) and identical images
* dedup downstream (compile_overlays.py skips cached CRCs). */
#define AUTOCAP_CHECK_FRAMES 120u /* ~2 s between pressure samples */
#define AUTOCAP_MIN_DISPATCHES 128u /* catches ~120/s FMV helper gaps */
#define AUTOCAP_MIN_INSNS 100000ull /* long compute gap pressure gate */
#define AUTOCAP_COOLDOWN_FRAMES 300u /* >= ~5 s between auto-fires */
/* Host-time twins of the two frame gates, at their 60 Hz equivalents. Each
* gate opens on whichever budget expires FIRST: frame counting alone
* deadlocks recovery, because the stall autocapture exists to fix is exactly
* when vblanks stop arriving (at 0.26 fps, 120 frames is ~8 minutes of wall
* clock). At full speed the budgets coincide, so cadence is unchanged. */
#define AUTOCAP_CHECK_MS 2000ull
#define AUTOCAP_COOLDOWN_MS 5000ull
#define AUTOCAP_BACKOFF_MAX 64u /* futile-retry ceiling: 64*5s ≈ 5min */
#define AUTOCAP_WRITE_RETRY_MAX_FRAMES 60u /* cap failed-I/O retry at ~1 s */
static int s_autocap_enabled = 0;
static uint64_t s_autocap_last_check = 0;
static uint64_t s_autocap_last_fire = 0;
static uint64_t s_autocap_last_disp = 0;
static uint64_t s_autocap_last_insns = 0;
static uint32_t s_autocap_triggers = 0;
static uint64_t s_autocap_last_delta = 0;
static uint64_t s_autocap_last_insns_delta = 0;
static uint64_t s_autocap_next_ok = 0; /* frame gate for next attempt */
static uint64_t s_autocap_sig_at_req = 0; /* manifest FNV at last request */
static int s_autocap_reg_at_req = -1; /* loader candidates at last req */
static uint32_t s_autocap_backoff = 1; /* cooldown multiplier (futile) */
static uint32_t s_autocap_futile = 0; /* futile skips (telemetry) */
static uint64_t s_autocap_provider_sig_pending = 0;
static uint64_t s_autocap_provider_retry_frame = 0;
static unsigned s_autocap_provider_attempts = 0;
static uint64_t s_autocap_last_check_ms = 0; /* host twin of last_check */
static uint64_t s_autocap_next_ok_ms = 0; /* host twin of next_ok */
static uint64_t autocap_now_ms(void) { return (uint64_t)SDL_GetTicks64(); }
/* Wedge visibility: which pre-sample gate the tick last returned at, and
* when the pressure sample last completed. A latched gate is silent from
* outside — triggers, futility and pressure counters simply stop moving —
* so record it and let autocompile_status report it. */
static uint64_t s_autocap_last_sample_ms = 0;
static int s_autocap_gate_hit = 0;
/* Gate identifiers, in the order overlay_autocapture_tick() tests them. */
enum {
AUTOCAP_GATE_NONE = 0, /* reached the pressure sample */
AUTOCAP_GATE_WRITE_JOIN, /* worker finished, snapshot requeued */
AUTOCAP_GATE_WRITE_RETRY, /* manifest write failed, retrying */
AUTOCAP_GATE_PROVIDER, /* compile request pending */
AUTOCAP_GATE_DISABLED, /* autocapture off / capture inactive */
AUTOCAP_GATE_WRITE_BUSY, /* worker thread still running */
AUTOCAP_GATE_INTERVAL, /* check budget not yet elapsed */
};
typedef struct {
uint8_t *ram;
uint32_t *dispatch_pc_bitmap;
uint32_t *dispatch_seg_bitmap; /* [PSX_CODE_SEGMENT_COUNT][words] */
uint32_t *exec_pc_bitmap;
uint32_t *bitmap;
uint32_t bitmap_words;
uint64_t manifest_sig;
uint64_t sequence;
uint64_t retry_frame;
unsigned attempts;
} AutocapWriteJob;
static SDL_Thread *s_autocap_write_thread;
static AutocapWriteJob *s_autocap_write_job;
typedef struct PreserveWriteJob {
AutocapWriteJob snapshot;
struct PreserveWriteJob *next;
unsigned attempts;
} PreserveWriteJob;
static SDL_mutex *s_preserve_mutex;
static SDL_cond *s_preserve_cond;
static SDL_Thread *s_preserve_thread;
static PreserveWriteJob *s_preserve_head;
static PreserveWriteJob *s_preserve_tail;
static int s_preserve_stop;
void overlay_autocapture_set_enabled(int on) {
s_autocap_enabled = on ? 1 : 0;
}
void overlay_autocapture_get_status(int *enabled, uint32_t *triggers,
uint64_t *last_delta) {
if (enabled) *enabled = s_autocap_enabled;
if (triggers) *triggers = s_autocap_triggers;
if (last_delta) *last_delta = s_autocap_last_delta;
}
uint64_t overlay_autocapture_last_insns_delta(void) {
return s_autocap_last_insns_delta;
}
void overlay_autocapture_get_futility(uint32_t *backoff, uint32_t *futile) {
if (backoff) *backoff = s_autocap_backoff;
if (futile) *futile = s_autocap_futile;
}
/* Wedge report: the latched gate and time since the last completed pressure
* sample. Sampling is host-time paced, so a long dry stretch means a gate
* latched — autocapture is wedged and nothing new will ever compile — not
* that the game is merely slow. (Seen live: a full disk made the manifest
* write retry forever; the only outward symptom was "the game got slow".) */
const char *overlay_autocapture_gate_name(void) {
switch (s_autocap_gate_hit) {
case AUTOCAP_GATE_WRITE_JOIN: return "write-join";
case AUTOCAP_GATE_WRITE_RETRY: return "write-retry";
case AUTOCAP_GATE_PROVIDER: return "provider-pending";
case AUTOCAP_GATE_DISABLED: return "disabled";
case AUTOCAP_GATE_WRITE_BUSY: return "write-busy";
case AUTOCAP_GATE_INTERVAL: return "interval";
default: return "none";
}
}
/* Milliseconds since the last completed pressure sample; 0 before the first
* sample (nothing to conclude yet). */
uint64_t overlay_autocapture_ms_since_sample(void) {
if (!s_autocap_last_sample_ms) return 0;
uint64_t now = autocap_now_ms();
return now > s_autocap_last_sample_ms ? now - s_autocap_last_sample_ms : 0;
}
void overlay_autocapture_get_gates(int *capture_active, int *write_state,
int *write_job_pending,
unsigned *write_job_attempts,
int *provider_pending,
unsigned *provider_attempts) {
if (capture_active) *capture_active = s_active;
if (write_state) *write_state =
SDL_AtomicGet(&s_autocap_write_state);
if (write_job_pending) *write_job_pending = s_autocap_write_job ? 1 : 0;
if (write_job_attempts) *write_job_attempts =
s_autocap_write_job ? s_autocap_write_job->attempts : 0u;
if (provider_pending) *provider_pending =
s_autocap_provider_sig_pending != 0;
if (provider_attempts) *provider_attempts = s_autocap_provider_attempts;
}
#ifdef PSX_OVERLAY_CAPTURE_TEST
int overlay_capture_test_write_state(void) {
return SDL_AtomicGet(&s_autocap_write_state);
}
unsigned overlay_capture_test_write_attempts(void) {
return s_autocap_write_job ? s_autocap_write_job->attempts : 0u;
}
int overlay_capture_test_provider_pending(void) {
return s_autocap_provider_sig_pending != 0;
}
#endif
/* FNV-1a of the just-written capture manifest. The manifest is a pure
* function of the capture inputs (live overlay bytes + observed seed-PC
* sets, both grow-only), so an unchanged hash means a compile request
* would redo exactly the work of the previous one. */
static void autocap_write_job_free(AutocapWriteJob *job)
{
if (!job) return;
free(job->ram); free(job->dispatch_pc_bitmap);
free(job->dispatch_seg_bitmap); free(job->exec_pc_bitmap);
free(job->bitmap); free(job);
}
static int autocap_write_thread_main(void *opaque)
{
AutocapWriteJob *job = (AutocapWriteJob *)opaque;
SDL_SetThreadPriority(SDL_THREAD_PRIORITY_LOW);
job->manifest_sig = write_and_commit_snapshot(
job->bitmap_words, job->bitmap, job->dispatch_pc_bitmap,
job->dispatch_seg_bitmap, job->exec_pc_bitmap, job->ram, "autocap",
job->sequence);
SDL_AtomicSet(&s_autocap_write_state, 2);
return 0;
}
static uint64_t autocap_write_retry_delay(unsigned attempts)
{
uint64_t frames = (uint64_t)(attempts ? attempts : 1u) * 6u;
return frames < AUTOCAP_WRITE_RETRY_MAX_FRAMES
? frames : AUTOCAP_WRITE_RETRY_MAX_FRAMES;
}
/* A durable contribution and a successfully started compiler request are two
* separate commits. Retain the former's signature until request() accepts it;
* otherwise a transient CreateProcess/pipe/thread failure would mark identical
* evidence futile and suppress compilation forever. Returns nonzero while a
* provider request is still pending. */
static int autocap_provider_request_try(const CodeProvider *cp, uint64_t frame)
{
if (!s_autocap_provider_sig_pending) return 0;
if (frame < s_autocap_provider_retry_frame) return 1;
/* busy remains true through AC_DONE until the emulation thread has
* committed every prepared DLL and completed the additive final rescan. */
if (cp->busy && cp->busy()) return 1;
int reg = overlay_loader_registered_count();
if (s_autocap_provider_sig_pending == s_autocap_sig_at_req &&
reg == s_autocap_reg_at_req) {
s_autocap_futile++;
if (s_autocap_backoff < AUTOCAP_BACKOFF_MAX)
s_autocap_backoff <<= 1;
s_autocap_next_ok = frame +
(uint64_t)AUTOCAP_COOLDOWN_FRAMES * s_autocap_backoff;
s_autocap_next_ok_ms = autocap_now_ms() +
AUTOCAP_COOLDOWN_MS * (uint64_t)s_autocap_backoff;
s_autocap_provider_sig_pending = 0;
s_autocap_provider_retry_frame = 0;
s_autocap_provider_attempts = 0;
return 0;
}
if (cp->request && cp->request()) {
s_autocap_backoff = 1;
s_autocap_sig_at_req = s_autocap_provider_sig_pending;
s_autocap_reg_at_req = reg;
s_autocap_triggers++;
s_autocap_provider_sig_pending = 0;
s_autocap_provider_retry_frame = 0;
s_autocap_provider_attempts = 0;
return 0;
}
s_autocap_provider_attempts++;
s_autocap_provider_retry_frame = frame +
autocap_write_retry_delay(s_autocap_provider_attempts);
return 1;
}
static int autocap_write_launch(AutocapWriteJob *job)
{
job->manifest_sig = 0;
s_autocap_write_job = job;
SDL_AtomicSet(&s_autocap_write_state, 1);
s_autocap_write_thread = SDL_CreateThread(autocap_write_thread_main,
"overlay-capture-write", job);
if (!s_autocap_write_thread) {
SDL_AtomicSet(&s_autocap_write_state, 0);
return 0;
}
return 1;
}
/* Copy coherent inputs on the emulation thread, then perform base64 formatting,
* file I/O, and verification reread on a worker. */
static AutocapWriteJob *capture_snapshot_create(uint32_t scope_lo,
uint32_t scope_hi,
int scoped)
{
extern uint8_t *memory_get_ram_ptr(void);
const size_t ram_size = psx_ram_live_bytes();
uint32_t bw = dirty_ram_get_bitmap_word_count();
AutocapWriteJob *job = (AutocapWriteJob *)calloc(1, sizeof(*job));
if (!job) return NULL;
job->ram = (uint8_t *)malloc(ram_size);
job->dispatch_pc_bitmap = (uint32_t *)malloc(
sizeof(g_dirty_ram_dispatch_pc_bitmap));
job->dispatch_seg_bitmap = (uint32_t *)malloc(
sizeof(g_dirty_ram_dispatch_seg_bitmap));
job->exec_pc_bitmap = (uint32_t *)malloc(sizeof(g_dirty_ram_exec_pc_bitmap));
job->bitmap = (uint32_t *)malloc((size_t)bw * sizeof(uint32_t));
if (!job->ram || !job->dispatch_pc_bitmap || !job->dispatch_seg_bitmap ||
!job->exec_pc_bitmap || !job->bitmap) {
autocap_write_job_free(job); return NULL;
}
memcpy(job->ram, memory_get_ram_ptr(), ram_size);
memcpy(job->dispatch_pc_bitmap, g_dirty_ram_dispatch_pc_bitmap,
sizeof(g_dirty_ram_dispatch_pc_bitmap));
memcpy(job->dispatch_seg_bitmap, g_dirty_ram_dispatch_seg_bitmap,
sizeof(g_dirty_ram_dispatch_seg_bitmap));
memcpy(job->exec_pc_bitmap, g_dirty_ram_exec_pc_bitmap,
sizeof(g_dirty_ram_exec_pc_bitmap));
if (scoped) {
/* DMA preservation includes a one-page executed halo so an ...FFC
* control transfer and its ...000 delay slot remain coherent. */
capture_executed_pages(job->bitmap, bw, job->exec_pc_bitmap,
scope_lo, scope_hi, 1);
} else {
for (uint32_t i = 0; i < bw; i++)
job->bitmap[i] = dirty_ram_get_bitmap_word(i);
}
job->bitmap_words = bw;
job->sequence = capture_next_sequence();
return job;
}
static int autocap_write_start(void)
{
/* Periodic convergence needs executed code, not every sticky dirty page in
* RAM. Evidence-scoping keeps the background manifest proportional to live
* coverage and avoids multi-megabyte rewrites every cooldown. */
AutocapWriteJob *job = capture_snapshot_create(
0, psx_ram_live_bytes(), 1);
if (!job) return 0;
if (!autocap_write_launch(job)) {
s_autocap_write_job = NULL;
autocap_write_job_free(job);
return 0;
}
return 1;
}
static int preserve_write_thread_main(void *opaque)
{
(void)opaque;
SDL_SetThreadPriority(SDL_THREAD_PRIORITY_LOW);
for (;;) {
SDL_LockMutex(s_preserve_mutex);
while (!s_preserve_head && !s_preserve_stop)
SDL_CondWait(s_preserve_cond, s_preserve_mutex);
if (!s_preserve_head && s_preserve_stop) {
SDL_UnlockMutex(s_preserve_mutex);
break;
}
PreserveWriteJob *job = s_preserve_head;
s_preserve_head = job->next;
if (!s_preserve_head) s_preserve_tail = NULL;
SDL_UnlockMutex(s_preserve_mutex);
job->snapshot.manifest_sig = write_and_commit_snapshot(
job->snapshot.bitmap_words, job->snapshot.bitmap,
job->snapshot.dispatch_pc_bitmap,
job->snapshot.dispatch_seg_bitmap, job->snapshot.exec_pc_bitmap,
job->snapshot.ram, "preserve-outgoing", job->snapshot.sequence);
if (!job->snapshot.manifest_sig) {
job->attempts++;
SDL_LockMutex(s_preserve_mutex);
int abandoning = s_preserve_stop && job->attempts >= 5u;
if (!abandoning) {
job->next = NULL;
if (s_preserve_tail) s_preserve_tail->next = job;
else s_preserve_head = job;
s_preserve_tail = job;
}
SDL_UnlockMutex(s_preserve_mutex);
if (abandoning) {
fprintf(stderr,
"psxrecomp: ERROR: outgoing overlay snapshot could not be retained after shutdown retries\n");
autocap_write_job_free(&job->snapshot);
} else {
SDL_Delay(job->attempts < 4u ? job->attempts * 100u : 1000u);
}
continue;
}
autocap_write_job_free(&job->snapshot);
}
return 0;
}
static int preserve_writer_start(void)
{
if (!s_preserve_mutex) s_preserve_mutex = SDL_CreateMutex();
if (!s_preserve_cond) s_preserve_cond = SDL_CreateCond();
if (!s_preserve_mutex || !s_preserve_cond) return 0;
SDL_LockMutex(s_preserve_mutex);
if (!s_preserve_thread) {
s_preserve_stop = 0;
s_preserve_thread = SDL_CreateThread(preserve_write_thread_main,
"overlay-preserve-write", NULL);
}
int ok = s_preserve_thread != NULL;
SDL_UnlockMutex(s_preserve_mutex);
return ok;
}
/* Transfer ownership of an already-coherent immutable snapshot to the
* retrying FIFO. This is also the shutdown path for a periodic job whose
* formatter/fsync/history commit failed: the live evidence epoch has moved on,
* so the old bytes must be retried as-is rather than reconstructed or dropped. */
static int preserve_snapshot_enqueue_owned(AutocapWriteJob *snapshot)
{
PreserveWriteJob *job;
if (!snapshot || !preserve_writer_start()) return 0;
job = (PreserveWriteJob *)calloc(1, sizeof(*job));
if (!job) return 0;
job->snapshot = *snapshot;
free(snapshot);
SDL_LockMutex(s_preserve_mutex);
if (s_preserve_tail) s_preserve_tail->next = job;
else s_preserve_head = job;
s_preserve_tail = job;
SDL_CondSignal(s_preserve_cond);
SDL_UnlockMutex(s_preserve_mutex);
return 1;
}
/* Capture coherent bytes before DMA mutates RAM, but keep base64, hashing,
* copying, and fsync off the emulation thread. The one FIFO writer bounds I/O
* concurrency; sequence-aware commits also order it against autocapture. */
static int preserve_snapshot_async(uint32_t scope_lo, uint32_t scope_hi)
{
if (!preserve_writer_start()) return 0;
AutocapWriteJob *snapshot = capture_snapshot_create(scope_lo, scope_hi, 1);
if (!snapshot) return 0;
if (preserve_snapshot_enqueue_owned(snapshot)) return 1;
autocap_write_job_free(snapshot);
return 0;
}
void overlay_autocapture_tick(void)
{
extern uint64_t s_frame_count;
extern int cdrom_load_in_progress(void);
const CodeProvider *cp = code_provider_active();
if (SDL_AtomicGet(&s_autocap_write_state) == 2) {
s_autocap_gate_hit = AUTOCAP_GATE_WRITE_JOIN;
AutocapWriteJob *job = s_autocap_write_job;
SDL_WaitThread(s_autocap_write_thread, NULL);
s_autocap_write_thread = NULL;
SDL_AtomicSet(&s_autocap_write_state, 0);
uint64_t sig = job ? job->manifest_sig : 0;
if (!sig && job) {
/* The queued snapshot owns the cleared evidence epoch. Keep that
* immutable job alive and retry it; never merge it back into or
* clear the newer live epoch that has accumulated meanwhile. */
job->attempts++;
job->retry_frame = s_frame_count +
autocap_write_retry_delay(job->attempts);
return;
}
s_autocap_write_job = NULL;
if (sig) {
s_autocap_provider_sig_pending = sig;
s_autocap_provider_retry_frame = s_frame_count;
s_autocap_provider_attempts = 0;
(void)autocap_provider_request_try(cp, s_frame_count);
}
autocap_write_job_free(job);
return;
}
/* A transient formatter/fsync/history-commit or thread-create failure must
* not discard an already-cleared evidence epoch. Retry the same immutable
* snapshot at bounded cadence before considering another periodic fire. */
if (s_autocap_write_job) {
s_autocap_gate_hit = AUTOCAP_GATE_WRITE_RETRY;
AutocapWriteJob *job = s_autocap_write_job;
if (SDL_AtomicGet(&s_autocap_write_state) == 0 &&
s_frame_count >= job->retry_frame &&
!autocap_write_launch(job)) {
job->attempts++;
job->retry_frame = s_frame_count +
autocap_write_retry_delay(job->attempts);
}
return;
}
if (s_autocap_provider_sig_pending) {
s_autocap_gate_hit = AUTOCAP_GATE_PROVIDER;
(void)autocap_provider_request_try(cp, s_frame_count);
return;
}
if (!s_autocap_enabled || !s_active) {
s_autocap_gate_hit = AUTOCAP_GATE_DISABLED;
return;
}
if (SDL_AtomicGet(&s_autocap_write_state) != 0) {
s_autocap_gate_hit = AUTOCAP_GATE_WRITE_BUSY;
return;
}
const uint64_t now_ms = autocap_now_ms();
if (s_frame_count - s_autocap_last_check < AUTOCAP_CHECK_FRAMES &&
now_ms - s_autocap_last_check_ms < AUTOCAP_CHECK_MS) {
s_autocap_gate_hit = AUTOCAP_GATE_INTERVAL;
return;
}
s_autocap_last_check = s_frame_count;
s_autocap_last_check_ms = now_ms;
s_autocap_last_sample_ms = now_ms;
s_autocap_gate_hit = AUTOCAP_GATE_NONE;
uint64_t disp = g_dirty_window_dispatches;
uint64_t delta = disp - s_autocap_last_disp;
s_autocap_last_disp = disp;
s_autocap_last_delta = delta;
uint64_t insns = g_dirty_ram_insns_run;
uint64_t insns_delta = insns - s_autocap_last_insns;
s_autocap_last_insns = insns;
s_autocap_last_insns_delta = insns_delta;
if (delta < AUTOCAP_MIN_DISPATCHES && insns_delta < AUTOCAP_MIN_INSNS)
return;
if (cdrom_load_in_progress()) return; /* coherent moment only */
if (cp->busy && cp->busy()) return;
if (s_frame_count < s_autocap_next_ok && now_ms < s_autocap_next_ok_ms)
return; /* cooldown (x backoff) */
/* Snapshot coherent inputs for the player-shareable coverage manifest;
* a worker writes/hashes it, then a later vblank applies futility/backoff.
* Unchanged content with no new candidates backs off without a provider run. */
s_autocap_last_fire = s_frame_count;
s_autocap_next_ok = s_frame_count + AUTOCAP_COOLDOWN_FRAMES;
s_autocap_next_ok_ms = now_ms + AUTOCAP_COOLDOWN_MS;
if (autocap_write_start()) {
/* The queued job now owns this coherent evidence epoch. Start the next
* one immediately so later periodic manifests contain only newly seen
* PCs/pages instead of rewriting the entire session-wide union. Failed
* jobs remain queued and retry; normal shutdown drains them. */
memset(g_dirty_ram_exec_pc_bitmap, 0,
sizeof(g_dirty_ram_exec_pc_bitmap));
dirty_ram_dispatch_evidence_clear(0u, DIRTY_RAM_EXEC_BITMAP_WORDS);
memset(g_dirty_ram_exec_page_bitmap, 0,
sizeof(g_dirty_ram_exec_page_bitmap));
}
}
void overlay_capture_wait_pending(void)
{
if (s_autocap_write_job) {
if (s_autocap_write_thread)
SDL_WaitThread(s_autocap_write_thread, NULL);
s_autocap_write_thread = NULL;
AutocapWriteJob *job = s_autocap_write_job;
s_autocap_write_job = NULL;
SDL_AtomicSet(&s_autocap_write_state, 0);
if (!job->manifest_sig && preserve_snapshot_enqueue_owned(job)) {
/* Owned by the retrying FIFO, which is drained just below. */
} else {
if (!job->manifest_sig) {
fprintf(stderr,
"psxrecomp: ERROR: periodic overlay snapshot could not be queued for shutdown retry\n");
}
autocap_write_job_free(job);
}
}
if (s_preserve_thread) {
SDL_LockMutex(s_preserve_mutex);
s_preserve_stop = 1;
SDL_CondSignal(s_preserve_cond);
SDL_UnlockMutex(s_preserve_mutex);
SDL_WaitThread(s_preserve_thread, NULL);
s_preserve_thread = NULL;
}
}
void overlay_autocapture_shutdown(void)
{
int state = SDL_AtomicGet(&s_autocap_write_state);
if (state == 0) return;
if (s_autocap_write_thread) {
SDL_WaitThread(s_autocap_write_thread, NULL);
s_autocap_write_thread = NULL;
}
/* The worker atomically published and durably archived the coherent
* snapshot before setting state=2. No compiler request is needed while
* shutting down; just release its copied inputs before the final snapshot. */
autocap_write_job_free(s_autocap_write_job);
s_autocap_write_job = NULL;
SDL_AtomicSet(&s_autocap_write_state, 0);
}
uint32_t overlay_capture_get_region_crc(uint32_t region_start,
uint32_t region_size)
{
int j;
uint8_t *image = (uint8_t *)calloc(region_size, 1);
if (!image) return 0;
for (j = 0; j < s_count; j++) {
OvEntry *blk = &s_entries[j];
if (!blk->bytes) continue;
if (blk->load_addr < region_start ||
blk->load_addr >= region_start + region_size) continue;
uint32_t off = blk->load_addr - region_start;
uint32_t copy_sz = blk->size;
if (off + copy_sz > region_size) copy_sz = region_size - off;
memcpy(image + off, blk->bytes, copy_sz);
}
uint32_t crc = crc32_compute(image, region_size);
free(image);
return crc;
}