Merge feat/render-pass-kit (motion kit, frame driver)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-10-02 10:17:29 -07:00
8 changed files with 998 additions and 0 deletions
+119
View File
@@ -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`):
+12
View File
@@ -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
+26
View File
@@ -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.
+119
View File
@@ -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 <stdint.h>
#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
+2
View File
@@ -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
+58
View File
@@ -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 <stdlib.h>
#include <string.h>
#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;
}
+447
View File
@@ -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 <math.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+215
View File
@@ -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 <math.h>
#include <stdio.h>
#include <string.h>
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;
}