From 6fb2d8cbb8531c522d6dfd4b0cbf774234318995 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:49:01 -0700 Subject: [PATCH] Replay recorded routes and export production frame costs --- docs/PRODUCTION_INPUT_REPLAY.md | 26 ++++++ docs/internal/FAITHFUL_TIMING_PLAN.md | 8 ++ runtime/CMakeLists.txt | 11 ++- runtime/include/host_span_ring.h | 4 + runtime/include/input_replay.h | 17 ++++ runtime/runtime.cmake | 1 + runtime/src/host_span_ring.c | 2 + runtime/src/input_replay.c | 66 +++++++++++++++ runtime/src/main.cpp | 114 +++++++++++++++++++++++++- runtime/src/psx_rewind.c | 8 +- runtime/tests/test_input_replay.c | 35 ++++++++ 11 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 docs/PRODUCTION_INPUT_REPLAY.md create mode 100644 runtime/include/input_replay.h create mode 100644 runtime/src/input_replay.c create mode 100644 runtime/tests/test_input_replay.c diff --git a/docs/PRODUCTION_INPUT_REPLAY.md b/docs/PRODUCTION_INPUT_REPLAY.md new file mode 100644 index 00000000..9ffdf149 --- /dev/null +++ b/docs/PRODUCTION_INPUT_REPLAY.md @@ -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. diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index 7f15b8af..fc325caa 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,14 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- **2026-10-21 (production R4 follow-up):** Removed debug-only PGXP receipt + writes from no-debug-tools builds; numeric PGXP and word-shadow suites pass. + Added disk-state-anchored applied-input replay and bounded frame/work/span + exports for production measurements. Five focused framework CTests and 26 + R4 checks pass on Windows. The owner's stricter default-Ultra acceptance bar + remains open; evidence is in R4 `analysis/render-60fps/SLOWDOWNS.md`. No guest + timing or projection arithmetic changed. + - **2026-10-21 (Windows continuation, `feat/render-60fps`):** Added cumulative render-transaction wall-time buckets, separating checkpoint copying from verification hashing. After the owner's moving-course correction, R4 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 94028664..b2f58a85 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -941,6 +941,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) @@ -991,7 +996,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() @@ -1697,6 +1702,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) diff --git a/runtime/include/host_span_ring.h b/runtime/include/host_span_ring.h index 4026a07b..3469e494 100644 --- a/runtime/include/host_span_ring.h +++ b/runtime/include/host_span_ring.h @@ -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; diff --git a/runtime/include/input_replay.h b/runtime/include/input_replay.h new file mode 100644 index 00000000..74afdc85 --- /dev/null +++ b/runtime/include/input_replay.h @@ -0,0 +1,17 @@ +#ifndef PSX_INPUT_REPLAY_H +#define PSX_INPUT_REPLAY_H +#include +#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 diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index bfaa49cb..701efde9 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -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 diff --git a/runtime/src/host_span_ring.c b/runtime/src/host_span_ring.c index c1c8fd79..c56f7b7f 100644 --- a/runtime/src/host_span_ring.c +++ b/runtime/src/host_span_ring.c @@ -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] : "?"; } diff --git a/runtime/src/input_replay.c b/runtime/src/input_replay.c new file mode 100644 index 00000000..a1b7caa7 --- /dev/null +++ b/runtime/src/input_replay.c @@ -0,0 +1,66 @@ +#include "input_replay.h" +#include +#include +#include +#include + +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; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fc425137..7b88d28a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -49,6 +49,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 @@ -1081,6 +1082,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) @@ -1103,11 +1108,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(); @@ -3879,6 +3889,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) { @@ -4803,7 +4817,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() { @@ -4969,6 +4987,77 @@ static void openxr_ready_report_tick(uint64_t frames) { } } + +/* 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 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 HOST_SPAN_RING_CAP ? total-HOST_SPAN_RING_CAP : 0; + for (uint64_t i=first; i= 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; @@ -9225,6 +9324,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 @@ -9573,7 +9673,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 @@ -21065,6 +21169,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 diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index 1b3a1a2c..4f2c68e5 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -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) diff --git a/runtime/tests/test_input_replay.c b/runtime/tests/test_input_replay.c new file mode 100644 index 00000000..e55a1e74 --- /dev/null +++ b/runtime/tests/test_input_replay.c @@ -0,0 +1,35 @@ +#include "input_replay.h" +#include +#include +#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