VR: add opt-in stereo and XR frame attribution

This commit is contained in:
Matt Stanley
2026-10-22 15:26:59 -07:00
committed by GitHub
17 changed files with 1770 additions and 7 deletions
+72
View File
@@ -0,0 +1,72 @@
# Frame tracing (stereo / OpenXR)
`runtime/src/frame_trace.c` records one row per traced stereo frame so a visible
VR hitch can be attributed to a stage instead of guessed at. It is the
measurement floor for the VR optimization work: nothing else should be tuned
before there are traces.
## Enable
PSX_FRAME_TRACE=1 # off by default; one branch when off
PSX_FRAME_TRACE_CAP=2048 # optional power-of-two ring size (default 2048)
The ring is static, the record holds fixed-size fields, and the record path
allocates nothing and writes no files (`runtime/tests/test_frame_trace_guard.py`
fails the build if that changes). Instrumentation off vs on is an A/B control.
## Read
Over the debug TCP server:
{"cmd":"frame_trace","format":"json","max":240}
{"cmd":"frame_trace","format":"chrome","max":240} # Perfetto trace-event JSON
`max` bounds the newest records (0 = all retained). The game-side helper is
`vr/trace_frames.py`, which writes `frame_trace.json`, `frame_trace.csv`,
optional `frame_trace_perfetto.json`, and a `summary.json` with P50/P95/P99 of
`pair_total`, each eye's total, and `xr_wait_frame`, plus stale/shed/failed
counts and the worst frames.
## Stages (microseconds)
guest_sim host time between traced frames
pair_total both eyes, pair open -> publish
eye_{begin,guest,end,restore}_{l,r}
one eye's VRAM backup+checkpoint, guest draw,
flush/capture/VRAM restore, machine restore
xr_wait_frame / xr_begin_frame xrWaitFrame / xrBeginFrame
xr_acquire / xr_wait_image / xr_copy / xr_release / xr_end_frame
hud_capture / hud_draw / hud_readback
a HUD capture transaction, its guest draw and
its readback
game_env / game_world / game_text / game_traverse / game_icon /
game_submit / game_laser
the guest scene rebuild one eye replays, split
by the call the game hook makes: draw env and
ordering-table setup, world passes, text,
scene traversal, HUD icon, DrawOTag, and the
hook's own per-eye raycast. PAIR totals (both
eyes write the same slots), so compare them
against eye_guest_l + eye_guest_r. Written by
the hook through
psx_mod_frame_trace_stage_begin/end, which
only accepts them inside a traced stereo eye.
Per frame: `seq`, `open_ms`, guest frame/cycle, stereo attempt id, published
pair id, `pair_age_ms` (age of the retained whole pair), `source` (1 pair / 2
native), `eye_failed`, shed skipped/streak, `prims`/`tex_prims` (the last eye's
own count), `prims_l`/`prims_r`, `tex_prims_l`/`tex_prims_r`,
`cycles_l`/`cycles_r` (guest cycles each eye's replay executed, separating
emulated machine work from host submission time), `vram_leaks`, `tex_bytes`, and
flags (`published`, `stale`, `shed`, `failed`, `xr_empty`, `scene`, `io`,
`no_telemetry`).
## Guarantees
* `xr_*` stages are CPU wall-clock waits, never GPU execution time.
* Only a complete pair is published; a stale frame retains the previous whole
pair (`pair_age_ms` says how old it is).
* A frame is `stale` when it published nothing, `shed` when the pair was skipped
on purpose, `failed` when a pair was attempted and lost, and
`no_telemetry` when an expected-to-be-instrumented frame recorded no stage.
* Timestamps are monotonic even if the host clock steps backwards.
+3 -2
View File
@@ -493,9 +493,9 @@ between Play and TCP availability.
## Complete command index (generated)
**383 commands registered** — 370 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
**384 commands registered** — 371 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
85 of 383 have prose above; **298 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
85 of 384 have prose above; **299 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code.
@@ -627,6 +627,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `frame_perf` | ✓ | | |
| `frame_range` | ✓ | ✓ | ✓ |
| `frame_timeseries` | ✓ | ✓ | ✓ |
| `frame_trace` | ✓ | | |
| `frame_wall_ring` | ✓ | | |
| `freeze_check` | ✓ | | |
| `game_options` | ✓ | | |
@@ -0,0 +1,21 @@
# VR frame tracing: source and validation
Adapted from [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp):
- [022eb1ed](https://github.com/FractalEngineer/psxrecomp/commit/022eb1edb2d6b2d9d505641e198bd9aeb5eb8ce1)
- [0e0d2de8](https://github.com/FractalEngineer/psxrecomp/commit/0e0d2de82777857248ae6c1bf7e9b7cba05e3d0a)
- [5c67dced](https://github.com/FractalEngineer/psxrecomp/commit/5c67dced708a67e905b77be455263009f566d4bb)
- [63271ce7](https://github.com/FractalEngineer/psxrecomp/commit/63271ce7b4e2fb5f616050e7d59a2e4642a33b55)
- [571a8f10](https://github.com/FractalEngineer/psxrecomp/commit/571a8f1082dbbc95af19e90ce0226a0cb40f302d)
- [87a3e822](https://github.com/FractalEngineer/psxrecomp/commit/87a3e8223cf7c05761326bfce06e80ffc527e132)
Original author: Yves <calibratedbeats@gmail.com>; retained as commit author
and Co-authored-by on this reconstructed port.
Adapted: opt-in trace ring, XR cadence, HUD timing, nested replay attribution,
exports and tests. Excluded: shedding policy, checkpoint reuse, game hooks and
chronological research. Rebuilt on upstream master `9caf4c22`.
Validation: Windows/GCC 13.2 Release: trace, source guard, sandbox, abort and
XR-off input tests pass (5/5); command index is current. XR-on integration is
checked separately after assembling the feature branches.
Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace); pin bump and rebuild, no regeneration.
+12
View File
@@ -1633,6 +1633,11 @@ if(BUILD_TESTING)
add_test(NAME present_sync_test COMMAND present_sync_test)
target_include_directories(frame_pacing_test PRIVATE include)
add_test(NAME frame_pacing_test COMMAND frame_pacing_test)
# Opt-in stereo/OpenXR frame-trace ring (frame_trace.c): disabled-by-default,
# ring wrap, timestamp monotonicity, missing telemetry, export validity.
add_executable(frame_trace_test tests/test_frame_trace.c src/frame_trace.c)
target_include_directories(frame_trace_test PRIVATE include)
add_test(NAME frame_trace_test COMMAND frame_trace_test)
# Render thread core ([video] render_thread, docs/RENDER_THREAD.md):
# ordering across ring wraps, the frame bound, stale-frame detection and
# the drain + context hand-off sync point, against a fake executor.
@@ -1778,6 +1783,7 @@ if(BUILD_TESTING)
add_executable(render_pass_abort_test
tests/test_render_pass_abort.c
src/render_pass.c
src/frame_trace.c
src/psx_cycles.c)
target_include_directories(render_pass_abort_test PRIVATE include)
add_test(NAME render_pass_abort_test COMMAND render_pass_abort_test)
@@ -1789,6 +1795,7 @@ if(BUILD_TESTING)
add_executable(render_pass_sandbox_test
tests/test_render_pass_sandbox.c
src/render_pass.c
src/frame_trace.c
src/render_pass_plan.c
src/psx_cycles.c
src/timers.c
@@ -2046,6 +2053,11 @@ if(BUILD_TESTING)
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_memory_debug_trace_guard.py
--cc ${CMAKE_C_COMPILER})
# The frame-trace record path must stay allocation- and IO-free, or
# it perturbs the frame it measures and no functional test notices.
add_test(NAME frame_trace_guard_test
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_trace_guard.py)
endif()
add_test(NAME fntrace_c_linkage_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fntrace_c_linkage.py)
add_test(NAME depth24_present_hold_vblank_test
+243
View File
@@ -0,0 +1,243 @@
#ifndef PSX_FRAME_TRACE_H
#define PSX_FRAME_TRACE_H
/* Opt-in, fixed-capacity frame-time tracing for the stereo/OpenXR path.
*
* WHY THIS EXISTS
* ---------------
* VR stutter reports ("it hitches when enemies spawn", "fast head turns judder",
* "one heavy frame causes a run of stutters") could not be attributed. The
* renderer already smoothed a few averages, but only for a handful of stages,
* they were never reset, and there was no per-frame history to correlate a
* visible hitch with. This module is the measurement floor the VR optimization
* program is built on: one record per stereo frame, monotonic, allocation-free
* on the hot path, exported only on demand.
*
* DESIGN CONTRACT
* ---------------
* * DISABLED BY DEFAULT (PSX_FRAME_TRACE=0). Every entry point returns
* immediately while disabled, so an instrumented build costs one branch.
* * NO per-frame heap allocation and NO file I/O on the record path. The ring
* is static; the record holds fixed-size fields only. Export writes into a
* caller buffer, outside the frame.
* * Stages are supplied by the caller in MICROSECONDS, measured with whatever
* host clock it already uses. The module never reads a clock for durations,
* only for the record's open timestamp, through one injectable function
* pointer. That keeps the module dependency-free and unit-testable.
* * CPU submission time is NOT GPU execution time. Stages named "xr_*" are
* wall-clock waits; nothing here claims GPU completion.
* * Emulation/main thread only, like the OpenXR lifecycle it observes.
*/
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Per-frame stages, in the order one stereo frame produces them. The header
* order IS the ABI; keep frame_trace.c's name table in sync. */
enum {
PSX_TRACE_GUEST_SIM = 0, /* guest work outside the stereo pair */
PSX_TRACE_PAIR_TOTAL, /* both eyes, pair open to publish */
PSX_TRACE_EYE_BEGIN_L, /* VRAM backup + checkpoint */
PSX_TRACE_EYE_GUEST_L, /* the plugin's guest draw */
PSX_TRACE_EYE_END_L, /* GPU flush, capture, VRAM restore */
PSX_TRACE_EYE_RESTORE_L, /* machine state restore */
PSX_TRACE_EYE_BEGIN_R,
PSX_TRACE_EYE_GUEST_R,
PSX_TRACE_EYE_END_R,
PSX_TRACE_EYE_RESTORE_R,
PSX_TRACE_XR_WAIT_FRAME, /* xrWaitFrame */
PSX_TRACE_XR_BEGIN_FRAME, /* xrBeginFrame */
PSX_TRACE_XR_ACQUIRE, /* swapchain acquire (both eyes) */
PSX_TRACE_XR_WAIT_IMAGE, /* xrWaitSwapchainImage (both eyes) */
PSX_TRACE_XR_COPY, /* eye blit into the runtime image */
PSX_TRACE_XR_RELEASE, /* swapchain release (both eyes) */
PSX_TRACE_XR_END_FRAME, /* xrEndFrame */
/* How old the submitted pose was: xrLocateViews return to xrEndFrame
* return. This is the pose the compositor reprojects from, so it bounds
* the correction a head turn can need. */
PSX_TRACE_POSE_AGE,
/* Host time a HUD capture transaction costs (psx_mod_render_capture_rgba).
* The wrist HUD captures the same guest slice twice per frame, once per
* matte colour, and both are full render transactions on the frame's own
* thread, so this is frame time the pair stats never showed. */
PSX_TRACE_HUD_CAPTURE,
/* The capture's two halves, so the cost can be aimed at: the transaction is
* the checkpoint plus the guest HUD draw plus its verify, the readback is
* the blit out of the pair target and the synchronous glReadPixels. */
PSX_TRACE_HUD_DRAW,
PSX_TRACE_HUD_READBACK,
/* The guest scene rebuild one eye replays, split by the call the game hook
* makes, so a hitch can be aimed at the guest call that grew instead of at
* the whole eye. These are PAIR totals: both eyes write the same slots.
* Written by the hook through psx_mod_frame_trace_stage_begin/end, which
* only accepts them inside a traced stereo eye.
* game_env draw environment, GP0 clear, ordering-table setup
* game_world the world/level passes
* game_text the generic text renderer
* game_traverse scene traversal: level groups and entities
* game_icon the HUD icon
* game_submit DrawOTag: walks the ordering table and submits it
* game_laser the hook's own per-eye raycast and line (host work)
* game_select the hook's own level visibility override (the LVTX
* entry filter): the eye-view AABB test and the leaf
* queue it writes. It runs INSIDE the guest traversal,
* and game_traverse is that call's wall time, so the
* guest's own traversal work is game_traverse minus
* game_select. Per eye and summed over the pair like the
* rest, and the only one that nests. */
PSX_TRACE_GAME_ENV,
PSX_TRACE_GAME_WORLD,
PSX_TRACE_GAME_TEXT,
PSX_TRACE_GAME_TRAVERSE,
PSX_TRACE_GAME_ICON,
PSX_TRACE_GAME_SUBMIT,
PSX_TRACE_GAME_LASER,
PSX_TRACE_GAME_SELECT,
PSX_TRACE_STAGE_COUNT
};
/* Record flags. A hitch is the interesting case, so absent conditions are
* explicit: a stale frame is one that published no new pair. */
enum {
PSX_FRAME_TRACE_STEREO_PUBLISHED = 1u << 0,
PSX_FRAME_TRACE_STALE_PAIR = 1u << 1,
PSX_FRAME_TRACE_SHED = 1u << 2,
PSX_FRAME_TRACE_FAILED = 1u << 3,
PSX_FRAME_TRACE_XR_EMPTY = 1u << 4,
PSX_FRAME_TRACE_SCENE_TRANSITION = 1u << 5,
PSX_FRAME_TRACE_IO_EVENT = 1u << 6,
PSX_FRAME_TRACE_NO_TELEMETRY = 1u << 7,
/* A stereo eye rendered from the pair's shared baseline instead of taking
* its own machine snapshot (see PR3's pair-scoped checkpoint reuse). */
PSX_FRAME_TRACE_BASELINE_REUSED = 1u << 8,
/* The XR frame carried no new pair, so the retained one was submitted (or
* nothing was, when resubmission is off). */
PSX_FRAME_TRACE_XR_REPEAT = 1u << 9
};
enum {
PSX_FRAME_TRACE_NOTE = 24, /* scene / IO tag */
PSX_FRAME_TRACE_REASON = 24 /* failure reason text */
};
typedef struct PSXFrameTraceRecord {
uint64_t seq; /* monotonic record id (starts at 1) */
uint64_t open_ms; /* host monotonic ms at open */
uint64_t guest_frame; /* guest frame counter, 0 if unknown */
uint64_t guest_cycle; /* guest cycle at open */
uint64_t stereo_attempt; /* stereo attempt id (0 if none) */
uint64_t pair_id; /* published pair id (0 if none) */
uint64_t pair_age_ms; /* ms since the last published pair (0 if none) */
uint32_t stages_us[PSX_TRACE_STAGE_COUNT];
uint32_t shed_skipped; /* pair attempts shed immediately before this frame */
uint32_t shed_streak_ms; /* how long that shed streak had lasted */
uint32_t prims; /* guest draw primitives (0 = not measured) */
uint32_t textured_prims; /* of which textured */
/* The same two counters per eye. `prims`/`textured_prims` above keep their
* meaning (the last eye of the pair wrote them); a change to what ONE eye
* selects needs these, because the pair's total hides which eye moved. */
uint32_t prims_l, prims_r;
uint32_t tex_prims_l, tex_prims_r;
/* Guest cycles each eye's replay executed, the machine work the eye cost in
* the emulated CPU rather than in host submission. 0 = not measured. */
uint32_t eye_cycles_l, eye_cycles_r;
uint32_t vram_leaks; /* GPU writes outside the pass rect this eye */
uint64_t tex_upload_bytes; /* texture bytes uploaded (0 = not measured) */
/* Microseconds between this submission's XrFrameState.predictedDisplayTime
* and the previous one, from the runtime's clock. Divided by the session's
* target_period_us this is how many granted display periods one presented
* pair consumed, which is the cadence the compositor actually saw. 0 on the
* first submission of a session or when there was no frame this record. */
uint32_t xr_display_delta_us;
uint32_t flags; /* PSX_FRAME_TRACE_* */
int32_t eye_failed; /* last eye that failed, -1 if none */
uint8_t source; /* 1 = stereo pair, 2 = native render */
uint8_t xr_submit_fresh; /* 1 = carried a new pair, 0 = repeat/none */
uint8_t reserved[2];
char note[PSX_FRAME_TRACE_NOTE];
char failure[PSX_FRAME_TRACE_REASON];
} PSXFrameTraceRecord;
/* Injectable monotonic millisecond clock. NULL (the default) records 0; the
* runtime installs psx_host_mono_ms(). Tests install a fake. */
typedef uint64_t (*PSXFrameTraceClock)(void);
void psx_frame_trace_set_clock(PSXFrameTraceClock fn);
/* Enables/disables capture. Disabling keeps the ring (read it, then reset).
* Reads PSX_FRAME_TRACE / PSX_FRAME_TRACE_CAP once. */
void psx_frame_trace_configure_from_env(void);
void psx_frame_trace_set_enabled(int on);
int psx_frame_trace_enabled(void);
/* Clears the ring, counters and any open record. */
void psx_frame_trace_reset(void);
/* Opens a record for the frame that is starting. Returns 1 if this call opened
* it, 0 if capture is off or a record is already open (the caller must then NOT
* commit). Cost is a branch while disabled. */
int psx_frame_trace_open(void);
/* Commits the open record, stamping pair age from the last published pair.
* No-op when nothing is open. */
void psx_frame_trace_commit(void);
/* Adds `us` microseconds to `stage` of the open record. Ignores unknown stages;
* saturates instead of wrapping. Cheap while closed. */
void psx_frame_trace_add(uint32_t stage, uint64_t us);
/* Per-frame annotations (open record). Each is a no-op when closed. */
void psx_frame_trace_ids(uint64_t guest_frame, uint64_t guest_cycle);
void psx_frame_trace_attempt(uint64_t attempt_id, uint32_t shed_skipped,
uint32_t shed_streak_ms);
void psx_frame_trace_result(int published, uint64_t pair_id, int eye_failed,
const char *failure);
void psx_frame_trace_source(uint8_t source);
/* Draw counters for `eye` (PSX_MOD_EYE_LEFT/0 or RIGHT/1). Also fills `prims`
* and `textured_prims`, which therefore remain the last eye's values. */
void psx_frame_trace_draw(int eye, uint32_t prims, uint32_t textured);
/* Guest cycles one eye's replay executed (the transaction's own delta). */
void psx_frame_trace_eye_cycles(int eye, uint32_t cycles);
void psx_frame_trace_gpu(uint64_t tex_upload_bytes, uint32_t vram_leaks);
/* XR cadence annotation (open record): the runtime's predicted-display-time
* delta for this submission and whether it carried a newly rendered pair. A
* repeat also sets PSX_FRAME_TRACE_XR_REPEAT. No-op when closed. */
void psx_frame_trace_xr_cadence(uint32_t display_delta_us, int submit_fresh);
void psx_frame_trace_flag(uint32_t flag);
void psx_frame_trace_note(const char *tag);
void psx_frame_trace_io(const char *tag); /* note + IO_EVENT flag */
/* Session-level metadata, emitted once in the export header rather than per
* record: the OpenXR runtime actually in use and the target display period in
* microseconds (0 = unknown, e.g. no headset). Without these a trace cannot be
* interpreted later, because the same pair time is over budget at 90 Hz and
* comfortably under it at 60 Hz. Change-checked internally, so calling it every
* frame is a strcmp; it is a no-op while capture is off. */
void psx_frame_trace_session(const char *runtime, uint32_t target_period_us);
/* Queries. `capacity` is the caller's array length; returns how many records
* were written (oldest first, at most the newest `capacity`). */
uint32_t psx_frame_trace_read(PSXFrameTraceRecord *out, uint32_t capacity);
uint32_t psx_frame_trace_capacity(void);
uint64_t psx_frame_trace_committed(void); /* records committed since reset */
uint64_t psx_frame_trace_dropped(void); /* records overwritten (ring wrap) */
uint64_t psx_frame_trace_missing(void); /* commits with no stage at all */
/* Exports. Each returns the number of bytes the full text needs (like
* snprintf); the buffer is always NUL-terminated when cap > 0. `max_records`
* (0 = all retained) bounds the newest records emitted. */
int psx_frame_trace_to_json(char *buf, int cap, uint32_t max_records);
int psx_frame_trace_to_csv(char *buf, int cap, uint32_t max_records);
/* Chrome trace-event JSON (Perfetto compatible): one span per stage. */
int psx_frame_trace_to_chrome_json(char *buf, int cap, uint32_t max_records);
const char *psx_frame_trace_stage_name(uint32_t stage);
/* Renders the set bits as "a|b|c" into buf; always NUL-terminated. */
const char *psx_frame_trace_flag_names(uint32_t flags, char *buf, int cap);
#ifdef __cplusplus
}
#endif
#endif /* PSX_FRAME_TRACE_H */
+3
View File
@@ -164,6 +164,9 @@ void gl_renderer_interpolation_diag(int *enabled, int *suspended,
/* Cumulative CPU-upload diagnostics: calls, rects, pixels, conversion ticks,
* texture-upload ticks, FBO-draw ticks. Active only with PSX_RUNTIME_PERF_DIAG. */
void gl_renderer_runtime_diag(uint64_t out[6]);
/* Cumulative scene primitive counters (pre double-draw): all submitted, and of
* those textured. Callers take deltas; the counters are never reset here. */
void gl_renderer_scene_prim_counts(uint64_t *prims, uint64_t *textured);
/* Present an ARGB8888 image (BGRA byte order) as a letterboxed quad + swap.
* Used for 24-bit (FMV) frames and the PSX_GL_FORCE_CPU_PRESENT diagnostic.
+19
View File
@@ -733,6 +733,25 @@ typedef int (*PSXModStereoFn)(struct CPUState*, void*, uint32_t eye);
int psx_mod_render_stereo(struct CPUState *cpu, const PSXModStereoFrame *frame,
PSXModStereoFn fn, void *user);
uint32_t psx_mod_render_stereo_status(void);
/* Annotate the current frame's trace record with a short tag (scene transition,
* state change). Only meaningful while PSX_FRAME_TRACE is on and a traced frame
* is open (a headset frame, opened by psx_mod_openxr_begin); a no-op otherwise. */
void psx_mod_frame_trace_note(const char *tag);
/* Bracket one guest call of a stereo eye's replay with a frame-trace sub-stage,
* so a hitch can be aimed at the call that grew instead of at the whole eye
* (the PSX_TRACE_GAME_* stages in frame_trace.h). The runtime does the timing,
* with the same clock and conversion as the eye's own stages, so the parts sum
* against eye_guest. A no-op unless PSX_FRAME_TRACE is on AND the call is made
* inside a traced stereo eye: a HUD capture's guest draws, the flat path and
* anything outside the eye's callback are ignored. A begin without a matching
* end (or an eye aborted mid-entry) contributes nothing; the unmeasured time
* stays visible as the difference between eye_guest and the sub-stages.
* Spans may nest: each stage keeps its own start tick, so a stage bracketed
* inside another is timed on its own and reports a subset of the outer span's
* wall time rather than a sibling of it. The hook's visibility override nests
* inside the guest traversal it is part of (game_select in game_traverse). */
void psx_mod_frame_trace_stage_begin(uint32_t stage);
void psx_mod_frame_trace_stage_end(uint32_t stage);
/* 0 disables stereo output; 1 shows a complete pair side by side. Capture
* itself does not enable presentation. Default 0; applies to this session. */
int psx_mod_set_stereo_presentation(uint32_t mode);
+1
View File
@@ -486,6 +486,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
${PSXRECOMP_ROOT}/runtime/src/starvation_ring.c
${PSXRECOMP_ROOT}/runtime/src/guest_cycle_profile.c
${PSXRECOMP_ROOT}/runtime/src/latency_ring.c
${PSXRECOMP_ROOT}/runtime/src/frame_trace.c
${PSXRECOMP_ROOT}/runtime/src/present_image_ring.c
${PSXRECOMP_ROOT}/runtime/src/gpu_timeline.c
${PSXRECOMP_ROOT}/runtime/src/data_shards.c
+30
View File
@@ -66,6 +66,7 @@
#include "crash_trace.h"
#include "gpu_gl_renderer.h"
#include "render_pass.h"
#include "frame_trace.h"
#include "mod_plugins.h" /* psx_mod_render_pass_status */
#include "lockstep.h"
#include "guest_tty.h"
@@ -9332,6 +9333,34 @@ static void handle_render_pass_dump(int id, const char *json)
send_fmt("{\"id\":%d,\"ok\":true,\"count\":%d}", id, count);
}
/* frame_trace [format=json|chrome] [max=<n>]: the opt-in stereo/OpenXR frame
* trace (frame_trace.c). Capturing requires PSX_FRAME_TRACE=1 at launch; this
* command only reads the ring and never runs in the frame path. `max` bounds the
* newest records returned (default 240). Both formats are JSON objects, so the
* reply embeds the export verbatim. */
static void handle_frame_trace(int id, const char *json) {
char format[16] = "json";
int max = json_get_int(json, "max", 240);
(void)json_get_str(json, "format", format, sizeof format);
if (max < 0) max = 0;
int chrome = strcmp(format, "chrome") == 0;
int need = chrome ? psx_frame_trace_to_chrome_json(NULL, 0, (uint32_t)max)
: psx_frame_trace_to_json(NULL, 0, (uint32_t)max);
if (need <= 0) { send_err(id, "frame trace export failed"); return; }
char *buf = (char *)malloc((size_t)need + 1);
if (!buf) { send_err(id, "out of memory"); return; }
if (chrome) (void)psx_frame_trace_to_chrome_json(buf, need + 1, (uint32_t)max);
else (void)psx_frame_trace_to_json(buf, need + 1, (uint32_t)max);
send_fmt("{\"id\":%d,\"ok\":true,\"enabled\":%d,\"capacity\":%u,"
"\"count\":%llu,\"dropped\":%llu,\"missing\":%llu,"
"\"format\":\"%s\",\"max\":%d,\"trace\":%s}",
id, psx_frame_trace_enabled(), psx_frame_trace_capacity(),
(unsigned long long)psx_frame_trace_committed(),
(unsigned long long)psx_frame_trace_dropped(),
(unsigned long long)psx_frame_trace_missing(), format, max, buf);
free(buf);
}
/* gl_wide_fast on=<0|1>: native-wide centre-blit fast path. 1 (default) = skip
* the redundant centre mirror and copy the canonical 4:3 frame into the wide
* surface centre at present (fast). 0 = re-rasterize the whole wide surface
@@ -16255,6 +16284,7 @@ static const CmdEntry s_commands[] = {
{ "openxr_hands_override", handle_openxr_hands_override },
#endif
{ "stereo_stats", handle_stereo_stats },
{ "frame_trace", handle_frame_trace },
{ "stereo_dump", handle_stereo_dump },
{ "render_pass_dump", handle_render_pass_dump },
{ "render_pass_refuse", handle_render_pass_refuse },
+498
View File
@@ -0,0 +1,498 @@
/* frame_trace.c — opt-in per-frame stereo/OpenXR timing ring. See frame_trace.h.
*
* Emulation/main thread only. The record path allocates nothing and writes no
* files; export functions fill a caller buffer and are called outside the frame.
*/
#include "frame_trace.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Compile-time ring bound. The live capacity is a power of two <= this, chosen
* by PSX_FRAME_TRACE_CAP (default 2048, ~34 s of 60 Hz or ~68 s of 30 Hz pairs).
* Static, so enabling the trace never adds an allocation to the frame path. */
#define PSX_FRAME_TRACE_MAX 4096
#define PSX_FRAME_TRACE_DEFAULT 2048
static PSXFrameTraceRecord s_ring[PSX_FRAME_TRACE_MAX];
/* An in-progress frame never overwrites the retained committed window. */
static PSXFrameTraceRecord s_pending;
static int s_configured;
static int s_enabled;
static uint32_t s_cap = PSX_FRAME_TRACE_DEFAULT;
static uint64_t s_opened; /* records opened since reset (== newest seq) */
static uint64_t s_committed; /* records committed since reset */
static uint64_t s_missing; /* commits that carried no stage at all */
static uint64_t s_mono_high; /* last timestamp handed out (monotonic floor) */
static int s_have_open;
static int s_have_publish;
static uint64_t s_last_publish_ms;
/* Session metadata (see psx_frame_trace_session). */
static char s_runtime[128];
static uint32_t s_target_period_us;
static PSXFrameTraceClock s_clock;
static const char *const s_stage_names[PSX_TRACE_STAGE_COUNT] = {
"guest_sim", "pair_total",
"eye_begin_l", "eye_guest_l", "eye_end_l", "eye_restore_l",
"eye_begin_r", "eye_guest_r", "eye_end_r", "eye_restore_r",
"xr_wait_frame", "xr_begin_frame", "xr_acquire", "xr_wait_image",
"xr_copy", "xr_release", "xr_end_frame", "pose_age", "hud_capture",
"hud_draw", "hud_readback",
"game_env", "game_world", "game_text", "game_traverse", "game_icon",
"game_submit", "game_laser", "game_select"
};
static const struct { uint32_t bit; const char *name; } s_flag_names[] = {
{ PSX_FRAME_TRACE_STEREO_PUBLISHED, "published" },
{ PSX_FRAME_TRACE_STALE_PAIR, "stale" },
{ PSX_FRAME_TRACE_SHED, "shed" },
{ PSX_FRAME_TRACE_FAILED, "failed" },
{ PSX_FRAME_TRACE_XR_EMPTY, "xr_empty" },
{ PSX_FRAME_TRACE_SCENE_TRANSITION, "scene" },
{ PSX_FRAME_TRACE_IO_EVENT, "io" },
{ PSX_FRAME_TRACE_NO_TELEMETRY, "no_telemetry" },
{ PSX_FRAME_TRACE_BASELINE_REUSED, "baseline_reused" },
{ PSX_FRAME_TRACE_XR_REPEAT, "xr_repeat" }
};
const char *psx_frame_trace_stage_name(uint32_t stage) {
return stage < PSX_TRACE_STAGE_COUNT ? s_stage_names[stage] : "?";
}
const char *psx_frame_trace_flag_names(uint32_t flags, char *buf, int cap) {
int off = 0;
if (!buf || cap <= 0) return "";
buf[0] = '\0';
for (size_t i = 0; i < sizeof s_flag_names / sizeof s_flag_names[0]; ++i) {
if (!(flags & s_flag_names[i].bit)) continue;
off += snprintf(buf + off, (size_t)(cap - off), "%s%s",
off ? "|" : "", s_flag_names[i].name);
if (off >= cap) break;
}
return buf;
}
static uint32_t next_pow2(uint32_t v) {
uint32_t p = 1;
while (p < v && p < PSX_FRAME_TRACE_MAX) p <<= 1;
return p;
}
static void configure_from_env(void) {
const char *e = getenv("PSX_FRAME_TRACE");
s_enabled = (e && e[0] && e[0] != '0') ? 1 : 0;
e = getenv("PSX_FRAME_TRACE_CAP");
if (e && e[0]) {
long v = strtol(e, NULL, 0);
if (v >= 64) s_cap = next_pow2((uint32_t)v);
}
}
static void ensure_configured(void) {
if (!s_configured) {
s_configured = 1;
configure_from_env();
}
}
void psx_frame_trace_configure_from_env(void) {
s_configured = 1;
configure_from_env();
}
void psx_frame_trace_set_clock(PSXFrameTraceClock fn) { s_clock = fn; }
void psx_frame_trace_set_enabled(int on) {
ensure_configured();
s_enabled = on ? 1 : 0;
}
int psx_frame_trace_enabled(void) {
ensure_configured();
return s_enabled;
}
void psx_frame_trace_reset(void) {
memset(s_ring, 0, sizeof s_ring);
s_opened = s_committed = s_missing = s_mono_high = 0;
s_have_open = s_have_publish = 0;
s_last_publish_ms = 0;
s_runtime[0] = '\0';
s_target_period_us = 0;
}
static PSXFrameTraceRecord *open_record(void) {
return &s_pending;
}
static uint64_t stamp_now(void) {
uint64_t now = s_clock ? s_clock() : 0;
/* Monotonic floor: a coarse or throttled clock must never make a later
* record look older than the one before it. */
if (now < s_mono_high) now = s_mono_high;
s_mono_high = now;
return now;
}
int psx_frame_trace_open(void) {
ensure_configured();
if (!s_enabled || s_have_open) return 0;
uint64_t seq = s_opened + 1u;
PSXFrameTraceRecord *r = &s_pending;
memset(r, 0, sizeof *r);
r->seq = seq;
r->eye_failed = -1;
r->open_ms = stamp_now();
s_opened = seq;
s_have_open = 1;
return 1;
}
void psx_frame_trace_add(uint32_t stage, uint64_t us) {
if (!s_have_open || stage >= PSX_TRACE_STAGE_COUNT) return;
PSXFrameTraceRecord *r = open_record();
uint64_t v = (uint64_t)r->stages_us[stage] + us;
r->stages_us[stage] = v > 0xFFFFFFFFu ? 0xFFFFFFFFu : (uint32_t)v;
}
void psx_frame_trace_ids(uint64_t guest_frame, uint64_t guest_cycle) {
if (!s_have_open) return;
PSXFrameTraceRecord *r = open_record();
r->guest_frame = guest_frame;
r->guest_cycle = guest_cycle;
}
void psx_frame_trace_attempt(uint64_t attempt_id, uint32_t shed_skipped,
uint32_t shed_streak_ms) {
if (!s_have_open) return;
PSXFrameTraceRecord *r = open_record();
r->stereo_attempt = attempt_id;
r->shed_skipped = shed_skipped;
r->shed_streak_ms = shed_streak_ms;
if (shed_skipped) r->flags |= PSX_FRAME_TRACE_SHED;
}
void psx_frame_trace_result(int published, uint64_t pair_id, int eye_failed,
const char *failure) {
if (!s_have_open) return;
PSXFrameTraceRecord *r = open_record();
r->pair_id = pair_id;
r->eye_failed = eye_failed < 0 ? -1 : eye_failed;
if (published) {
r->flags |= PSX_FRAME_TRACE_STEREO_PUBLISHED;
r->source = 1;
return;
}
r->flags |= PSX_FRAME_TRACE_STALE_PAIR;
if (failure && failure[0]) {
r->flags |= PSX_FRAME_TRACE_FAILED;
snprintf(r->failure, sizeof r->failure, "%s", failure);
}
}
void psx_frame_trace_source(uint8_t source) {
if (!s_have_open) return;
PSXFrameTraceRecord *r = open_record();
/* First writer wins: a published stereo pair must not be relabelled
* "native" by a later end-of-frame annotation. */
if (!r->source) r->source = source;
}
void psx_frame_trace_draw(int eye, uint32_t prims, uint32_t textured) {
if (!s_have_open || eye < 0 || eye > 1) return;
PSXFrameTraceRecord *r = open_record();
/* Per-eye fields first: `prims`/`textured_prims` are the legacy pair-level
* view of the same counter and stay the last eye's values, as before. */
if (eye == 0) { r->prims_l = prims; r->tex_prims_l = textured; }
else { r->prims_r = prims; r->tex_prims_r = textured; }
r->prims = prims;
r->textured_prims = textured;
}
void psx_frame_trace_eye_cycles(int eye, uint32_t cycles) {
if (!s_have_open || eye < 0 || eye > 1) return;
PSXFrameTraceRecord *r = open_record();
if (eye == 0) r->eye_cycles_l = cycles;
else r->eye_cycles_r = cycles;
}
void psx_frame_trace_gpu(uint64_t tex_upload_bytes, uint32_t vram_leaks) {
if (!s_have_open) return;
PSXFrameTraceRecord *r = open_record();
r->tex_upload_bytes += tex_upload_bytes;
uint64_t leaks = (uint64_t)r->vram_leaks + vram_leaks;
r->vram_leaks = leaks > 0xFFFFFFFFu ? 0xFFFFFFFFu : (uint32_t)leaks;
}
void psx_frame_trace_xr_cadence(uint32_t display_delta_us, int submit_fresh) {
if (!s_have_open) return;
PSXFrameTraceRecord *r = open_record();
r->xr_display_delta_us = display_delta_us;
r->xr_submit_fresh = submit_fresh ? 1u : 0u;
if (!submit_fresh) r->flags |= PSX_FRAME_TRACE_XR_REPEAT;
}
void psx_frame_trace_flag(uint32_t flag) {
if (!s_have_open) return;
open_record()->flags |= flag;
}
void psx_frame_trace_note(const char *tag) {
if (!s_have_open || !tag) return;
snprintf(open_record()->note, PSX_FRAME_TRACE_NOTE, "%s", tag);
}
void psx_frame_trace_io(const char *tag) {
if (!s_have_open) return;
PSXFrameTraceRecord *r = open_record();
r->flags |= PSX_FRAME_TRACE_IO_EVENT;
if (tag) snprintf(r->note, PSX_FRAME_TRACE_NOTE, "%s", tag);
}
void psx_frame_trace_session(const char *runtime, uint32_t target_period_us) {
if (!s_enabled) return;
if (target_period_us != s_target_period_us) s_target_period_us = target_period_us;
if (runtime && runtime != s_runtime && strcmp(runtime, s_runtime) != 0)
snprintf(s_runtime, sizeof s_runtime, "%s", runtime);
}
void psx_frame_trace_commit(void) {
if (!s_have_open) return;
PSXFrameTraceRecord *r = open_record();
int any = 0;
for (uint32_t i = 0; i < PSX_TRACE_STAGE_COUNT; ++i)
if (r->stages_us[i]) { any = 1; break; }
/* A frame that only shed its pair genuinely has no stage: that is "stale",
* not "no telemetry". Only an expected-to-be-instrumented frame that
* recorded nothing at all is missing. */
if (!any && !(r->flags & PSX_FRAME_TRACE_SHED)) {
s_missing++;
r->flags |= PSX_FRAME_TRACE_NO_TELEMETRY;
}
if (r->flags & PSX_FRAME_TRACE_STEREO_PUBLISHED) {
s_have_publish = 1;
s_last_publish_ms = r->open_ms;
r->pair_age_ms = 0;
} else if (s_have_publish) {
r->pair_age_ms = r->open_ms - s_last_publish_ms;
}
s_ring[(r->seq - 1u) & (uint64_t)(s_cap - 1u)] = *r;
s_committed++;
s_have_open = 0;
}
uint32_t psx_frame_trace_capacity(void) { return s_cap; }
uint64_t psx_frame_trace_committed(void) { return s_committed; }
uint64_t psx_frame_trace_dropped(void) {
return s_committed > s_cap ? s_committed - s_cap : 0;
}
uint64_t psx_frame_trace_missing(void) { return s_missing; }
uint32_t psx_frame_trace_read(PSXFrameTraceRecord *out, uint32_t capacity) {
uint32_t n = 0;
if (!out || !capacity || !s_committed) return 0;
uint64_t retained = s_committed < s_cap ? s_committed : s_cap;
uint64_t take = capacity < retained ? capacity : retained;
uint64_t first = s_committed - take + 1u; /* oldest of the newest `take` */
for (uint64_t seq = first; seq <= s_committed; ++seq) {
const PSXFrameTraceRecord *r =
&s_ring[(seq - 1u) & (uint64_t)(s_cap - 1u)];
if (r->seq != seq) continue; /* overwritten / not ours */
out[n++] = *r;
}
return n;
}
/* ---- export ------------------------------------------------------------- */
static int app(char *buf, int cap, int off, const char *fmt, ...) {
va_list ap;
if (off < 0) off = 0;
if (buf && off < cap) {
va_start(ap, fmt);
int n = vsnprintf(buf + off, (size_t)(cap - off), fmt, ap);
va_end(ap);
return n < 0 ? off : off + n;
}
va_start(ap, fmt);
int n = vsnprintf(NULL, 0, fmt, ap); /* measure only */
va_end(ap);
return n < 0 ? off : off + n;
}
/* JSON string body with the few escapes a generated tool must not break on. */
static const char *jstr(const char *s, char *tmp, int cap) {
int o = 0;
if (cap <= 0) return "";
tmp[0] = '\0';
for (const unsigned char *p = (const unsigned char *)s; p && *p; ++p) {
const unsigned char c = *p;
const int need = c == '"' || c == '\\' || c == '\n' ? 2 : c < 0x20 ? 6 : 1;
if (need > cap - 1 - o) break;
if (c == '"' || c == '\\') { tmp[o++] = '\\'; tmp[o++] = (char)c; }
else if (c == '\n') { tmp[o++] = '\\'; tmp[o++] = 'n'; }
else if (c < 0x20) o += snprintf(tmp + o, (size_t)(cap - o), "\\u%04x", c);
else tmp[o++] = (char)c;
}
tmp[o] = '\0';
return tmp;
}
static int rec_json(char *buf, int cap, int off, const PSXFrameTraceRecord *r) {
char t1[PSX_FRAME_TRACE_REASON * 2 + 8], t2[PSX_FRAME_TRACE_NOTE * 2 + 8];
off = app(buf, cap, off,
"{\"seq\":%llu,\"open_ms\":%llu,\"guest_frame\":%llu,"
"\"guest_cycle\":%llu,\"attempt\":%llu,\"pair_id\":%llu,"
"\"pair_age_ms\":%llu,\"source\":%u,\"eye_failed\":%d,"
"\"shed\":%u,\"shed_ms\":%u,\"prims\":%u,\"tex_prims\":%u,"
"\"prims_l\":%u,\"prims_r\":%u,\"tex_prims_l\":%u,"
"\"tex_prims_r\":%u,\"cycles_l\":%u,\"cycles_r\":%u,"
"\"vram_leaks\":%u,\"tex_bytes\":%llu,\"flags\":%u,"
"\"xr_display_delta_us\":%u,\"xr_submit_fresh\":%u,"
"\"failure\":\"%s\",\"note\":\"%s\",\"stages_us\":{",
(unsigned long long)r->seq, (unsigned long long)r->open_ms,
(unsigned long long)r->guest_frame,
(unsigned long long)r->guest_cycle,
(unsigned long long)r->stereo_attempt,
(unsigned long long)r->pair_id,
(unsigned long long)r->pair_age_ms, r->source, r->eye_failed,
r->shed_skipped, r->shed_streak_ms, r->prims, r->textured_prims,
r->prims_l, r->prims_r, r->tex_prims_l, r->tex_prims_r,
r->eye_cycles_l, r->eye_cycles_r,
r->vram_leaks, (unsigned long long)r->tex_upload_bytes, r->flags,
r->xr_display_delta_us, (unsigned)r->xr_submit_fresh,
jstr(r->failure, t1, sizeof t1), jstr(r->note, t2, sizeof t2));
for (uint32_t s = 0; s < PSX_TRACE_STAGE_COUNT; ++s)
off = app(buf, cap, off, "%s\"%s\":%u", s ? "," : "",
s_stage_names[s], r->stages_us[s]);
return app(buf, cap, off, "}}");
}
/* Newest `max_records` (0 = all retained), oldest first. Returns the count and
* the seq of the first record to read; iteration indexes the ring in place so
* no export call touches the stack with a whole-ring copy. */
static uint32_t export_window(uint32_t max_records, uint64_t *first_seq) {
uint64_t retained = s_committed < s_cap ? s_committed : s_cap;
if (!retained) { *first_seq = 1; return 0; }
uint32_t n = (uint32_t)retained;
uint32_t take = (max_records && max_records < n) ? max_records : n;
*first_seq = s_committed - take + 1u;
return take;
}
static const PSXFrameTraceRecord *at_seq(uint64_t seq) {
const PSXFrameTraceRecord *r = &s_ring[(seq - 1u) & (uint64_t)(s_cap - 1u)];
return r->seq == seq ? r : NULL;
}
int psx_frame_trace_to_json(char *buf, int cap, uint32_t max_records) {
char t1[256];
uint64_t first;
if (cap < 0 || (buf && cap == 0)) return 0;
if (buf) buf[0] = '\0';
uint32_t take = export_window(max_records, &first);
int off = app(buf, cap, 0,
"{\"enabled\":%d,\"capacity\":%u,\"count\":%llu,"
"\"dropped\":%llu,\"missing\":%llu,"
"\"runtime\":\"%s\",\"target_period_us\":%u,\"records\":[",
s_enabled, s_cap, (unsigned long long)s_committed,
(unsigned long long)psx_frame_trace_dropped(),
(unsigned long long)s_missing,
jstr(s_runtime, t1, sizeof t1), s_target_period_us);
int emitted = 0;
for (uint64_t seq = first; emitted < (int)take; ++seq) {
const PSXFrameTraceRecord *r = at_seq(seq);
if (!r) continue;
off = app(buf, cap, off, "%s", emitted ? "," : "");
off = rec_json(buf, cap, off, r);
emitted++;
}
return app(buf, cap, off, "]}");
}
int psx_frame_trace_to_csv(char *buf, int cap, uint32_t max_records) {
char flags[96];
uint64_t first;
if (cap < 0 || (buf && cap == 0)) return 0;
if (buf) buf[0] = '\0';
uint32_t take = export_window(max_records, &first);
int off = app(buf, cap, 0,
"seq,open_ms,guest_frame,guest_cycle,attempt,pair_id,"
"pair_age_ms,source,eye_failed,flags,shed,streak_ms,prims,"
"tex_prims,prims_l,prims_r,tex_prims_l,tex_prims_r,cycles_l,"
"cycles_r,vram_leaks,tex_bytes,xr_display_delta_us,"
"xr_submit_fresh");
for (uint32_t s = 0; s < PSX_TRACE_STAGE_COUNT; ++s)
off = app(buf, cap, off, ",%s", s_stage_names[s]);
off = app(buf, cap, off, ",failure,note\n");
int emitted = 0;
for (uint64_t seq = first; emitted < (int)take; ++seq) {
const PSXFrameTraceRecord *r = at_seq(seq);
if (!r) continue;
emitted++;
off = app(buf, cap, off,
"%llu,%llu,%llu,%llu,%llu,%llu,%llu,%u,%d,\"%s\",%u,%u,%u,"
"%u,%u,%u,%u,%u,%u,%u,%u,%llu,%u,%u",
(unsigned long long)r->seq, (unsigned long long)r->open_ms,
(unsigned long long)r->guest_frame,
(unsigned long long)r->guest_cycle,
(unsigned long long)r->stereo_attempt,
(unsigned long long)r->pair_id,
(unsigned long long)r->pair_age_ms, r->source, r->eye_failed,
psx_frame_trace_flag_names(r->flags, flags, sizeof flags),
r->shed_skipped, r->shed_streak_ms, r->prims, r->textured_prims,
r->prims_l, r->prims_r, r->tex_prims_l, r->tex_prims_r,
r->eye_cycles_l, r->eye_cycles_r,
r->vram_leaks, (unsigned long long)r->tex_upload_bytes,
r->xr_display_delta_us, (unsigned)r->xr_submit_fresh);
for (uint32_t s = 0; s < PSX_TRACE_STAGE_COUNT; ++s)
off = app(buf, cap, off, ",%u", r->stages_us[s]);
off = app(buf, cap, off, ",\"%s\",\"%s\"\n", r->failure, r->note);
}
return off;
}
int psx_frame_trace_to_chrome_json(char *buf, int cap, uint32_t max_records) {
char flags[96];
uint64_t first;
if (cap < 0 || (buf && cap == 0)) return 0;
if (buf) buf[0] = '\0';
uint32_t take = export_window(max_records, &first);
int off = app(buf, cap, 0, "{\"traceEvents\":[");
int first_ev = 1, emitted = 0;
for (uint64_t seq = first; emitted < (int)take; ++seq) {
const PSXFrameTraceRecord *r = at_seq(seq);
if (!r) continue;
emitted++;
uint64_t base_us = r->open_ms * 1000u, at = 0;
for (uint32_t s = 0; s < PSX_TRACE_STAGE_COUNT; ++s) {
uint64_t dur = r->stages_us[s];
if (dur) {
off = app(buf, cap, off,
"%s{\"name\":\"%s\",\"ph\":\"X\",\"ts\":%llu,"
"\"dur\":%llu,\"pid\":1,\"tid\":1,\"args\":{"
"\"seq\":%llu,\"attempt\":%llu,\"pair_id\":%llu,"
"\"flags\":\"%s\"}}",
first_ev ? "" : ",", s_stage_names[s],
(unsigned long long)(base_us + at),
(unsigned long long)dur,
(unsigned long long)r->seq,
(unsigned long long)r->stereo_attempt,
(unsigned long long)r->pair_id,
psx_frame_trace_flag_names(r->flags, flags, sizeof flags));
first_ev = 0;
}
at += dur;
}
}
return app(buf, cap, off, "]}");
}
+8
View File
@@ -7611,6 +7611,14 @@ uint64_t gl_renderer_perf_prim_split(double *out_tex_frac) {
return s_scene_prims;
}
/* Cumulative scene primitives for per-frame attribution (frame_trace). Callers
* take deltas; these counters are never reset here. */
void gl_renderer_scene_prim_counts(uint64_t *prims, uint64_t *textured) {
GL_RT_SYNC("scene_prim_counts");
if (prims) *prims = s_scene_prims;
if (textured) *textured = s_scene_prims_tex;
}
int gl_renderer_perf_aggregate(int wide_filter, double out[18]) {
GL_RT_SYNC("perf_aggregate");
for (int i = 0; i < 18; i++) out[i] = 0.0;
+80 -3
View File
@@ -9,6 +9,8 @@
#include "psx_openxr.h"
#include "vr_pose_math.h"
#include "openxr_color.h"
#include "frame_trace.h"
#include "gpu_gl_renderer.h"
#include <string.h>
#include <stdlib.h>
#include <math.h>
@@ -103,6 +105,17 @@ int psx_openxr_surface_valid(const PSXModOpenXRSurface *v) {
#define XR_USE_GRAPHICS_API_OPENGL
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
/* Frame-trace stage helper: perf ticks -> microseconds (CPU submission/wait
* wall time only, never GPU execution time). */
static uint64_t xr_trace_us(uint64_t ticks) {
static double inv = 0.0;
if (inv == 0.0) {
double f = (double)gl_renderer_perf_frequency();
inv = f > 0.0 ? 1000000.0 / f : 1.0;
}
return (uint64_t)((double)ticks * inv);
}
static XrInstance s_instance;
static XrSystemId s_system;
static XrSession s_session;
@@ -123,6 +136,13 @@ static XrView s_views[2];
static PSXModRenderView s_render[2];
static double s_oq[4],s_op[3];
static XrTime s_time;
/* Cadence telemetry for the frame trace: the runtime's own predicted display
* time delta between submissions (what the compositor actually saw), and when
* the pose for the current frame was sampled (xrLocateViews return). */
static XrTime s_prev_display_time;
static int s_prev_display_valid;
static uint32_t s_display_delta_us;
static uint64_t s_pose_sample_ticks;
/* Loss handling. A lost session/instance (or an unusable swapchain) is only
* flagged where it is detected; service_loss() tears the stack down at the end
* of the public call so no caller loses handles mid-sequence. */
@@ -354,6 +374,9 @@ static void hands_locate(int focused) {
/* Destroy every XR object (children before parents). Safe on a partially
* initialised stack and on a lost session: destroy calls need no live session. */
static void xr_destroy(void) {
/* A new session must not inherit the previous one's display times: the
* first delta after a re-init would be meaningless. */
s_prev_display_valid=0;s_prev_display_time=0;
for(int eye=0;eye<2;eye++) {
for(int k=0;k<2;k++) {
if(s_pose_space[eye][k])xrDestroySpace(s_pose_space[eye][k]);
@@ -547,11 +570,38 @@ int psx_openxr_begin(int width,int height,double units) {
s_recovering=0;
}
if(!events() || !s_stats.running){(void)service_loss();return 0;}
/* Open this frame's trace record before the blocking waits. The record is
* committed by psx_openxr_end(); the two failure returns below commit it
* themselves so a lost session cannot leave a record open forever. */
psx_frame_trace_open();
/* Cleared so a frame that never reaches xrLocateViews (no render request,
* lost tracking, bad view maths) cannot report a stale pose age. */
s_pose_sample_ticks=0;
uint64_t trace_t = gl_renderer_perf_ticks();
XrFrameWaitInfo wait={XR_TYPE_FRAME_WAIT_INFO};XrFrameState fs={XR_TYPE_FRAME_STATE};
if(!check(xrWaitFrame(s_session,&wait,&fs),"wait_frame")){(void)service_loss();return 0;}
if(!check(xrWaitFrame(s_session,&wait,&fs),"wait_frame")){
psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
(void)service_loss();psx_frame_trace_commit();return 0;}
psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
s_stats.waits++;s_time=fs.predictedDisplayTime;s_stats.predicted_time=(uint64_t)s_time;
/* The runtime's own clock between submissions. This is the cadence the
* compositor saw, independent of our frame timing: delta / display period
* is how many granted periods one presented frame consumed. */
s_display_delta_us=0;
if(s_prev_display_valid && s_time>s_prev_display_time) {
uint64_t d=(uint64_t)(s_time-s_prev_display_time)/1000u;
s_display_delta_us=d>0xFFFFFFFFull?0xFFFFFFFFu:(uint32_t)d;
}
s_prev_display_time=s_time;s_prev_display_valid=1;
/* Record the runtime display period with its name for cadence analysis. */
psx_frame_trace_session(s_stats.runtime,
fs.predictedDisplayPeriod>0?(uint32_t)(fs.predictedDisplayPeriod/1000):0u);
trace_t = gl_renderer_perf_ticks();
XrFrameBeginInfo begin={XR_TYPE_FRAME_BEGIN_INFO};
if(!check(xrBeginFrame(s_session,&begin),"begin_frame")){(void)service_loss();return 0;}
if(!check(xrBeginFrame(s_session,&begin),"begin_frame")){
psx_frame_trace_add(PSX_TRACE_XR_BEGIN_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
(void)service_loss();psx_frame_trace_commit();return 0;}
psx_frame_trace_add(PSX_TRACE_XR_BEGIN_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
s_stats.frame_open=1;s_stats.tracking=0;
if(!fs.shouldRender){psx_openxr_end(0,NULL);return 0;}
XrViewLocateInfo loc={XR_TYPE_VIEW_LOCATE_INFO};loc.viewConfigurationType=XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
@@ -559,6 +609,7 @@ int psx_openxr_begin(int width,int height,double units) {
XrViewState vs={XR_TYPE_VIEW_STATE};uint32_t count=0;
memset(s_views,0,sizeof s_views);s_views[0].type=s_views[1].type=XR_TYPE_VIEW;
int located=check(xrLocateViews(s_session,&loc,&vs,2,&count,s_views),"locate_views");
s_pose_sample_ticks=gl_renderer_perf_ticks();
s_stats.view_flags=(uint64_t)vs.viewStateFlags;
if(!located || count!=2 ||
(vs.viewStateFlags&(XR_VIEW_STATE_ORIENTATION_VALID_BIT|XR_VIEW_STATE_POSITION_VALID_BIT))!=
@@ -619,26 +670,37 @@ int psx_openxr_quad(double distance,double width,double height) {
int psx_openxr_end(int keep,PSXOpenXRCopy copy) {
#if defined(PSX_OPENXR)
if(!s_stats.frame_open)return 0;
/* Defensive open: normally psx_openxr_begin() already opened the record. */
psx_frame_trace_open();
uint64_t trace_t;
XrCompositionLayerProjectionView pv[2]={{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW},{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
int complete=keep && copy && s_stats.tracking;
int quad=s_quad_distance>0;
for(int eye=0;eye<(quad?1:2) && complete;eye++) {
uint32_t index=0;
trace_t = gl_renderer_perf_ticks();
XrSwapchainImageAcquireInfo ai={XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
if(!check(xrAcquireSwapchainImage(s_chain[eye],&ai,&index),"acquire")){complete=0;break;}
psx_frame_trace_add(PSX_TRACE_XR_ACQUIRE, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
XrSwapchainImageWaitInfo wi={XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};wi.timeout=1000000000;
trace_t = gl_renderer_perf_ticks();
XrResult wr=xrWaitSwapchainImage(s_chain[eye],&wi);
/* A timeout leaves ownership acquired: wait again (bounded) before
* releasing. Only XR_SUCCESS means the image is writable; the timeout
* code is a success code and must not be mistaken for it. */
if(wr==XR_TIMEOUT_EXPIRED){wi.timeout=2000000000;wr=xrWaitSwapchainImage(s_chain[eye],&wi);}
psx_frame_trace_add(PSX_TRACE_XR_WAIT_IMAGE, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
int writable=check(wr,"wait_image") && wr==XR_SUCCESS;
if(writable) {
trace_t = gl_renderer_perf_ticks();
if(index>=s_count[eye] || !copy(eye,s_images[eye][index].image,s_w[eye],s_h[eye])) {
s_stats.stage="copy_image";s_stats.failures++;complete=0;
}
psx_frame_trace_add(PSX_TRACE_XR_COPY, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
trace_t = gl_renderer_perf_ticks();
XrSwapchainImageReleaseInfo ri={XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
if(!check(xrReleaseSwapchainImage(s_chain[eye],&ri),"release"))complete=0;
psx_frame_trace_add(PSX_TRACE_XR_RELEASE, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
} else {
/* Acquired but never writable. Release it best-effort so the acquire
* count cannot leak; a runtime that refuses (the wait never completed)
@@ -665,7 +727,13 @@ int psx_openxr_end(int keep,PSXOpenXRCopy copy) {
XrFrameEndInfo end={XR_TYPE_FRAME_END_INFO};end.displayTime=s_time;
end.environmentBlendMode=XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
end.layerCount=complete?1:0;end.layers=complete?layers:NULL;
trace_t = gl_renderer_perf_ticks();
int ok=check(xrEndFrame(s_session,&end),"end_frame");s_stats.frame_open=0;
psx_frame_trace_add(PSX_TRACE_XR_END_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
if(s_pose_sample_ticks)
psx_frame_trace_add(PSX_TRACE_POSE_AGE,
xr_trace_us(gl_renderer_perf_ticks()-s_pose_sample_ticks));
psx_frame_trace_xr_cadence(s_display_delta_us, complete);
if(ok && complete) {
s_stats.submitted_surfaces=quad?0:((s_surface_mask&1)!=0)+((s_surface_mask&2)!=0);
s_stats.submitted++;s_stats.submitted_pair_id=s_pair_id;
@@ -675,9 +743,18 @@ int psx_openxr_end(int keep,PSXOpenXRCopy copy) {
if(s_source==2)s_stats.native_submitted++;
if(quad){s_stats.quad_submitted++;s_stats.quad_distance_m=s_quad_distance;
s_stats.quad_width_m=s_quad_width;s_stats.quad_height_m=s_quad_height;}
} else {s_stats.empty++;s_stats.submitted_surfaces=0;}
/* A fresh stereo pair already labelled the record source 1; only
* annotate native presentation when nothing else claimed it. */
psx_frame_trace_source(2);
} else {
s_stats.empty++;s_stats.submitted_surfaces=0;
psx_frame_trace_flag(PSX_FRAME_TRACE_XR_EMPTY);
psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR);
psx_frame_trace_source(2);
}
if(ok && complete)s_stats.stage="submitted";
s_quad_distance=s_quad_width=s_quad_height=0;
psx_frame_trace_commit();
(void)service_loss(); /* after the frame is closed: lost session / bad swapchain */
return ok && complete;
#else
+143 -2
View File
@@ -26,8 +26,10 @@
#include "cpu_state.h"
#include "dirty_ram_interp.h"
#include "dma.h"
#include "frame_trace.h"
#include "gpu.h"
#include "gpu_gl_renderer.h"
#include "host_time.h"
#include "mod_plugins.h"
#include "mod_runtime.h"
#include "interrupts.h"
@@ -282,6 +284,9 @@ static const char *s_checkpoint_failure;
static RenderStereoStats s_stereo;
static int s_pair_busy;
static uint32_t s_pair_retry;
/* A HUD-only capture transaction reuses eye 0's slot but is not an eye draw;
* frame tracing must not attribute its cost to the left eye. */
static int s_capture_only;
static int pass_refuse(const char *reason, uint32_t status) {
/* A refusal always carries a reason: last_failure.reason == NULL means
@@ -338,6 +343,11 @@ void render_pass_reset_session(void) {
s_stereo.last_eye = -1;
s_pair_busy = 0;
s_pair_retry = 0;
/* Frame tracing is opt-in per session: re-read the env, install the host
* clock, and clear any previous session's records. */
psx_frame_trace_configure_from_env();
psx_frame_trace_set_clock(psx_host_mono_ms);
psx_frame_trace_reset();
gl_renderer_stereo_reset();
gl_renderer_pass_set_flip_shown(0);
}
@@ -393,6 +403,14 @@ void render_stereo_get_stats(RenderStereoStats *out) { if (out) *out = s_stereo;
int psx_mod_set_stereo_presentation(uint32_t mode) {
return mode <= 1u ? gl_renderer_stereo_set_presentation(mode) : 0;
}
/* Game-side annotation for the frame trace (mod_plugins.h): a title hook marks
* a scene/state transition on the record the runtime opened for this frame. A
* no-op when tracing is off or no record is open. */
void psx_mod_frame_trace_note(const char *tag) {
psx_frame_trace_note(tag);
psx_frame_trace_flag(PSX_FRAME_TRACE_SCENE_TRANSITION);
}
int psx_mod_render_view_offset(int32_t x, int32_t y, int32_t z) {
int32_t xyz[3] = {x, y, z};
if (!g_psx_render_pass_active) return 0;
@@ -605,6 +623,51 @@ static double ema_ms(double cur, uint64_t ticks) {
return cur > 0.0 ? cur * 0.9 + ms * 0.1 : ms;
}
/* Ticks -> microseconds for frame_trace. CPU submission time only: it is not
* GPU execution time. */
static double s_us_per_tick = 0.0;
static uint64_t trace_ticks_to_us(uint64_t ticks) {
if (s_us_per_tick == 0.0) {
double f = (double)gl_renderer_perf_frequency();
s_us_per_tick = f > 0.0 ? 1000000.0 / f : 1.0;
}
return (uint64_t)((double)ticks * s_us_per_tick);
}
/* ---- guest sub-stage spans (mod_plugins.h) ------------------------------
* The game hook brackets the guest calls it replays once per eye, and the
* runtime times them with its own tick clock, exactly as the eye stages are
* timed, so the parts sum against eye_guest. Accepted only inside a traced
* stereo eye (s_sub_eye): a HUD capture's guest draws and the flat path report
* their own stages and must not write these slots. */
static int s_sub_eye = -1; /* traced eye accepting spans, -1 = none */
/* One start tick per stage rather than one open span at a time, so a stage can
* be bracketed inside another: the hook's visibility override runs inside the
* guest traversal it is part of. The outer span keeps its own wall time, so a
* nested stage is a subset of it rather than a sibling. */
static int s_sub_open[PSX_TRACE_STAGE_COUNT];
static uint64_t s_sub_ticks[PSX_TRACE_STAGE_COUNT];
/* No span survives an eye boundary, so an eye aborted mid-entry cannot carry a
* start tick into the next one. */
static void sub_spans_forget(void) {
memset(s_sub_open, 0, sizeof s_sub_open);
}
void psx_mod_frame_trace_stage_begin(uint32_t stage) {
if (s_sub_eye < 0 || stage >= PSX_TRACE_STAGE_COUNT) return;
s_sub_open[stage] = 1; /* an unpaired begin restarts that span */
s_sub_ticks[stage] = gl_renderer_perf_ticks();
}
void psx_mod_frame_trace_stage_end(uint32_t stage) {
if (s_sub_eye < 0 || stage >= PSX_TRACE_STAGE_COUNT) return;
if (!s_sub_open[stage]) return; /* an end with no begin records nothing */
uint64_t now = gl_renderer_perf_ticks();
psx_frame_trace_add(stage, trace_ticks_to_us(now - s_sub_ticks[stage]));
s_sub_open[stage] = 0;
}
static void watchdog_overrun(void) {
s_stats.watchdog++;
s_stats.watchdog_flag = 1;
@@ -692,6 +755,10 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass
s_attempt.attempt = ++s_stats.pass_attempts;
s_attempt.plan = s_plan_serial;
s_attempt.guest_cycle = psx_cycle_count;
/* No guest sub-stage span may survive into this transaction: the eye that
* accepts them is decided below, once the trace decision is known. */
s_sub_eye = -1;
sub_spans_forget();
if (pass) {
s_attempt.struct_size = pass->struct_size;
if (pass->struct_size >= sizeof *pass) {
@@ -726,6 +793,16 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass
if (inv == 0.0) inv = 1000.0 / (double)gl_renderer_perf_frequency();
s_ms_per_tick = inv;
}
/* Per-eye trace sampling (opt-in): primitive counts and upload pixels are
* cumulative, so the eye's cost is the delta across its transaction. */
int trace_on = eye >= 0 && !s_capture_only && psx_frame_trace_enabled();
uint64_t trace_prims0 = 0, trace_tex0 = 0, trace_diag0[6] = {0,0,0,0,0,0};
if (trace_on) {
gl_renderer_scene_prim_counts(&trace_prims0, &trace_tex0);
gl_renderer_runtime_diag(trace_diag0);
}
/* This eye, and only this eye, accepts the hook's guest sub-stage spans. */
s_sub_eye = trace_on ? (int)eye : -1;
reuse = !open && eye != RP_EYE_LOCAL && s_restored_valid &&
s_restored_plan == s_plan_serial &&
s_restored_cycle == psx_cycle_count &&
@@ -794,7 +871,13 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass
"%s", s_abort_detail);
ok = 0;
}
s_stats.guest_cycles_last = psx_cycle_count - cycles_before;
/* Any span still open here was never ended by the hook, or the eye aborted
* mid-entry through the longjmp above. Drop them instead of closing them:
* the elapsed time is then not charged to an entry that never finished, and
* it shows up as the difference between eye_guest and the guest sub-stages. */
sub_spans_forget();
uint64_t guest_cycles = psx_cycle_count - cycles_before;
s_stats.guest_cycles_last = guest_cycles;
if (eye >= 0) gte_render_view_get(s_stereo.eye_view[eye]);
tg = gl_renderer_perf_ticks();
s_pass_cpu = NULL;
@@ -827,6 +910,26 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass
s_stats.avg_end_ms = ema_ms(s_stats.avg_end_ms, te - tg);
s_stats.avg_restore_ms = ema_ms(s_stats.avg_restore_ms, tr - te);
if (trace_on) {
uint32_t base = eye == PSX_MOD_EYE_LEFT ? PSX_TRACE_EYE_BEGIN_L
: PSX_TRACE_EYE_BEGIN_R;
psx_frame_trace_add(base + 0, trace_ticks_to_us(tb - t0));
psx_frame_trace_add(base + 1, trace_ticks_to_us(tg - tb));
psx_frame_trace_add(base + 2, trace_ticks_to_us(te - tg));
psx_frame_trace_add(base + 3, trace_ticks_to_us(tr - te));
uint64_t p1 = 0, t1x = 0, diag1[6] = {0,0,0,0,0,0};
gl_renderer_scene_prim_counts(&p1, &t1x);
gl_renderer_runtime_diag(diag1);
psx_frame_trace_draw(eye, (uint32_t)(p1 - trace_prims0),
(uint32_t)(t1x - trace_tex0));
psx_frame_trace_eye_cycles(eye, (uint32_t)guest_cycles);
/* Upload pixels -> bytes assumes the 16-bit VRAM format uploads use. */
psx_frame_trace_gpu((diag1[2] - trace_diag0[2]) * 2u,
(uint32_t)leaks);
s_sub_eye = -1;
sub_spans_forget();
}
if (verify_on()) {
hash_after = state_hash(cpu);
s_stats.verify_checks++;
@@ -920,10 +1023,31 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame,
if (status != PSX_MOD_RENDER_PASS_READY) {
s_stereo.refused++; s_stereo.last_failure = "status"; return 0;
}
/* Frame trace (opt-in). The runtime already opened this frame's record in
* psx_openxr_begin() when a headset is active; opening here covers the
* desktop stereo path, and the guest-sim gap is the host time since the
* previous traced frame. */
int trace_on = psx_frame_trace_enabled();
int trace_opened = trace_on ? psx_frame_trace_open() : 0;
if (trace_on) {
static uint64_t prev_ms;
static int prev_valid;
uint64_t now_ms = psx_host_mono_ms();
if (prev_valid)
psx_frame_trace_add(PSX_TRACE_GUEST_SIM, (now_ms - prev_ms) * 1000u);
prev_ms = now_ms; prev_valid = 1;
psx_frame_trace_attempt(s_stereo.attempts, 0, 0);
}
budget_ms = frame->period_vblanks * (double)g_psx_vblank_cycles /
33868.8 * 0.8;
if (s_stereo.avg_pair_ms > budget_ms && ++s_pair_retry < 30u) {
s_stereo.shed++; return 0;
s_stereo.shed++;
if (trace_on) {
psx_frame_trace_attempt(s_stereo.attempts, s_pair_retry, 0);
psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR);
if (trace_opened) psx_frame_trace_commit();
}
return 0;
}
s_pair_retry = 0;
if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles();
@@ -978,6 +1102,13 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame,
s_stereo.retained_pair_id = s_stereo.last_pair_id;
gl_renderer_stereo_stage_reset();
}
if (trace_on) {
psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, (uint64_t)(elapsed * 1000.0));
psx_frame_trace_result(ok, ok ? s_stereo.last_pair_id : 0,
ok ? -1 : s_stereo.last_eye,
ok ? NULL : s_stereo.last_failure);
if (trace_opened) psx_frame_trace_commit();
}
s_pair_busy = 0;
return ok;
}
@@ -989,11 +1120,21 @@ int psx_mod_render_capture_rgba(CPUState *cpu,const PSXModRenderPass *rect,
!rect->w || !rect->h || rect->x+rect->w>1024 || rect->y+rect->h>512 ||
(uint64_t)rect->w*rect->h*4>bytes || transaction_status(1)!=PSX_MOD_RENDER_PASS_READY)return 0;
if(g_overlay_flush_pending_cycles)g_overlay_flush_pending_cycles();
uint64_t trace_t0=gl_renderer_perf_ticks();
s_pair_busy=1;s_plan_serial++;
gl_renderer_stereo_stage_reset();
PSXModRenderPass capture=*rect;capture.alpha_q16=0;
s_capture_only=1; /* this eye-0 transaction is a HUD capture, not an eye draw */
int ok=render_transaction(cpu,&capture,fn,user,0);
s_capture_only=0;
uint64_t trace_t1=gl_renderer_perf_ticks();
psx_frame_trace_add(PSX_TRACE_HUD_DRAW,
trace_ticks_to_us(trace_t1-trace_t0));
if(ok)ok=gl_renderer_stereo_stage_readback(0,rect->w,rect->h,rgba);
psx_frame_trace_add(PSX_TRACE_HUD_READBACK,
trace_ticks_to_us(gl_renderer_perf_ticks()-trace_t1));
gl_renderer_stereo_stage_reset();s_pair_busy=0;
psx_frame_trace_add(PSX_TRACE_HUD_CAPTURE,
trace_ticks_to_us(gl_renderer_perf_ticks()-trace_t0));
return ok;
}
+368
View File
@@ -0,0 +1,368 @@
/* Unit tests for the opt-in frame-trace ring (frame_trace.c).
*
* The trace is the floor the VR stutter investigation stands on, so its
* contracts must hold without a headset: a disabled trace costs nothing and
* records nothing, timestamps never go backwards even when the host clock does,
* the ring wraps without losing the newest records, a frame that measured
* nothing is marked as such, and every export is NUL-terminated and safe to
* truncate. ctest -R frame_trace_test
*/
#include "frame_trace.h"
#include <stdio.h>
#include <string.h>
/* Evaluate side effects and failures even when NDEBUG is defined. */
#define CHECK(expr) do { \
if (!(expr)) { \
fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); \
return 1; \
} \
} while (0)
static uint64_t g_fake_ms;
static uint64_t fake_clock(void) { return g_fake_ms; }
/* A drifting clock: monotonic except one deliberate backwards step. */
static uint64_t g_step;
static uint64_t stepping_clock(void) {
static const uint64_t seq[] = { 100, 120, 90, 140, 200 };
return seq[(g_step++) % (sizeof seq / sizeof seq[0])];
}
static int frame_with(uint64_t base_us, int publish, uint64_t pair_id,
int eye_failed, const char *failure) {
uint64_t us = base_us;
if (!psx_frame_trace_open()) return 0;
psx_frame_trace_ids(0x1000 + pair_id, 0x2000 + pair_id);
psx_frame_trace_add(PSX_TRACE_EYE_BEGIN_L, us);
psx_frame_trace_add(PSX_TRACE_EYE_GUEST_L, us * 2);
psx_frame_trace_add(PSX_TRACE_EYE_END_L, us + 3);
psx_frame_trace_add(PSX_TRACE_EYE_RESTORE_L, 5);
psx_frame_trace_add(PSX_TRACE_EYE_BEGIN_R, us + 1);
psx_frame_trace_add(PSX_TRACE_EYE_GUEST_R, us * 2 + 1);
psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, 700);
psx_frame_trace_add(PSX_TRACE_XR_END_FRAME, 40);
psx_frame_trace_result(publish, pair_id, eye_failed, failure);
psx_frame_trace_commit();
return 1;
}
int main(void) {
/* ---- disabled by default: nothing recorded, nothing to export ---- */
psx_frame_trace_set_clock(fake_clock);
psx_frame_trace_reset();
psx_frame_trace_set_enabled(0);
CHECK(psx_frame_trace_enabled() == 0);
CHECK(psx_frame_trace_open() == 0);
psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, 999);
psx_frame_trace_commit();
CHECK(psx_frame_trace_committed() == 0);
{
char buf[256];
int n = psx_frame_trace_to_json(buf, sizeof buf, 0);
CHECK(n > 0 && n < (int)sizeof buf);
CHECK(strstr(buf, "\"enabled\":0") != NULL);
CHECK(strstr(buf, "\"records\":[]") != NULL);
/* Session metadata is a no-op while capture is off. */
psx_frame_trace_session("ShouldNotAppear", 12345);
CHECK(psx_frame_trace_to_json(buf, sizeof buf, 0) > 0);
CHECK(strstr(buf, "\"runtime\":\"\"") != NULL);
CHECK(strstr(buf, "\"target_period_us\":0") != NULL);
}
/* ---- enabled: stages land, result/pair age, monotonic seq ---- */
psx_frame_trace_set_enabled(1);
CHECK(psx_frame_trace_capacity() >= 64);
g_fake_ms = 1000;
CHECK(frame_with(10, 1, 7, -1, NULL));
g_fake_ms = 1033;
CHECK(frame_with(100, 0, 7, 1, "eye_capture")); /* stale: publish failed */
g_fake_ms = 1066;
CHECK(frame_with(12, 1, 8, -1, NULL));
CHECK(psx_frame_trace_committed() == 3);
{
PSXFrameTraceRecord recs[8];
uint32_t n = psx_frame_trace_read(recs, 8);
CHECK(n == 3);
CHECK(recs[0].seq == 1 && recs[2].seq == 3);
CHECK(recs[0].open_ms <= recs[1].open_ms &&
recs[1].open_ms <= recs[2].open_ms);
/* stage accumulation is exact */
CHECK(recs[0].stages_us[PSX_TRACE_EYE_GUEST_L] == 20);
CHECK(recs[0].stages_us[PSX_TRACE_EYE_BEGIN_R] == 11);
CHECK(recs[0].stages_us[PSX_TRACE_XR_WAIT_FRAME] == 700);
/* published then stale */
CHECK((recs[0].flags & PSX_FRAME_TRACE_STEREO_PUBLISHED) != 0);
CHECK(recs[0].pair_id == 7 && recs[0].pair_age_ms == 0);
CHECK((recs[1].flags & PSX_FRAME_TRACE_STALE_PAIR) != 0);
CHECK((recs[1].flags & PSX_FRAME_TRACE_FAILED) != 0);
CHECK(recs[1].eye_failed == 1);
CHECK(strcmp(recs[1].failure, "eye_capture") == 0);
CHECK(recs[1].pair_age_ms == 33);
CHECK((recs[2].flags & PSX_FRAME_TRACE_STEREO_PUBLISHED) != 0);
CHECK(recs[2].pair_id == 8 && recs[2].pair_age_ms == 0);
}
/* ---- missing telemetry: an open + commit with no stage is marked ---- */
g_fake_ms = 1100;
CHECK(psx_frame_trace_open() == 1);
CHECK(psx_frame_trace_open() == 0); /* no double open */
psx_frame_trace_commit();
CHECK(psx_frame_trace_missing() == 1);
{
PSXFrameTraceRecord recs[8];
uint32_t n = psx_frame_trace_read(recs, 8);
CHECK(n == 4);
CHECK((recs[3].flags & PSX_FRAME_TRACE_NO_TELEMETRY) != 0);
}
/* ---- shed annotation and flag rendering ---- */
g_fake_ms = 1133;
CHECK(psx_frame_trace_open() == 1);
psx_frame_trace_attempt(42, 3, 250);
psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR);
psx_frame_trace_commit();
{
PSXFrameTraceRecord recs[8];
char names[96];
uint32_t n = psx_frame_trace_read(recs, 8);
CHECK(n == 5);
CHECK(recs[4].stereo_attempt == 42);
CHECK(recs[4].shed_skipped == 3 && recs[4].shed_streak_ms == 250);
CHECK((recs[4].flags & PSX_FRAME_TRACE_SHED) != 0);
/* A shed-only frame is stale, not "missing telemetry". */
CHECK((recs[4].flags & PSX_FRAME_TRACE_NO_TELEMETRY) == 0);
CHECK(psx_frame_trace_missing() == 1);
psx_frame_trace_flag_names(recs[4].flags, names, sizeof names);
CHECK(strstr(names, "stale") && strstr(names, "shed"));
}
/* ---- stage saturation rather than wrap ---- */
g_fake_ms = 1200;
CHECK(psx_frame_trace_open() == 1);
psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, 0xF0000000u);
psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, 0xF0000000u);
psx_frame_trace_commit();
{
PSXFrameTraceRecord recs[8];
uint32_t n = psx_frame_trace_read(recs, 8);
CHECK(recs[n - 1].stages_us[PSX_TRACE_PAIR_TOTAL] == 0xFFFFFFFFu);
}
/* ---- a backwards clock step must not produce a backwards timestamp ---- */
psx_frame_trace_reset();
{
PSXFrameTraceRecord recs[8];
psx_frame_trace_set_clock(stepping_clock);
for (int i = 0; i < 5; ++i) {
CHECK(psx_frame_trace_open() == 1);
psx_frame_trace_add(PSX_TRACE_GUEST_SIM, (uint32_t)(1 + i));
psx_frame_trace_commit();
}
uint32_t n = psx_frame_trace_read(recs, 8);
CHECK(n == 5);
for (uint32_t i = 1; i < n; ++i)
CHECK(recs[i].open_ms >= recs[i - 1].open_ms);
CHECK(recs[0].open_ms == 100);
CHECK(recs[2].open_ms >= recs[1].open_ms); /* the 90 was clamped up */
}
/* ---- ring wrap keeps the newest records ---- */
psx_frame_trace_reset();
psx_frame_trace_set_clock(fake_clock);
{
uint32_t cap = psx_frame_trace_capacity();
uint32_t extra = cap + 10u;
for (uint32_t i = 0; i < extra; ++i) {
g_fake_ms = 2000u + i;
CHECK(psx_frame_trace_open() == 1);
psx_frame_trace_add(PSX_TRACE_GUEST_SIM, i + 1u);
psx_frame_trace_commit();
}
CHECK(psx_frame_trace_committed() == extra);
CHECK(psx_frame_trace_dropped() == extra - cap);
PSXFrameTraceRecord recs[8];
CHECK(psx_frame_trace_read(recs, 8) == 8);
CHECK(recs[7].seq == extra); /* newest present */
CHECK(recs[0].seq == extra - 7u);
CHECK(recs[7].stages_us[PSX_TRACE_GUEST_SIM] == extra);
/* capacity-limited read returns exactly `capacity` of the newest */
PSXFrameTraceRecord one[1];
CHECK(psx_frame_trace_read(one, 1) == 1);
CHECK(one[0].seq == extra);
}
/* Opening the next frame must not retire a committed slot before commit. */
{
static PSXFrameTraceRecord retained[4096];
uint32_t capacity = psx_frame_trace_capacity();
uint64_t committed = psx_frame_trace_committed();
CHECK(psx_frame_trace_open() == 1);
psx_frame_trace_note("not committed");
CHECK(psx_frame_trace_read(retained, capacity) == capacity);
CHECK(retained[0].seq == committed - capacity + 1);
CHECK(retained[capacity - 1].seq == committed);
psx_frame_trace_commit();
CHECK(psx_frame_trace_read(retained, capacity) == capacity);
CHECK(retained[capacity - 1].seq == committed + 1);
}
/* ---- exports: valid, NULL-terminated, truncation-safe ---- */
{
static char big[1 << 20];
int n = psx_frame_trace_to_json(big, sizeof big, 16);
CHECK(n > 0 && n < (int)sizeof big);
CHECK(big[0] == '{' && strstr(big, "\"records\":[") != NULL);
CHECK(big[n - 1] == '}' && big[n - 2] == ']');
CHECK(strstr(big, "\"stages_us\"") != NULL);
/* The record limit caps the emitted count. */
CHECK(strstr(big, "\"seq\":") != NULL);
n = psx_frame_trace_to_csv(big, sizeof big, 4);
CHECK(n > 0 && n < (int)sizeof big);
CHECK(strncmp(big, "seq,open_ms,", 12) == 0);
CHECK(strstr(big, "guest_sim") && strstr(big, "xr_end_frame"));
n = psx_frame_trace_to_chrome_json(big, sizeof big, 4);
CHECK(n > 0 && n < (int)sizeof big);
CHECK(strncmp(big, "{\"traceEvents\":[", 16) == 0 && big[n - 1] == '}');
CHECK(strstr(big, "\"ph\":\"X\"") != NULL);
/* A tiny buffer must still be terminated and report the full length. */
char small[8];
int need = psx_frame_trace_to_json(small, sizeof small, 0);
CHECK(need > (int)sizeof small);
CHECK(small[sizeof small - 1] == '\0');
CHECK(small[0] == '{');
}
/* ---- session metadata: runtime + target refresh, JSON-escaped ---- */
psx_frame_trace_session("TestRuntime/1.0", 11111); /* 90 Hz headset */
{
static char meta[1 << 16];
int m = psx_frame_trace_to_json(meta, sizeof meta, 1);
CHECK(m > 0 && m < (int)sizeof meta);
CHECK(strstr(meta, "\"runtime\":\"TestRuntime/1.0\"") != NULL);
CHECK(strstr(meta, "\"target_period_us\":11111") != NULL);
/* A runtime name with a quote must not break the document. */
psx_frame_trace_session("Odd\"Runtime", 0);
m = psx_frame_trace_to_json(meta, sizeof meta, 1);
CHECK(m > 0 && m < (int)sizeof meta);
CHECK(strstr(meta, "\"runtime\":\"Odd\\\"Runtime\"") != NULL);
CHECK(strstr(meta, "\"target_period_us\":0") != NULL);
}
/* Fixed scratch buffers may shorten a string, never split a JSON escape. */
{
char controls[128]; static char json[1 << 16];
memset(controls, 1, sizeof controls - 1); controls[127] = 0;
psx_frame_trace_session(controls, 0);
CHECK(psx_frame_trace_to_json(json, sizeof json, 1) > 0);
const char *value = strstr(json, "\"runtime\":\"");
CHECK(value != NULL); value += strlen("\"runtime\":\"");
const char *end = strchr(value, '\"');
CHECK(end && (end - value) % 6 == 0);
for (; value < end; value += 6) CHECK(!memcmp(value, "\\u0001", 6));
}
/* ---- XR cadence: the runtime's display-time delta and repeat marking ----
* One presented pair per granted display period is the pacing target, so a
* frame that carried no new pair must be distinguishable from one that did
* without reading the counterpart stats. */
psx_frame_trace_reset();
psx_frame_trace_set_enabled(1);
{
PSXFrameTraceRecord recs[8];
static char cad[1 << 16];
char names[96];
CHECK(psx_frame_trace_open()); /* carried a new pair */
psx_frame_trace_add(PSX_TRACE_POSE_AGE, 4200);
psx_frame_trace_result(1, 11, -1, NULL);
psx_frame_trace_xr_cadence(22222, 1);
psx_frame_trace_commit();
CHECK(psx_frame_trace_open()); /* carried no new pair */
psx_frame_trace_result(0, 11, -1, NULL);
psx_frame_trace_xr_cadence(22222, 0);
psx_frame_trace_commit();
CHECK(psx_frame_trace_read(recs, 8) == 2);
CHECK(recs[0].xr_display_delta_us == 22222);
CHECK(recs[0].xr_submit_fresh == 1);
CHECK((recs[0].flags & PSX_FRAME_TRACE_XR_REPEAT) == 0);
CHECK(recs[0].stages_us[PSX_TRACE_POSE_AGE] == 4200);
CHECK(recs[1].xr_submit_fresh == 0);
CHECK((recs[1].flags & PSX_FRAME_TRACE_XR_REPEAT) != 0);
/* The pose was never sampled for the repeat, so no age is claimed. */
CHECK(recs[1].stages_us[PSX_TRACE_POSE_AGE] == 0);
CHECK(psx_frame_trace_to_json(cad, sizeof cad, 0) > 0);
CHECK(strstr(cad, "\"xr_display_delta_us\":22222") != NULL);
CHECK(strstr(cad, "\"xr_submit_fresh\":1") != NULL);
CHECK(strstr(cad, "\"pose_age\":4200") != NULL);
CHECK(psx_frame_trace_to_csv(cad, sizeof cad, 0) > 0);
CHECK(strstr(cad, "xr_display_delta_us,xr_submit_fresh") != NULL);
CHECK(strstr(cad, ",22222,1,") != NULL);
CHECK(strstr(cad, ",22222,0,") != NULL);
CHECK(strstr(psx_frame_trace_flag_names(recs[1].flags, names, sizeof names),
"xr_repeat") != NULL);
}
/* ---- per-eye draw counters and guest cycles ---------------------------
* The pair's own prim count is the last eye's, which is useless for asking
* which eye a change moved, and the guest sub-stages of the replay are pair
* totals written by the game hook. */
psx_frame_trace_reset();
psx_frame_trace_set_enabled(1);
{
PSXFrameTraceRecord recs[4];
static char buf[1 << 16];
CHECK(psx_frame_trace_open());
/* An unknown eye or a closed record must not write anything. */
psx_frame_trace_draw(2, 999, 999);
psx_frame_trace_eye_cycles(-1, 999);
psx_frame_trace_add(PSX_TRACE_GAME_TRAVERSE, 0);
psx_frame_trace_draw(0, 669, 667);
psx_frame_trace_draw(1, 947, 944);
psx_frame_trace_eye_cycles(0, 145000);
psx_frame_trace_eye_cycles(1, 151000);
psx_frame_trace_add(PSX_TRACE_GAME_TRAVERSE, 1200);
psx_frame_trace_add(PSX_TRACE_GAME_TRAVERSE, 1400);
psx_frame_trace_add(PSX_TRACE_GAME_SUBMIT, 800);
psx_frame_trace_commit();
CHECK(psx_frame_trace_read(recs, 4) == 1);
CHECK(recs[0].prims_l == 669 && recs[0].prims_r == 947);
CHECK(recs[0].tex_prims_l == 667 && recs[0].tex_prims_r == 944);
/* `prims`/`tex_prims` keep their meaning: the last eye of the pair. */
CHECK(recs[0].prims == 947 && recs[0].textured_prims == 944);
CHECK(recs[0].eye_cycles_l == 145000 && recs[0].eye_cycles_r == 151000);
/* A guest sub-stage accumulates both eyes' spans of that call. */
CHECK(recs[0].stages_us[PSX_TRACE_GAME_TRAVERSE] == 2600);
CHECK(recs[0].stages_us[PSX_TRACE_GAME_SUBMIT] == 800);
CHECK(psx_frame_trace_to_json(buf, sizeof buf, 0) > 0);
CHECK(strstr(buf, "\"prims_l\":669") && strstr(buf, "\"prims_r\":947"));
CHECK(strstr(buf, "\"cycles_l\":145000") &&
strstr(buf, "\"cycles_r\":151000"));
CHECK(strstr(buf, "\"game_traverse\":2600") &&
strstr(buf, "\"game_submit\":800") &&
strstr(buf, "\"game_laser\":0"));
CHECK(psx_frame_trace_to_csv(buf, sizeof buf, 0) > 0);
CHECK(strstr(buf, "prims_l,prims_r,tex_prims_l,tex_prims_r,cycles_l,"
"cycles_r") != NULL);
CHECK(strstr(buf, ",669,947,667,944,145000,151000,") != NULL);
/* The name table must stay in enum order (the header order is the ABI). */
CHECK(strcmp(psx_frame_trace_stage_name(PSX_TRACE_GAME_ENV), "game_env") == 0
&& strcmp(psx_frame_trace_stage_name(PSX_TRACE_GAME_LASER),
"game_laser") == 0);
}
psx_frame_trace_set_enabled(0);
printf("frame_trace_test: all checks passed\n");
return 0;
}
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Guard: the frame-trace RECORD path must not allocate or touch files.
frame_trace.c exists so a visible VR hitch can be correlated with a stage
without changing the thing being measured. A malloc(), a stdio file write, or a
formatted print added to the per-frame path would perturb the frame it is
supposed to observe, and no functional test would notice. This check reads the
source and fails if such a call appears in a record-path function.
Export functions (to_json/to_csv/to_chrome_json) are explicitly allowed to use
vsnprintf, and are never called from the record path; they are listed as
exceptions by name so the intent is not mistaken for an oversight.
ctest -R frame_trace_guard_test
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "runtime" / "src" / "frame_trace.c"
# Functions that run per frame. Their bodies must stay allocation- and IO-free.
RECORD_PATH = [
"psx_frame_trace_open",
"psx_frame_trace_commit",
"psx_frame_trace_add",
"psx_frame_trace_ids",
"psx_frame_trace_attempt",
"psx_frame_trace_result",
"psx_frame_trace_source",
"psx_frame_trace_draw",
"psx_frame_trace_eye_cycles",
"psx_frame_trace_gpu",
"psx_frame_trace_flag",
"psx_frame_trace_note",
"psx_frame_trace_io",
"psx_frame_trace_reset",
"psx_frame_trace_enabled",
"stamp_now",
"open_record",
]
# Forbidden call patterns in a record-path body. Word boundaries matter:
# "printf" must not match "snprintf"/"vsnprintf", which write into the record's
# own fixed buffers and touch neither heap nor files.
FORBIDDEN = [
r"(?<![A-Za-z_])printf\s*\(",
r"(?<![A-Za-z_])puts\s*\(",
r"(?<![A-Za-z_])malloc\s*\(",
r"(?<![A-Za-z_])calloc\s*\(",
r"(?<![A-Za-z_])realloc\s*\(",
r"(?<![A-Za-z_])free\s*\(",
r"(?<![A-Za-z_])strdup\s*\(",
r"(?<![A-Za-z_])fopen\s*\(",
r"(?<![A-Za-z_])fclose\s*\(",
r"(?<![A-Za-z_])fwrite\s*\(",
r"(?<![A-Za-z_])fread\s*\(",
r"(?<![A-Za-z_])fprintf\s*\(",
r"(?<![A-Za-z_])fflush\s*\(",
r"(?<![A-Za-z_])fputs\s*\(",
r"(?<![A-Za-z_])fputc\s*\(",
r"(?<![A-Za-z_])fseek\s*\(",
]
def function_body(text: str, name: str) -> str | None:
"""The definition of `name` (top-level, so the signature starts at column 0)."""
lines = text.splitlines()
for i, line in enumerate(lines):
if re.match(r"^[A-Za-z_][^\n]*\b" + re.escape(name) + r"\s*\(", line):
body = [line]
for j in range(i + 1, len(lines)):
body.append(lines[j])
if lines[j].rstrip() == "}":
break
return "\n".join(body)
return None
def main() -> int:
if not SRC.exists():
print(f"frame_trace_guard_test: SKIP: {SRC} not found", file=sys.stderr)
return 77
text = SRC.read_text(encoding="utf-8", errors="replace")
failures: list[str] = []
if not re.search(r"static\s+PSXFrameTraceRecord\s+s_ring\s*\[", text):
failures.append("frame_trace.c: the ring is not a static array "
"(the record path must not own heap)")
for name in RECORD_PATH:
body = function_body(text, name)
if body is None:
failures.append(f"frame_trace.c: record-path function {name}() not found")
continue
for pattern in FORBIDDEN:
if re.search(pattern, body):
failures.append(f"frame_trace.c: {name}() matches {pattern} "
"(the per-frame path must not allocate or do IO)")
if failures:
print("frame_trace_guard_test: FAIL", file=sys.stderr)
for f in failures:
print(" " + f, file=sys.stderr)
return 1
print(f"frame_trace_guard_test: {len(RECORD_PATH)} record-path functions are "
"allocation- and IO-free")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7
View File
@@ -232,6 +232,13 @@ int gl_renderer_stereo_publish(uint64_t id, uint64_t cycle, const int32_t view[2
s_stereo_published = id;
return 1;
}
/* Referenced by render_pass.c's frame tracing and stereo shedding. */
void gl_renderer_runtime_diag(uint64_t out[6]) { memset(out, 0, 6 * sizeof *out); }
void gl_renderer_scene_prim_counts(uint64_t *prims, uint64_t *textured) {
if (prims) *prims = 0;
if (textured) *textured = 0;
}
uint64_t psx_host_mono_ms(void) { return 1; }
/* ---- an overlay shard's cycle shim (overlay_dispatch_preamble.c.inc) --- */
static uint32_t s_shard_pending;
+145
View File
@@ -23,6 +23,7 @@
* The GPU, presenter and remaining devices are mocks. Build/run:
* ctest -R render_pass_sandbox_test */
#include "frame_trace.h"
#include "render_pass.h"
#include "render_pass_plan.h"
@@ -222,6 +223,9 @@ void gpu_pass_checkpoint_restore(void) {}
static uint64_t s_ticks;
uint64_t gl_renderer_perf_ticks(void) { return s_ticks += 1000; }
uint64_t gl_renderer_perf_frequency(void) { return 1000000000u; }
/* 1000 ticks per sample and a 1 GHz tick make one runtime sample exactly 1 us,
* so a bracketed guest span is the caller's own bump plus a known microsecond. */
static void tick_bump(uint64_t ticks) { s_ticks += ticks; }
static uint32_t s_gl_status = PSX_MOD_RENDER_PASS_READY;
uint32_t gl_renderer_pass_unavailable(void) { return s_gl_status; }
uint32_t gl_renderer_pass_plan(uint32_t p, uint32_t s, uint32_t *a,
@@ -301,6 +305,18 @@ int gl_renderer_stereo_publish(uint64_t id, uint64_t cycle, const int32_t view[2
return 1;
}
/* Frame-trace draw/diag inputs and the host clock the stereo shedding and
* tracing use. Not GL state this sandbox models, but render_pass.c references
* them, so they must exist to link. */
void gl_renderer_runtime_diag(uint64_t out[6]) { memset(out, 0, 6 * sizeof *out); }
static uint64_t s_prims_stub;
void gl_renderer_scene_prim_counts(uint64_t *prims, uint64_t *textured) {
if (prims) *prims = s_prims_stub;
if (textured) *textured = s_prims_stub / 2u;
}
static uint64_t s_host_ms;
uint64_t psx_host_mono_ms(void) { return s_host_ms; }
/* ---- 1. store policy --------------------------------------------------- */
static void test_store_policy(void) {
RenderPassStoreTarget t = target();
@@ -763,6 +779,134 @@ static void test_stereo(void) {
"capture failure still restores live state");
f.period_vblanks = 0;
CHECK(!psx_mod_render_stereo(&cpu, &f, pair_draw, NULL), "invalid cadence refused");
/* Tracing is observation only: the same pair must publish the same id and
* reach the same guest-state hashes with capture on as with it off. */
{
uint64_t off_pub, off_hash[2], off_pair;
f.period_vblanks = 2;
psx_frame_trace_set_enabled(0);
render_pass_reset_session(); psx_frame_trace_set_enabled(0);
s_gl_status = 0; s_begin_ok = 1; s_eye_calls = 0;
cpu.gpr[8] = 123; s_ram[0x456] = 0x77;
CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, "pair with tracing off");
render_stereo_get_stats(&stats);
off_pub = s_stereo_published;
off_hash[0] = stats.eye_hash[0]; off_hash[1] = stats.eye_hash[1];
off_pair = stats.last_pair_id;
render_pass_reset_session();
psx_frame_trace_set_enabled(1); psx_frame_trace_reset();
s_gl_status = 0; s_begin_ok = 1; s_eye_calls = 0;
cpu.gpr[8] = 123; s_ram[0x456] = 0x77;
CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, "pair with tracing on");
render_stereo_get_stats(&stats);
CHECK(s_stereo_published == off_pub && stats.last_pair_id == off_pair &&
stats.eye_hash[0] == off_hash[0] && stats.eye_hash[1] == off_hash[1],
"tracing changes neither the published pair nor the guest state");
CHECK(psx_frame_trace_committed() > 0, "the traced pair was recorded");
psx_frame_trace_set_enabled(0); psx_frame_trace_reset();
}
}
/* ---- 4. per-eye trace counters and guest sub-stage spans ---------------- */
/* The shape of the game hook's per-eye replay: bracket the guest calls, and let
* the runtime time them. See vr/psx_vr_stereo.c vr_draw_scene. */
static int s_traced_calls;
static int traced_draw(CPUState *cpu, void *user, uint32_t eye) {
(void)user;
s_traced_calls++;
s_prims_stub += 100u + 100u * eye; /* 100 prims, then 200 */
psx_mod_frame_trace_stage_begin(PSX_TRACE_GAME_TRAVERSE);
tick_bump(3000); /* 3 us, plus 1 per sample */
/* The visibility override runs inside the traversal it belongs to, so its
* span nests in that one: it must be timed on its own without truncating
* the span it is inside. */
psx_mod_frame_trace_stage_begin(PSX_TRACE_GAME_SELECT);
tick_bump(2000);
psx_mod_frame_trace_stage_end(PSX_TRACE_GAME_SELECT);
tick_bump(1000);
psx_mod_frame_trace_stage_end(PSX_TRACE_GAME_TRAVERSE);
/* An end for a stage that was never opened, and a begin that is never
* ended: neither may reach the record. */
psx_mod_frame_trace_stage_begin(PSX_TRACE_GAME_ENV);
tick_bump(1000);
psx_mod_frame_trace_stage_end(PSX_TRACE_GAME_SUBMIT);
psx_mod_frame_trace_stage_begin(PSX_TRACE_GAME_ICON);
tick_bump(1000);
psx_advance_cycles(4000u + 1000u * eye);
return 1;
}
static void test_trace_substages(void) {
CPUState cpu = {0};
PSXModStereoFrame f = {sizeof f, 2, 0, 240, 320, 240};
PSXFrameTraceRecord rec;
PSXModRenderPass rect = {sizeof rect, 0, 0, 0, 2, 2};
int first = 1, second = 2;
uint8_t pixels[32] = {0};
/* A session reset re-reads PSX_FRAME_TRACE from the env, so enabling the
* trace has to come after it (as it does in a real session). */
render_pass_reset_session();
s_gl_status = 0; s_begin_ok = 1; s_eye_decline = -1; s_eye_calls = 0;
s_prims_stub = 0; s_traced_calls = 0;
psx_frame_trace_set_enabled(1);
psx_frame_trace_reset();
CHECK(psx_mod_render_stereo(&cpu, &f, traced_draw, NULL) == 1,
"the traced pair rendered");
CHECK(s_traced_calls == 2, "both eyes replayed the guest draw");
CHECK(psx_frame_trace_read(&rec, 1) == 1, "the pair left one record");
CHECK(rec.prims_l == 100 && rec.prims_r == 200,
"each eye's own primitive delta is recorded");
CHECK(rec.prims == 200 && rec.textured_prims == 100,
"the pair-level count is still the last eye's");
CHECK(rec.eye_cycles_l >= 4000u && rec.eye_cycles_r >= 5000u &&
rec.eye_cycles_r > rec.eye_cycles_l,
"each eye's own guest cycles are recorded");
CHECK(rec.stages_us[PSX_TRACE_GAME_TRAVERSE] == 18,
"both eyes' guest spans are recorded (9 us per eye, nesting included)");
CHECK(rec.stages_us[PSX_TRACE_GAME_SELECT] == 6,
"a nested span is timed on its own (3 us per eye) and stays a subset");
CHECK(rec.stages_us[PSX_TRACE_GAME_ENV] == 0 &&
rec.stages_us[PSX_TRACE_GAME_SUBMIT] == 0,
"an end that does not match an open span records nothing");
CHECK(rec.stages_us[PSX_TRACE_GAME_ICON] == 0,
"an unpaired begin records nothing rather than leak into the next span");
/* A HUD capture's guest draws run outside a traced eye: the hook's spans
* must not land in the frame they belong to. */
psx_frame_trace_reset();
s_prims_stub = 0;
s_traced_calls = 0;
render_pass_reset_session(); s_gl_status = 0; s_begin_ok = 1;
psx_frame_trace_set_enabled(1);
CHECK(psx_frame_trace_open(), "a frame record is open across the capture");
CHECK(psx_mod_render_capture_rgba(&cpu, &rect, traced_draw, &first,
pixels, sizeof pixels) &&
psx_mod_render_capture_rgba(&cpu, &rect, traced_draw, &second,
pixels, sizeof pixels),
"the captures rendered both mattes");
CHECK(s_traced_calls == 2 && psx_frame_trace_read(&rec, 1) == 0,
"the capture replayed the draw and left no record of its own");
psx_frame_trace_commit();
CHECK(psx_frame_trace_read(&rec, 1) == 1 &&
rec.stages_us[PSX_TRACE_GAME_TRAVERSE] == 0 &&
rec.prims_l == 0 && rec.prims_r == 0,
"a capture's guest draws write no per-eye or sub-stage slots");
/* Same with the trace off: the hook may bracket freely. */
psx_frame_trace_set_enabled(0);
psx_frame_trace_reset();
s_prims_stub = 0;
s_traced_calls = 0;
render_pass_reset_session(); s_gl_status = 0; s_begin_ok = 1;
CHECK(psx_mod_render_stereo(&cpu, &f, traced_draw, NULL) == 1,
"the pair renders with tracing off");
CHECK(s_traced_calls == 2 && psx_frame_trace_committed() == 0,
"nothing is recorded while tracing is off");
render_pass_reset_session();
}
static int local_draw(CPUState *cpu, void *user, uint32_t alpha) {
@@ -879,6 +1023,7 @@ int main(void) {
test_journal();
test_stereo();
test_isolated_capture();
test_trace_substages();
test_local_view();
test_texture_stream_journal();
CHECK(s_prec_open == 0, "precision checkpoints balanced");