From 09bcfdc6aec700b807cc77cffca63c4535fda892 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:39:10 -0700 Subject: [PATCH] Frame generation on the render thread from recorded draw lists [video] frame_generation (opt-in, with render_thread): the render thread keeps each game frame's replayed draw list, matches the triangles of the last two (frame_gen.c), and presents in-between frames at the display refresh by redrawing the newer list into separate surfaces with matched vertices interpolated. It never runs guest code. Real frames are composed at their flip and presented after the generated ones, with the same pixels and order. Generation uses only measured surplus: a plan from render-thread CPU, generated-frame cost and swap time, a breaker after backpressure or late guest frames, and a pause while dynamic resolution is over budget. The render-thread core gains a timed tick hook. Frame-cost queries pause around generation, so dynamic resolution measures real frames only. Tests: frame_gen_test, render_thread_test (tick), gl_frame_gen_test (real frames identical off/on; phase 0/1 equal the endpoints). Docs: docs/FRAME_GENERATION.md. Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_GENERATION.md | 96 ++ docs/RENDER_THREAD.md | 1 + recompiler/src/config_loader.cpp | 3 + recompiler/src/config_loader.h | 9 + .../tests/video_enhancement_settings_test.cpp | 16 + runtime/CMakeLists.txt | 21 + runtime/include/frame_gen.h | 95 ++ runtime/include/gpu_gl_renderer.h | 12 + runtime/include/render_thread.h | 15 + runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 13 + runtime/src/frame_gen.c | 192 +++ runtime/src/gpu_gl_renderer.c | 1146 ++++++++++++++++- runtime/src/main.cpp | 22 + runtime/src/render_thread.c | 53 +- runtime/tests/run_gl_frame_gen.py | 147 +++ runtime/tests/run_gl_readback_region.py | 2 + runtime/tests/run_gl_render_thread.py | 2 +- runtime/tests/run_gl_scale_invariance.py | 2 + runtime/tests/run_gl_texture_filter.py | 2 + runtime/tests/test_frame_gen.c | 151 +++ runtime/tests/test_gl_frame_gen.c | 278 ++++ runtime/tests/test_render_thread.c | 47 + 23 files changed, 2307 insertions(+), 19 deletions(-) create mode 100644 docs/FRAME_GENERATION.md create mode 100644 runtime/include/frame_gen.h create mode 100644 runtime/src/frame_gen.c create mode 100644 runtime/tests/run_gl_frame_gen.py create mode 100644 runtime/tests/test_frame_gen.c create mode 100644 runtime/tests/test_gl_frame_gen.c diff --git a/docs/FRAME_GENERATION.md b/docs/FRAME_GENERATION.md new file mode 100644 index 00000000..aa1d1f11 --- /dev/null +++ b/docs/FRAME_GENERATION.md @@ -0,0 +1,96 @@ +# Frame generation (`[video] frame_generation`) + +Opt-in, OpenGL with the render thread only, off by default +(`PSX_FRAME_GEN=0/1` overrides for one run). Off, nothing in this document +runs and the renderer is the render thread of docs/RENDER_THREAD.md. + +```toml +[video] +render_thread = true +frame_generation = true +``` + +## Rule + +Real frames at the guest's rate come first and resolution is the only +quality lever for them (dynamic resolution). Frames above that are a bonus, +drawn by the render thread **from recorded data only**: no guest code runs +again, nothing is snapshotted or rolled back, and only when the render thread +has measured time to spare. + +## How + +- **Lists.** The render thread keeps a copy of every replayed state and + drawing record (not transfers, copies, presents, steps). Display buffers are + learned from presents; a list is one game frame's drawing into one buffer and + closes when drawing moves to another buffer, or when the game flips to the + buffer being drawn. (R4 starts drawing the next frame a VBlank before it + flips, so a flip alone is not a frame boundary.) +- **Cadence.** A flip (a present of a different display address) is a new + game frame; a present of the same buffer is the same frame again. R4 + updates every other VBlank, so generation interpolates between flips (30 Hz + game frames), not between VBlanks. +- **Matching** (`frame_gen.c`). Triangles are keyed by op, texture page, + CLUT, texture coordinates and the draw area they were clipped to + (relative to their buffer); positions are relative to the buffer. Each + newer triangle pairs, in draw order, with the nearest older one of the same + key whose three vertices moved at most 96 native px and alike (within + 24 px of each other). R4 races pair 87-97 % of their triangles. +- **Drawing.** An in-between frame redraws the newer list into separate + surfaces (a copy of the displayed buffer's hr rect and wide rows) with each + matched triangle at `lerp(older, newer, t)`, sub-pixel via the precise + triangle path; unmatched triangles, rects, lines, fills (2D, HUD) draw as + the newer frame has them. The raw texture mirror is not packed from the + generated surfaces; all bookkeeping the draws touch is restored, so the + real stream is unaffected. +- **Schedule.** With `n` in-between frames per game frame, at the flip the + real frame is composed and kept (its buffer may be drawn over before it is + shown), frames `t = k/(n+1)` are presented `flip/(n+1)` apart, then the + kept real frame. Timed work runs from the render-thread core's `tick` + hook between records and while idle. +- **Latency.** Interpolation shows a real frame after the in-between frames + that lead to it: up to `n/(n+1)` of a game frame later than without + generation (R4 at 30 Hz on 120 Hz: 25 ms at n = 3, 17 ms at n = 1). + Extrapolation (no delay, guessed motion) is not offered. +- **Plan.** `n = fg_plan(...)`: slots = round(flip interval × refresh) + (R4 on 120 Hz: 4), limited to what fits in 85 % of the interval after the + real frames' render-thread CPU time and one swap, at the measured cost of a + generated frame (CPU, GPU via `GL_TIME_ELAPSED`, plus a swap). On macOS a + real frame's `TIME_ELAPSED` span reads close to the whole interval (it + counts the GPU waiting for records), so it is not used here; GPU overload + shows as backpressure. The measured frame cost dynamic resolution uses + excludes generation (its query is paused, segments summed). +- **Breaker.** Off for 3 s (doubling to 24 s on repeats within 10 s) after + the queue backed up (the guest waited), the render thread fell two frames + behind, or a guest frame took over 1.5 intervals. While dynamic resolution + is over budget or stepping down, generation pauses (1 s, no escalation). + One frame behind just shows the waiting real frame at once. +- **Sync points** show the waiting real frame and restart the lists at the + next flip. + +## Limits + +- Only triangles move; sprites, lines and fills are the newer frame's. +- A texture the frame drew earlier into its own displayed buffer is sampled + as the raw mirror holds it when the in-between frame is drawn. +- Single-buffered games never flip, so nothing is generated. +- Windowed high-resolution mode and depth24 frames are not generated. + +## Debug + +`{"cmd":"frame_gen"}` (not a sync point): generated / real presents, flips, +dups, plan (`last_n`, `slots`), costs (`real_ms`, `gen_ms`, `swap_ms`), last +match counts, breaker, last hold, total swaps. + +## Tests + +- `frame_gen_test`: matching (moved scenes, keys, move and deformation + limits, duplicates, pair-once), interpolation endpoints, the plan, the + breaker. +- `render_thread_test`: the tick hook runs on the render thread with the + context, never early, and wakes an idle thread. +- `gl_frame_gen_test` (real GL, `gpu;opengl;hardware`): a double-buffered + 30 Hz scene, VRAM and native-wide presents, flipping right after drawing + and a VBlank late: the distinct real presented images are identical with + generation off and on, generated frames are presented, and an in-between + frame composed at t = 1 / t = 0 equals the newer / older real frame. diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 617ebdcc..6a81fbd8 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -167,6 +167,7 @@ resolution (below), not of this layer. | render passes | first `gl_renderer_pass_*` call in a frame is a sync point; the rest of that frame is synchronous. Recommended: leave `render_thread` off with the frame-rate mod | | frame interpolation | ineligible (held) while enabled; not started when it is on at boot | | OpenXR | ineligible while a session is active | +| frame generation | `[video] frame_generation`: in-between frames drawn by the render thread from recorded lists, docs/FRAME_GENERATION.md | | native-wide / widescreen | recorded; tags, latch and wide-surface mirror as above | | internal resolution changes | `gr_set_scale` and every resolution entry point are sync points; dynamic-resolution level steps are recorded (below) | | screenshots / debug captures | `screenshot*` sync; `present_shot` is fulfilled on the render thread | diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 05483809..6ef03792 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -802,6 +802,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("render_thread")) { rt.video_render_thread = toml::find(video, "render_thread"); } + if (video.contains("frame_generation")) { + rt.video_frame_generation = toml::find(video, "frame_generation"); + } if (video.contains("vsync")) { const auto mode = toml::find(video, "vsync"); if (mode == "on" || mode == "vsync") rt.video_vsync = 1; diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 81fbe07e..ebaa58a8 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -574,6 +574,15 @@ struct RuntimeConfig { // default; PSX_RENDER_THREAD=0/1 overrides. bool video_render_thread = false; + // frame_generation: with render_thread, the render thread draws + // in-between frames at the display's refresh from the last two game + // frames' draw lists when it has time to spare (never by running guest + // code). The real frames are unchanged and come first; a breaker turns + // generation off for seconds after a late frame. Adds up to one game + // frame of latency (docs/FRAME_GENERATION.md). Off by default; + // PSX_FRAME_GEN=0/1 overrides. + bool video_frame_generation = false; + // low_latency_input: re-sample the pad after the wall-clock pacer (just // before present) so the next CPU frame reads near-fresh input instead of // input ~one frame stale. Default on. vsync: present/swap mode — diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 6df8a99f..a775acfd 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -335,6 +335,21 @@ static void test_render_thread() { fs::remove(q); } +/* [video] frame_generation (docs/FRAME_GENERATION.md): opt-in, off by default. */ +static void test_frame_generation() { + fs::path p = write_game_toml("psxrecomp_fg_default.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(!gc.runtime.video_frame_generation, "frame_generation defaults OFF"); + fs::remove(p); + fs::path q = write_game_toml("psxrecomp_fg_on.toml", + "[video]\n" + "render_thread = true\n" + "frame_generation = true\n"); + auto gq = PSXRecompV4::load_game_config(q); + check(gq.runtime.video_frame_generation, "[video] frame_generation = true is honoured"); + fs::remove(q); +} + /* docs/ENHANCEMENTS.md G1.11: the PGXP title keys. Defaults keep the * historical behaviour (tolerance 0.5, position cache consulted, IR-path * shadows); a title built with the hooks sets all three. */ @@ -400,6 +415,7 @@ int main() { test_user_settings_fov_scale(); test_texture_window_batching(); test_render_thread(); + test_frame_generation(); test_pgxp_title_keys(); if (failures) { diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 40c9d0d4..3d62ac39 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -227,6 +227,20 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-render-thread-results") set_tests_properties(gl_render_thread_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) + # Frame generation ([video] frame_generation): the real presented + # images are identical with it off and on (VRAM and native-wide + # presents), generated frames are presented, and an in-between frame + # at phase 1 / 0 equals the newer / older real frame. + add_test(NAME gl_frame_gen_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_frame_gen.py" + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_frame_gen.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-frame-gen-results") + set_tests_properties(gl_frame_gen_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") @@ -1211,6 +1225,13 @@ if(BUILD_TESTING) target_link_libraries(dynamic_resolution_rt_test PRIVATE m) endif() add_test(NAME dynamic_resolution_rt_test COMMAND dynamic_resolution_rt_test) + # Frame generation's matching, interpolation, plan and breaker. + add_executable(frame_gen_test tests/test_frame_gen.c src/frame_gen.c) + target_include_directories(frame_gen_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(frame_gen_test PRIVATE m) + endif() + add_test(NAME frame_gen_test COMMAND frame_gen_test) # Render-pass time freeze against mock devices (psx_cycles.c only). add_executable(render_pass_freeze_test diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h new file mode 100644 index 00000000..5c83a03a --- /dev/null +++ b/runtime/include/frame_gen.h @@ -0,0 +1,95 @@ +#ifndef PSX_FRAME_GEN_H +#define PSX_FRAME_GEN_H +/* + * Frame generation from recorded draw lists ([video] frame_generation, + * docs/FRAME_GENERATION.md). The renderer-independent half: the primitives + * of two consecutive game frames (one per display flip), their matching, + * vertex interpolation, how many in-between frames fit, and the breaker. + * The GL backend (gpu_gl_renderer.c) owns the record lists themselves, + * drawing and presenting; nothing here touches GL or guest state. + * + * A primitive is a triangle of one frame's draw list with a match key (the + * draw op, texture page, CLUT, texture coordinates, the draw area it was + * clipped to) and its three screen positions in native pixels relative to + * that frame's displayed buffer. Matching pairs each primitive of the newer + * frame with one of the older frame's, in draw order: the same key, every + * vertex moved at most max_move, and the three vertices moved alike (a + * rigid-ish motion, at most max_deform apart). A primitive with no partner + * is drawn where the newer frame has it. + */ +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct FgPrim { + uint32_t rec; /* index of its record in the owner's list */ + uint32_t key; /* match key (fg_hash) */ + float x[3], y[3]; /* native px, relative to the list's display origin */ +} FgPrim; + +typedef struct FgPrimList { + FgPrim *v; + uint32_t n, cap; +} FgPrimList; + +void fg_prims_reset(FgPrimList *l); +void fg_prims_free(FgPrimList *l); +/* Append; returns 0 when out of memory. */ +int fg_prims_add(FgPrimList *l, const FgPrim *p); + +/* FNV-1a over 32-bit words, for building keys. */ +uint32_t fg_hash(uint32_t h, const int32_t *w, int n); +#define FG_HASH_INIT 2166136261u + +typedef struct FgMatchParams { + float max_move; /* native px a vertex may move between the frames */ + float max_deform; /* native px the vertices' moves may differ by */ + int window; /* same-key candidates considered per primitive */ +} FgMatchParams; +void fg_match_defaults(FgMatchParams *p); + +typedef struct FgMatchStats { + uint32_t prims, matched, moved, unmatched; +} FgMatchStats; + +/* For every primitive of `newer`, the index of its partner in `older` or -1 + * (match[newer->n]). Each older primitive pairs at most once. Returns the + * matched count. */ +uint32_t fg_match(const FgPrimList *older, const FgPrimList *newer, + const FgMatchParams *p, int32_t *match, FgMatchStats *st); + +/* Positions at phase t between a (t = 0) and b (t = 1). */ +void fg_lerp(const FgPrim *a, const FgPrim *b, double t, float x[3], float y[3]); + +/* How many in-between frames to draw per game frame. + * flip_s the game frame's interval (time between flips) + * refresh_hz the display's refresh rate + * real_cost_s the render thread's cost of one game frame (all its VBlank + * frames), 0 when unknown + * gen_cost_s the cost of one generated frame, 0 when not yet measured + * budget share of the interval the real and generated work may use + * Returns 0..slots-1, where slots = round(flip_s * refresh_hz); an unknown + * generation cost allows one frame while the real cost leaves half the + * interval, so the cost gets measured. */ +int fg_plan(double flip_s, double refresh_hz, double real_cost_s, + double gen_cost_s, double budget, int max_gens); + +/* Breaker: after a trip, generation stays off for hold seconds; a trip + * within `repeat_s` of the end of the last hold doubles the hold, up to max. */ +typedef struct FgBreaker { + double until, hold, last_end; + double base_hold, max_hold, repeat_s; + uint32_t trips; + const char *reason; +} FgBreaker; +void fg_breaker_init(FgBreaker *b, double base_hold, double max_hold, double repeat_s); +void fg_breaker_trip(FgBreaker *b, double now, const char *reason); +int fg_breaker_open(const FgBreaker *b, double now); /* 1 = generation allowed */ + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index d403dffc..6efa688f 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -326,6 +326,18 @@ typedef struct GlDynresStats { uint64_t hr_reallocs, wide_reallocs; /* surfaces reallocated at a step */ } GlDynresStats; void gl_renderer_dynres_stats(GlDynresStats *out); +/* Frame generation ([video] frame_generation, docs/FRAME_GENERATION.md): + * in-between frames drawn by the render thread from the last two game + * frames' draw lists when it has time to spare. Off by default; with it off + * nothing changes. set: a sync point. configure: the display's refresh and + * the guest's frame rate (any thread). hold: trip the breaker (generation + * stays off for a few seconds), e.g. while dynamic resolution is over budget. + * json: the debug server's {"cmd":"frame_gen"} fields (no sync point). */ +void gl_renderer_set_frame_generation(int on); +int gl_renderer_frame_generation(void); +void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz); +void gl_renderer_frame_gen_hold(const char *reason); +int gl_renderer_frame_gen_json(char *out, int cap); /* Host time the renderer spent, as running totals in performance-counter * ticks (only kept while dynamic resolution is on): waits for the frame * blend's next present, render passes, blend presents' own work, and time diff --git a/runtime/include/render_thread.h b/runtime/include/render_thread.h index d57e5772..cf6a9978 100644 --- a/runtime/include/render_thread.h +++ b/runtime/include/render_thread.h @@ -52,6 +52,12 @@ typedef struct RtCmd { * (current == 0; the backend flushes first). */ typedef void (*RtExecFn)(void *user, const RtCmd *cmd, const void *payload); typedef void (*RtCtxFn)(void *user, int current); +/* tick (optional): timed work on the render thread while it holds the + * context (frame generation). Called with the current time before a record + * is executed and while the ring is empty, once the deadline it last + * returned has passed; returns the next deadline (rt_now_ns clock), 0 for + * none. An idle render thread sleeps no later than that deadline. */ +typedef uint64_t (*RtTickFn)(void *user, uint64_t now_ns); typedef struct RtConfig { size_t ring_bytes; /* power of two; >= 1 MiB */ @@ -59,6 +65,7 @@ typedef struct RtConfig { RtExecFn exec; RtCtxFn ctx; void *user; + RtTickFn tick; /* optional (NULL) */ } RtConfig; /* Start: the context must be current on the calling thread; on success it is @@ -89,6 +96,14 @@ int rt_frames_ahead(void); /* Render-thread query: the consumer's running idle total (waiting for * records, or parked while the emulation thread holds the context). */ uint64_t rt_render_idle_ns(void); +/* Monotonic nanoseconds (the clock tick deadlines use). */ +uint64_t rt_now_ns(void); +/* Any thread: running count of times the emulation thread blocked on the + * in-flight bound or a full ring (it waited on the render thread). */ +uint64_t rt_backpressure_events(void); +/* Render thread, from tick or exec: ask for tick at `deadline_ns` (earliest + * wins over the one tick last returned). */ +void rt_tick_at(uint64_t deadline_ns); /* Sync point. Drain, then move the context to the calling thread (held). * reason is a static string kept in the acquire ring. No-op when already held diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 0fad0fe8..ac549c55 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -370,6 +370,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/gpu_vram_dirty.c ${PSXRECOMP_ROOT}/runtime/src/gpu_render.c ${PSXRECOMP_ROOT}/runtime/src/render_thread.c + ${PSXRECOMP_ROOT}/runtime/src/frame_gen.c ${PSXRECOMP_ROOT}/runtime/src/gpu_gl_renderer.c ${PSXRECOMP_ROOT}/runtime/src/gpu_hd_textures.cpp ${PSXRECOMP_ROOT}/runtime/src/hd_texture_pack.cpp diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index e3be3123..f766de68 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -10419,6 +10419,18 @@ static void handle_render_thread(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf[0] ? buf : "\"active\":0"); } +/* Frame generation ([video] frame_generation, docs/FRAME_GENERATION.md): + * {"cmd":"frame_gen"} -> enabled, active, generated / real presents, flips, + * the last plan (in-between frames per game frame, display slots), costs, + * match counts, breaker. Not a sync point (render-thread counters, racy). */ +static void handle_frame_gen(int id, const char *json) +{ + (void)json; + char buf[1024]; + if (gl_renderer_frame_gen_json(buf, sizeof buf) <= 0) buf[0] = 0; + send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf[0] ? buf : "\"enabled\":0"); +} + /* Presented-image ring (see present_image_ring.h): * {"cmd":"present_image_ring_stats"} -> oldest, newest, count, frozen * {"cmd":"present_image_ring_get","frame":N,"path":"x.png"} */ @@ -15493,6 +15505,7 @@ static const CmdEntry s_commands[] = { { "capture_mark", handle_capture_mark }, { "present_image_ring_stats", handle_present_image_ring_stats }, { "render_thread", handle_render_thread }, + { "frame_gen", handle_frame_gen }, { "present_image_ring_get", handle_present_image_ring_get }, { "kernel_bless", handle_kernel_bless }, { "ws_aspect", handle_ws_aspect }, diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c new file mode 100644 index 00000000..867defb4 --- /dev/null +++ b/runtime/src/frame_gen.c @@ -0,0 +1,192 @@ +/* Frame generation: matching, interpolation, planning (include/frame_gen.h, + * docs/FRAME_GENERATION.md). */ +#include "frame_gen.h" + +#include +#include +#include + +void fg_prims_reset(FgPrimList *l) { l->n = 0; } + +void fg_prims_free(FgPrimList *l) { + free(l->v); + l->v = NULL; + l->n = l->cap = 0; +} + +int fg_prims_add(FgPrimList *l, const FgPrim *p) { + if (l->n == l->cap) { + uint32_t nc = l->cap ? l->cap * 2u : 1024u; + FgPrim *nv = (FgPrim *)realloc(l->v, (size_t)nc * sizeof *nv); + if (!nv) return 0; + l->v = nv; + l->cap = nc; + } + l->v[l->n++] = *p; + return 1; +} + +uint32_t fg_hash(uint32_t h, const int32_t *w, int n) { + for (int i = 0; i < n; i++) { + uint32_t x = (uint32_t)w[i]; + for (int b = 0; b < 4; b++) { + h ^= (x >> (8 * b)) & 0xFFu; + h *= 16777619u; + } + } + return h; +} + +void fg_match_defaults(FgMatchParams *p) { + p->max_move = 96.0f; + p->max_deform = 24.0f; + p->window = 16; +} + +/* Open-addressing table from key to the first older primitive with it; the + * older primitives with one key are chained in draw order. */ +typedef struct { + uint32_t *keys; + int32_t *head; + uint32_t mask; +} KeyTab; + +static int tab_init(KeyTab *t, uint32_t n) { + uint32_t cap = 64; + while (cap < n * 2u) cap <<= 1; + t->keys = (uint32_t *)malloc((size_t)cap * sizeof *t->keys); + t->head = (int32_t *)malloc((size_t)cap * sizeof *t->head); + t->mask = cap - 1; + if (!t->keys || !t->head) return 0; + for (uint32_t i = 0; i < cap; i++) t->head[i] = -1; + return 1; +} + +static int32_t *tab_slot(KeyTab *t, uint32_t key) { + uint32_t i = (key * 2654435761u) & t->mask; + while (t->head[i] >= 0 && t->keys[i] != key) i = (i + 1) & t->mask; + t->keys[i] = key; + return &t->head[i]; +} + +uint32_t fg_match(const FgPrimList *older, const FgPrimList *newer, + const FgMatchParams *p, int32_t *match, FgMatchStats *st) { + FgMatchStats s; + memset(&s, 0, sizeof s); + s.prims = newer->n; + for (uint32_t i = 0; i < newer->n; i++) match[i] = -1; + if (!older->n || !newer->n) { + s.unmatched = newer->n; + if (st) *st = s; + return 0; + } + KeyTab t; + int32_t *next = (int32_t *)malloc((size_t)older->n * sizeof *next); + uint8_t *used = (uint8_t *)calloc(older->n, 1); + if (!tab_init(&t, older->n) || !next || !used) { + free(t.keys); free(t.head); free(next); free(used); + s.unmatched = newer->n; + if (st) *st = s; + return 0; + } + /* Chains in draw order: insert from the back. */ + for (uint32_t i = older->n; i-- > 0;) { + int32_t *h = tab_slot(&t, older->v[i].key); + next[i] = *h; + *h = (int32_t)i; + } + const float mm = p->max_move * p->max_move; + const float md = p->max_deform * p->max_deform; + for (uint32_t j = 0; j < newer->n; j++) { + const FgPrim *b = &newer->v[j]; + int32_t *h = tab_slot(&t, b->key); + /* Drop used entries at the chain head so later lookups stay short. */ + while (*h >= 0 && used[*h]) *h = next[*h]; + int32_t best = -1; + float best_d = 0.0f; + int seen = 0; + for (int32_t i = *h; i >= 0 && seen < p->window; i = next[i]) { + if (used[i]) continue; + seen++; + const FgPrim *a = &older->v[i]; + float dx[3], dy[3], d = 0.0f; + int ok = 1; + for (int k = 0; k < 3 && ok; k++) { + dx[k] = b->x[k] - a->x[k]; + dy[k] = b->y[k] - a->y[k]; + float d2 = dx[k] * dx[k] + dy[k] * dy[k]; + if (d2 > mm) ok = 0; + d += d2; + } + for (int k = 1; k < 3 && ok; k++) { + float ex = dx[k] - dx[0], ey = dy[k] - dy[0]; + if (ex * ex + ey * ey > md) ok = 0; + } + if (!ok) continue; + if (best < 0 || d < best_d) { + best = i; + best_d = d; + if (d == 0.0f) break; /* did not move: nothing closer */ + } + } + if (best >= 0) { + used[best] = 1; + match[j] = best; + s.matched++; + if (best_d > 0.0f) s.moved++; + } else { + s.unmatched++; + } + } + free(t.keys); free(t.head); free(next); free(used); + if (st) *st = s; + return s.matched; +} + +void fg_lerp(const FgPrim *a, const FgPrim *b, double t, float x[3], float y[3]) { + for (int k = 0; k < 3; k++) { + if (t >= 1.0) { x[k] = b->x[k]; y[k] = b->y[k]; continue; } + if (t <= 0.0) { x[k] = a->x[k]; y[k] = a->y[k]; continue; } + x[k] = (float)((double)a->x[k] + ((double)b->x[k] - (double)a->x[k]) * t); + y[k] = (float)((double)a->y[k] + ((double)b->y[k] - (double)a->y[k]) * t); + } +} + +int fg_plan(double flip_s, double refresh_hz, double real_cost_s, + double gen_cost_s, double budget, int max_gens) { + if (flip_s <= 0.0 || refresh_hz <= 0.0 || max_gens <= 0) return 0; + int slots = (int)floor(flip_s * refresh_hz + 0.5); + if (slots < 2) return 0; + int n = slots - 1; + if (n > max_gens) n = max_gens; + const double room = budget * flip_s - (real_cost_s > 0.0 ? real_cost_s : 0.0); + if (room <= 0.0) return 0; + if (gen_cost_s <= 0.0) return room >= 0.5 * budget * flip_s ? 1 : 0; + int fit = (int)floor(room / gen_cost_s); + return fit < n ? (fit > 0 ? fit : 0) : n; +} + +void fg_breaker_init(FgBreaker *b, double base_hold, double max_hold, double repeat_s) { + memset(b, 0, sizeof *b); + b->base_hold = base_hold; + b->max_hold = max_hold; + b->repeat_s = repeat_s; + b->last_end = -1e30; + b->until = -1e30; +} + +void fg_breaker_trip(FgBreaker *b, double now, const char *reason) { + b->trips++; + b->reason = reason; + if (now < b->until) return; /* already open: the hold stands */ + if (b->hold > 0.0 && now - b->last_end <= b->repeat_s) { + b->hold *= 2.0; + if (b->hold > b->max_hold) b->hold = b->max_hold; + } else { + b->hold = b->base_hold; + } + b->until = now + b->hold; + b->last_end = b->until; +} + +int fg_breaker_open(const FgBreaker *b, double now) { return now >= b->until; } diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 506f76ed..cbebc723 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -375,6 +375,15 @@ static int s_rthx_prim = 0; /* current record carries prim tags * static int s_rthx_tagged = 0, s_rthx_backdrop = 0; static int s_rths_flat_bd = 0, s_rths_vp_w = 0, s_rths_bg_full = 0; static inline int rth_replaying(void) { return s_rth_on && rt_on_render_thread(); } +/* Frame generation (defined with the render-thread code near the end). */ +static int s_fg_on; +static int s_fg_drawing, s_fg_presenting, s_fg_broken, s_fg_capture_real; +static void fg_capture(const RtCmd *c, const void *payload); +static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stale); +static void fg_flush(void); +static void fg_invalidate(void); +static uint64_t fg_tick(void *user, uint64_t now); +static void fg_note_guest_frame(void); extern int psx_ws_prim_is_tagged(void); /* gpu.c: is the current GP0 prim sprite-tagged? */ extern int psx_ws_prim_in_backdrop(void); /* gpu.c: is its source addr in the flower-field struct? */ extern int gpu_ws_nw_flat_backdrop_enabled(void); /* gpu.c: per-title flat backdrop opt-in */ @@ -2544,6 +2553,9 @@ static const HiwTile *hiw_ensure(int x0, int x1) { /* ---- coherency: hr FBO -> raw mirror (pack) ------------------------------ */ static void pack_flush(void) { if (!s_raster_ok || !s_pack_dirty.set) return; + /* A generated frame draws into its own surfaces: the raw mirror keeps + * the real VRAM (its pack dirt is restored afterwards). */ + if (s_fg_drawing) return; hiw_flush_queue(); /* queued window draws sample the raw mirror as it is now */ int x = s_pack_dirty.x0, y = s_pack_dirty.y0; int w = s_pack_dirty.x1 - s_pack_dirty.x0 + 1; @@ -8177,6 +8189,10 @@ static int ov_needs_present(void) { : host_osd_needs_present(); } +static uint64_t s_swaps_total = 0; /* every swap, real or generated (diagnostic) */ +static void gl_swap_now(int generated); +static int fg_keep_real(void); /* frame generation */ +static void fg_note_swap(uint64_t ns); static void gl_swap_with_osd(void) { openxr_present_native(); /* Copy guest content before host-only overlays. */ s_native_surface_pending=0; /* Hold-last/resim cannot reuse a native source. */ @@ -8254,12 +8270,30 @@ static void gl_swap_with_osd(void) { present_shot_done(wrote); } } + /* Frame generation: a real frame shown after its in-between frames is + * composed now (its buffer may be drawn over before it is shown) and + * kept; fg_present_real swaps it later. */ + if (s_fg_capture_real && fg_keep_real()) return; + gl_swap_now(s_fg_presenting); +} + +/* The swap itself (and its accounting). */ +static void gl_swap_now(int generated) { + (void)generated; /* Dynamic resolution's ledger: time blocked in the swap is the driver's * vsync wait unless the frame was late (main.cpp). Keep one owned swap. */ +#ifdef GL_PRESENT_TEST_HOOK + GL_PRESENT_TEST_HOOK(generated); +#endif + s_swaps_total++; uint64_t t0 = s_dyn_on ? SDL_GetPerformanceCounter() : 0; const uint64_t rthf_t0 = rth_replaying() ? host_now_ns_rthf() : 0; SDL_GL_SwapWindow(s_win); - if (rthf_t0) s_rthf_swap_ns += host_now_ns_rthf() - rthf_t0; + if (rthf_t0) { + const uint64_t sw = host_now_ns_rthf() - rthf_t0; + s_rthf_swap_ns += sw; + fg_note_swap(sw); + } if (s_dyn_on) { s_dyn_last_swap_ticks = SDL_GetPerformanceCounter() - t0; s_dyn_ledger.swap_ticks += s_dyn_last_swap_ticks; @@ -9474,11 +9508,13 @@ static int rth_record_present(uint16_t op, int n, const int32_t *v) { return 1; } +static void rth_present_payload(uint16_t op, const uint8_t *p); static void rth_replay_present(const RtCmd *c, const uint8_t *p) { - RthOvHdr hd; - memcpy(&hd, p, sizeof hd); static int skipped_last = 0; - if (rt_frames_ahead() >= 2 && !skipped_last) { + const int stale = rt_frames_ahead() >= 2 && !skipped_last; + /* Frame generation schedules (or drops) presents itself. */ + if (s_fg_on && fg_on_present(c->op, p, c->payload, stale)) { skipped_last = 0; return; } + if (stale) { /* Two later frames are already recorded, so the emulation thread is * at (or near) the in-flight bound waiting on us. Showing this frame * would only delay those; skip its present (its drawing has been @@ -9491,6 +9527,13 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { return; } skipped_last = 0; + rth_present_payload(c->op, p); +} + +/* Present a recorded present's payload (header, overlay copies). */ +static void rth_present_payload(uint16_t op, const uint8_t *p) { + RthOvHdr hd; + memcpy(&hd, p, sizeof hd); const uint8_t *q = p + sizeof hd; memset(&s_rth_ov, 0, sizeof s_rth_ov); s_rth_ov.valid = 1; @@ -9504,7 +9547,7 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { q += (size_t)hd.w[i] * hd.h[i] * 4u; } const int32_t *a = hd.args; - if (c->op == RTH_PRESENT_VRAM) + if (op == RTH_PRESENT_VRAM) gl_renderer_present_vram(a[0], a[1], a[2], a[3], a[4], a[5]); else (void)gl_renderer_present_wide_fbo(a[0], a[1], a[2], a[3]); @@ -9525,10 +9568,17 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { * its span holds the emulation thread's own drawing. Published as running * totals for the emulation thread (gl_renderer_render_thread_costs). */ #define RTHF_Q 8 +/* Frame generation pauses a frame's measurement while it draws (the frame + * then has up to RTHF_SEG queries, summed). */ +#define RTHF_SEG 4 static _Atomic int s_rthf_want = 0; /* host: measure (dynres active) */ static int s_rthf_qok = -1; /* render thread: queries usable */ -static GLuint s_rthf_q[RTHF_Q]; +static GLuint s_rthf_q[RTHF_Q][RTHF_SEG]; +static int s_rthf_nseg[RTHF_Q]; static uint64_t s_rthf_slot_cpu[RTHF_Q]; +static int s_rthf_paused = 0; /* a pause ended this frame's query */ +static uint64_t s_rthf_pause_t0 = 0, s_rthf_pause_swap = 0, s_rthf_excl = 0; +static void fg_note_real_cost(uint64_t cost_ns); /* frame generation */ static unsigned s_rthf_head = 0, s_rthf_tail = 0; static int s_rthf_open = 0, s_rthf_taint = 0, s_rthf_has_q = 0; static uint64_t s_rthf_t0 = 0, s_rthf_idle0 = 0, s_rthf_swap0 = 0; @@ -9549,17 +9599,28 @@ static void rthf_publish(uint64_t cpu, uint64_t gpu, int has_gpu) { } atomic_fetch_add(&s_rthf_cost_ns, cost); atomic_fetch_add(&s_rthf_frames, 1); /* last: the totals above are in */ + /* Frame generation budgets with the CPU time: on macOS a TIME_ELAPSED + * span also counts the GPU waiting for a frame's records to arrive, so + * it reads close to the whole interval whatever the work. GPU overload + * shows up as a slow swap and a queue that backs up (the breaker). */ + fg_note_real_cost(cpu); } /* Read every finished query pair, oldest first, without waiting. */ static void rthf_poll(void) { while (s_rthf_tail != s_rthf_head) { const unsigned i = s_rthf_tail % RTHF_Q; - GLuint64 avail = 0, ns = 0; - p_glGetQueryObjectui64v(s_rthf_q[i], GL_QUERY_RESULT_AVAILABLE, &avail); + GLuint64 avail = 0, ns = 0, sum = 0; + /* Queries complete in order: the last segment's result means all. */ + p_glGetQueryObjectui64v(s_rthf_q[i][s_rthf_nseg[i] - 1], GL_QUERY_RESULT_AVAILABLE, + &avail); if (!avail) break; - p_glGetQueryObjectui64v(s_rthf_q[i], GL_QUERY_RESULT, &ns); - rthf_publish(s_rthf_slot_cpu[i], (uint64_t)ns, 1); + for (int k = 0; k < s_rthf_nseg[i]; k++) { + ns = 0; + p_glGetQueryObjectui64v(s_rthf_q[i][k], GL_QUERY_RESULT, &ns); + sum += ns; + } + rthf_publish(s_rthf_slot_cpu[i], (uint64_t)sum, 1); s_rthf_tail++; } } @@ -9569,22 +9630,54 @@ static void rthf_begin(void) { s_rthf_qok = (p_glGenQueries && p_glBeginQuery && p_glEndQuery && p_glGetQueryObjectui64v && !(getenv("PSX_DYNRES_GPU_TIMER") && getenv("PSX_DYNRES_GPU_TIMER")[0] == '0')) ? 1 : 0; - if (s_rthf_qok) p_glGenQueries(RTHF_Q, s_rthf_q); + if (s_rthf_qok) p_glGenQueries(RTHF_Q * RTHF_SEG, &s_rthf_q[0][0]); } s_rthf_open = 1; s_rthf_taint = 0; + s_rthf_paused = 0; + s_rthf_excl = 0; s_rthf_t0 = host_now_ns_rthf(); s_rthf_idle0 = rt_render_idle_ns(); s_rthf_swap0 = s_rthf_swap_ns; s_rthf_has_q = s_rthf_qok && s_rthf_head - s_rthf_tail < RTHF_Q; if (s_rthf_has_q) { gl_perf_yield(); /* close frame_perf's open bracket, if any */ - p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[s_rthf_head % RTHF_Q]); + const unsigned i = s_rthf_head % RTHF_Q; + s_rthf_nseg[i] = 1; + p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[i][0]); + } +} + +/* Frame generation draws between a frame's records: its work is not the + * frame's. Pause ends the frame's GPU query and starts excluding wall time + * (and its swaps); resume opens the next query segment. */ +static void rthf_pause(void) { + if (!s_rthf_open || s_rthf_paused) return; + s_rthf_paused = 1; + s_rthf_pause_t0 = host_now_ns_rthf(); + s_rthf_pause_swap = s_rthf_swap_ns; + if (s_rthf_has_q) p_glEndQuery(GL_TIME_ELAPSED); +} +static void rthf_resume(void) { + if (!s_rthf_open || !s_rthf_paused) return; + s_rthf_paused = 0; + s_rthf_excl += host_now_ns_rthf() - s_rthf_pause_t0; + s_rthf_swap_ns = s_rthf_pause_swap; /* the generated frames' swaps */ + if (s_rthf_has_q) { + const unsigned i = s_rthf_head % RTHF_Q; + if (s_rthf_nseg[i] < RTHF_SEG) { + p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[i][s_rthf_nseg[i]++]); + } else { + /* Out of segments: the rest of the frame goes unmeasured. */ + s_rthf_taint = 1; + p_glBeginQuery(GL_TIME_ELAPSED, s_rthf_q[i][RTHF_SEG - 1]); + } } } static void rthf_end(void) { if (!s_rthf_open) return; + if (s_rthf_paused) rthf_resume(); s_rthf_open = 0; if (s_rthf_taint) { /* The query pair is still issued so the ring stays in order; the @@ -9600,7 +9693,8 @@ static void rthf_end(void) { const uint64_t idle = rt_render_idle_ns() - s_rthf_idle0; const uint64_t swap = s_rthf_swap_ns - s_rthf_swap0; const uint64_t span = now - s_rthf_t0; - const uint64_t cpu = span > idle + swap ? span - idle - swap : 0; + const uint64_t skip = idle + swap + s_rthf_excl; + const uint64_t cpu = span > skip ? span - skip : 0; if (s_rthf_has_q) { const unsigned i = s_rthf_head % RTHF_Q; p_glEndQuery(GL_TIME_ELAPSED); @@ -9614,7 +9708,8 @@ static void rthf_end(void) { void gl_renderer_render_thread_measure(int on) { atomic_store(&s_rthf_want, on ? 1 : 0); } static int rthf_owns_timer(void) { - return s_rth_on && atomic_load_explicit(&s_rthf_want, memory_order_relaxed) && s_rthf_qok > 0; + return s_rth_on && (atomic_load_explicit(&s_rthf_want, memory_order_relaxed) || s_fg_on) && + s_rthf_qok > 0; } void gl_renderer_render_thread_costs(GlRthCosts *out) { @@ -9627,6 +9722,1013 @@ void gl_renderer_render_thread_costs(GlRthCosts *out) { out->dropped = atomic_load(&s_rthf_dropped); } +/* ---- frame generation ([video] frame_generation) --------------------------- + * docs/FRAME_GENERATION.md. Render thread only. Each game frame's draw list + * (the replayed records between two display flips) is kept; with the last + * two, the render thread draws in-between frames at the display's refresh + * from data alone: the newer list redrawn into separate surfaces with each + * matched triangle moved part of the way back to where the older frame had + * it (frame_gen.c matches them). Unmatched, 2D and HUD primitives, fills and + * every other record draw as the newer frame has them. Nothing here writes + * the real surfaces, VRAM or guest state: the real frames are the same + * images, presented in the same order, one game frame later at most (the + * delay interpolation needs: the in-between frames before a real frame are + * shown in the time it would have been shown alone). */ +#include "frame_gen.h" + +/* Captured record: header + payload copy. */ +typedef struct { uint16_t op, flags; uint32_t bytes; } FgRec; +typedef struct { + int semi_en, semi_mode, mask_set, mask_check, tw[4], mod[4]; + int area[4], off[2], wide_on, wide_base, view[4], rths[3]; +} FgState; +typedef struct { + uint8_t *buf; size_t len, cap; + uint32_t *off; uint32_t n, ncap; /* record offsets into buf */ + FgPrimList prims; /* triangles, local coords */ + int32_t *rec2prim; uint32_t r2p_cap; /* record -> prim index, -1 */ + FgState start; /* draw state at the list start */ + int valid, wide, disp[4]; /* closed: display rect it shows */ + int wide_w, wide_off, scale, linear, force43; +} FgList; +/* Raw triangle captured while recording a list (absolute coords). */ +typedef struct { uint32_t rec, key0; float x[3], y[3]; int area[4]; } FgRaw; + +static int s_fg_on = 0; /* configured (any thread reads) */ +static int s_fg_force = 0; /* PSX_FRAME_GEN_FORCE: tests */ +static _Atomic double s_fg_refresh_hz = 0.0, s_fg_guest_hz = 59.94; +static _Atomic int s_fg_late = 0; /* emulation thread: a late frame */ +static const char *_Atomic s_fg_hold_reason = NULL; +static _Atomic uint64_t s_fg_hold_until = 0; /* rt_now_ns: no generation before */ +static uint64_t s_fg_emu_last_ns = 0; /* emulation thread */ +static FgList s_fg_l[4]; /* older, newer, capturing, spare */ +static int s_fg_older = 0, s_fg_newer = 1, s_fg_cur = 2; +static int s_fg_pa = -1, s_fg_pb = -1; /* the pair a schedule draws from */ +static FgRaw *s_fg_raw = NULL; static uint32_t s_fg_nraw = 0, s_fg_rawcap = 0; +static int s_fg_pc_valid = 0; static int32_t s_fg_pc[6]; /* pending PRECISE */ +static int s_fg_have_last = 0, s_fg_last_dx = 0, s_fg_last_dy = 0; +/* Display buffers seen in presents (x, y, w, h), and the one the capturing + * list draws into: a list is one game frame's drawing into one buffer, closed + * when drawing moves to another buffer (games flip at a VBlank after they + * start the next frame, so a flip is not a list boundary). */ +static int s_fg_buf[4][4], s_fg_nbuf = 0, s_fg_cur_buf = -1; +static int s_fg_vblanks = 0, s_fg_flip_vb = 1; /* presents per flip */ +static int32_t *s_fg_match = NULL; static uint32_t s_fg_match_cap = 0; +static FgMatchStats s_fg_mst; +static FgBreaker s_fg_brk; +static int s_fg_brk_init = 0; +static uint64_t s_fg_bp_seen = 0; +static double s_fg_real_ema = 0.0, s_fg_gen_ema = 0.0; /* seconds */ +static const char *s_fg_hold_why = NULL; +static double s_fg_gen_gpu_ms = 0.0, s_fg_gen_cpu_ms = 0.0; /* the last one measured */ +/* Schedule after a flip: n generated frames, then the real one. */ +static int s_fg_pending = 0, s_fg_n = 0, s_fg_k = 0; +static uint64_t s_fg_t0 = 0, s_fg_step_ns = 0; +static uint16_t s_fg_real_op = 0; +static uint8_t *s_fg_real_p = NULL; static size_t s_fg_real_cap = 0; +/* Generated-frame surfaces (the displayed buffer's hr rect and wide rows). */ +static GLuint s_fg_hr_tex = 0, s_fg_hr_rb = 0, s_fg_hr_fbo = 0; +static int s_fg_hr_S = 0; +static GLuint s_fg_w_tex = 0, s_fg_w_rb = 0, s_fg_w_fbo = 0; +static int s_fg_w_S = 0, s_fg_w_w = 0; +static int s_fg_drawing = 0, s_fg_presenting = 0; /* generated draws / swap */ +static int s_fg_broken = 0; /* the context was handed away */ +static int s_fg_capture_real = 0; /* gl_swap_with_osd keeps the image */ +static GLuint s_fg_real_tex = 0, s_fg_real_fbo = 0; +static int s_fg_real_w = 0, s_fg_real_h = 0, s_fg_real_kept = 0; +static int s_fg_partial = 0; /* the capturing list is incomplete */ +static GLuint s_fg_q[4]; static int s_fg_qok = -1; static unsigned s_fg_qh = 0, s_fg_qt = 0; +static uint64_t s_fg_q_cpu[4]; +/* Statistics (render thread writes; the debug server reads, racy by design). */ +static uint64_t s_fg_generated = 0, s_fg_real_presents = 0, s_fg_flips = 0, + s_fg_flushed = 0, s_fg_skipped_plan = 0, s_fg_dups = 0; +static int s_fg_last_n = 0, s_fg_last_slots = 0; +static double s_fg_last_match_ms = 0.0; + +static double fg_now_s(void) { return (double)rt_now_ns() * 1e-9; } + +/* Render thread: every swap's wall time. A swap waits for the compositor + * (and on a busy WindowServer for its round trip); each generated frame adds + * one, so the plan counts it. */ +static double s_fg_swap_ema = 0.0; +static void fg_note_swap(uint64_t ns) { + const double c = (double)ns * 1e-9; + s_fg_swap_ema = s_fg_swap_ema > 0.0 ? s_fg_swap_ema * 0.9 + c * 0.1 : c; +} + +static void fg_note_real_cost(uint64_t cost_ns) { + const double c = (double)cost_ns * 1e-9; + s_fg_real_ema = s_fg_real_ema > 0.0 ? s_fg_real_ema * 0.9 + c * 0.1 : c; +} + +static void fg_list_clear(FgList *l) { + l->len = 0; l->n = 0; l->valid = 0; + fg_prims_reset(&l->prims); +} + +static void fg_state_capture(FgState *st) { + st->semi_en = s_semi_en; st->semi_mode = s_semi_mode; + st->mask_set = s_mask_set; st->mask_check = s_mask_check; + st->tw[0] = s_tw_mask_x; st->tw[1] = s_tw_mask_y; st->tw[2] = s_tw_off_x; st->tw[3] = s_tw_off_y; + st->mod[0] = s_mod_r; st->mod[1] = s_mod_g; st->mod[2] = s_mod_b; st->mod[3] = s_mod_raw; + st->area[0] = s_area_x1; st->area[1] = s_area_y1; st->area[2] = s_area_x2; st->area[3] = s_area_y2; + st->off[0] = s_off_x; st->off[1] = s_off_y; + st->wide_on = g_wide_cur != 0; st->wide_base = g_wide_cur_base; + st->view[0] = view_enabled; st->view[1] = view_shift; + st->view[2] = view_pad_left; st->view[3] = view_pad_right; + st->rths[0] = s_rths_flat_bd; st->rths[1] = s_rths_vp_w; st->rths[2] = s_rths_bg_full; +} + +/* Restore draw state without the setters' side effects (stencil rebuilds, + * wide-surface allocation); batches were flushed by the caller. */ +static void fg_state_apply(const FgState *st, GLuint wide_cur) { + s_semi_en = st->semi_en; s_semi_mode = st->semi_mode; + s_mask_set = st->mask_set; s_mask_check = st->mask_check; + s_tw_mask_x = st->tw[0]; s_tw_mask_y = st->tw[1]; s_tw_off_x = st->tw[2]; s_tw_off_y = st->tw[3]; + s_mod_r = st->mod[0]; s_mod_g = st->mod[1]; s_mod_b = st->mod[2]; s_mod_raw = st->mod[3]; + s_area_x1 = st->area[0]; s_area_y1 = st->area[1]; s_area_x2 = st->area[2]; s_area_y2 = st->area[3]; + s_off_x = st->off[0]; s_off_y = st->off[1]; + g_wide_cur = wide_cur; g_wide_cur_base = st->wide_base; + view_enabled = st->view[0]; view_shift = st->view[1]; + view_pad_left = st->view[2]; view_pad_right = st->view[3]; + s_rths_flat_bd = st->rths[0]; s_rths_vp_w = st->rths[1]; s_rths_bg_full = st->rths[2]; + s_pc_valid = 0; s_pq_valid = 0; s_projected_uv_valid = 0; +} + +static int fg_list_append(FgList *l, uint16_t op, uint16_t flags, const void *p, uint32_t bytes) { + size_t need = l->len + sizeof(FgRec) + ((bytes + 3u) & ~3u); + if (need > l->cap) { + size_t nc = l->cap ? l->cap : (size_t)1 << 20; + while (nc < need) nc *= 2; + uint8_t *nb = (uint8_t *)realloc(l->buf, nc); + if (!nb) return -1; + l->buf = nb; l->cap = nc; + } + if (l->n == l->ncap) { + uint32_t nc = l->ncap ? l->ncap * 2u : 8192u; + uint32_t *no = (uint32_t *)realloc(l->off, (size_t)nc * sizeof *no); + if (!no) return -1; + l->off = no; l->ncap = nc; + } + FgRec h = { op, flags, bytes }; + memcpy(l->buf + l->len, &h, sizeof h); + if (bytes) memcpy(l->buf + l->len + sizeof h, p, bytes); + l->off[l->n] = (uint32_t)l->len; + l->len = need; + return (int)l->n++; +} + +static int fg_raw_add(const FgRaw *r) { + if (s_fg_nraw == s_fg_rawcap) { + uint32_t nc = s_fg_rawcap ? s_fg_rawcap * 2u : 4096u; + FgRaw *nr = (FgRaw *)realloc(s_fg_raw, (size_t)nc * sizeof *nr); + if (!nr) return 0; + s_fg_raw = nr; s_fg_rawcap = nc; + } + s_fg_raw[s_fg_nraw++] = *r; + return 1; +} + +/* Which records a generated frame redraws: draw state and drawing, never + * transfers, copies, presents, steps or diagnostics. */ +static int fg_op_kept(uint16_t op) { + switch (op) { + case RTH_SEMI: case RTH_MASK: case RTH_TWIN: case RTH_MOD: case RTH_PRECISE: + case RTH_PERSP: case RTH_FILL: case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: + case RTH_TEX_TRI: case RTH_SHADED_TEX_TRI: case RTH_FLAT_RECT: case RTH_TEX_RECT: + case RTH_TEX_RECT_SCALED: case RTH_LINE: case RTH_SHADED_LINE: case RTH_AREA: + case RTH_OFFSET: case RTH_WIDE_VIEW: case RTH_WIDE_TARGET: case RTH_WIDE_DISABLE: + case RTH_WIDE_CLEAR: case RTH_WIDE_CLEAR_MARGINS: case RTH_PROJ_TRI: case RTH_STATE: + return 1; + default: + return 0; + } +} + +/* Positions of a triangle record (absolute native px) and its key words. */ +static int fg_tri_geom(uint16_t op, const void *payload, const int32_t *pc, + float x[3], float y[3], uint32_t *key) { + const int32_t *v = (const int32_t *)payload; + int xi[3], k = 0; + int32_t kw[12]; + switch (op) { + case RTH_FLAT_TRI: + for (int i = 0; i < 3; i++) { x[i] = (float)v[2 * i]; y[i] = (float)v[2 * i + 1]; } + kw[k++] = op; kw[k++] = v[6]; + break; + case RTH_GOURAUD_TRI: + for (int i = 0; i < 3; i++) { x[i] = (float)v[3 * i]; y[i] = (float)v[3 * i + 1]; } + kw[k++] = op; + break; + case RTH_TEX_TRI: + for (int i = 0; i < 3; i++) { + x[i] = (float)v[4 * i]; y[i] = (float)v[4 * i + 1]; + kw[k++] = v[4 * i + 2] | (v[4 * i + 3] << 16); + } + kw[k++] = op; kw[k++] = v[12]; kw[k++] = v[13]; kw[k++] = v[14]; + break; + case RTH_SHADED_TEX_TRI: + for (int i = 0; i < 3; i++) { + x[i] = (float)v[5 * i]; y[i] = (float)v[5 * i + 1]; + kw[k++] = v[5 * i + 2] | (v[5 * i + 3] << 16); + } + kw[k++] = op; kw[k++] = v[15]; kw[k++] = v[16]; kw[k++] = v[17]; kw[k++] = v[18]; + break; + case RTH_PROJ_TRI: { + PSXProjectedVertex vx[3]; + memcpy(vx, payload, sizeof vx); + const int32_t *a = (const int32_t *)((const uint8_t *)payload + sizeof vx); + for (int i = 0; i < 3; i++) { + x[i] = (float)vx[i].x; y[i] = (float)vx[i].y; + kw[k++] = (int32_t)floor(vx[i].u * 16.0) | ((int32_t)floor(vx[i].v * 16.0) << 16); + } + kw[k++] = op; kw[k++] = a[0]; kw[k++] = a[1]; kw[k++] = a[2]; kw[k++] = a[3]; + pc = NULL; + break; + } + default: + return 0; + } + (void)xi; + if (pc) /* a sub-pixel position set for this triangle (16.16) */ + for (int i = 0; i < 3; i++) { + x[i] = (float)pc[2 * i] / 65536.0f; + y[i] = (float)pc[2 * i + 1] / 65536.0f; + } + *key = fg_hash(FG_HASH_INIT, kw, k); + return 1; +} + +static int fg_buf_of(int x, int y) { + for (int i = 0; i < s_fg_nbuf; i++) + if (x >= s_fg_buf[i][0] && x < s_fg_buf[i][0] + s_fg_buf[i][2] && + y >= s_fg_buf[i][1] && y < s_fg_buf[i][1] + s_fg_buf[i][3]) return i; + return -1; +} +static void fg_list_close(FgList *l, const int disp[4]); +static void fg_list_open(void); + +/* Close the capturing list as the frame of its buffer; it becomes the newer + * frame, the newer one the older. */ +static void fg_rotate(void) { + fg_list_close(&s_fg_l[s_fg_cur], s_fg_buf[s_fg_cur_buf]); + if (s_fg_partial) { s_fg_l[s_fg_cur].valid = 0; s_fg_partial = 0; } + s_fg_older = s_fg_newer; s_fg_newer = s_fg_cur; + /* A slot no frame in use holds (a schedule pins its pair). */ + int free_slot = -1; + for (int k = 0; k < 4 && free_slot < 0; k++) + if (k != s_fg_older && k != s_fg_newer && + !(s_fg_pending && (k == s_fg_pa || k == s_fg_pb))) free_slot = k; + if (free_slot < 0) { + fg_flush(); + for (int k = 0; k < 4 && free_slot < 0; k++) + if (k != s_fg_older && k != s_fg_newer) free_slot = k; + } + s_fg_cur = free_slot; + fg_list_open(); +} + +/* Called for every replayed record (gl_rth_exec), before it executes. */ +static void fg_capture(const RtCmd *c, const void *payload) { + if (!fg_op_kept(c->op)) return; + /* Which buffer a draw goes to: a fill's rect, else the draw area. */ + int tb = -1; + switch (c->op) { + case RTH_FILL: { + const int32_t *v = (const int32_t *)payload; + tb = fg_buf_of(v[0], v[1]); + break; + } + case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: case RTH_SHADED_TEX_TRI: + case RTH_PROJ_TRI: case RTH_FLAT_RECT: case RTH_TEX_RECT: case RTH_TEX_RECT_SCALED: + case RTH_LINE: case RTH_SHADED_LINE: + tb = fg_buf_of(s_area_x1, s_area_y1); + break; + default: break; + } + if (tb >= 0 && tb != s_fg_cur_buf) { + if (s_fg_cur_buf >= 0) { + fg_rotate(); /* drawing moved on: the list so far is that buffer's frame */ + } else { + /* The first buffer seen: what was captured belongs to no frame. */ + fg_list_open(); + s_fg_partial = 0; + } + s_fg_cur_buf = tb; + } + FgList *l = &s_fg_l[s_fg_cur]; + int r = fg_list_append(l, c->op, c->flags, payload, c->payload); + if (r < 0) return; + if (c->op == RTH_PRECISE) { + const int32_t *v = (const int32_t *)payload; + s_fg_pc_valid = v[0] != 0; + if (s_fg_pc_valid) memcpy(s_fg_pc, v + 1, sizeof s_fg_pc); + return; + } + FgRaw raw; + if (fg_tri_geom(c->op, payload, s_fg_pc_valid ? s_fg_pc : NULL, raw.x, raw.y, &raw.key0)) { + raw.rec = (uint32_t)r; + raw.area[0] = s_area_x1; raw.area[1] = s_area_y1; + raw.area[2] = s_area_x2; raw.area[3] = s_area_y2; + (void)fg_raw_add(&raw); + } + switch (c->op) { /* a precise override describes one triangle */ + case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: + case RTH_SHADED_TEX_TRI: case RTH_PROJ_TRI: + s_fg_pc_valid = 0; + break; + default: break; + } +} + +static int fg_area_meets(const int *area, const int *disp) { + return area[2] >= disp[0] && area[0] < disp[0] + disp[2] && + area[3] >= disp[1] && area[1] < disp[1] + disp[3]; +} + +/* Close the capturing list as the frame of buffer disp (x, y, w, h). */ +static void fg_list_close(FgList *l, const int disp[4]) { + l->valid = 1; + memcpy(l->disp, disp, sizeof l->disp); + l->wide_w = g_wide_w; l->wide_off = g_wide_off; l->scale = s_out_scale; + if (l->r2p_cap < l->n) { + int32_t *nr = (int32_t *)realloc(l->rec2prim, (size_t)l->n * sizeof *nr); + if (!nr) { l->valid = 0; return; } + l->rec2prim = nr; l->r2p_cap = l->n; + } + for (uint32_t i = 0; i < l->n; i++) l->rec2prim[i] = -1; + fg_prims_reset(&l->prims); + for (uint32_t i = 0; i < s_fg_nraw; i++) { + const FgRaw *r = &s_fg_raw[i]; + if (!fg_area_meets(r->area, disp)) continue; + FgPrim p; + p.rec = r->rec; + const int32_t aw[4] = { r->area[0] - disp[0], r->area[1] - disp[1], + r->area[2] - disp[0], r->area[3] - disp[1] }; + p.key = fg_hash(r->key0, aw, 4); + for (int k = 0; k < 3; k++) { p.x[k] = r->x[k] - (float)disp[0]; p.y[k] = r->y[k] - (float)disp[1]; } + if (!fg_prims_add(&l->prims, &p)) { l->valid = 0; return; } + l->rec2prim[r->rec] = (int32_t)(l->prims.n - 1); + } +} + +static void fg_list_open(void) { + FgList *l = &s_fg_l[s_fg_cur]; + fg_list_clear(l); + fg_state_capture(&l->start); + s_fg_nraw = 0; + s_fg_pc_valid = 0; +} + +static void fg_invalidate(void) { + for (int i = 0; i < 4; i++) s_fg_l[i].valid = 0; + s_fg_have_last = 0; + s_fg_cur_buf = -1; + s_fg_partial = 0; + s_fg_broken = 0; + fg_list_open(); +} + +/* Surfaces for the generated image, at the current level. */ +static int fg_surfaces_ensure(int wide) { + const int S = s_hr_scale; + if (s_fg_hr_S != S) { + if (s_fg_hr_fbo) { + p_glDeleteFramebuffers(1, &s_fg_hr_fbo); glDeleteTextures(1, &s_fg_hr_tex); + p_glDeleteRenderbuffers(1, &s_fg_hr_rb); + s_fg_hr_fbo = s_fg_hr_tex = s_fg_hr_rb = 0; + } + s_fg_hr_S = 0; + if (!hr_alloc_surface(S, &s_fg_hr_tex, &s_fg_hr_rb, &s_fg_hr_fbo)) return 0; + s_fg_hr_S = S; + } + if (wide && (s_fg_w_S != S || s_fg_w_w != g_wide_w)) { + if (s_fg_w_fbo) { + p_glDeleteFramebuffers(1, &s_fg_w_fbo); glDeleteTextures(1, &s_fg_w_tex); + p_glDeleteRenderbuffers(1, &s_fg_w_rb); + s_fg_w_fbo = s_fg_w_tex = s_fg_w_rb = 0; + } + s_fg_w_S = 0; + if (!wide_alloc_surface(S, &s_fg_w_tex, &s_fg_w_rb, &s_fg_w_fbo)) return 0; + s_fg_w_S = S; s_fg_w_w = g_wide_w; + } + return 1; +} + +static void fg_surfaces_free(void) { + if (s_fg_hr_fbo) { p_glDeleteFramebuffers(1, &s_fg_hr_fbo); glDeleteTextures(1, &s_fg_hr_tex); + p_glDeleteRenderbuffers(1, &s_fg_hr_rb); } + if (s_fg_w_fbo) { p_glDeleteFramebuffers(1, &s_fg_w_fbo); glDeleteTextures(1, &s_fg_w_tex); + p_glDeleteRenderbuffers(1, &s_fg_w_rb); } + s_fg_hr_fbo = s_fg_hr_tex = s_fg_hr_rb = 0; s_fg_hr_S = 0; + s_fg_w_fbo = s_fg_w_tex = s_fg_w_rb = 0; s_fg_w_S = 0; s_fg_w_w = 0; + if (s_fg_real_fbo) { p_glDeleteFramebuffers(1, &s_fg_real_fbo); glDeleteTextures(1, &s_fg_real_tex); } + s_fg_real_fbo = s_fg_real_tex = 0; s_fg_real_w = s_fg_real_h = 0; s_fg_real_kept = 0; +} + +static void fg_blit(GLuint src, GLuint dst, int x, int y, int w, int h) { + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, src); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, dst); + glDisable(GL_SCISSOR_TEST); + glStencilMask(0xFF); + p_glBlitFramebuffer(x, y, x + w, y + h, x, y, x + w, y + h, + GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); +} + +static void fg_draw_tri_at(uint16_t op, const int32_t *v, const void *payload, + const float x[3], const float y[3]) { + int32_t w[19]; + int xi[3], yi[3], integral = 1; + for (int i = 0; i < 3; i++) { + xi[i] = (int)floorf(x[i] + 0.5f); yi[i] = (int)floorf(y[i] + 0.5f); + if ((float)xi[i] != x[i] || (float)yi[i] != y[i]) integral = 0; + } + if (op == RTH_PROJ_TRI) { + PSXProjectedVertex vx[3]; + memcpy(vx, payload, sizeof vx); + const int32_t *a = (const int32_t *)((const uint8_t *)payload + sizeof vx); + for (int i = 0; i < 3; i++) { vx[i].x = x[i]; vx[i].y = y[i]; } + gl_renderer_draw_projected_triangle(vx, (uint16_t)a[0], (uint16_t)a[1], (uint16_t)a[2], + a[3], a[4], a[5]); + return; + } + if (!integral) { + glb_set_precise_triangle(1, (int32_t)lrintf(x[0] * 65536.0f), (int32_t)lrintf(y[0] * 65536.0f), + (int32_t)lrintf(x[1] * 65536.0f), (int32_t)lrintf(y[1] * 65536.0f), + (int32_t)lrintf(x[2] * 65536.0f), (int32_t)lrintf(y[2] * 65536.0f)); + } + switch (op) { + case RTH_FLAT_TRI: + glb_draw_flat_triangle(xi[0], yi[0], xi[1], yi[1], xi[2], yi[2], (uint16_t)v[6]); + break; + case RTH_GOURAUD_TRI: + glb_draw_gouraud_triangle(xi[0], yi[0], (uint16_t)v[2], xi[1], yi[1], (uint16_t)v[5], + xi[2], yi[2], (uint16_t)v[8]); + break; + case RTH_TEX_TRI: + memcpy(w, v, 15 * sizeof *w); + for (int i = 0; i < 3; i++) { w[4 * i] = xi[i]; w[4 * i + 1] = yi[i]; } + glb_draw_textured_triangle(w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], + w[8], w[9], w[10], w[11], + (uint16_t)w[12], (uint16_t)w[13], (uint16_t)w[14]); + break; + case RTH_SHADED_TEX_TRI: + memcpy(w, v, 19 * sizeof *w); + for (int i = 0; i < 3; i++) { w[5 * i] = xi[i]; w[5 * i + 1] = yi[i]; } + glb_draw_shaded_textured_triangle(w[0], w[1], w[2], w[3], (uint32_t)w[4], + w[5], w[6], w[7], w[8], (uint32_t)w[9], + w[10], w[11], w[12], w[13], (uint32_t)w[14], + (uint16_t)w[15], (uint16_t)w[16], (uint16_t)w[17], w[18]); + break; + default: break; + } +} + +static int fg_rect_meets(int x, int y, int w, int h, const int *disp) { + return x < disp[0] + disp[2] && x + w > disp[0] && y < disp[1] + disp[3] && y + h > disp[1]; +} + +/* Redraw list b at phase t (0 = a's positions, 1 = b's) into the surfaces + * currently bound as hr / the displayed wide surface. */ +static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wide) { + const int *disp = b->disp; + const float ddx = (float)(b->disp[0]), ddy = (float)(b->disp[1]); + fg_state_apply(&b->start, (b->start.wide_on && b->start.wide_base == disp[0]) ? gen_wide : 0); + int pc_pending = 0; int32_t pc[7]; + for (uint32_t r = 0; r < b->n; r++) { + FgRec h; + memcpy(&h, b->buf + b->off[r], sizeof h); + const void *pl = b->buf + b->off[r] + sizeof h; + const int32_t *v = (const int32_t *)pl; + s_rthx_prim = (h.flags & RTHF_PRIM) != 0; + s_rthx_tagged = (h.flags & RTHF_TAGGED) != 0; + s_rthx_backdrop = (h.flags >> RTHF_BD_SHIFT) & 3; + const int area[4] = { s_area_x1, s_area_y1, s_area_x2, s_area_y2 }; + const int in = fg_area_meets(area, disp); + switch (h.op) { + case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; + case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; + case RTH_TWIN: glb_set_texture_window((uint32_t)v[0]); break; + case RTH_MOD: glb_set_color_modulation(v[0], v[1], v[2], v[3]); break; + case RTH_PRECISE: pc_pending = v[0] != 0; memcpy(pc, v, sizeof pc); break; + case RTH_PERSP: { + float q[3]; + memcpy(q, &v[1], sizeof q); + glb_set_perspective_triangle(v[0], q[0], q[1], q[2]); + break; + } + case RTH_AREA: glb_set_draw_area(v[0], v[1], v[2], v[3]); break; + case RTH_OFFSET: glb_set_draw_offset(v[0], v[1]); break; + case RTH_STATE: s_rths_flat_bd = v[0]; s_rths_vp_w = v[1]; s_rths_bg_full = v[2]; break; + case RTH_WIDE_VIEW: glb_wide_set_view(v[0], v[1], v[2], v[3]); break; + case RTH_WIDE_TARGET: + flush_flat_batch(); flush_tex_batch(); + g_wide_cur = (v[0] == disp[0]) ? gen_wide : 0; + g_wide_cur_base = v[0]; + break; + case RTH_WIDE_DISABLE: glb_wide_disable_target(); break; + case RTH_WIDE_CLEAR: + if (v[0] == disp[0] && gen_wide) glb_wide_clear(v[0], v[1], v[2], (uint16_t)v[3]); + break; + case RTH_WIDE_CLEAR_MARGINS: + if (v[0] == disp[0] && gen_wide) + glb_wide_clear_margins(v[0], v[1], v[2], (uint16_t)v[3], v[4]); + break; + case RTH_FILL: + if (fg_rect_meets(v[0], v[1], v[2], v[3], disp)) + glb_fill_rect(v[0], v[1], v[2], v[3], (uint16_t)v[4]); + break; + case RTH_FLAT_RECT: + if (in) glb_draw_flat_rect(v[0], v[1], v[2], v[3], (uint16_t)v[4]); + break; + case RTH_TEX_RECT: + if (in) glb_draw_textured_rect(v[0], v[1], v[2], v[3], v[4], v[5], + (uint16_t)v[6], (uint16_t)v[7], (uint16_t)v[8]); + break; + case RTH_TEX_RECT_SCALED: + if (in) glb_draw_textured_rect_scaled(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], + (uint16_t)v[8], (uint16_t)v[9], (uint16_t)v[10]); + break; + case RTH_LINE: + if (in) glb_draw_line(v[0], v[1], v[2], v[3], (uint16_t)v[4]); + break; + case RTH_SHADED_LINE: + if (in) glb_draw_shaded_line(v[0], v[1], (uint16_t)v[2], v[3], v[4], (uint16_t)v[5]); + break; + case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: + case RTH_SHADED_TEX_TRI: case RTH_PROJ_TRI: { + const int pi = b->rec2prim ? b->rec2prim[r] : -1; + if (pi < 0) { /* outside the display: drawn into VRAM already */ + pc_pending = 0; + break; + } + const int32_t ai = s_fg_match ? s_fg_match[pi] : -1; + const FgPrim *pb = &b->prims.v[pi]; + float x[3], y[3]; + if (ai >= 0 && t < 1.0) { + fg_lerp(&a->prims.v[ai], pb, t, x, y); + } else { + for (int k = 0; k < 3; k++) { x[k] = pb->x[k]; y[k] = pb->y[k]; } + } + for (int k = 0; k < 3; k++) { x[k] += ddx; y[k] += ddy; } + fg_draw_tri_at(h.op, v, pl, x, y); + precise_consumed(); + pc_pending = 0; + break; + } + default: break; + } + (void)pc_pending; + } + s_rthx_prim = 0; + flush_line_batch(); + flush_flat_batch(); + flush_tex_batch(); + hiw_flush_queue(); +} + +/* The generated image composed like a real present (no hold-last, no + * interpolation capture, no present bookkeeping), then swapped. */ +static void fg_compose(const FgList *b, GLuint wide_fbo, GLuint wide_tex, int linear, + int force_4_3) { + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); + int lx, ly, lw, lh; + if (b->wide) { + letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); + wide_blit_center(wide_fbo, b->disp[0], b->disp[1], b->disp[3]); + } else if (force_4_3) { + letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); + } else { + letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); + } + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, ww, wh); + if (lx != 0 || ly != 0 || lw != ww || lh != wh) { + glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); + } + if (b->wide) { + present_target_quad(wide_tex, (float)g_wide_w, (float)VRAM_H, 0, b->disp[1], g_wide_w, + b->disp[3], linear, lx, ly, lw, lh, 1, 1, s_out_scale); + } else { + present_bezel(ww, wh, lx, ly, lw, lh); + present_target_quad(s_hr_tex, (float)VRAM_W, (float)VRAM_H, b->disp[0], b->disp[1], + b->disp[2], b->disp[3], linear, lx, ly, lw, lh, 1, 1, s_out_scale); + } +} + +/* Draw and present the frame at phase t between the two lists. Returns 1 + * when a frame was presented. `swap` 0 leaves the composed image in the + * back buffer (tests). */ +static int fg_generate(double t, int swap) { + if (s_fg_pa < 0 || s_fg_pb < 0) return 0; + FgList *a = &s_fg_l[s_fg_pa], *b = &s_fg_l[s_fg_pb]; + if (!a->valid || !b->valid || s_hiw) return 0; + if (b->wide && (g_wide_w <= 0 || g_wide_w != b->wide_w)) return 0; + int iw = -1; + if (b->wide) { + for (int i = 0; i < WIDE_MAX_SURF; i++) + if (s_wide_fbo[i] && s_wide_base[i] == b->disp[0]) { iw = i; break; } + if (iw < 0) return 0; + } + /* Land the real stream's queued work on the real surfaces first. */ + flush_line_batch(); + flush_flat_batch(); + flush_tex_batch(); + flush_cpu_upload(); + hiw_flush_queue(); + pack_flush(); + if (!fg_surfaces_ensure(b->wide)) return 0; + const int S = s_hr_scale; + /* Real bookkeeping the generated draws would touch. */ + FgState real; + fg_state_capture(&real); + const GLuint real_wide_cur = g_wide_cur; + const int real_pc = s_pc_valid, real_pq = s_pq_valid; + DirtyRect pack = s_pack_dirty, sten = s_stencil_stale, cpu = s_cpu_dirty; + const int sten_valid = s_stencil_valid, gpu_dirty = s_gpu_dirty; + uint64_t pres_dirty[PRES_ROWS]; + memcpy(pres_dirty, s_present_dirty, sizeof pres_dirty); + int wst[4] = { 0, 0, 0, 0 }; + if (iw >= 0) { wst[0] = s_wst_x0[iw]; wst[1] = s_wst_y0[iw]; wst[2] = s_wst_x1[iw]; wst[3] = s_wst_y1[iw]; } + /* The displayed buffer as the real frame has it, then swap the surfaces. */ + const int dy = b->disp[1] < 0 ? 0 : b->disp[1]; + const int dh = b->disp[1] + b->disp[3] > VRAM_H ? VRAM_H - dy : b->disp[1] + b->disp[3] - dy; + fg_blit(s_hr_fbo, s_fg_hr_fbo, b->disp[0] * S, dy * S, b->disp[2] * S, dh * S); + if (iw >= 0) fg_blit(s_wide_fbo[iw], s_fg_w_fbo, 0, dy * S, g_wide_w * S, dh * S); + GLuint t0 = s_hr_tex, f0 = s_hr_fbo, r0 = s_hr_rb; + s_hr_tex = s_fg_hr_tex; s_hr_fbo = s_fg_hr_fbo; s_hr_rb = s_fg_hr_rb; + GLuint wt = 0, wf = 0, wr = 0; int was = 0; + if (iw >= 0) { + wt = s_wide_tex[iw]; wf = s_wide_fbo[iw]; wr = s_wide_rb[iw]; was = s_wide_as[iw]; + s_wide_tex[iw] = s_fg_w_tex; s_wide_fbo[iw] = s_fg_w_fbo; s_wide_rb[iw] = s_fg_w_rb; + s_wide_as[iw] = S; + } + s_fg_drawing = 1; + fg_replay(a, b, t, iw >= 0 ? s_fg_w_fbo : 0); + fg_compose(b, iw >= 0 ? s_fg_w_fbo : 0, iw >= 0 ? s_fg_w_tex : 0, b->linear, b->force43); + s_fg_drawing = 0; + if (swap) { + s_fg_presenting = 1; + gl_swap_with_osd(); + s_fg_presenting = 0; + } + /* Back to the real surfaces and state. */ + s_hr_tex = t0; s_hr_fbo = f0; s_hr_rb = r0; + if (iw >= 0) { + s_wide_tex[iw] = wt; s_wide_fbo[iw] = wf; s_wide_rb[iw] = wr; s_wide_as[iw] = was; + s_wst_x0[iw] = wst[0]; s_wst_y0[iw] = wst[1]; s_wst_x1[iw] = wst[2]; s_wst_y1[iw] = wst[3]; + } + fg_state_apply(&real, real_wide_cur); + s_pc_valid = real_pc; s_pq_valid = real_pq; + s_pack_dirty = pack; s_stencil_stale = sten; s_cpu_dirty = cpu; + s_stencil_valid = sten_valid; s_gpu_dirty = gpu_dirty; + memcpy(s_present_dirty, pres_dirty, sizeof pres_dirty); + sw_set_semi_transparency(s_semi_en, s_semi_mode); + sw_set_mask_bits(s_mask_set, s_mask_check); + sw_set_draw_area(s_area_x1, s_area_y1, s_area_x2, s_area_y2); + sw_set_draw_offset(s_off_x, s_off_y); + sw_set_color_modulation(s_mod_r, s_mod_g, s_mod_b, s_mod_raw); + sw_set_texture_window((uint32_t)(s_tw_mask_x | (s_tw_mask_y << 5) | (s_tw_off_x << 10) | + (s_tw_off_y << 15))); + return 1; +} + +/* ---- frame generation: schedule (render thread) ---- */ +static void rth_present_payload(uint16_t op, const uint8_t *p); /* below */ + +static void fg_trip(const char *why) { + if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 3.0, 24.0, 10.0); s_fg_brk_init = 1; } + fg_breaker_trip(&s_fg_brk, fg_now_s(), why); +} + +static void fg_gen_cost_poll(void) { + while (s_fg_qt != s_fg_qh) { + const unsigned i = s_fg_qt % 4u; + GLuint64 avail = 0, ns = 0; + p_glGetQueryObjectui64v(s_fg_q[i], GL_QUERY_RESULT_AVAILABLE, &avail); + if (!avail) break; + p_glGetQueryObjectui64v(s_fg_q[i], GL_QUERY_RESULT, &ns); + double c = (double)(ns > s_fg_q_cpu[i] ? ns : s_fg_q_cpu[i]) * 1e-9; + s_fg_gen_ema = s_fg_gen_ema > 0.0 ? s_fg_gen_ema * 0.8 + c * 0.2 : c; + s_fg_gen_gpu_ms = (double)ns * 1e-6; + s_fg_gen_cpu_ms = (double)s_fg_q_cpu[i] * 1e-6; + s_fg_qt++; + } +} + +/* One generated frame at phase t, timed (GPU query, CPU wall minus swap). */ +static void fg_generate_timed(double t) { + rthf_pause(); + /* (Re)allocation at a new level is not the generated frame's cost. */ + if (s_fg_pb >= 0) (void)fg_surfaces_ensure(s_fg_l[s_fg_pb].wide); + if (s_fg_qok < 0) { + s_fg_qok = (p_glGenQueries && p_glBeginQuery && p_glEndQuery && p_glGetQueryObjectui64v) ? 1 : 0; + if (s_fg_qok) p_glGenQueries(4, s_fg_q); + } + const int q = s_fg_qok > 0 && s_fg_qh - s_fg_qt < 4u; + if (q) { gl_perf_yield(); p_glBeginQuery(GL_TIME_ELAPSED, s_fg_q[s_fg_qh % 4u]); } + const uint64_t c0 = host_now_ns_rthf(), sw0 = s_rthf_swap_ns; + /* The overlay of the real frame these lead up to. */ + if (s_fg_real_p) { + RthOvHdr hd; + memcpy(&hd, s_fg_real_p, sizeof hd); + const uint8_t *qq = s_fg_real_p + sizeof hd; + memset(&s_rth_ov, 0, sizeof s_rth_ov); + s_rth_ov.valid = 1; + s_rth_ov.needs_present = hd.needs_present; + s_rth_ov.slide = hd.slide; + for (int i = 0; i < 4; i++) + if (hd.has & (1 << i)) { + s_rth_ov.px[i] = (const uint32_t *)qq; + s_rth_ov.w[i] = hd.w[i]; s_rth_ov.h[i] = hd.h[i]; + qq += (size_t)hd.w[i] * hd.h[i] * 4u; + } + } + /* Timed without the swap: it waits for the compositor, not for work. */ + const int ok = fg_generate(t, 0); + const uint64_t cpu = host_now_ns_rthf() - c0 - (s_rthf_swap_ns - sw0); + if (q) { + p_glEndQuery(GL_TIME_ELAPSED); + s_fg_q_cpu[s_fg_qh % 4u] = cpu; + s_fg_qh++; + } + if (ok) { + s_fg_presenting = 1; + gl_swap_with_osd(); + s_fg_presenting = 0; + } + s_rth_ov.valid = 0; + if (ok) s_fg_generated++; + rthf_resume(); +} + +/* Keep the composed back buffer (a real frame) for later. 1 when kept. */ +static int fg_keep_real(void) { + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww <= 0 || wh <= 0) return 0; + if (!s_fg_real_fbo || s_fg_real_w != ww || s_fg_real_h != wh) { + if (s_fg_real_fbo) { p_glDeleteFramebuffers(1, &s_fg_real_fbo); glDeleteTextures(1, &s_fg_real_tex); } + s_fg_real_fbo = s_fg_real_tex = 0; + s_fg_real_tex = make_tex(GL_RGBA8, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE); + if (!make_fbo(&s_fg_real_fbo, s_fg_real_tex, 0)) { + glDeleteTextures(1, &s_fg_real_tex); + s_fg_real_tex = s_fg_real_fbo = 0; + return 0; + } + s_fg_real_w = ww; s_fg_real_h = wh; + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_fg_real_fbo); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer(0, 0, ww, wh, 0, 0, ww, wh, GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + s_fg_real_kept = 1; + return 1; +} + +static void fg_present_real(void) { + s_fg_pending = 0; + if (!s_fg_real_kept) return; /* nothing changed: the present was skipped */ + s_fg_real_kept = 0; + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_fg_real_fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + glDisable(GL_SCISSOR_TEST); + if (ww != s_fg_real_w || wh != s_fg_real_h) { + glViewport(0, 0, ww, wh); + glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); + } + p_glBlitFramebuffer(0, 0, s_fg_real_w, s_fg_real_h, 0, 0, s_fg_real_w, s_fg_real_h, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + gl_swap_now(0); + s_fg_real_presents++; +} + +/* Present the waiting real frame now (generated frames still due are + * dropped): a newer present, a sync point, the queue backing up. */ +static void fg_flush(void) { + if (!s_fg_pending) return; + if (s_fg_k <= s_fg_n) s_fg_flushed++; + fg_present_real(); +} + +static uint64_t fg_tick(void *user, uint64_t now) { + (void)user; + if (!s_fg_pending) return 0; + /* Behind: a later frame is complete already, or the guest waited on us. */ + /* A whole frame waiting behind this one: the real frame goes now. Two, + * or the guest waited on the queue: the breaker as well. */ + const uint64_t bp = rt_backpressure_events(); + const int ahead = rt_frames_ahead(); + if (!s_fg_force && (ahead >= 1 || bp != s_fg_bp_seen)) { + if (bp != s_fg_bp_seen) fg_trip("queue backed up"); + else if (ahead >= 2) fg_trip("render thread behind"); + s_fg_bp_seen = bp; + fg_flush(); + return 0; + } + const uint64_t due = s_fg_t0 + (uint64_t)(s_fg_k - 1) * s_fg_step_ns; + if (now < due) return due; + if (s_fg_k <= s_fg_n) { + fg_generate_timed((double)s_fg_k / (double)(s_fg_n + 1)); + s_fg_k++; + return s_fg_t0 + (uint64_t)(s_fg_k - 1) * s_fg_step_ns; + } + fg_present_real(); + return 0; +} + +/* A present record replayed with frame generation on. Returns 1 when it was + * handled (scheduled or dropped), 0 to present it as usual. */ +static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stale) { + RthOvHdr hd; + memcpy(&hd, p, sizeof hd); + const int32_t *a = hd.args; + const int wide = op == RTH_PRESENT_WIDE; + int disp[4]; + if (wide) { + const int nw = g_wide_w - 2 * g_wide_off; + disp[0] = a[0]; disp[1] = a[1]; disp[2] = nw > 0 ? nw : 320; disp[3] = a[2]; + } else { + disp[0] = a[0]; disp[1] = a[1]; disp[2] = a[2]; disp[3] = a[3]; + } + if (s_fg_broken) { /* frames drawn without the render thread in between */ + s_fg_broken = 0; + for (int i = 0; i < 4; i++) if (i != s_fg_cur) s_fg_l[i].valid = 0; + s_fg_have_last = 0; + s_fg_partial = 1; /* the list being captured misses records */ + } + { /* Learn the display buffers. */ + int k = 0; + while (k < s_fg_nbuf && memcmp(s_fg_buf[k], disp, sizeof s_fg_buf[k]) != 0) k++; + if (k == s_fg_nbuf) { + if (s_fg_nbuf == 4) { memmove(s_fg_buf[0], s_fg_buf[1], sizeof s_fg_buf[0] * 3); s_fg_nbuf = 3; s_fg_cur_buf = -1; } + memcpy(s_fg_buf[s_fg_nbuf++], disp, sizeof s_fg_buf[0]); + } + } + const int flip = !s_fg_have_last || disp[0] != s_fg_last_dx || disp[1] != s_fg_last_dy; + s_fg_vblanks++; + if (!flip) { + /* The same game frame again (a 30 Hz game's second VBlank): while + * its in-between frames are being shown, that is what it shows. */ + s_fg_dups++; + return s_fg_pending ? 1 : 0; + } + if (s_fg_pending) fg_flush(); + s_fg_flips++; + s_fg_flip_vb = s_fg_have_last ? s_fg_vblanks : 1; + if (s_fg_flip_vb > 4) s_fg_flip_vb = 4; + s_fg_vblanks = 0; + s_fg_have_last = 1; s_fg_last_dx = disp[0]; s_fg_last_dy = disp[1]; + /* A game that flips right after drawing: the capturing list is this + * buffer's frame, complete. (One that flips later has moved on to the + * other buffer already, which closed it.) */ + if (s_fg_cur_buf >= 0 && s_fg_buf[s_fg_cur_buf][0] == disp[0] && + s_fg_buf[s_fg_cur_buf][1] == disp[1]) { + fg_rotate(); + s_fg_cur_buf = -1; + } + /* The newest closed list is this buffer's frame; the one before it the + * previous game frame. */ + FgList *A = &s_fg_l[s_fg_older], *B = &s_fg_l[s_fg_newer]; + if (B->valid && (B->disp[0] != disp[0] || B->disp[1] != disp[1])) B = A = NULL; + if (B) { B->wide = wide; B->linear = wide ? a[3] : a[4]; B->force43 = wide ? 0 : a[5]; } + const double now = fg_now_s(); + if (atomic_exchange(&s_fg_late, 0)) fg_trip("guest late"); + const char *why = atomic_exchange(&s_fg_hold_reason, NULL); + if (why) s_fg_hold_why = why; + const int held = rt_now_ns() < atomic_load(&s_fg_hold_until); + const uint64_t bp = rt_backpressure_events(); + if (bp != s_fg_bp_seen) { s_fg_bp_seen = bp; fg_trip("queue backed up"); } + if (stale) fg_trip("render thread behind"); + if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 3.0, 24.0, 10.0); s_fg_brk_init = 1; } + if (s_fg_qok > 0) fg_gen_cost_poll(); /* once per game frame: a poll flushes */ + const double ghz = atomic_load(&s_fg_guest_hz), rhz = atomic_load(&s_fg_refresh_hz); + const double flip_s = (double)s_fg_flip_vb / (ghz > 1.0 ? ghz : 59.94); + const int slots = (int)floor(flip_s * rhz + 0.5); + s_fg_last_slots = slots; + int n = 0; + if (A && B && A->valid && B->valid && A->disp[2] == B->disp[2] && + A->disp[3] == B->disp[3] && A->wide_w == B->wide_w && A->scale > 0) { + if (s_fg_force) n = slots > 1 ? slots - 1 : 1; + else if (fg_breaker_open(&s_fg_brk, now) && !stale && !held) + n = fg_plan(flip_s, rhz, s_fg_real_ema * (double)s_fg_flip_vb + s_fg_swap_ema, + s_fg_gen_ema > 0.0 ? s_fg_gen_ema + s_fg_swap_ema : 0.0, 0.85, 7); + if (n == 0) s_fg_skipped_plan++; + } + s_fg_last_n = n; + if (n <= 0) return 0; + /* Match the two frames' triangles. */ + const uint64_t m0 = rt_now_ns(); + if (s_fg_match_cap < B->prims.n) { + int32_t *nm = (int32_t *)realloc(s_fg_match, (size_t)B->prims.n * sizeof *nm); + if (!nm) return 0; + s_fg_match = nm; s_fg_match_cap = B->prims.n; + } + FgMatchParams mp; + fg_match_defaults(&mp); + fg_match(&A->prims, &B->prims, &mp, s_fg_match, &s_fg_mst); + s_fg_last_match_ms = (double)(rt_now_ns() - m0) * 1e-6; + /* The real frame waits for the generated ones before it. */ + if (s_fg_real_cap < bytes) { + uint8_t *nb = (uint8_t *)realloc(s_fg_real_p, bytes); + if (!nb) return 0; + s_fg_real_p = nb; s_fg_real_cap = bytes; + } + memcpy(s_fg_real_p, p, bytes); + s_fg_real_op = op; + /* The real frame, composed and kept now. */ + s_fg_real_kept = 0; + s_fg_capture_real = 1; + rth_present_payload(op, p); + s_fg_capture_real = 0; + s_fg_pa = s_fg_older; s_fg_pb = s_fg_newer; + s_fg_n = n; s_fg_k = 1; + s_fg_t0 = rt_now_ns(); + s_fg_step_ns = (uint64_t)(flip_s / (double)(n + 1) * 1e9); + s_fg_pending = 1; + s_fg_bp_seen = bp; + uint64_t next = fg_tick(NULL, rt_now_ns()); + if (next) rt_tick_at(next); + return 1; +} + +/* ---- frame generation: public ---- */ +void gl_renderer_set_frame_generation(int on) { + GL_RT_SYNC("frame_generation"); + s_fg_on = on ? 1 : 0; + const char *e = getenv("PSX_FRAME_GEN_FORCE"); + s_fg_force = e && e[0] == '1'; + if (!s_fg_on) { + if (s_ctx) fg_surfaces_free(); + fg_invalidate(); + } else { + fg_invalidate(); + } +} +int gl_renderer_frame_generation(void) { return s_fg_on; } + +void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz) { + atomic_store(&s_fg_refresh_hz, refresh_hz); + if (guest_hz > 1.0) atomic_store(&s_fg_guest_hz, guest_hz); +} + +/* Not a breaker trip: generation pauses while the real frames are under + * pressure (dynamic resolution over budget or stepping down) and for a second + * after, without the breaker's escalating holds. */ +void gl_renderer_frame_gen_hold(const char *reason) { + if (!s_fg_on) return; + atomic_store(&s_fg_hold_reason, reason ? reason : "hold"); + atomic_store(&s_fg_hold_until, rt_now_ns() + 1000000000ull); +} + +/* Emulation thread, once per frame boundary: a frame that took much longer + * than the guest's interval trips the breaker. */ +static void fg_note_guest_frame(void) { + if (!s_fg_on) return; + const uint64_t now = rt_now_ns(); + const double ghz = atomic_load(&s_fg_guest_hz); + if (s_fg_emu_last_ns && ghz > 1.0 && + (double)(now - s_fg_emu_last_ns) > 1.5e9 / ghz) + atomic_store(&s_fg_late, 1); + s_fg_emu_last_ns = now; +} + +int gl_renderer_frame_gen_json(char *out, int cap) { + const double now = fg_now_s(); + const int open = s_fg_brk_init ? fg_breaker_open(&s_fg_brk, now) : 1; + return snprintf(out, (size_t)cap, + "\"enabled\":%d,\"active\":%d,\"forced\":%d,\"generated\":%llu,\"real\":%llu," + "\"flips\":%llu,\"dups\":%llu,\"flushed\":%llu,\"not_planned\":%llu," + "\"last_n\":%d,\"slots\":%d,\"flip_vblanks\":%d,\"refresh_hz\":%.1f," + "\"real_ms\":%.3f,\"gen_ms\":%.3f,\"gen_cpu_ms\":%.3f,\"gen_gpu_ms\":%.3f,\"swap_ms\":%.3f," + "\"match_ms\":%.3f," + "\"prims\":%u,\"matched\":%u,\"moved\":%u,\"unmatched\":%u," + "\"breaker_s\":%.2f,\"trips\":%u,\"last_trip\":\"%s\",\"held_s\":%.2f," + "\"last_hold\":\"%s\",\"swaps\":%llu", + s_fg_on, s_fg_on && s_rth_on && open, s_fg_force, + (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, + (unsigned long long)s_fg_flips, (unsigned long long)s_fg_dups, + (unsigned long long)s_fg_flushed, (unsigned long long)s_fg_skipped_plan, + s_fg_last_n, s_fg_last_slots, s_fg_flip_vb, atomic_load(&s_fg_refresh_hz), + s_fg_real_ema * 1e3, s_fg_gen_ema * 1e3, s_fg_gen_cpu_ms, s_fg_gen_gpu_ms, + s_fg_swap_ema * 1e3, + s_fg_last_match_ms, + s_fg_mst.prims, s_fg_mst.matched, s_fg_mst.moved, s_fg_mst.unmatched, + open ? 0.0 : s_fg_brk.until - now, s_fg_brk.trips, + s_fg_brk.reason ? s_fg_brk.reason : "", + (double)(atomic_load(&s_fg_hold_until) > rt_now_ns() + ? atomic_load(&s_fg_hold_until) - rt_now_ns() : 0) * 1e-9, + s_fg_hold_why ? s_fg_hold_why : "", (unsigned long long)s_swaps_total); +} + /* ---- the recording vtable ------------------------------------------------ */ #define RTH_DIRECT_OR(rec) do { if (rth_record_mode()) { rec; return; } } while (0) static void rtb_init(uint16_t *vram) { @@ -9851,7 +10953,9 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { s_rthx_tagged = (c->flags & RTHF_TAGGED) != 0; s_rthx_backdrop = (c->flags >> RTHF_BD_SHIFT) & 3; if (c->op == RTH_FRAME) { rthf_end(); return; } - if (!s_rthf_open && atomic_load_explicit(&s_rthf_want, memory_order_relaxed)) rthf_begin(); + if (!s_rthf_open && (atomic_load_explicit(&s_rthf_want, memory_order_relaxed) || s_fg_on)) + rthf_begin(); + if (s_fg_on) fg_capture(c, payload); switch (c->op) { case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; @@ -9944,6 +11048,13 @@ static void gl_rth_ctx(void *user, int current) { if (current) { SDL_GL_MakeCurrent(s_win, s_ctx); } else { + if (rt_on_render_thread() && s_fg_on) { + /* The emulation thread takes over (and may draw frames the lists + * never see): show the waiting real frame now, and start the + * lists afresh at the next flip after it hands back. */ + fg_flush(); + s_fg_broken = 1; + } glFlush(); SDL_GL_MakeCurrent(s_win, NULL); if (rt_on_render_thread() && s_rthf_open) s_rthf_taint = 1; @@ -9978,6 +11089,8 @@ int gl_renderer_render_thread_start(int max_frames) { cfg.exec = gl_rth_exec; cfg.ctx = gl_rth_ctx; cfg.user = NULL; + cfg.tick = fg_tick; + fg_invalidate(); if (!rt_start(&cfg)) { s_rth_on = 0; s_vram = s_rth_vram_pub; @@ -10008,13 +11121,14 @@ int gl_renderer_render_thread_active(void) { return s_rth_on; } * may not, then close the frame (the in-flight bound applies here). */ void gl_renderer_render_thread_frame_boundary(void) { if (!s_rth_on) return; + fg_note_guest_frame(); if (rt_held()) { if (gl_rth_eligible()) gl_rth_release(); } else if (!gl_rth_eligible()) { gl_rth_acquire("ineligible"); } /* The render thread's per-frame cost (dynamic resolution) ends here. */ - if (!rt_held() && atomic_load_explicit(&s_rthf_want, memory_order_relaxed)) + if (!rt_held() && (atomic_load_explicit(&s_rthf_want, memory_order_relaxed) || s_fg_on)) RTH_REC(RTH_FRAME, 0, 0); rt_frame_end(); } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 25ecfaf3..6ec628cb 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1457,6 +1457,8 @@ static int g_low_latency_input = 1; * GL call are done; s_render_thread_tried keeps it to one attempt. */ static int g_render_thread = 0; static int g_render_thread_frames = 2; +/* [video] frame_generation (docs/FRAME_GENERATION.md), with the render thread. */ +static int g_frame_generation = 0; static int s_render_thread_tried = 0; static int g_video_vsync = 1; static int g_frame_interpolation = 0; @@ -9319,7 +9321,13 @@ static void dynres_tick_rt(double now_s, double wall, double period, int held, g_dynres.last_costs = co; g_dynres.last_bp_ns = bp_ns; DynrtSample smp{ period, wall, frames, cost, bp, held }; + const int prev_level = c.level; const int level = dynrt_sample(&c, now_s, &smp); + /* Frame generation only spends surplus: not while the real frames are + * over budget or the level is stepping down. */ + if (level < prev_level || c.over_streak > 0) + gl_renderer_frame_gen_hold(level < prev_level ? "dynres stepped down" + : "dynres over budget"); if (c.windows != g_dynres.rt_last_windows) { g_dynres.rt_last_windows = c.windows; g_dynres.rt_win_cpu_ms = g_dynres.rt_acc_frames @@ -9580,13 +9588,24 @@ static void render_thread_vblank(void) { gl_renderer_render_thread_start(g_render_thread_frames)) { std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", g_render_thread_frames); + if (g_frame_generation) { + gl_renderer_set_frame_generation(1); + std::fprintf(stdout, "psxrecomp: frame generation on (render thread, " + "from surplus only)\n"); + } } else { std::fprintf(stdout, "psxrecomp: render thread requested but not started " "(needs the OpenGL backend without netplay, frame " "interpolation or a 24-bit display)\n"); + if (g_frame_generation) + std::fprintf(stdout, "psxrecomp: frame generation needs the render thread; off\n"); } std::fflush(stdout); } + if (g_frame_generation) + gl_renderer_frame_gen_configure(g_host_refresh_hz, + g_guest_frame_period_ms > 0.0 + ? 1000.0 / g_guest_frame_period_ms : 0.0); gl_renderer_render_thread_frame_boundary(); #endif } @@ -15697,6 +15716,7 @@ int main(int argc, char** argv) { gl_renderer_set_texture_window_batching( gc.runtime.video_texture_window_batching ? 1 : 0); g_render_thread = gc.runtime.video_render_thread ? 1 : 0; + g_frame_generation = gc.runtime.video_frame_generation ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; @@ -18003,6 +18023,8 @@ session_reboot: * flight (default 2). */ if (const char* e = std::getenv("PSX_RENDER_THREAD")) g_render_thread = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_FRAME_GEN")) + g_frame_generation = (*e && *e != '0') ? 1 : 0; if (const char* e = std::getenv("PSX_RENDER_THREAD_FRAMES")) g_render_thread_frames = std::atoi(e) > 0 ? std::atoi(e) : 2; /* Scanlines: env override wins over config, same as the corrections above, diff --git a/runtime/src/render_thread.c b/runtime/src/render_thread.c index 7ec395f6..0cd14fde 100644 --- a/runtime/src/render_thread.c +++ b/runtime/src/render_thread.c @@ -25,6 +25,10 @@ static void mtx_lock(rt_mutex *m) { AcquireSRWLockExclusive(m); } static void mtx_unlock(rt_mutex *m) { ReleaseSRWLockExclusive(m); } static void cnd_init(rt_cond *c) { InitializeConditionVariable(c); } static void cnd_wait(rt_cond *c, rt_mutex *m) { SleepConditionVariableSRW(c, m, INFINITE, 0); } +static void cnd_wait_ns(rt_cond *c, rt_mutex *m, uint64_t ns) { + DWORD ms = (DWORD)((ns + 999999u) / 1000000u); + SleepConditionVariableSRW(c, m, ms ? ms : 1, 0); +} static void cnd_bcast(rt_cond *c) { WakeAllConditionVariable(c); } static uint64_t now_ns(void) { LARGE_INTEGER f, t; @@ -47,6 +51,19 @@ static void mtx_lock(rt_mutex *m) { pthread_mutex_lock(m); } static void mtx_unlock(rt_mutex *m) { pthread_mutex_unlock(m); } static void cnd_init(rt_cond *c) { pthread_cond_init(c, NULL); } static void cnd_wait(rt_cond *c, rt_mutex *m) { pthread_cond_wait(c, m); } +static void cnd_wait_ns(rt_cond *c, rt_mutex *m, uint64_t ns) { +# if defined(__APPLE__) + struct timespec rel = { (time_t)(ns / 1000000000u), (long)(ns % 1000000000u) }; + pthread_cond_timedwait_relative_np(c, m, &rel); +# else + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + uint64_t t = (uint64_t)ts.tv_nsec + ns % 1000000000u; + ts.tv_sec += (time_t)(ns / 1000000000u + t / 1000000000u); + ts.tv_nsec = (long)(t % 1000000000u); + pthread_cond_timedwait(c, m, &ts); +# endif +} static void cnd_bcast(rt_cond *c) { pthread_cond_broadcast(c); } static uint64_t now_ns(void) { struct timespec ts; @@ -81,6 +98,8 @@ static struct { RtExecFn exec; RtCtxFn ctx; void *user; + RtTickFn tick; + uint64_t tick_at; /* consumer: next tick deadline (0 = none) */ /* shared positions / counters */ _Atomic uint64_t wpos, rpos; @@ -107,6 +126,7 @@ static struct { uint64_t records, bytes, acquires, releases, oversize; uint64_t bp_waits, full_waits, bp_ns, full_ns, acq_ns, high_water; _Atomic uint64_t busy_ns, idle_ns; /* consumer */ + _Atomic uint64_t bp_events; /* producer writes, any thread reads */ RtAcquireEvent acq_ring[ACQ_RING]; uint64_t acq_n; @@ -127,6 +147,23 @@ int rt_running(void) { return atomic_load(&R.running); } int rt_on_render_thread(void) { return t_render; } int rt_held(void) { return R.held; } uint64_t rt_render_idle_ns(void) { return atomic_load(&R.idle_ns); } +uint64_t rt_now_ns(void) { return now_ns(); } +uint64_t rt_backpressure_events(void) { return atomic_load(&R.bp_events); } +void rt_tick_at(uint64_t deadline_ns) { + if (!t_render || !deadline_ns) return; + if (!R.tick_at || deadline_ns < R.tick_at) R.tick_at = deadline_ns; +} + +/* Consumer: run tick when its deadline has passed. */ +static void run_tick(void) { + if (!R.tick || !R.tick_at) return; + uint64_t now = now_ns(); + if (now < R.tick_at) return; + R.tick_at = 0; + uint64_t next = R.tick(R.user, now); + if (next && (!R.tick_at || next < R.tick_at)) R.tick_at = next; + atomic_fetch_add(&R.busy_ns, now_ns() - now); +} static inline uint64_t align_up(uint64_t n) { return (n + (RT_ALIGN - 1)) & ~(uint64_t)(RT_ALIGN - 1); @@ -171,6 +208,7 @@ static void *render_main(void *arg) uint64_t r = atomic_load(&R.rpos); int have_ctx = 1; for (;;) { + run_tick(); uint64_t w = atomic_load_explicit(&R.wpos, memory_order_acquire); if (r == w) { int spun = 0; @@ -183,8 +221,15 @@ static void *render_main(void *arg) uint64_t t0 = now_ns(); mtx_lock(&R.mtx); atomic_store(&R.render_sleeping, 1); - while (atomic_load(&R.wpos) == r && !R.stop && !R.release_req) - cnd_wait(&R.cv_work, &R.mtx); + while (atomic_load(&R.wpos) == r && !R.stop && !R.release_req) { + if (R.tick_at) { /* timed work due: sleep no later than it */ + uint64_t now = now_ns(); + if (now >= R.tick_at) break; + cnd_wait_ns(&R.cv_work, &R.mtx, R.tick_at - now); + } else { + cnd_wait(&R.cv_work, &R.mtx); + } + } atomic_store(&R.render_sleeping, 0); if (R.release_req && atomic_load(&R.wpos) == r) { serve_release_locked(); @@ -264,6 +309,7 @@ void *rt_cmd_begin(uint16_t op, uint16_t flags, uint32_t payload_bytes) { R.full_waits++; emu_wait(pred_space, w + pad + size); R.full_ns += now_ns() - t0; + atomic_fetch_add(&R.bp_events, 1); } if (pad) { RtCmd *p = (RtCmd *)(R.ring + off); @@ -324,6 +370,7 @@ void rt_frame_end(void) { R.bp_waits++; emu_wait(pred_frames, (uint64_t)R.max_frames); R.bp_ns += now_ns() - t0; + atomic_fetch_add(&R.bp_events, 1); } } @@ -386,6 +433,8 @@ int rt_start(const RtConfig *cfg) { R.exec = cfg->exec; R.ctx = cfg->ctx; R.user = cfg->user; + R.tick = cfg->tick; + R.tick_at = 0; mtx_init(&R.mtx); cnd_init(&R.cv_work); cnd_init(&R.cv_emu); diff --git a/runtime/tests/run_gl_frame_gen.py b/runtime/tests/run_gl_frame_gen.py new file mode 100644 index 00000000..128fba32 --- /dev/null +++ b/runtime/tests/run_gl_frame_gen.py @@ -0,0 +1,147 @@ +"""Build and run test_gl_frame_gen.c on a hidden real OpenGL context. + +Frame generation ([video] frame_generation, docs/FRAME_GENERATION.md): per +scale and present path (VRAM, native-wide), the fixture runs with the render +thread on and generation off, then on (forced). The real presented images, in +order, must be identical; the run with generation on must have presented +generated frames and passes its own endpoint checks (phase 1 = the newer real +image, phase 0 = the older one). Build flags and skips as +run_gl_render_thread.py. +""" +import argparse +import json +import os +import pathlib +import platform +import re +import subprocess +import sys +import tempfile + +MAC_FRAMEWORKS = ["Cocoa", "OpenGL", "IOKit", "CoreVideo", "CoreAudio", "AudioToolbox", + "Carbon", "ForceFeedback", "GameController", "Metal", "QuartzCore", + "CoreMedia", "AVFoundation", "Foundation", "CoreHaptics", + "UniformTypeIdentifiers"] +# The static SDL3's Win32 dependencies (as run_gl_texture_filter.py links them). +WIN_LIBS = ["opengl32", "kernel32", "user32", "gdi32", "winmm", "imm32", "ole32", + "oleaut32", "version", "uuid", "advapi32", "setupapi", "shell32", "dinput8"] +SKIP_EXIT = 77 +WINDOWS = os.name == "nt" or platform.system().startswith(("MINGW", "MSYS", "CYGWIN")) +KEYS = ("real",) + + +def parse(stdout): + out = {} + for k in KEYS: + m = re.search(r"^" + k + r"=([0-9a-f]{16})$", stdout, re.M) + out[k] = m[1] if m else None + m = re.search(r"^checks=(\d+) failures=(\d+)$", stdout, re.M) + out["failures"] = int(m[2]) if m else None + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--cc", default="cc") + ap.add_argument("--sdl-include", required=True) + ap.add_argument("--sdl-library", required=True) + ap.add_argument("--output", required=True) + ap.add_argument("--fixture", type=pathlib.Path) + ap.add_argument("--scales", default="1,3") + ap.add_argument("--frames", default="24") + args = ap.parse_args() + sdl_includes = [str(pathlib.Path(d).resolve()) for d in args.sdl_include.split(";") if d] + args.sdl_library = str(pathlib.Path(args.sdl_library).resolve()) + framework = pathlib.Path(__file__).resolve().parents[2] + fixture = args.fixture or framework / "runtime/tests/test_gl_frame_gen.c" + out_root = pathlib.Path(args.output).resolve() + out_root.mkdir(parents=True, exist_ok=True) + dest = pathlib.Path(tempfile.mkdtemp(prefix="fg-", dir=out_root)) + print("Evidence directory:", dest) + receipt = [] + + def run(command): + command = [str(c) for c in command] + r = subprocess.run(command, cwd=dest, capture_output=True, text=True, + encoding="utf-8", errors="replace") + receipt.append({"cmd": command, "exit": r.returncode, + "stdout": r.stdout, "stderr": r.stderr[-4000:]}) + (dest / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8") + return r + + includes = ["-I", framework / "runtime/include", "-I", framework / "runtime/src"] + for d in sdl_includes: + includes += ["-I", d] + src = framework / "runtime/src" + # The fixture #includes gpu_gl_renderer.c and drives it through the real + # facade (gpu_render.c) and render thread (render_thread.c). + sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), + ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), + ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), + ("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")] + sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] + # MinGW's PE linker reports undefined references from sections it later + # collects, so there LTO drops them first (as run_gl_scale_invariance.py). + if WINDOWS: + sections.append("-flto") + objs = [] + for name, path in sources: + o = dest / (name + ".o") + r = run([args.cc, "-std=gnu11", "-O1", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", + "-DGL_SILENCE_DEPRECATION=1", "-w", *sections, *includes, "-c", path, "-o", o]) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + objs.append(o) + probe = dest / ("probe.exe" if WINDOWS else "probe") + link = [args.cc, *objs, args.sdl_library, "-o", probe] + if platform.system() == "Darwin": + for f in MAC_FRAMEWORKS: + link += ["-framework", f] + link += ["-liconv", "-lm", "-lpthread", "-Wl,-dead_strip"] + elif WINDOWS: + # -static: no libwinpthread/libgcc DLLs needed next to probe.exe. + link += ["-flto", "-static", *["-l" + x for x in WIN_LIBS], "-lm", "-Wl,--gc-sections"] + else: + link += ["-lGL", "-lm", "-ldl", "-lpthread", "-Wl,--gc-sections"] + r = run(link) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + + ok = True + first = True + for s in [int(v) for v in args.scales.split(",") if v]: + for path, timing in (("vram", "flip"), ("wide", "flip"), ("vram", "late"), ("wide", "late")): + runs = {} + for fg in (0, 1): + r = run([probe, s, fg, path, args.frames, timing]) + if first and r.returncode == SKIP_EXIT: + print("SKIP:", r.stderr.strip()[-600:]) + return SKIP_EXIT + first = False + p = parse(r.stdout) + tail = r.stdout.strip().splitlines()[-6:] + print(f"scale {s} {path} {timing} frame_generation={fg}: exit={r.returncode}", tail, + r.stderr.strip()[-800:]) + if r.returncode or p["failures"] != 0 or p["real"] is None: + ok = False + m = re.search(r"^presents=(\d+) generated=(\d+)$", r.stdout, re.M) + p["presents"] = int(m[1]) if m else -1 + p["generated"] = int(m[2]) if m else -1 + runs[fg] = p + if runs[0]["real"] != runs[1]["real"] or runs[0]["presents"] != runs[1]["presents"]: + print(f"FAIL scale {s} {path} {timing}: the real frames differ with generation on " + f"({runs[0]['real']}/{runs[0]['presents']} off, " + f"{runs[1]['real']}/{runs[1]['presents']} on)") + ok = False + if runs[0]["generated"] != 0 or runs[1]["generated"] <= 0: + print(f"FAIL scale {s} {path} {timing}: generated frames off={runs[0]['generated']} " + f"on={runs[1]['generated']}") + ok = False + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py index 14810c25..88f8ec05 100644 --- a/runtime/tests/run_gl_readback_region.py +++ b/runtime/tests/run_gl_readback_region.py @@ -73,6 +73,8 @@ def main(): ("xr", framework / "runtime/src/psx_openxr.c")] if (framework / "runtime/src/render_thread.c").exists(): sources.append(("rth", framework / "runtime/src/render_thread.c")) + if (framework / "runtime/src/frame_gen.c").exists(): + sources.append(("fg", framework / "runtime/src/frame_gen.c")) for name, source in sources: if run([compiler / "gcc.exe", "-std=c11", "-O2", "-flto", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", *includes, "-c", source, diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index e338ba7f..b0bf1b2d 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -85,7 +85,7 @@ def main(): sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), - ("facade", src / "gpu_render.c")] + ("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")] sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] # MinGW's PE linker reports undefined references from sections it later # collects, so there LTO drops them first (as run_gl_scale_invariance.py). diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index 33c55215..1a13368d 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -136,6 +136,8 @@ def main(): sources.append(("xr", framework / "runtime/src/psx_openxr.c")) if (framework / "runtime/src/render_thread.c").exists(): sources.append(("rth", framework / "runtime/src/render_thread.c")) + if (framework / "runtime/src/frame_gen.c").exists(): + sources.append(("fg", framework / "runtime/src/frame_gen.c")) # Unused renderer functions reference the rest of the runtime; the linker # drops them (-dead_strip, or per-function sections with --gc-sections). # MinGW's PE linker reports undefined references from sections it later diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py index 6f29cc6d..2a35c0e8 100644 --- a/runtime/tests/run_gl_texture_filter.py +++ b/runtime/tests/run_gl_texture_filter.py @@ -41,6 +41,8 @@ def main(): sources.append(('xr',fw/'runtime/src/psx_openxr.c')) if (fw/'runtime/src/render_thread.c').exists(): sources.append(('rth',fw/'runtime/src/render_thread.c')) + if (fw/'runtime/src/frame_gen.c').exists(): + sources.append(('fg',fw/'runtime/src/frame_gen.c')) objects=[] for name,source in sources: obj=out/(name+'.o'); run([args.cc,*flags,'-c',source,'-o',obj]); objects.append(obj) diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c new file mode 100644 index 00000000..cdae85da --- /dev/null +++ b/runtime/tests/test_frame_gen.c @@ -0,0 +1,151 @@ +/* Frame generation's renderer-independent half (src/frame_gen.c, + * docs/FRAME_GENERATION.md): matching triangles of two frames, the + * interpolation endpoints, the plan of how many in-between frames fit, and + * the breaker. */ +#include "frame_gen.h" + +#include +#include +#include +#include + +static int checks, failures; +static void check(int ok, const char *what) { + checks++; + if (!ok) { failures++; fprintf(stderr, "FAIL: %s\n", what); } +} + +static FgPrim tri(uint32_t key, float x, float y, float s) { + FgPrim p; + memset(&p, 0, sizeof p); + p.key = key; + p.x[0] = x; p.y[0] = y; + p.x[1] = x + s; p.y[1] = y; + p.x[2] = x; p.y[2] = y + s; + return p; +} + +static void add(FgPrimList *l, FgPrim p) { p.rec = l->n; check(fg_prims_add(l, &p), "add"); } + +static void test_identical_and_moved(void) { + FgPrimList a = { 0 }, b = { 0 }; + for (int i = 0; i < 200; i++) add(&a, tri((uint32_t)(i % 7) + 1, (float)(i % 20) * 16.0f, (float)(i / 20) * 16.0f, 12.0f)); + for (int i = 0; i < 200; i++) add(&b, tri((uint32_t)(i % 7) + 1, (float)(i % 20) * 16.0f + 3.0f, (float)(i / 20) * 16.0f - 2.0f, 12.0f)); + FgMatchParams mp; + fg_match_defaults(&mp); + int32_t *m = (int32_t *)malloc(sizeof(int32_t) * b.n); + FgMatchStats st; + uint32_t got = fg_match(&a, &b, &mp, m, &st); + check(got == 200 && st.matched == 200 && st.unmatched == 0, "a moved scene matches every triangle"); + int pairs_ok = 1; + for (uint32_t i = 0; i < b.n; i++) if (m[i] != (int32_t)i) pairs_ok = 0; + check(pairs_ok, "each triangle pairs with itself (draw order, nearest)"); + /* Same positions: zero moves. */ + got = fg_match(&a, &a, &mp, m, &st); + check(got == 200 && st.moved == 0, "an unchanged scene matches without motion"); + free(m); + fg_prims_free(&a); fg_prims_free(&b); +} + +static void test_keys_limits_and_duplicates(void) { + FgPrimList a = { 0 }, b = { 0 }; + FgMatchParams mp; + fg_match_defaults(&mp); + /* 0: different key never pairs. 1: moved past max_move. 2: deformed. + * 3/4: two copies of one key; the newer ones swapped in draw order but + * each still nearest its own. */ + add(&a, tri(10, 0, 0, 10)); + add(&a, tri(11, 0, 0, 10)); + add(&a, tri(12, 0, 0, 10)); + add(&a, tri(13, 100, 100, 10)); + add(&a, tri(13, 200, 100, 10)); + add(&b, tri(99, 0, 0, 10)); + add(&b, tri(11, mp.max_move + 10.0f, 0, 10)); + FgPrim d = tri(12, 0, 0, 10); + d.x[1] += mp.max_deform + 8.0f; /* one vertex moves alone */ + add(&b, d); + add(&b, tri(13, 204, 101, 10)); + add(&b, tri(13, 103, 99, 10)); + int32_t m[5]; + FgMatchStats st; + fg_match(&a, &b, &mp, m, &st); + check(m[0] == -1, "a different key does not pair"); + check(m[1] == -1, "a move past max_move does not pair (placed, not moved)"); + check(m[2] == -1, "a triangle whose vertices moved apart does not pair"); + check(m[3] == 4 && m[4] == 3, "duplicate keys pair by distance"); + check(st.matched == 2 && st.unmatched == 3, "match stats"); + fg_prims_free(&a); fg_prims_free(&b); +} + +static void test_each_older_pairs_once(void) { + FgPrimList a = { 0 }, b = { 0 }; + add(&a, tri(5, 0, 0, 10)); + add(&b, tri(5, 0, 0, 10)); + add(&b, tri(5, 1, 0, 10)); + FgMatchParams mp; + fg_match_defaults(&mp); + int32_t m[2]; + fg_match(&a, &b, &mp, m, NULL); + check(m[0] == 0 && m[1] == -1, "an older triangle pairs at most once"); + /* Empty older list: nothing pairs. */ + FgPrimList e = { 0 }; + fg_match(&e, &b, &mp, m, NULL); + check(m[0] == -1 && m[1] == -1, "no history: nothing pairs"); + fg_prims_free(&a); fg_prims_free(&b); +} + +static void test_lerp(void) { + FgPrim a = tri(1, 10, 20, 8), b = tri(1, 30, 10, 8); + float x[3], y[3]; + fg_lerp(&a, &b, 0.0, x, y); + check(x[0] == a.x[0] && y[2] == a.y[2], "phase 0 is the older frame exactly"); + fg_lerp(&a, &b, 1.0, x, y); + check(x[1] == b.x[1] && y[1] == b.y[1], "phase 1 is the newer frame exactly"); + fg_lerp(&a, &b, 0.25, x, y); + check(fabsf(x[0] - 15.0f) < 1e-5f && fabsf(y[0] - 17.5f) < 1e-5f, "phase 0.25"); +} + +static void test_plan(void) { + const double f30 = 2.0 / 59.94, f60 = 1.0 / 59.94; + /* 30 Hz game frames on a 120 Hz panel: 4 slots, 3 in-between. */ + check(fg_plan(f30, 120.0, 0.010, 0.002, 0.85, 7) == 3, "30 Hz on 120 Hz: three in-between frames"); + /* 60 Hz game frames on 120 Hz: one. On 60 Hz: none. */ + check(fg_plan(f60, 120.0, 0.004, 0.002, 0.85, 7) == 1, "60 Hz on 120 Hz: one"); + check(fg_plan(f60, 60.0, 0.004, 0.002, 0.85, 7) == 0, "60 Hz on 60 Hz: none"); + check(fg_plan(f30, 60.0, 0.010, 0.002, 0.85, 7) == 1, "30 Hz on 60 Hz: one"); + /* Only what fits: 33.4 ms * 0.85 = 28.4; real 20 ms leaves 8.4 -> two of 4 ms. */ + check(fg_plan(f30, 120.0, 0.020, 0.004, 0.85, 7) == 2, "only what fits"); + check(fg_plan(f30, 120.0, 0.030, 0.001, 0.85, 7) == 0, "no surplus: none"); + /* Unknown generation cost: one, if half the budget is free. */ + check(fg_plan(f30, 120.0, 0.010, 0.0, 0.85, 7) == 1, "unmeasured: one to measure"); + check(fg_plan(f30, 120.0, 0.020, 0.0, 0.85, 7) == 0, "unmeasured without room: none"); + check(fg_plan(f30, 240.0, 0.001, 0.0001, 0.85, 2) == 2, "max_gens caps"); +} + +static void test_breaker(void) { + FgBreaker b; + fg_breaker_init(&b, 3.0, 24.0, 10.0); + check(fg_breaker_open(&b, 0.0), "starts allowing generation"); + fg_breaker_trip(&b, 1.0, "late"); + check(!fg_breaker_open(&b, 3.9) && fg_breaker_open(&b, 4.0), "a trip holds 3 s"); + fg_breaker_trip(&b, 5.0, "late"); /* within 10 s of the last hold's end */ + check(!fg_breaker_open(&b, 10.9) && fg_breaker_open(&b, 11.0), "a repeat doubles the hold"); + fg_breaker_trip(&b, 6.0, "late"); /* already held: the hold stands */ + check(fg_breaker_open(&b, 11.0), "a trip while held does not extend it"); + fg_breaker_trip(&b, 40.0, "late"); /* long after: back to the base hold */ + check(!fg_breaker_open(&b, 42.9) && fg_breaker_open(&b, 43.0), "a later trip starts over"); + check(b.trips == 4 && b.reason && strcmp(b.reason, "late") == 0, "trips counted with a reason"); + for (int i = 0; i < 10; i++) fg_breaker_trip(&b, 43.0 + 30.0 * i + (i ? 0 : 0), "x"); + check(b.hold <= 24.0, "the hold is capped"); +} + +int main(void) { + test_identical_and_moved(); + test_keys_limits_and_duplicates(); + test_each_older_pairs_once(); + test_lerp(); + test_plan(); + test_breaker(); + printf("frame_gen_test: checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c new file mode 100644 index 00000000..fae8b147 --- /dev/null +++ b/runtime/tests/test_gl_frame_gen.c @@ -0,0 +1,278 @@ +/* Frame generation on a real, hidden OpenGL context + * (docs/FRAME_GENERATION.md). Run by run_gl_frame_gen.py with the render + * thread on and frame generation off (argv[2] == 0) or on (1, forced: every + * flip generates, whatever the surplus). A 30 Hz "game" double-buffers at + * x = 0 / x = 512 and presents every VBlank (each game frame twice), paced at + * 60 Hz: a fill, textured / shaded / flat triangles that move a little each + * game frame, a fixed HUD rect, render-to-texture and a VRAM copy outside the + * display, and every fifth frame a triangle that jumps (never matched). + * argv[3] == "wide" presents through a native-wide surface instead of VRAM; + * argv[5] == "late" flips to each frame one VBlank after the next one has + * started drawing (as R4 does). + * Printed: real (digest of every distinct real presented image, in order; a + * present that shows the same image again is not counted: generation replaces + * those with in-between frames), presents, generated, and + * with generation on the endpoint checks: the in-between image composed at + * phase 1 equals the newer real frame's image, at phase 0 the older one's + * (the last two game frames move without a jump, so every triangle pairs). + * Original source-owned scene; no retail payload. */ +static void fixture_present(int generated); +#define GL_PRESENT_TEST_HOOK(gen) fixture_present(gen) +#include "gpu_gl_renderer.c" +#include "mod_texture_banks.c" +uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} +int g_psx_vram_dirty_tracking=0; +uint64_t s_frame_count=0; +void gpu_vram_dirty_mark_row_impl(uint32_t y){(void)y;} +void gpu_vram_dirty_mark_rect(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_vram_dirty_mark_all(void){} +int psx_netplay_active(void){return 0;} +const GpuRenderBackend *vk_backend_get(void){return NULL;} +int gpu_display_is_depth24(void){return 0;} +void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->width=320;out->height=240;} +/* Guest state the backend reads per primitive. It changes with every + * primitive here, so a replay that read it live (instead of the value + * captured when the primitive was recorded) would draw a different image. */ +static int g_prim_id; +static int g_bg_full; +int psx_ws_prim_in_backdrop(void){return (g_prim_id % 3) == 0;} +int psx_ws_prim_is_tagged(void){return (g_prim_id % 5) == 0;} +int gpu_ws_nw_flat_backdrop_enabled(void){return 0;} +int gpu_ws_background_requires_full_composite(void){return g_bg_full;} +int g_ws_tex_edge_pct=0; +void psx_ws_dbg_gate_frame_snapshot(void){} +void gpu_depth24_upload_span_reset(void){} +int gpu_ws_netplay_local_viewport_width(void){return 0;} +int render_pass_netplay_enabled(void){return 0;} +uint64_t psx_cycle_count=0; +uint32_t g_psx_vblank_cycles=564480u; +/* Frontend hooks of the present path (host overlays, pacing, rings). */ +int host_osd_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int host_osd_volume_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int host_osd_needs_present(void){return 0;} +void host_osd_present_done(void){} +int psx_rewind_overlay_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +float psx_rewind_slide(void){return 0.0f;} +int psx_savestate_menu_overlay_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int present_shot_take(char *out,int n){(void)out;(void)n;return 0;} +void present_shot_done(int ok){(void)ok;} +void psx_host_sleep_ms(uint32_t ms){(void)ms;} +/* Called on every present. On the render thread it also stands in for a slow + * swap, so replay runs well behind recording and anything it read live from + * the "guest" (instead of what was recorded) would be a later value. */ +int psx_present_vsync_owns_cadence(void){return 0;} +void latency_ring_mark(LatencyStage s){(void)s;} +void gpu_timeline_note(uint8_t k,uint32_t a,uint32_t b){(void)k;(void)a;(void)b;} + +static uint16_t vram[1024*512]; +static int checks, failures; +static void check(int ok, const char *label) { + checks++; + if (!ok) { fprintf(stderr, "FAIL %s\n", label); failures++; } +} +static uint64_t fnv(const void *pp, size_t n, uint64_t h) { + const uint8_t *p = (const uint8_t *)pp; + for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } + return h; +} + +/* Presented images: every swap (render thread or not) passes here first. */ +static uint64_t real_seq = 0xcbf29ce484222325ull; +static uint64_t n_real = 0, n_gen = 0; +static uint32_t *img_last[2]; /* the last two real images */ +static int img_w, img_h; +static uint64_t read_back(uint32_t *dst) { + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + img_w = ww; img_h = wh; + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glReadPixels(0, 0, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE, dst); + return fnv(dst, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); +} +static void fixture_present(int generated) { + if (generated) { n_gen++; return; } + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + for (int i = 0; i < 2; i++) + if (!img_last[i]) img_last[i] = (uint32_t *)malloc((size_t)ww * wh * 4); + uint32_t *t = img_last[0]; img_last[0] = img_last[1]; img_last[1] = t; + static uint64_t last_d = 0; + uint64_t d = read_back(img_last[1]); + if (d == last_d) { /* the same image shown again: not a new real frame */ + uint32_t *u = img_last[0]; img_last[0] = img_last[1]; img_last[1] = u; + return; + } + last_d = d; + if (getenv("FG_DUMP")) { + printf("R %llu %016llx\n", (unsigned long long)n_real, (unsigned long long)d); + char fn[64]; snprintf(fn, sizeof fn, "real%02llu.rgba", (unsigned long long)n_real); + FILE *f = fopen(fn, "wb"); fwrite(img_last[1], 4, (size_t)ww * wh, f); fclose(f); + } + real_seq = fnv(&d, sizeof d, real_seq); + n_real++; +} + +#define FRAME_W 320 +#define FRAME_H 240 + +static void textures(void) { + static uint16_t page[64*64], clut[16]; + for (int i = 0; i < 64*64; i++) page[i] = (uint16_t)((i * 0x1357u) ^ (i >> 3)); + for (int i = 0; i < 16; i++) clut[i] = (uint16_t)(i ? (0x0421u * (uint16_t)i) | ((i & 1) << 15) : 0); + /* Outside both buffers (x 0..319, 512..831; y 0..239). */ + for (int i = 0; i < 64*64; i++) vram[(i / 64) * 1024 + 896 + i % 64] = page[i]; + gr_vram_transfer_in(896, 0, 64, 64, page); + gr_vram_transfer_in(512, 300, 16, 1, clut); +} + +static int wide_mode; +/* One game frame drawn into buffer bx. Positions move by whole pixels with + * g; every fifth frame triangle 0 jumps across the screen. */ +static void game_frame_part(int g, int bx, int part) { + g_prim_id = -1; + gr_set_draw_area(bx, 0, bx + FRAME_W - 1, FRAME_H - 1); + gr_set_draw_offset(bx, 0); + if (wide_mode) gr_wide_set_target(bx); + if (part == 0) { + gr_fill_rect(bx, 0, FRAME_W, FRAME_H, 0x1084); + if (wide_mode) gr_wide_clear(bx, 0, FRAME_H, 0x1084); + } + gr_set_color_modulation(128, 128, 128, 0); + gr_set_semi_transparency(0, 0); + for (int i = part ? 20 : 0; i < (part ? 40 : 20); i++) { + int x = bx + 10 + (i % 8) * 36 + ((i * 3 + g * (1 + i % 3)) % 9) - 4; + int y = 12 + (i / 8) * 42 + (g * (i % 2 ? 1 : -1)) % 5; + if (i == 0 && g % 5 == 0) { x = bx + 250 - (g * 37) % 200; y = 180; } + switch (i % 4) { + case 0: gr_draw_shaded_textured_triangle(x, y, 0, 0, 0x808080, x + 30, y + 2, 63, 4, 0x6090b0, + x + 3, y + 32, 8, 63, 0xb09060, 512, 300, 0x000e, 0); break; + case 1: gr_draw_textured_triangle(x, y, 4, 4, x + 28, y, 60, 4, x, y + 28, 4, 60, 512, 300, 0x000e); break; + case 2: gr_draw_gouraud_triangle(x, y, 0x7c00, x + 26, y + 4, 0x03e0, x + 6, y + 30, 0x001f); break; + default: gr_draw_flat_triangle(x, y, x + 24, y + 20, x - 4, y + 26, (uint16_t)(0x2108 * (i & 3) + 0x0421)); break; + } + } + if (part == 0) return; + gr_draw_flat_rect(bx + 8, 220, 80, 12, 0x7fff); /* HUD */ + gr_draw_flat_rect(900, 300, 32, 32, (uint16_t)(0x03e0 + g)); /* render to texture */ + gr_copy_rect(bx + 10, 10, 900, 400, 16, 16); /* copy out of the display */ +} + +static void present(int bx) { + if (wide_mode) (void)gl_renderer_present_wide_fbo(bx, 0, FRAME_H, 0); + else gl_renderer_present_vram(bx, 0, FRAME_W, FRAME_H, 0, 0); + gl_renderer_render_thread_frame_boundary(); +} + +int main(int argc, char **argv) { + int scale = argc > 1 ? atoi(argv[1]) : 2; + int fg = argc > 2 && argv[2][0] == '1'; + wide_mode = argc > 3 && !strcmp(argv[3], "wide"); + int frames = argc > 4 ? atoi(argv[4]) : 24; + /* "late": the game flips to a frame one VBlank after it starts drawing + * the next one (as R4 does), so a flip is not a list boundary. */ + int late = argc > 5 && !strcmp(argv[5], "late"); + if (SDL_Init(SDL_INIT_VIDEO) != 0) { + fprintf(stderr, "SKIP no video (%s)\n", SDL_GetError()); + return 77; + } + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#if defined(__APPLE__) + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window *win = SDL_CreateWindow("Frame generation hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) { fprintf(stderr, "SKIP no window (%s)\n", SDL_GetError()); return 77; } + { + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + SDL_GLContext probe = SDL_GL_CreateContext(win); + if (!probe) { + fprintf(stderr, "SKIP no GL 3.3 core context (%s)\n", SDL_GetError()); + return 77; + } + SDL_GL_MakeCurrent(win, NULL); + SDL_GL_DeleteContext(probe); + } + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(scale); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } + textures(); + if (wide_mode) gr_wide_configure(426, 53); + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + if (fg) { + setenv("PSX_FRAME_GEN_FORCE", "1", 1); + gl_renderer_set_frame_generation(1); + check(gl_renderer_frame_generation() == 1, "frame generation on"); + } + gl_renderer_frame_gen_configure(120.0, 59.94); + for (int g = 0; g < frames; g++) { + const int bx = (g & 1) ? 512 : 0, prev = bx ^ 512; + if (late) { + game_frame_part(g, bx, 0); + present(prev); /* flips to the previous frame */ + SDL_Delay(16); + game_frame_part(g, bx, 1); + present(prev); + SDL_Delay(16); + continue; + } + game_frame_part(g, bx, 0); + game_frame_part(g, bx, 1); + present(bx); /* the game frame's first VBlank: a flip */ + SDL_Delay(16); + present(bx); /* its second: the same buffer again */ + SDL_Delay(16); + } + SDL_Delay(60); + gl_renderer_render_thread_sync("test"); /* the render thread parks; we hold the context */ + if (fg) { + char js[1024]; + gl_renderer_frame_gen_json(js, sizeof js); + printf("fg {%s}\n", js); + /* Endpoints: compose (no swap) at phase 1 and 0 between the last two + * game frames and compare with their real images. */ + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + uint32_t *img = (uint32_t *)malloc((size_t)ww * wh * 4); + check(img_last[0] && img_last[1], "two real images kept"); + check(fg_generate(1.0, 0) == 1, "compose at phase 1"); + uint64_t d1 = read_back(img); + uint64_t r1 = fnv(img_last[1], (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + if (getenv("FG_DUMP")) { + FILE *f = fopen("p1.rgba", "wb"); fwrite(img, 4, (size_t)ww * wh, f); fclose(f); + f = fopen("r1.rgba", "wb"); fwrite(img_last[1], 4, (size_t)ww * wh, f); fclose(f); + f = fopen("r0.rgba", "wb"); fwrite(img_last[0], 4, (size_t)ww * wh, f); fclose(f); + printf("dump %dx%d\n", ww, wh); + } + check(d1 == r1, "phase 1 equals the newer real frame"); + check(fg_generate(0.0, 0) == 1, "compose at phase 0"); + uint64_t d0 = read_back(img); + uint64_t r0 = fnv(img_last[0], (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + long diff = 0; + for (int i = 0; i < ww * wh; i++) if (img[i] != img_last[0][i]) diff++; + printf("phase0_diff_px=%ld matched=%u/%u\n", diff, s_fg_mst.matched, s_fg_mst.prims); + check(d0 == r0, "phase 0 equals the older real frame"); + check(fg_generate(0.5, 0) == 1, "compose at phase 0.5"); + uint64_t dm = read_back(img); + if (getenv("FG_DUMP")) { + FILE *f = fopen("pm.rgba", "wb"); fwrite(img, 4, (size_t)ww * wh, f); fclose(f); + } + check(dm != r0 && dm != r1, "phase 0.5 is neither endpoint"); + free(img); + check(n_gen > 0, "generated frames were presented"); + } + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("real=%016llx\n", (unsigned long long)real_seq); + printf("presents=%llu generated=%llu\n", (unsigned long long)n_real, (unsigned long long)n_gen); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); + SDL_DestroyWindow(win); + SDL_Quit(); + return failures ? 1 : 0; +} diff --git a/runtime/tests/test_render_thread.c b/runtime/tests/test_render_thread.c index 2353be2b..e7554d77 100644 --- a/runtime/tests/test_render_thread.c +++ b/runtime/tests/test_render_thread.c @@ -234,12 +234,59 @@ static void test_stop_while_held(void) { rt_stop(); } +/* 6. Timed work (frame generation's hook): an exec asks for a tick 5 ms out + * while the ring then goes idle; the idle render thread wakes for it (not + * only when records arrive), holds the context, and each tick can ask for + * the next. Ticks never run before their deadline. */ +static _Atomic int tick_count, tick_early, tick_no_ctx, tick_off_thread; +static uint64_t tick_due; +static uint64_t fake_tick(void *user, uint64_t now) { + (void)user; + if (now < tick_due) atomic_fetch_add(&tick_early, 1); + if (atomic_load(&ctx_owner) != 2) atomic_fetch_add(&tick_no_ctx, 1); + if (!rt_on_render_thread()) atomic_fetch_add(&tick_off_thread, 1); + int n = atomic_fetch_add(&tick_count, 1) + 1; + if (n >= 4) return 0; + tick_due = now + 2000000u; + return tick_due; +} +static void tick_exec(void *user, const RtCmd *c, const void *payload) { + fake_exec(user, c, payload); + if (c->op == OP_PRESENT) { + tick_due = rt_now_ns() + 5000000u; + rt_tick_at(tick_due); + } +} +static void test_tick(void) { + reset_state(); + atomic_store(&tick_count, 0); + g_max_frames = 2; + RtConfig cfg = { 1u << 20, 2, tick_exec, fake_ctx, NULL, fake_tick }; + CHECK(rt_start(&cfg), "start with tick"); + void *p = rt_cmd_begin(OP_PRESENT, 0, 16); + CHECK(p != NULL, "present record"); + rt_cmd_commit(); + rt_frame_end(); + const uint64_t t0 = rt_now_ns(); + while (atomic_load(&tick_count) < 4 && rt_now_ns() - t0 < 2000000000ull) sleep_us(500); + const uint64_t el = rt_now_ns() - t0; + CHECK(atomic_load(&tick_count) == 4, "four ticks while idle (%d)", atomic_load(&tick_count)); + CHECK(el >= 10000000u, "ticks waited for their deadlines (%.1f ms)", (double)el * 1e-6); + CHECK(atomic_load(&tick_early) == 0, "no tick before its deadline"); + CHECK(atomic_load(&tick_no_ctx) == 0 && atomic_load(&tick_off_thread) == 0, + "ticks on the render thread, holding the context"); + sleep_us(20000); + CHECK(atomic_load(&tick_count) == 4, "no tick after 0 was returned"); + rt_stop(); +} + int main(void) { test_order_and_wrap(); test_oversize(); test_backpressure(); test_acquire_release(); test_stop_while_held(); + test_tick(); printf("checks=%d failures=%d\n", checks, failures); return failures ? 1 : 0; }