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; }