Replay recorded routes and export production frame costs

This commit is contained in:
Shokunin
2026-10-21 02:49:01 -07:00
parent 3fe7e085a4
commit 6ce13573e7
11 changed files with 281 additions and 5 deletions
+26
View File
@@ -0,0 +1,26 @@
# Production input replay
`PSX_INPUT_REPLAY=/absolute/path/route.txt` supplies recorded active-low digital
pad words in every build, including `PSX_DEBUG_TOOLS=OFF`. Each non-comment
line is `frame_count hexadecimal_buttons`, for example `60 bfff`. Counts must
be positive; pad words must fit 16 bits. The route starts at the first VBlank
after a successful disk savestate load, advances by guest frames, and returns
neutral input after its end. A failed requested state load is fatal. Netplay
and file replay cannot run together.
Use the exact state paired with the recording. This is a host input source;
it does not bypass savestate compatibility checks or change guest timing.
Optional bounded benchmark controls:
- `PSX_INPUT_REPLAY_STOP=2700`: request normal shutdown ten frames after this
zero-based route offset.
- `PSX_INPUT_REPLAY_REPORT=/absolute/path/frames.csv`: export frame stamps and
cumulative guest, pacer and render-queue times at shutdown. Enable existing
`PSX_RUNTIME_PERF_DIAG=1` for guest/pacer counters. Times use microseconds;
differences between adjacent rows describe the intervening guest interval.
- `PSX_INPUT_REPLAY_SHOTS=/absolute/path/prefix`: request screenshots at route
offsets 800 and STOP+2, outside the usual measured interval 900..2700.
The caller chooses new output paths and provides a process timeout. Reports
measure guest VBlanks, not distinct rendered images or display scanout.
+2
View File
@@ -213,6 +213,8 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-10-21 (BRIEF-4 Stage 1):** Port recorded-route measurement support from `a9da7fce`; validation pending. No guest timing changes.
- **2026-10-07 — DuckStation texture authoring fidelity (beads-qi66).**
The early Tomba capture report exposed per-draw crops and automatic page
fallback in the opt-in HD texture enhancement. An independent synthetic
+10 -1
View File
@@ -928,6 +928,11 @@ if(BUILD_TESTING)
target_include_directories(pgxp_test PRIVATE include)
add_test(NAME pgxp_test COMMAND pgxp_test)
add_executable(pgxp_production_test tests/test_pgxp.cpp src/pgxp.cpp src/psx_ram_geometry.c)
target_include_directories(pgxp_production_test PRIVATE include)
target_compile_definitions(pgxp_production_test PRIVATE PSX_NO_DEBUG_TOOLS)
add_test(NAME pgxp_production_test COMMAND pgxp_production_test)
add_executable(pgxp_word_shadow_test tests/test_pgxp_word_shadow.cpp
src/pgxp.cpp src/psx_ram_geometry.c)
target_include_directories(pgxp_word_shadow_test PRIVATE include)
@@ -978,7 +983,7 @@ if(BUILD_TESTING)
endforeach()
foreach(_pgxp_isolated_test
gte_register_access_test gte_hle_contract_test pgxp_test pgxp_session_test pgxp_session_hooks_test)
gte_register_access_test gte_hle_contract_test pgxp_test pgxp_production_test pgxp_session_test pgxp_session_hooks_test)
target_sources(${_pgxp_isolated_test} PRIVATE tests/pgxp_test_mod_memory.cpp)
endforeach()
@@ -1667,6 +1672,10 @@ if(BUILD_TESTING)
add_test(NAME frame_interpolation_schedule_test
COMMAND frame_interpolation_schedule_test)
add_executable(input_replay_test tests/test_input_replay.c src/input_replay.c)
target_include_directories(input_replay_test PRIVATE include)
add_test(NAME input_replay_test COMMAND input_replay_test "${CMAKE_CURRENT_BINARY_DIR}/input_replay_fixture.txt")
add_executable(render_pass_plan_test
tests/test_render_pass_plan.c
src/render_pass_plan.c)
+4
View File
@@ -45,6 +45,10 @@ typedef enum HostSpanKind {
HSPAN_MOD_WORK, /* emu: resident pack / mod host work */
HSPAN_FRAME_PRESENT, /* emu: frontend present for the frame */
HSPAN_DISC_READ, /* emu: one sector read from the disc image (arg lba) */
HSPAN_HOST_EVENTS, /* emu: pump and dispatch host window events */
HSPAN_REWIND_CAPTURE, /* emu: snapshot, store and thumbnail */
HSPAN_DISPLAY_PROBE, /* emu: host refresh/mode query */
HSPAN_AUDIO_UPDATE, /* emu: host audio pump */
HSPAN_KIND_COUNT
} HostSpanKind;
+17
View File
@@ -0,0 +1,17 @@
#ifndef PSX_INPUT_REPLAY_H
#define PSX_INPUT_REPLAY_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Optional host-input replay in every build. Text rows: frame count, hex pad
* word (active low). Starts at the first VBlank after a disk state load.
* No guest state is stored here; repeated reads of a frame are idempotent. */
int input_replay_load(const char *path);
void input_replay_on_load(uint64_t next_frame);
int input_replay_buttons(uint64_t frame);
uint64_t input_replay_frame(uint64_t frame); /* UINT64_MAX while inactive */
#ifdef __cplusplus
}
#endif
#endif
+1
View File
@@ -418,6 +418,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
${PSXRECOMP_ROOT}/runtime/src/render_pass_frame.c
${PSXRECOMP_ROOT}/runtime/src/dynamic_resolution.c
${PSXRECOMP_ROOT}/runtime/src/host_time.c
${PSXRECOMP_ROOT}/runtime/src/input_replay.c
${PSXRECOMP_ROOT}/runtime/src/quality_tier.c
${PSXRECOMP_ROOT}/runtime/src/quality_probe.c
${PSXRECOMP_ROOT}/runtime/src/quality_presets.cpp
+2
View File
@@ -142,6 +142,8 @@ const char *host_span_kind_name(unsigned kind) {
"none", "autocapture_tick", "provider_poll", "publish_commit", "loader_rescan",
"dll_register", "lazy_load", "capture_on_dma", "capture_before_dma", "ac_request", "ac_done_join",
"ac_prepare_image", "ac_child", "overlay_dispatch", "mod_work", "frame_present", "disc_read",
"host_events", "rewind_capture",
"display_probe", "audio_update",
};
return kind < HSPAN_KIND_COUNT ? names[kind] : "?";
}
+66
View File
@@ -0,0 +1,66 @@
#include "input_replay.h"
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <errno.h>
typedef struct ReplayStep { uint64_t end; unsigned buttons; } ReplayStep;
static ReplayStep *s_steps;
static size_t s_count, s_index;
static uint64_t s_base;
static int s_armed;
int input_replay_load(const char *path) {
FILE *f = fopen(path, "r");
ReplayStep *steps = NULL;
size_t count = 0;
uint64_t end = 0;
char line[256];
int ok = f != NULL;
if (!f) return 0;
while (fgets(line, sizeof line, f)) {
char *p = line;
unsigned long long frames, buttons;
char *endp;
while (isspace((unsigned char)*p)) p++;
if (!*p || *p == '#') continue;
if (*p == '-' || *p == '+') { ok = 0; break; }
errno = 0; frames = strtoull(p, &endp, 10);
if (endp == p || errno || !isspace((unsigned char)*endp) ||
!frames || frames > UINT32_MAX) { ok = 0; break; }
p = endp;
while (isspace((unsigned char)*p)) p++;
if (*p == '-' || *p == '+') { ok = 0; break; }
errno = 0; buttons = strtoull(p, &endp, 16);
if (endp == p || errno || buttons > 65535) { ok = 0; break; }
while (isspace((unsigned char)*endp)) endp++;
if (*endp || count >= 65536 || end + frames > UINT32_MAX) { ok = 0; break; }
ReplayStep *next = realloc(steps, (count+1)*sizeof *steps);
if (!next) { ok = 0; break; }
steps = next;
end += frames;
steps[count++] = (ReplayStep){end, buttons};
}
if (ferror(f)) ok = 0;
fclose(f);
if (!ok || !count) { free(steps); return 0; }
free(s_steps);
s_steps = steps; s_count = count; s_index = 0; s_armed = 0;
return 1;
}
void input_replay_on_load(uint64_t next_frame) {
s_base = next_frame; s_index = 0; s_armed = s_count != 0;
}
uint64_t input_replay_frame(uint64_t frame) {
return s_armed && frame >= s_base ? frame-s_base : UINT64_MAX;
}
int input_replay_buttons(uint64_t frame) {
uint64_t at = input_replay_frame(frame);
if (at == UINT64_MAX) return -1;
if (s_index && at < s_steps[s_index-1].end) s_index = 0;
while (s_index < s_count && at >= s_steps[s_index].end) s_index++;
return s_index < s_count ? (int)s_steps[s_index].buttons : 65535;
}
+112 -2
View File
@@ -46,6 +46,7 @@
#include "autocompile.h"
#include "code_provider.h"
#include "host_span_ring.h"
#include "input_replay.h"
#ifdef PSX_HAS_OVERLAY_DISPATCH
extern "C" void psx_overlay_static_get_stats(uint64_t *, uint64_t *, uint64_t *, uint64_t *);
#endif
@@ -1075,6 +1076,10 @@ static void post_load_probe_on_vblank(int turbo_active, int present_reached) {
* restored VRAM — including a blank if display was disabled in the snapshot. */
static void savestate_input_guard_arm(void);
extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok) {
if (is_load && !ok && std::getenv("PSX_INPUT_REPLAY")) {
std::puts("psxrecomp: requested input replay savestate failed to load");
std::exit(1);
}
char buf[64];
const int disp = slot + 1;
if (!is_load && ok)
@@ -1097,11 +1102,16 @@ extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok)
extern "C" void psx_frontend_on_savestate_refused(int is_load, int slot,
const char *reason) {
if (is_load && std::getenv("PSX_INPUT_REPLAY")) {
std::puts(reason);
std::exit(1);
}
(void)is_load; (void)slot;
host_osd_push(reason, 3000);
}
extern "C" void psx_frontend_on_savestate_loaded(void) {
input_replay_on_load(s_frame_count + 1);
psx_projection_reset_session();
psx_local_mouse_reset();
mod_runtime_on_savestate_loaded();
@@ -3868,6 +3878,10 @@ static void refresh_host_display_cadence(int force_log, int force_probe) {
return;
}
g_host_refresh_last_probe_ms = now_ms ? now_ms : 1ull;
struct ProbeSpan {
uint64_t start = host_span_begin();
~ProbeSpan() { host_span_end(HSPAN_DISPLAY_PROBE, start, 0); }
} probe_span;
double host_hz = 0.0, max_hz = 0.0;
if (disp_idx >= 0) {
@@ -4762,7 +4776,11 @@ static void runtime_perf_frame_end() {
class RuntimePerfFrameScope {
public:
~RuntimePerfFrameScope() { runtime_perf_frame_end(); }
uint64_t start = host_span_begin();
~RuntimePerfFrameScope() {
runtime_perf_frame_end();
host_span_end(HSPAN_FRAME_PRESENT, start, 0);
}
};
static uint64_t runtime_perf_section_begin() {
@@ -4789,6 +4807,77 @@ static void runtime_perf_section_end(uint64_t start, uint64_t *total) {
* waits on the render queue), the render thread's busy share, the dynamic
* resolution level and Smooth motion's generated / real presents per second. */
static uint64_t g_frame_report_wait_ticks = 0; /* offline pacer waits */
/* Explicit, file-driven production benchmark. No debug server, sampler or
* per-instruction tracing is needed. Capture outside the measured interval;
* export the existing frame ring before requesting the normal quit path. */
static void input_replay_frontend_tick(uint64_t frame) {
uint64_t relative = input_replay_frame(frame);
if (relative == UINT64_MAX) return;
static uint64_t stop = 0;
static const char *report = nullptr, *shots = nullptr;
static bool initialized = false, finished = false;
struct WorkSample { uint64_t frame, guest, pacer, queue; };
static std::vector<WorkSample> work;
if (!initialized) {
if (const char *e = std::getenv("PSX_INPUT_REPLAY_STOP")) stop = std::strtoull(e, nullptr, 10);
report = std::getenv("PSX_INPUT_REPLAY_REPORT");
shots = std::getenv("PSX_INPUT_REPLAY_SHOTS");
if (report && *report) work.resize(HOST_FRAME_STAMP_CAP);
initialized = true;
}
if (finished) return;
if (report && *report) {
RtStats rs{};
rt_get_stats(&rs);
const uint64_t hz = SDL_GetPerformanceFrequency();
work[frame % HOST_FRAME_STAMP_CAP] = {frame, g_runtime_perf.guest_work_ticks * 1000000 / hz,
g_runtime_perf.pacer_ticks * 1000000 / hz,
(rs.backpressure_ns + rs.ring_full_ns + rs.acquire_ns) / 1000};
}
if (shots && (relative == 800 || (stop > 0 && relative == stop+2))) {
char path[512];
std::snprintf(path, sizeof path, "%s-%llu.png", shots, (unsigned long long)relative);
present_shot_request(path);
}
if (!stop || relative < stop+10) return;
finished = true;
if (report && *report) {
FILE *f = std::fopen(report, "w");
if (!f) throw std::runtime_error("cannot write input replay frame report");
std::fputs("frame,relative,us,interp,checks,faults,io_kb,guest_us,pacer_us,queue_us\n", f);
uint64_t total = host_frame_stamp_total();
uint64_t first = total > HOST_FRAME_STAMP_CAP ? total-HOST_FRAME_STAMP_CAP : 0;
for (uint64_t i=first; i<total; i++) {
uint32_t fr, ii, ck, pf, io; uint64_t us;
if (!host_frame_stamp_get(i, &fr, &us, &ii, &ck, &pf, &io)) continue;
uint64_t rel = input_replay_frame(fr);
if (rel == UINT64_MAX) continue;
const auto &w = work[fr % HOST_FRAME_STAMP_CAP];
if (w.frame != fr) continue;
std::fprintf(f, "%u,%llu,%llu,%u,%u,%u,%u,%llu,%llu,%llu\n", fr,
(unsigned long long)rel, (unsigned long long)us, ii, ck, pf, io,
(unsigned long long)w.guest, (unsigned long long)w.pacer,
(unsigned long long)w.queue);
}
if (std::fclose(f) != 0) throw std::runtime_error("cannot finish input replay frame report");
std::string span_path = std::string(report) + ".spans.csv";
f = std::fopen(span_path.c_str(), "w");
if (!f) throw std::runtime_error("cannot write input replay span report");
std::fputs("start_us,dur_us,frame,kind,emu,arg\n", f);
total = host_span_total();
first = total > HOST_SPAN_RING_CAP ? total-HOST_SPAN_RING_CAP : 0;
for (uint64_t i=first; i<total; ++i) {
HostSpan s;
if (host_span_get(i, &s))
std::fprintf(f, "%llu,%u,%u,%s,%u,%u\n", (unsigned long long)s.t0_us,
s.dur_us, s.frame, host_span_kind_name(s.kind), s.emu, s.arg);
}
if (std::fclose(f) != 0) throw std::runtime_error("cannot finish input replay span report");
}
SDL_Event event{}; event.type = SDL_QUIT; SDL_PushEvent(&event);
}
static void frame_report_tick(uint64_t frames) {
static int interval_ms = -1;
static uint64_t first_ticks = 0, last_ticks = 0, last_frames = 0;
@@ -4987,6 +5076,10 @@ static void sdl_audio_pump_midframe(void) {
}
static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) {
struct AudioSpan {
uint64_t start = host_span_begin();
~AudioSpan() { host_span_end(HSPAN_AUDIO_UPDATE, start, 0); }
} audio_span;
{ /* Tag audio events with the vblank frame counter. */
extern uint64_t s_frame_count;
audio_trace_note_frame((uint32_t)s_frame_count);
@@ -8814,6 +8907,10 @@ static int debug_toggles_on(void);
static int debug_toggle_key(int key, char *out, int cap);
#endif
static bool drain_host_events() {
struct EventSpan {
uint64_t start = host_span_begin();
~EventSpan() { host_span_end(HSPAN_HOST_EVENTS, start, 0); }
} event_span;
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
if (psx_local_mouse_event(ev)) continue;
@@ -9006,6 +9103,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
int override = -1;
int override_p2 = -1;
#endif
int replay_override = input_replay_buttons(s_frame_count);
if (replay_override >= 0) override = replay_override;
/* Always-on wall-time stamp for this guest frame (host_span_ring.h). */
extern uint64_t s_frame_count;
uint32_t stamp_static_checks = 0;
@@ -9026,6 +9125,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
}
runtime_perf_frame_begin();
RuntimePerfFrameScope runtime_perf_frame_scope;
input_replay_frontend_tick(s_frame_count);
runtime_perf_diag_tick();
/* Lightweight frontend telemetry from PR #13. Count simulated vblanks rather
@@ -9374,7 +9474,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
* frame hooks may touch guest state, so neither may depend on whether this
* frame is presented — otherwise headless validation runs a different game
* than the windowed build. Hooks still run after all normal input sampling. */
mod_call_frame_hooks();
{
const uint64_t start = host_span_begin();
mod_call_frame_hooks();
host_span_end(HSPAN_MOD_WORK, start, 0);
}
/* Resize-driven mode updates the pending aspect during BIOS boot too, but
* the actual wide compositor remains disengaged until game entry. No-op
@@ -20735,6 +20839,12 @@ session_reboot:
memcard_init_slots(memcard_dir_str.c_str(), slots);
}
if (!rematch_session) {
if (const char *route = std::getenv("PSX_INPUT_REPLAY")) {
if (psx_netplay_active() || !input_replay_load(route)) {
std::puts("psxrecomp: input replay requires a valid route and an offline session");
return 1;
}
}
std::atexit(memcard_flush_all);
/* Persist the game's native OPTION settings on any exit path (belt-and-
* suspenders; save-on-change already persists them live). No-op if not yet
+6 -2
View File
@@ -7,6 +7,7 @@
#include "gpu.h"
#include "host_keymap.h"
#include "host_osd.h"
#include "host_span_ring.h"
#include "interrupts.h"
#include "mdec.h"
#include "psx_cycles.h"
@@ -583,8 +584,11 @@ void psx_rewind_poll(CPUState *cpu, uint32_t resume_pc)
(void)do_load(cpu, tick);
return;
}
if (s_capture_due && !s_open && !psx_netplay_active() && !s_title_blocked)
(void)do_capture(cpu, resume_pc);
if (s_capture_due && !s_open && !psx_netplay_active() && !s_title_blocked) {
const uint64_t start = host_span_begin();
const int ok = do_capture(cpu, resume_pc);
host_span_end(HSPAN_REWIND_CAPTURE, start, (uint32_t)ok);
}
}
int psx_rewind_toggle(void)
+35
View File
@@ -0,0 +1,35 @@
#include "input_replay.h"
#include <stdio.h>
#include <stdlib.h>
#define CHECK(x) do { if (!(x)) { puts(#x); return 1; } } while (0)
int main(int argc, char **argv) {
CHECK(argc == 2);
FILE *f = fopen(argv[1], "w"); CHECK(f);
fputs("# neutral then steer\n2 ffff\n3 bfdf\n1 bfff\n", f); fclose(f);
CHECK(input_replay_load(argv[1]));
CHECK(input_replay_buttons(0) == -1);
input_replay_on_load(100);
CHECK(input_replay_buttons(99) == -1);
CHECK(input_replay_buttons(100) == 65535);
CHECK(input_replay_buttons(101) == 65535);
CHECK(input_replay_buttons(102) == 0xbfdf);
CHECK(input_replay_buttons(102) == 0xbfdf);
CHECK(input_replay_buttons(104) == 0xbfdf);
CHECK(input_replay_buttons(105) == 0xbfff);
CHECK(input_replay_buttons(106) == 65535);
CHECK(input_replay_buttons(102) == 0xbfdf);
input_replay_on_load(200);
CHECK(input_replay_frame(200) == 0);
CHECK(input_replay_buttons(202) == 0xbfdf);
f = fopen(argv[1], "w"); CHECK(f);
fputs("0 bfff\n", f); fclose(f);
CHECK(!input_replay_load(argv[1]));
const char *bad[] = {"-1 bfff\n", "4294967296 bfff\n", "1 10000\n", "1 bfff junk\n", "1 -1\n"};
for (unsigned i=0; i<sizeof bad/sizeof *bad; i++) {
f = fopen(argv[1], "w"); CHECK(f); fputs(bad[i], f); fclose(f);
CHECK(!input_replay_load(argv[1]));
}
remove(argv[1]);
puts("input replay: PASS");
return 0;
}