mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
Frame generation on the render thread from recorded draw lists
[video] frame_generation (opt-in, with render_thread): the render thread keeps each game frame's replayed draw list, matches the triangles of the last two (frame_gen.c), and presents in-between frames at the display refresh by redrawing the newer list into separate surfaces with matched vertices interpolated. It never runs guest code. Real frames are composed at their flip and presented after the generated ones, with the same pixels and order. Generation uses only measured surplus: a plan from render-thread CPU, generated-frame cost and swap time, a breaker after backpressure or late guest frames, and a pause while dynamic resolution is over budget. The render-thread core gains a timed tick hook. Frame-cost queries pause around generation, so dynamic resolution measures real frames only. Tests: frame_gen_test, render_thread_test (tick), gl_frame_gen_test (real frames identical off/on; phase 0/1 equal the endpoints). Docs: docs/FRAME_GENERATION.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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<bool>(video, "render_thread");
|
||||
}
|
||||
if (video.contains("frame_generation")) {
|
||||
rt.video_frame_generation = toml::find<bool>(video, "frame_generation");
|
||||
}
|
||||
if (video.contains("vsync")) {
|
||||
const auto mode = toml::find<std::string>(video, "vsync");
|
||||
if (mode == "on" || mode == "vsync") rt.video_vsync = 1;
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 "$<TARGET_FILE:SDL3::SDL3>"
|
||||
--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
|
||||
|
||||
@@ -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 <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/* Frame generation: matching, interpolation, planning (include/frame_gen.h,
|
||||
* docs/FRAME_GENERATION.md). */
|
||||
#include "frame_gen.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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; }
|
||||
+1130
-16
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user