diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 6275f333..3090994e 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -73,6 +73,125 @@ back to `HOLD` when the status is `READY` again. A backend mode that cannot host passes (for example a renderer path whose VRAM is not one texture) should report `BACKEND` from `gl_renderer_pass_unavailable()`. +## Flip modes + +`psx_mod_set_render_pass_flip()` says how the game flips relative to the pass +point (default `PENDING`, reset at every session start): + +- `PENDING`: frame N is drawn and waits; the next flip shows the pass rect. + This is the PsyQ `VSync(0)`-then-`PutDispEnv` loop, and also any game that + flips only after the next frame is submitted (Vigilante 8: 2nd Offense + flips from a VBlank callback armed by the next submit). Phase 0 is + re-captured when the flip is seen, so a game that adds a last layer to the + shown buffer after the pass point (V8:2 draws its HUD at the next submit) + shows its own final image at phase 0. +- `SHOWN`: the game flips each frame as soon as it is drawn, so at the pass + point frame N is already on screen. The rect is that on-screen rect; frame + N and its in-betweens are shown from the next flip, one game frame later + than stock (the latency of any interpolator with one frame of history). + +Decide by decoding the game's DISPENV / DRAWENV tables and where it calls +`PutDispEnv` and `DrawOTag`, not by assumption: in V8:2 `DISPENV[i]` shows +the buffer `DRAWENV[1-i]` draws. + +## Replaying a span of frame code + +Re-implementing a game's per-frame draw call sequence in a plugin copies one +path through it (HUD, messages, pause menu, replay mode each branch +differently) and drifts from it. Instead, a plugin records the registers at +the start of the game's draw section during the real frame (an instruction +hook, `mod_instruction_sites`) and, inside a pass, replays that section: + +```c +int psx_mod_run_guest_span(struct CPUState *cpu, uint32_t start_pc, uint32_t stop_pc); +``` + +Every PC in `[start_pc, stop_pc)` is interpreted from its RAM bytes, even +where a compiled body or a compiled resume point (the return address of a +call) begins, so the span may start in the middle of a compiled function. +Calls the span makes leave the range as plain transfers and run with the +full dispatch trampoline back to their return address (compiled code is CPS: +a callee's own calls and returns go through the trampoline). A call may be +the span's last instruction. Anything else that leaves the range ends the +span with failure. Only inside a pass: the sandbox rolls a failed or runaway +span back, and the watchdog landing closes an open span. The span is a +replay of the game's own instructions, never a substitute for code the +recompiler could not translate; it stays out of capture, alias seeding and +per-PC entry statistics, and fires instruction hooks as compiled code does. +`render_pass_stats` counts `spans` / `span_failures` and keeps the last +failure (`span_fail`: reason, exit PC, `$ra`, post-call PC). + +## Blending game state (render_pass_motion.h) + +What makes a pass an in-between image is the state the draw code reads, +placed part of the way between two logic ticks. `render_pass_motion.h` holds +that for any title: + +```c +psx_motion_begin(set, tick); /* each frame, capture point */ +psx_motion_track(set, PSX_MOTION_MATRIX, obj + 0x20, psx_motion_identity(type, fn, mesh)); +psx_motion_prepare(set, &limits, &stats); /* pair with the previous frame */ +/* in each pass */ +psx_motion_apply(set, alpha); /* write blends to guest RAM */ +psx_motion_blend_at(set, kind, addr, alpha, &out); /* a value a hook hands on */ +``` + +Kinds: PsyQ `MATRIX` (slerped rotation, linear translation), its translation +only (billboards), `VECTOR`, `SVECTOR`, 12-bit `ANGLES` (shorter arc) and a +`SCALAR`. Values pair by address and identity, so a heap block reused by +another object never blends between the two. `limits.move_per_tick` (game +units) and `limits.turn_radians` separate motion from placement: a value +past them was respawned, teleported or cut to and keeps its current value. +Measure the game's real motion first; V8:2's world spans about 2^27 units +per axis and moves up to 2^16 per frame. + +## Per-frame driver + +```c +int psx_mod_activate_render_pass_rate(const char *pkg, const char *feature, + const char *option, uint32_t flip_mode); +uint32_t psx_mod_render_pass_frame(struct CPUState *cpu, + const PSXModRenderPassFrame *frame, + PSXModRenderPassFn fn, void *user); +``` + +The first reads a choice option (`display` or a rate) and selects the FLIP +source, HOLD blend, the flip mode and the rate. The second plans one game +frame, runs `fn` at each phase into the frame's rect, and switches the +presenter to a crossfade while passes are unavailable for a lasting reason +(back to HOLD when they return). + +## Porting a title + +1. Find the main loop's draw section: the range from camera setup through + OT build and HUD to the submit call, and every call in it. +2. Decode DISPENV/DRAWENV and the submit/flip path: which buffer the next + flip shows at the pass point, and in what order the game draws into it + (immediate `DrawOTag`, VBlank callback). Pick the flip mode. +3. Find the pass point (a function entry after the section's OT build, before + submit) and the logic tick counter. +4. Find what the draw code reads that moves: the camera input (often the + argument of a set-camera function: track it with `blend_at` from an entry + hook), object transforms and their identity, particles. +5. Measure motion per frame with histograms (mod counters) before setting + limits. +6. Plugin: instruction hook at the section start (registers), capture at the + pass point, `psx_mod_render_pass_frame` with a pass function that applies + the blend, replays the span and submits as the screen gets it. +7. Verify in `--hidden-window` (below): `render_pass_stats` passes with no + span failures, `render_pass_dump` images between their neighbours, + `PSX_RENDER_PASS_VERIFY=1` with `verify_mismatch` 0. + +`Vigilante82PSXRecomp/src/mods/v82_frame_rate_plugin.cpp` is the worked +example. + +## Testing without a window + +`--hidden-window` runs the full windowed runtime (presenter, interpolation, +render passes) in a window that is never shown and never opens a modal or a +file picker. `--headless` skips the presenter, so plans there are always +empty. Drive it over TCP; set `SDL_AUDIODRIVER=dummy` to keep it silent. + ## What a pass may and may not do While `fn` runs (`g_psx_render_pass_active`): diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index f9aed576..824c6bc4 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -811,6 +811,18 @@ if(BUILD_TESTING) endif() add_test(NAME render_pass_plan_test COMMAND render_pass_plan_test) + # Motion kit and per-frame driver for render-pass plugins: blending, + # identity matching, limits and the crossfade fallback, on mock RAM. + add_executable(render_pass_motion_test + tests/test_render_pass_motion.c + src/render_pass_motion.c + src/render_pass_frame.c) + target_include_directories(render_pass_motion_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(render_pass_motion_test PRIVATE m) + endif() + add_test(NAME render_pass_motion_test COMMAND render_pass_motion_test) + # Render-pass time freeze against mock devices (psx_cycles.c only). add_executable(render_pass_freeze_test tests/test_render_pass_freeze.c diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 6e434ae0..96d78bf1 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -419,6 +419,32 @@ enum { }; int psx_mod_set_render_pass_flip(uint32_t mode); +/* + * The per-frame plumbing every frame-rate plugin repeats (render_pass_frame.c). + * Blending the game state itself: render_pass_motion.h. + * + * Activation: read a choice option holding "display" (follow the measured + * display refresh) or a frame rate, and select the FLIP source, HOLD blend, + * `flip_mode` and that rate. Returns 0 if any setting was refused. + */ +int psx_mod_activate_render_pass_rate(const char* package, const char* feature, + const char* option, uint32_t flip_mode); +typedef struct PSXModRenderPassFrame { + uint32_t struct_size; /* sizeof(PSXModRenderPassFrame) */ + uint32_t period_vblanks; /* VBlanks this game frame stays on screen */ + uint32_t shown_after_vblanks; /* VBlank presents before it is shown */ + uint16_t x, y, w, h; /* VRAM rect the passes draw */ +} PSXModRenderPassFrame; +/* + * Plan this game frame's passes and run `fn` at each phase. While passes are + * unavailable for a lasting reason (BACKEND, DISABLED) the presenter is + * switched to a motion-adaptive crossfade, and back to HOLD once they return. + * Returns how many passes produced an image. + */ +uint32_t psx_mod_render_pass_frame(struct CPUState* cpu, + const PSXModRenderPassFrame* frame, + PSXModRenderPassFn fn, void* user); + /* * Replay part of an already-loaded guest function inside a render pass: * run from start_pc with the CPU state given until control reaches stop_pc. diff --git a/runtime/include/render_pass_motion.h b/runtime/include/render_pass_motion.h new file mode 100644 index 00000000..9bb1ea7b --- /dev/null +++ b/runtime/include/render_pass_motion.h @@ -0,0 +1,119 @@ +#ifndef PSX_RENDER_PASS_MOTION_H +#define PSX_RENDER_PASS_MOTION_H +/* + * Motion kit for render-pass plugins (docs/RENDER_PASSES.md, "Blending game + * state"). A pass redraws the scene with the game's own code between two + * logic ticks; what makes the image an in-between one is the game state the + * draw code reads, placed part of the way from the previous tick to the + * current one. This kit owns that part for any title: + * + * per game frame, at the plugin's capture point + * psx_motion_begin(set, tick) the game's logic tick counter + * psx_motion_track(set, kind, addr, identity) for each value the draw + * code reads (object matrices, + * positions, angles, the camera) + * psx_motion_prepare(set, &limits, &stats) pair with the previous frame + * in each pass + * psx_motion_apply(set, alpha) write the blends into guest RAM + * psx_motion_blend_at(set, addr, ...) or blend one value a hook hands on + * + * Values are matched by address AND identity: game objects are heap blocks, + * and a freed address reused by another object must never blend between the + * two. A value that moved further than the limits allow in the ticks between + * the frames was placed (respawn, teleport, camera cut), not moved; it keeps + * its current value, as do values without history. Nothing is written + * outside psx_motion_apply, which a pass's sandbox rolls back. + * + * Guest RAM is read and written through psx_mod_read_* / psx_mod_write_*. + */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* What lives at a tracked address (guest RAM layout). */ +enum { + /* PsyQ MATRIX: 3x3 s16 rotation in 4.12 at +0x00..+0x11, s32 t[3] at + * +0x14. Rotation slerps when both rows are orthonormal, otherwise it + * blends elementwise; translation blends linearly. */ + PSX_MOTION_MATRIX = 1, + /* Only the translation of a MATRIX (+0x14..+0x1F): for matrices whose + * rotation the draw code rebuilds itself (billboards). */ + PSX_MOTION_MATRIX_TRANSLATION = 2, + PSX_MOTION_VECTOR = 3, /* s32 x, y, z */ + PSX_MOTION_SVECTOR = 4, /* s16 x, y, z */ + /* s16 x, y, z angles, 4096 per turn: each blends along the shorter arc. */ + PSX_MOTION_ANGLES = 5, + PSX_MOTION_SCALAR = 6 /* s32 */ +}; + +typedef struct PSXMotionLimits { + /* Distance one value may move per logic tick and still blend + * (translation, VECTOR, SVECTOR, SCALAR, in the game's units). */ + double move_per_tick; + /* Rotation between the two frames beyond which a value snaps + * (MATRIX rotation, ANGLES). 0 means pi (any turn up to a half turn). */ + double turn_radians; +} PSXMotionLimits; + +typedef struct PSXMotionStats { + uint32_t tracked; /* values captured this frame */ + uint32_t blended; /* ... that will blend */ + uint32_t placed; /* ... that moved or turned past the limits */ + uint32_t unmatched; /* ... new, or a different identity at the address */ + uint32_t ticks; /* logic ticks between the two frames (0 = no history) */ +} PSXMotionStats; + +typedef struct PSXMotionMatrix { + int16_t r[9]; + int32_t t[3]; +} PSXMotionMatrix; + +typedef struct PSXMotionSet PSXMotionSet; + +PSXMotionSet* psx_motion_set_create(uint32_t capacity); +void psx_motion_set_destroy(PSXMotionSet* set); + +/* Start this frame's capture. `tick` is the game's logic tick counter at + * this frame; the previous capture becomes the history. */ +void psx_motion_begin(PSXMotionSet* set, uint32_t tick); +/* Record the value of `kind` at `addr` as the draw code will read it. + * `identity` names what lives there (psx_motion_identity of its type, + * handler, model...). One value per (addr, kind) per frame; a repeat + * replaces the earlier one. Returns 0 when the set is full. */ +int psx_motion_track(PSXMotionSet* set, uint32_t kind, uint32_t addr, + uint32_t identity); +/* Pair this capture with the previous one. Returns how many values will + * blend. History counts only when it is the capture just before this one + * with 1..8 ticks between them; otherwise nothing blends. */ +uint32_t psx_motion_prepare(PSXMotionSet* set, const PSXMotionLimits* limits, + PSXMotionStats* stats); +/* Write every prepared value at t (0 = previous frame, 1 = this frame). */ +void psx_motion_apply(const PSXMotionSet* set, double t); +/* Blend one prepared value at t into `out` without writing guest RAM: + * a PSXMotionMatrix for the MATRIX kinds, int32_t[3] for VECTOR / SVECTOR / + * ANGLES, int32_t for SCALAR. Returns 0 when that value does not blend this + * frame (out then holds its current value) or was not tracked (out + * untouched, returns -1). */ +int psx_motion_blend_at(const PSXMotionSet* set, uint32_t kind, uint32_t addr, + double t, void* out); +/* Forget all history (scene change, load, a frame the plugin skipped). */ +void psx_motion_invalidate(PSXMotionSet* set); + +/* Mix up to three words into an identity tag. */ +uint32_t psx_motion_identity(uint32_t a, uint32_t b, uint32_t c); + +/* The math, for plugins that blend values the set does not hold. Returns 0 + * when the rotation turned past turn_radians (out = b). */ +int psx_motion_blend_matrix(const PSXMotionMatrix* a, const PSXMotionMatrix* b, + double t, double turn_radians, + PSXMotionMatrix* out); +void psx_motion_read_matrix(uint32_t addr, PSXMotionMatrix* out); +void psx_motion_write_matrix(uint32_t addr, const PSXMotionMatrix* m, + int rotation); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 7c390bf1..89e80b5d 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -353,6 +353,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/frame_interpolation.c ${PSXRECOMP_ROOT}/runtime/src/render_pass.c ${PSXRECOMP_ROOT}/runtime/src/render_pass_plan.c + ${PSXRECOMP_ROOT}/runtime/src/render_pass_motion.c + ${PSXRECOMP_ROOT}/runtime/src/render_pass_frame.c ${PSXRECOMP_ROOT}/runtime/src/host_time.c ${PSXRECOMP_ROOT}/runtime/src/psx_fiber.c ${PSXRECOMP_ROOT}/runtime/src/sio.c diff --git a/runtime/src/render_pass_frame.c b/runtime/src/render_pass_frame.c new file mode 100644 index 00000000..9a4d8156 --- /dev/null +++ b/runtime/src/render_pass_frame.c @@ -0,0 +1,58 @@ +/* Per-frame driver and activation for render-pass frame-rate plugins: the + * parts every title repeats around psx_mod_render_pass_plan / _pass. */ +#include "mod_plugins.h" + +#include +#include + +#define FRAME_MAX_PHASES 16u + +static int s_crossfade; /* passes lastingly unavailable: presenter crossfades */ + +int psx_mod_activate_render_pass_rate(const char* package, const char* feature, + const char* option, uint32_t flip_mode) { + char value[16]; + unsigned long fps = 0; /* 0 follows the measured display refresh */ + if (package && feature && option && + psx_mod_option_value(package, feature, option, value, sizeof value) && + strcmp(value, "display") != 0) { + char* end = value; + const unsigned long parsed = strtoul(value, &end, 10); + if (end != value && *end == '\0') fps = parsed; + } + s_crossfade = 0; + if (!psx_mod_set_frame_interpolation_source(PSX_MOD_FRAME_SOURCE_FLIP) || + !psx_mod_set_render_pass_flip(flip_mode) || + !psx_mod_set_frame_interpolation_blend(PSX_MOD_FRAME_INTERPOLATION_HOLD)) + return 0; + return psx_mod_set_frame_interpolation((uint32_t)fps); +} + +uint32_t psx_mod_render_pass_frame(struct CPUState* cpu, + const PSXModRenderPassFrame* frame, + PSXModRenderPassFn fn, void* user) { + uint32_t phases[FRAME_MAX_PHASES], n, kept = 0; + const uint32_t status = psx_mod_render_pass_status(); + const int lasting = status == PSX_MOD_RENDER_PASS_BACKEND || + status == PSX_MOD_RENDER_PASS_DISABLED; + PSXModRenderPass pass; + if (lasting != s_crossfade) { + s_crossfade = lasting; + psx_mod_set_frame_interpolation_blend( + lasting ? PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE + : PSX_MOD_FRAME_INTERPOLATION_HOLD); + } + if (!cpu || !frame || !fn || frame->struct_size < sizeof *frame || + frame->period_vblanks == 0 || frame->w == 0 || frame->h == 0) + return 0; + n = psx_mod_render_pass_plan(frame->period_vblanks, frame->shown_after_vblanks, + phases, FRAME_MAX_PHASES); + memset(&pass, 0, sizeof pass); + pass.struct_size = sizeof pass; + pass.x = frame->x; pass.y = frame->y; pass.w = frame->w; pass.h = frame->h; + for (uint32_t i = 0; i < n; i++) { + pass.alpha_q16 = phases[i]; + if (psx_mod_render_pass(cpu, &pass, fn, user)) kept++; + } + return kept; +} diff --git a/runtime/src/render_pass_motion.c b/runtime/src/render_pass_motion.c new file mode 100644 index 00000000..89d9eeb1 --- /dev/null +++ b/runtime/src/render_pass_motion.c @@ -0,0 +1,447 @@ +/* Motion kit for render-pass plugins: capture, pair and blend the game state + * a pass redraws from. See include/render_pass_motion.h. */ +#include "render_pass_motion.h" +#include "mod_plugins.h" + +#include +#include +#include + +#define MOTION_MAX_TICKS 8u +#define MOTION_PI 3.14159265358979323846 + +typedef struct MotionEntry { + uint32_t addr, identity, seq; + uint8_t kind; + int32_t v[12]; /* MATRIX: r[0..8], t at [9..11]; others from v[0] */ +} MotionEntry; + +typedef struct MotionPair { uint32_t prev, cur; } MotionPair; + +struct PSXMotionSet { + uint32_t cap; + MotionEntry* cap_buf[2]; + uint32_t n[2]; + uint32_t tick[2]; + int began[2]; /* a capture was made into this buffer */ + int sorted[2]; + int cur; /* buffer the current capture writes */ + int history; /* the other buffer holds the capture just before */ + MotionPair* pairs; + uint32_t npairs; + uint32_t ticks; +}; + +uint32_t psx_motion_identity(uint32_t a, uint32_t b, uint32_t c) { + uint32_t h = 2166136261u; + const uint32_t w[3] = {a, b, c}; + for (int i = 0; i < 3; i++) + for (int k = 0; k < 4; k++) h = (h ^ ((w[i] >> (8 * k)) & 0xFFu)) * 16777619u; + return h; +} + +PSXMotionSet* psx_motion_set_create(uint32_t capacity) { + PSXMotionSet* s; + if (capacity == 0) return NULL; + s = (PSXMotionSet*)calloc(1, sizeof *s); + if (!s) return NULL; + s->cap = capacity; + s->cap_buf[0] = (MotionEntry*)calloc(capacity, sizeof(MotionEntry)); + s->cap_buf[1] = (MotionEntry*)calloc(capacity, sizeof(MotionEntry)); + s->pairs = (MotionPair*)calloc(capacity, sizeof(MotionPair)); + if (!s->cap_buf[0] || !s->cap_buf[1] || !s->pairs) { + psx_motion_set_destroy(s); + return NULL; + } + return s; +} + +void psx_motion_set_destroy(PSXMotionSet* s) { + if (!s) return; + free(s->cap_buf[0]); + free(s->cap_buf[1]); + free(s->pairs); + free(s); +} + +void psx_motion_invalidate(PSXMotionSet* s) { + if (!s) return; + s->history = 0; + s->began[0] = s->began[1] = 0; + s->n[0] = s->n[1] = 0; + s->npairs = 0; + s->ticks = 0; +} + +void psx_motion_begin(PSXMotionSet* s, uint32_t tick) { + if (!s) return; + s->history = s->began[s->cur]; + s->cur ^= 1; + s->n[s->cur] = 0; + s->tick[s->cur] = tick; + s->began[s->cur] = 1; + s->sorted[s->cur] = 0; + s->npairs = 0; + s->ticks = 0; +} + +/* ---- guest RAM --------------------------------------------------------- */ + +void psx_motion_read_matrix(uint32_t addr, PSXMotionMatrix* m) { + for (int i = 0; i < 9; i++) m->r[i] = (int16_t)psx_mod_read_half(addr + 2u * (uint32_t)i); + for (int i = 0; i < 3; i++) m->t[i] = (int32_t)psx_mod_read_word(addr + 0x14u + 4u * (uint32_t)i); +} + +void psx_motion_write_matrix(uint32_t addr, const PSXMotionMatrix* m, int rotation) { + if (rotation) + for (int i = 0; i < 9; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)m->r[i]); + for (int i = 0; i < 3; i++) psx_mod_write_word(addr + 0x14u + 4u * (uint32_t)i, (uint32_t)m->t[i]); +} + +static void read_entry(MotionEntry* e) { + switch (e->kind) { + case PSX_MOTION_MATRIX: + case PSX_MOTION_MATRIX_TRANSLATION: { + PSXMotionMatrix m; + psx_motion_read_matrix(e->addr, &m); + for (int i = 0; i < 9; i++) e->v[i] = m.r[i]; + for (int i = 0; i < 3; i++) e->v[9 + i] = m.t[i]; + break; + } + case PSX_MOTION_VECTOR: + for (int i = 0; i < 3; i++) e->v[i] = (int32_t)psx_mod_read_word(e->addr + 4u * (uint32_t)i); + break; + case PSX_MOTION_SVECTOR: + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) e->v[i] = (int16_t)psx_mod_read_half(e->addr + 2u * (uint32_t)i); + break; + case PSX_MOTION_SCALAR: + e->v[0] = (int32_t)psx_mod_read_word(e->addr); + break; + } +} + +int psx_motion_track(PSXMotionSet* s, uint32_t kind, uint32_t addr, uint32_t identity) { + MotionEntry* e; + if (!s || kind < PSX_MOTION_MATRIX || kind > PSX_MOTION_SCALAR) return 0; + if (!s->began[s->cur] || s->n[s->cur] >= s->cap) return 0; + e = &s->cap_buf[s->cur][s->n[s->cur]]; + memset(e, 0, sizeof *e); + e->addr = addr; + e->kind = (uint8_t)kind; + e->identity = identity; + e->seq = s->n[s->cur]++; + s->sorted[s->cur] = 0; + read_entry(e); + return 1; +} + +/* ---- math ---------------------------------------------------------------- */ + +static int orthonormal(const int16_t* r) { + for (int i = 0; i < 3; i++) { + double len = 0; + for (int j = 0; j < 3; j++) len += (double)r[3 * i + j] * r[3 * i + j]; + if (fabs(sqrt(len) - 4096.0) > 4096.0 * 0.04) return 0; + for (int k = i + 1; k < 3; k++) { + double dot = 0; + for (int j = 0; j < 3; j++) dot += (double)r[3 * i + j] * r[3 * k + j]; + if (fabs(dot) > 4096.0 * 4096.0 * 0.04) return 0; + } + } + return 1; +} + +static void to_quat(const int16_t* r, double q[4]) { + double a[9], n; + for (int i = 0; i < 9; i++) a[i] = r[i] / 4096.0; + const double tr = a[0] + a[4] + a[8]; + if (tr > 0) { + double s = sqrt(tr + 1.0) * 2; + q[0] = 0.25 * s; q[1] = (a[7] - a[5]) / s; q[2] = (a[2] - a[6]) / s; q[3] = (a[3] - a[1]) / s; + } else if (a[0] > a[4] && a[0] > a[8]) { + double s = sqrt(1.0 + a[0] - a[4] - a[8]) * 2; + q[0] = (a[7] - a[5]) / s; q[1] = 0.25 * s; q[2] = (a[1] + a[3]) / s; q[3] = (a[2] + a[6]) / s; + } else if (a[4] > a[8]) { + double s = sqrt(1.0 + a[4] - a[0] - a[8]) * 2; + q[0] = (a[2] - a[6]) / s; q[1] = (a[1] + a[3]) / s; q[2] = 0.25 * s; q[3] = (a[5] + a[7]) / s; + } else { + double s = sqrt(1.0 + a[8] - a[0] - a[4]) * 2; + q[0] = (a[3] - a[1]) / s; q[1] = (a[2] + a[6]) / s; q[2] = (a[5] + a[7]) / s; q[3] = 0.25 * s; + } + n = sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]); + for (int i = 0; i < 4; i++) q[i] /= n; +} + +static int16_t fixed12(double v) { + double x = v * 4096.0; + if (x > 32767.0) x = 32767.0; + if (x < -32768.0) x = -32768.0; + return (int16_t)lround(x); +} + +static double turn_limit(double turn) { return turn > 0 ? turn : MOTION_PI; } + +/* Angle between two orientations, or -1 when either is not a rotation. */ +static double rotation_angle(const int16_t* a, const int16_t* b) { + double p[4], q[4], d; + if (!orthonormal(a) || !orthonormal(b)) return -1; + to_quat(a, p); + to_quat(b, q); + d = fabs(p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]); + if (d > 1.0) d = 1.0; + return 2.0 * acos(d); +} + +static int blend_rotation(const int16_t* a, const int16_t* b, double t, double turn, int16_t* out) { + if (orthonormal(a) && orthonormal(b)) { + double p[4], q[4], d, angle, wa, wb, r[4]; + to_quat(a, p); + to_quat(b, q); + d = p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]; + if (d < 0) { for (int i = 0; i < 4; i++) q[i] = -q[i]; d = -d; } + if (d > 1.0) d = 1.0; + angle = acos(d); + if (2.0 * angle > turn_limit(turn)) { memcpy(out, b, 9 * sizeof *out); return 0; } + wa = 1 - t; wb = t; + if (angle > 1e-6) { wa = sin((1 - t) * angle) / sin(angle); wb = sin(t * angle) / sin(angle); } + for (int i = 0; i < 4; i++) r[i] = wa * p[i] + wb * q[i]; + { + const double w = r[0], x = r[1], y = r[2], z = r[3]; + const double m[9] = {1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), + 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), + 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)}; + for (int i = 0; i < 9; i++) out[i] = fixed12(m[i]); + } + return 1; + } + /* Scaled or sheared: elementwise, refusing jumps no motion produces. */ + for (int i = 0; i < 9; i++) + if (abs((int)a[i] - (int)b[i]) > 4096) { memcpy(out, b, 9 * sizeof *out); return 0; } + for (int i = 0; i < 9; i++) out[i] = (int16_t)lround(a[i] + (b[i] - a[i]) * t); + return 1; +} + +int psx_motion_blend_matrix(const PSXMotionMatrix* a, const PSXMotionMatrix* b, + double t, double turn, PSXMotionMatrix* out) { + const int ok = blend_rotation(a->r, b->r, t, turn, out->r); + for (int i = 0; i < 3; i++) + out->t[i] = ok ? (int32_t)llround(a->t[i] + ((double)b->t[i] - a->t[i]) * t) : b->t[i]; + return ok; +} + +static double dist3(const int32_t* a, const int32_t* b) { + double s = 0; + for (int i = 0; i < 3; i++) { const double d = (double)b[i] - a[i]; s += d * d; } + return sqrt(s); +} + +static int32_t arc12(int32_t from, int32_t to) { /* shortest signed delta */ + int32_t d = (to - from) & 4095; + return d > 2048 ? d - 4096 : d; /* an exact half turn goes forward */ +} + +/* ---- pairing ------------------------------------------------------------- */ + +static int entry_cmp(const void* x, const void* y) { + const MotionEntry* a = (const MotionEntry*)x; + const MotionEntry* b = (const MotionEntry*)y; + if (a->addr != b->addr) return a->addr < b->addr ? -1 : 1; + if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1; + return a->seq < b->seq ? -1 : a->seq > b->seq; +} + +/* Sort by (addr, kind); of repeats the last tracked wins. */ +static void sort_capture(PSXMotionSet* s, int b) { + MotionEntry* e = s->cap_buf[b]; + uint32_t n = s->n[b], w = 0; + if (s->sorted[b]) return; + qsort(e, n, sizeof *e, entry_cmp); + for (uint32_t i = 0; i < n; i++) { + if (w && e[w - 1].addr == e[i].addr && e[w - 1].kind == e[i].kind) e[w - 1] = e[i]; + else e[w++] = e[i]; + } + s->n[b] = w; + s->sorted[b] = 1; +} + +/* 1 when the pair moved within the limits over `ticks`. */ +static int within_limits(const MotionEntry* a, const MotionEntry* b, + const PSXMotionLimits* lim, uint32_t ticks) { + const double move = lim->move_per_tick > 0 ? lim->move_per_tick * ticks : HUGE_VAL; + switch (a->kind) { + case PSX_MOTION_MATRIX: { + double ang; + int16_t ra[9], rb[9]; + if (dist3(&a->v[9], &b->v[9]) > move) return 0; + for (int i = 0; i < 9; i++) { ra[i] = (int16_t)a->v[i]; rb[i] = (int16_t)b->v[i]; } + ang = rotation_angle(ra, rb); + if (ang >= 0) return ang <= turn_limit(lim->turn_radians); + for (int i = 0; i < 9; i++) if (abs(a->v[i] - b->v[i]) > 4096) return 0; + return 1; + } + case PSX_MOTION_MATRIX_TRANSLATION: + return dist3(&a->v[9], &b->v[9]) <= move; + case PSX_MOTION_VECTOR: + case PSX_MOTION_SVECTOR: + return dist3(a->v, b->v) <= move; + case PSX_MOTION_ANGLES: { + const double lim12 = turn_limit(lim->turn_radians) * 4096.0 / (2.0 * MOTION_PI); + for (int i = 0; i < 3; i++) if (fabs((double)arc12(a->v[i], b->v[i])) > lim12) return 0; + return 1; + } + case PSX_MOTION_SCALAR: + return fabs((double)b->v[0] - a->v[0]) <= move; + } + return 0; +} + +uint32_t psx_motion_prepare(PSXMotionSet* s, const PSXMotionLimits* lim, + PSXMotionStats* stats) { + PSXMotionStats st; + const PSXMotionLimits none = {0, 0}; + const int c = s ? s->cur : 0, h = c ^ 1; + memset(&st, 0, sizeof st); + if (!lim) lim = &none; + if (!s || !s->began[c]) { if (stats) *stats = st; return 0; } + sort_capture(s, c); + st.tracked = s->n[c]; + s->npairs = 0; + s->ticks = 0; + if (s->history && s->began[h]) { + const uint32_t ticks = s->tick[c] - s->tick[h]; + if (ticks >= 1 && ticks <= MOTION_MAX_TICKS) s->ticks = ticks; + } + if (!s->ticks) { + st.unmatched = st.tracked; + } else { + const MotionEntry* pe = s->cap_buf[h]; + const MotionEntry* ce = s->cap_buf[c]; + uint32_t i = 0; + sort_capture(s, h); + for (uint32_t j = 0; j < s->n[c]; j++) { + while (i < s->n[h] && (pe[i].addr < ce[j].addr || + (pe[i].addr == ce[j].addr && pe[i].kind < ce[j].kind))) + i++; + if (i >= s->n[h] || pe[i].addr != ce[j].addr || pe[i].kind != ce[j].kind || + pe[i].identity != ce[j].identity) { + st.unmatched++; + } else if (!within_limits(&pe[i], &ce[j], lim, s->ticks)) { + st.placed++; + } else { + s->pairs[s->npairs].prev = i; + s->pairs[s->npairs].cur = j; + s->npairs++; + } + } + } + st.blended = s->npairs; + st.ticks = s->ticks; + if (stats) *stats = st; + return s->npairs; +} + +/* Blend one pair into `v` (entry layout). */ +static void blend_entry(const MotionEntry* a, const MotionEntry* b, double t, int32_t* v) { + switch (a->kind) { + case PSX_MOTION_MATRIX: { + int16_t ra[9], rb[9], ro[9]; + for (int i = 0; i < 9; i++) { ra[i] = (int16_t)a->v[i]; rb[i] = (int16_t)b->v[i]; } + blend_rotation(ra, rb, t, MOTION_PI * 2, ro); /* limits were applied at prepare */ + for (int i = 0; i < 9; i++) v[i] = ro[i]; + for (int i = 9; i < 12; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + } + case PSX_MOTION_MATRIX_TRANSLATION: + for (int i = 0; i < 9; i++) v[i] = b->v[i]; + for (int i = 9; i < 12; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) + v[i] = (int32_t)lround(a->v[i] + arc12(a->v[i], b->v[i]) * t); + break; + default: + for (int i = 0; i < 3; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + } +} + +static void write_entry(uint8_t kind, uint32_t addr, const int32_t* v) { + switch (kind) { + case PSX_MOTION_MATRIX: + case PSX_MOTION_MATRIX_TRANSLATION: { + PSXMotionMatrix m; + for (int i = 0; i < 9; i++) m.r[i] = (int16_t)v[i]; + for (int i = 0; i < 3; i++) m.t[i] = v[9 + i]; + psx_motion_write_matrix(addr, &m, kind == PSX_MOTION_MATRIX); + break; + } + case PSX_MOTION_VECTOR: + for (int i = 0; i < 3; i++) psx_mod_write_word(addr + 4u * (uint32_t)i, (uint32_t)v[i]); + break; + case PSX_MOTION_SVECTOR: + for (int i = 0; i < 3; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)v[i]); + break; + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)(v[i] & 4095)); + break; + case PSX_MOTION_SCALAR: + psx_mod_write_word(addr, (uint32_t)v[0]); + break; + } +} + +void psx_motion_apply(const PSXMotionSet* s, double t) { + if (!s || !s->npairs) return; + const MotionEntry* pe = s->cap_buf[s->cur ^ 1]; + const MotionEntry* ce = s->cap_buf[s->cur]; + for (uint32_t k = 0; k < s->npairs; k++) { + int32_t v[12]; + const MotionEntry* a = &pe[s->pairs[k].prev]; + const MotionEntry* b = &ce[s->pairs[k].cur]; + blend_entry(a, b, t, v); + write_entry(b->kind, b->addr, v); + } +} + +static void export_value(uint8_t kind, const int32_t* v, void* out) { + if (kind == PSX_MOTION_MATRIX || kind == PSX_MOTION_MATRIX_TRANSLATION) { + PSXMotionMatrix* m = (PSXMotionMatrix*)out; + for (int i = 0; i < 9; i++) m->r[i] = (int16_t)v[i]; + for (int i = 0; i < 3; i++) m->t[i] = v[9 + i]; + } else if (kind == PSX_MOTION_SCALAR) { + *(int32_t*)out = v[0]; + } else { + memcpy(out, v, 3 * sizeof(int32_t)); + } +} + +int psx_motion_blend_at(const PSXMotionSet* s, uint32_t kind, uint32_t addr, + double t, void* out) { + const MotionEntry* ce; + uint32_t lo = 0, hi; + if (!s || !out || !s->began[s->cur]) return -1; + ce = s->cap_buf[s->cur]; + hi = s->n[s->cur]; + if (!s->sorted[s->cur]) { + for (uint32_t j = hi; j-- > 0;) /* unprepared: newest wins */ + if (ce[j].addr == addr && ce[j].kind == kind) { export_value(ce[j].kind, ce[j].v, out); return 0; } + return -1; + } + while (lo < hi) { + const uint32_t mid = (lo + hi) / 2; + if (ce[mid].addr < addr || (ce[mid].addr == addr && ce[mid].kind < kind)) lo = mid + 1; + else hi = mid; + } + if (lo >= s->n[s->cur] || ce[lo].addr != addr || ce[lo].kind != kind) return -1; + for (uint32_t k = 0; k < s->npairs; k++) { + if (s->pairs[k].cur == lo) { + int32_t v[12]; + blend_entry(&s->cap_buf[s->cur ^ 1][s->pairs[k].prev], &ce[lo], t, v); + export_value(ce[lo].kind, v, out); + return 1; + } + } + export_value(ce[lo].kind, ce[lo].v, out); + return 0; +} diff --git a/runtime/tests/test_render_pass_motion.c b/runtime/tests/test_render_pass_motion.c new file mode 100644 index 00000000..6098ec78 --- /dev/null +++ b/runtime/tests/test_render_pass_motion.c @@ -0,0 +1,215 @@ +/* render_pass_motion.c and render_pass_frame.c against a mock guest RAM and + * mock render-pass / mod APIs. */ +#include "mod_plugins.h" +#include "render_pass_motion.h" + +#include +#include +#include + +static int failures; +#define CHECK(cond, msg) do { if (!(cond)) { failures++; printf("FAIL: %s\n", msg); } } while (0) + +/* ---- mock guest RAM ------------------------------------------------------ */ +static uint8_t s_ram[0x10000]; +static uint32_t off(uint32_t a) { return a & 0xFFFFu; } +uint8_t psx_mod_read_byte(uint32_t a) { return s_ram[off(a)]; } +uint16_t psx_mod_read_half(uint32_t a) { uint16_t v; memcpy(&v, s_ram + off(a), 2); return v; } +uint32_t psx_mod_read_word(uint32_t a) { uint32_t v; memcpy(&v, s_ram + off(a), 4); return v; } +void psx_mod_write_byte(uint32_t a, uint8_t v) { s_ram[off(a)] = v; } +void psx_mod_write_half(uint32_t a, uint16_t v) { memcpy(s_ram + off(a), &v, 2); } +void psx_mod_write_word(uint32_t a, uint32_t v) { memcpy(s_ram + off(a), &v, 4); } + +/* ---- mock mod / pass APIs (render_pass_frame.c) --------------------------- */ +static char s_option[16] = "display"; +static uint32_t s_fps = 99, s_source = 99, s_blend = 99, s_flip = 99, s_blend_calls; +static uint32_t s_status, s_phases, s_period, s_shown, s_passes_run; +int psx_mod_option_value(const char* p, const char* f, const char* o, char* out, uint32_t n) { + (void)p; (void)f; (void)o; + snprintf(out, n, "%s", s_option); + return 1; +} +int psx_mod_set_frame_interpolation(uint32_t fps) { s_fps = fps; return 1; } +int psx_mod_set_frame_interpolation_source(uint32_t s) { s_source = s; return 1; } +int psx_mod_set_frame_interpolation_blend(uint32_t b) { s_blend = b; s_blend_calls++; return 1; } +int psx_mod_set_render_pass_flip(uint32_t m) { s_flip = m; return 1; } +uint32_t psx_mod_render_pass_status(void) { return s_status; } +uint32_t psx_mod_render_pass_plan(uint32_t period, uint32_t shown, uint32_t* a, uint32_t max) { + s_period = period; s_shown = shown; + if (s_status != PSX_MOD_RENDER_PASS_READY) return 0; + for (uint32_t i = 0; i < s_phases && i < max; i++) a[i] = (i + 1) * 65536u / (s_phases + 1); + return s_phases < max ? s_phases : max; +} +int psx_mod_render_pass(struct CPUState* cpu, const PSXModRenderPass* p, + PSXModRenderPassFn fn, void* user) { + s_passes_run++; + return fn(cpu, user, p->alpha_q16); +} + +/* ---- helpers --------------------------------------------------------------- */ +static void put_matrix(uint32_t a, double yaw, int32_t x, int32_t y, int32_t z) { + PSXMotionMatrix m; + const double c = cos(yaw), s = sin(yaw); + const double r[9] = {c, 0, s, 0, 1, 0, -s, 0, c}; + for (int i = 0; i < 9; i++) m.r[i] = (int16_t)lround(r[i] * 4096.0); + m.t[0] = x; m.t[1] = y; m.t[2] = z; + psx_motion_write_matrix(a, &m, 1); +} +static double yaw_of(uint32_t a) { + PSXMotionMatrix m; + psx_motion_read_matrix(a, &m); + return atan2(m.r[2] / 4096.0, m.r[0] / 4096.0); +} + +static void test_matrix_slerp(void) { + PSXMotionSet* s = psx_motion_set_create(16); + PSXMotionLimits lim = {1000.0, 0}; + PSXMotionStats st; + PSXMotionMatrix m; + put_matrix(0x100, 0.0, 0, 0, 0); + psx_motion_begin(s, 10); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x100, 7); + CHECK(psx_motion_prepare(s, &lim, &st) == 0 && st.ticks == 0, "first frame has no history"); + put_matrix(0x100, 1.2, 800, 0, -400); + psx_motion_begin(s, 12); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x100, 7); + CHECK(psx_motion_prepare(s, &lim, &st) == 1 && st.ticks == 2 && st.blended == 1, + "the same object two ticks later blends"); + psx_motion_apply(s, 0.5); + psx_motion_read_matrix(0x100, &m); + CHECK(fabs(yaw_of(0x100) - 0.6) < 0.002, "rotation slerps to the half-way angle"); + CHECK(m.t[0] == 400 && m.t[2] == -200, "translation blends linearly"); + { + double len = 0; + for (int j = 0; j < 3; j++) len += (double)m.r[j] * m.r[j]; + CHECK(fabs(sqrt(len) - 4096.0) < 3.0, "the blended rotation stays a rotation"); + } + CHECK(psx_motion_blend_at(s, PSX_MOTION_MATRIX, 0x100, 1.0, &m) == 1 && + m.t[0] == 800, "blend_at at t=1 is the current value"); + psx_motion_set_destroy(s); +} + +static void test_identity_and_limits(void) { + PSXMotionSet* s = psx_motion_set_create(16); + PSXMotionLimits lim = {1000.0, 1.0}; + PSXMotionStats st; + int32_t v[3]; + put_matrix(0x200, 0.0, 0, 0, 0); /* moves within limits */ + put_matrix(0x300, 0.0, 0, 0, 0); /* reused by another object */ + put_matrix(0x400, 0.0, 0, 0, 0); /* teleports */ + put_matrix(0x500, 0.0, 0, 0, 0); /* spins past the turn limit */ + psx_motion_begin(s, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x200, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x300, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x400, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x500, 1); + psx_motion_prepare(s, &lim, &st); + put_matrix(0x200, 0.0, 1500, 0, 0); + put_matrix(0x300, 0.0, 10, 0, 0); + put_matrix(0x400, 0.0, 5000, 0, 0); + put_matrix(0x500, 2.0, 0, 0, 0); + psx_motion_begin(s, 3); /* two ticks: 2000 units allowed */ + psx_motion_track(s, PSX_MOTION_MATRIX, 0x200, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x300, 2); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x400, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x500, 1); + psx_motion_track(s, PSX_MOTION_VECTOR, 0x600, 1); /* new this frame */ + CHECK(psx_motion_prepare(s, &lim, &st) == 1, "only the moving object blends"); + CHECK(st.placed == 2 && st.unmatched == 2 && st.tracked == 5, + "teleport and spin are placed; reuse and new are unmatched"); + psx_motion_apply(s, 0.5); + CHECK((int32_t)psx_mod_read_word(0x300 + 0x14) == 10, "a reused address keeps its new object"); + CHECK((int32_t)psx_mod_read_word(0x400 + 0x14) == 5000, "a teleport is drawn where it landed"); + CHECK((int32_t)psx_mod_read_word(0x200 + 0x14) == 750, "the mover is half-way"); + CHECK(psx_motion_blend_at(s, PSX_MOTION_VECTOR, 0x600, 0.5, v) == 0, "no history: current value"); + CHECK(psx_motion_blend_at(s, PSX_MOTION_VECTOR, 0x700, 0.5, v) == -1, "untracked: refused"); + + /* A gap (a frame not captured) or too many ticks: nothing blends. */ + psx_motion_begin(s, 20); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x200, 1); + CHECK(psx_motion_prepare(s, &lim, &st) == 0 && st.ticks == 0, "17 ticks apart is not one motion"); + psx_motion_invalidate(s); + psx_motion_begin(s, 21); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x200, 1); + CHECK(psx_motion_prepare(s, &lim, &st) == 0, "after invalidate there is no history"); + psx_motion_set_destroy(s); +} + +static void test_angles_vectors_scalars(void) { + PSXMotionSet* s = psx_motion_set_create(16); + PSXMotionLimits lim = {100.0, 0}; + PSXMotionStats st; + psx_mod_write_half(0x800, 4000); psx_mod_write_half(0x802, 100); psx_mod_write_half(0x804, 0); + psx_mod_write_half(0x810, (uint16_t)-50); psx_mod_write_half(0x812, 0); psx_mod_write_half(0x814, 0); + psx_mod_write_word(0x820, 1000); + psx_motion_begin(s, 5); + psx_motion_track(s, PSX_MOTION_ANGLES, 0x800, 1); + psx_motion_track(s, PSX_MOTION_SVECTOR, 0x810, 1); + psx_motion_track(s, PSX_MOTION_SCALAR, 0x820, 1); + psx_motion_prepare(s, &lim, &st); + psx_mod_write_half(0x800, 96); psx_mod_write_half(0x802, 300); psx_mod_write_half(0x804, 2048); + psx_mod_write_half(0x810, 50); + psx_mod_write_word(0x820, 1100); + psx_motion_begin(s, 6); + psx_motion_track(s, PSX_MOTION_ANGLES, 0x800, 1); + psx_motion_track(s, PSX_MOTION_SVECTOR, 0x810, 1); + psx_motion_track(s, PSX_MOTION_SCALAR, 0x820, 1); + CHECK(psx_motion_prepare(s, &lim, &st) == 3, "angles, svector and scalar blend"); + psx_motion_apply(s, 0.5); + CHECK(psx_mod_read_half(0x800) == 0, "4000 -> 96 crosses 4096 the short way (0)"); + CHECK(psx_mod_read_half(0x802) == 200, "100 -> 300 is 200"); + CHECK(psx_mod_read_half(0x804) == 1024, "0 -> 2048 half turn blends to 1024"); + CHECK((int16_t)psx_mod_read_half(0x810) == 0, "signed svector -50 -> 50 is 0"); + CHECK(psx_mod_read_word(0x820) == 1050, "scalar blends"); + psx_motion_set_destroy(s); +} + +static int s_fn_calls; +static double s_fn_last; +static int frame_fn(struct CPUState* cpu, void* user, uint32_t alpha) { + (void)cpu; (void)user; + s_fn_calls++; + s_fn_last = alpha / 65536.0; + return 1; +} + +static void test_frame_driver(void) { + PSXModRenderPassFrame f; + char cpu_storage[4096]; + struct CPUState* cpu = (struct CPUState*)cpu_storage; + strcpy(s_option, "165"); + CHECK(psx_mod_activate_render_pass_rate("p", "f", "rate", PSX_MOD_RENDER_PASS_FLIP_PENDING) && + s_fps == 165 && s_source == PSX_MOD_FRAME_SOURCE_FLIP && + s_blend == PSX_MOD_FRAME_INTERPOLATION_HOLD && + s_flip == PSX_MOD_RENDER_PASS_FLIP_PENDING, "activation selects rate, FLIP, HOLD, flip mode"); + strcpy(s_option, "display"); + psx_mod_activate_render_pass_rate("p", "f", "rate", PSX_MOD_RENDER_PASS_FLIP_SHOWN); + CHECK(s_fps == 0 && s_flip == PSX_MOD_RENDER_PASS_FLIP_SHOWN, "display refresh is rate 0"); + + memset(&f, 0, sizeof f); + f.struct_size = sizeof f; + f.period_vblanks = 2; f.w = 320; f.h = 240; + s_status = PSX_MOD_RENDER_PASS_READY; + s_phases = 3; + s_blend_calls = 0; + CHECK(psx_mod_render_pass_frame(cpu, &f, frame_fn, NULL) == 3 && s_fn_calls == 3 && + s_period == 2 && s_fn_last > 0.7, "every planned phase runs"); + CHECK(s_blend_calls == 0, "READY keeps HOLD without re-setting it"); + s_status = PSX_MOD_RENDER_PASS_BACKEND; + CHECK(psx_mod_render_pass_frame(cpu, &f, frame_fn, NULL) == 0 && + s_blend == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE, "lasting refusal crossfades"); + s_status = PSX_MOD_RENDER_PASS_SESSION; + psx_mod_render_pass_frame(cpu, &f, frame_fn, NULL); + CHECK(s_blend == PSX_MOD_FRAME_INTERPOLATION_HOLD, "a transient reason returns to HOLD"); + f.struct_size = 4; + CHECK(psx_mod_render_pass_frame(cpu, &f, frame_fn, NULL) == 0, "a short struct is refused"); +} + +int main(void) { + test_matrix_slerp(); + test_identity_and_limits(); + test_angles_vectors_scalars(); + test_frame_driver(); + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); + return failures ? 1 : 0; +}