From 3e262c128da3d9f6a8169e4e81096425fb74803e Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:52:39 -0700 Subject: [PATCH] Render passes: unit-test the sandbox (store drops, frozen devices, journal) The store policy and the CPU side of the out-of-rect VRAM journal move into render_pass_plan.c as pure functions (render_pass_store_to, render_pass_vram_policy, render_pass_journal_add/_rollback); memory.c and gpu_gl_renderer.c only wire them to their arrays and GL copies. Behaviour is unchanged. render_pass_sandbox_test covers what the plan's U5a list asked for: - every device class a pass may not reach (SPU key-on and control, CD, timer, other DMA channels, GP1, SIO, memory control, MDEC, expansion, KSEG2) is dropped under its class and never delivered, while GP0, GP1 DMA mode/info, I_STAT/I_MASK, GPU/OTC DMA and DPCR reach the device; - a real pass (render_pass.c, psx_cycles.c, timers.c) that makes those stores, acknowledges and masks every interrupt, runs 10^6 cycles while timer 2 is armed to interrupt every 1000, and changes a timer behind the store policy's back: drops counted per class, no device advanced, no interrupt raised, RAM, scratchpad, I_STAT/I_MASK, timers and clock restored, and the timer interrupts again once time is live; - fast-forward and backend status refuse the pass without running it; - VRAM writes inside the rect are allowed, outside ones journaled once (covered writes not twice, clipped to VRAM) and rolled back exactly, newest first; a full journal or a mode that cannot journal refuses. Mutations it catches: SPU or CD mis-classed, device service not frozen, rollback order or entry skipped, I_STAT, timer, RAM or scratchpad restore removed. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 24 +- runtime/CMakeLists.txt | 16 + runtime/include/render_pass_plan.h | 51 +++ runtime/src/gpu_gl_renderer.c | 72 ++-- runtime/src/memory.c | 47 +-- runtime/src/render_pass_plan.c | 91 +++++ runtime/tests/test_render_pass_guards.py | 18 +- runtime/tests/test_render_pass_sandbox.c | 483 +++++++++++++++++++++++ 8 files changed, 721 insertions(+), 81 deletions(-) create mode 100644 runtime/tests/test_render_pass_sandbox.c diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index a979d201..428d5d46 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -150,6 +150,24 @@ internal resolutions; a size change frees the old set. write/MMIO/cycle fingerprints of a run with passes must equal a run without them. -Tests: `render_pass_plan_test`, `render_pass_freeze_test` and -`render_pass_abort_test` (runtime ctest), -`render_pass_guards` (source guard, recompiler ctest). +Tests (runtime ctest unless noted): + +- `render_pass_plan_test`: phase planning per rate, shedding, selection, the + MMIO allow-list. +- `render_pass_freeze_test`: 10^6 frozen cycles, exact clock restore, + watchdog. +- `render_pass_sandbox_test`: the store policy `memory.c` routes every pass + store through (SPU, CD, timer, other-DMA, GP1 and other device stores + dropped and counted per class, never delivered; GP0, I_STAT/I_MASK and + GPU/OTC DMA delivered); a real pass over `render_pass.c`, `psx_cycles.c` + and `timers.c` that makes those stores and runs 10^6 cycles while a timer + is armed to interrupt every 1000 (no device advances, no interrupt, RAM, + scratchpad, I_STAT/I_MASK, timers and clock restored); status gates; the + out-of-rect VRAM journal policy and its exact rollback of the CPU VRAM rows. +- `render_pass_abort_test`: a watchdog abort from nested frames. +- `render_pass_guards` (source guard, recompiler ctest): the choke points + above, and that the codegen-hashed headers do not carry the pass API. + +The GPU half of the VRAM transaction (hr colour, stencil, raw mirror blits) +needs an OpenGL context and is checked at runtime by +`PSX_RENDER_PASS_VERIFY=1`. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 1d97dd22..fdb198bf 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -634,6 +634,22 @@ if(BUILD_TESTING) add_test(NAME render_pass_abort_test COMMAND render_pass_abort_test) endif() + # Render-pass sandbox: the store policy memory.c uses, a real pass + # (render_pass.c, psx_cycles.c, timers.c) that makes device stores and + # runs frozen cycles, and the out-of-rect VRAM journal. + add_executable(render_pass_sandbox_test + tests/test_render_pass_sandbox.c + src/render_pass.c + src/render_pass_plan.c + src/psx_cycles.c + src/timers.c + src/psx_video_timing.c) + target_include_directories(render_pass_sandbox_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(render_pass_sandbox_test PRIVATE m) + endif() + add_test(NAME render_pass_sandbox_test COMMAND render_pass_sandbox_test) + add_executable(gpu_primitive_reject_test tests/test_gpu_primitive_reject.c) target_include_directories(gpu_primitive_reject_test PRIVATE include) add_test(NAME gpu_primitive_reject_test COMMAND gpu_primitive_reject_test) diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index fc0f1b4a..a9eb33ee 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -12,6 +12,7 @@ * rendered at phase a shows the game state a of the way from F to the next * frame. Phases are carried as Q16 (65536 = 1). */ +#include #include #ifdef __cplusplus @@ -62,6 +63,56 @@ double render_pass_budget(double idle_ticks, double pass_ticks, * RENDER_PASS_DROP_* class it is dropped and counted under (render_pass.h). */ int render_pass_mmio_class(uint32_t phys, uint32_t val, uint32_t width); +/* One guest store made inside a pass (memory.c render_pass_store): RAM and + * scratchpad are written directly, bypassing every live-timeline observer; + * MMIO reaches `mmio_write` only when render_pass_mmio_class allows it; KSEG2 + * (cache control), mod memory, expansion and ROM are dropped. Returns -1 when + * the store was made (or absorbed: an isolated-cache store changes nothing a + * pass restores), else the RENDER_PASS_DROP_* class it was dropped under, + * which the caller counts. */ +typedef struct RenderPassStoreTarget { + uint8_t *ram; /* main RAM, ram_size bytes (mirrored) */ + uint32_t ram_size; + uint8_t *scratchpad; + uint32_t scratchpad_size; + int isolate_cache; /* COP0 SR IsC is set */ + void (*mmio_write)(uint32_t phys, uint32_t val, uint32_t width); +} RenderPassStoreTarget; +int render_pass_store_to(const RenderPassStoreTarget *t, uint32_t addr, + uint32_t val, uint32_t width); + +/* VRAM journal for a pass's writes outside its display rect + * (gpu_gl_renderer.c pass_refuse_write): the CPU-side policy and the CPU VRAM + * rows. The renderer keeps the matching GPU copies per entry. */ +#define RENDER_PASS_JOURNAL_MAX 16 +enum { + RENDER_PASS_VRAM_ALLOW = 0, /* inside the rect, empty, or already journaled */ + RENDER_PASS_VRAM_JOURNAL = 1, /* outside: back up, then allow */ + RENDER_PASS_VRAM_REFUSE = 2 /* outside and cannot be journaled */ +}; +typedef struct RenderPassJournal { + int n; + int x[RENDER_PASS_JOURNAL_MAX], y[RENDER_PASS_JOURNAL_MAX]; + int w[RENDER_PASS_JOURNAL_MAX], h[RENDER_PASS_JOURNAL_MAX]; + uint16_t *rows[RENDER_PASS_JOURNAL_MAX]; + size_t cap[RENDER_PASS_JOURNAL_MAX]; +} RenderPassJournal; +/* Clip the write (*x, *y, *w, *h) to vram_w x vram_h and decide it against + * the pass rect (px, py, pw, ph) and the journal. can_journal = 0 refuses + * every outside write (e.g. native-wide margins are not journaled). */ +int render_pass_vram_policy(const RenderPassJournal *j, int px, int py, + int pw, int ph, int vram_w, int vram_h, + int can_journal, int *x, int *y, int *w, int *h); +/* Record the CPU rows of rect (x, y, w, h) of vram (vram_w halfwords per + * row) as the next entry. Returns its index, or -1 when full / out of + * memory (nothing recorded). */ +int render_pass_journal_add(RenderPassJournal *j, const uint16_t *vram, + int vram_w, int x, int y, int w, int h); +/* Put every entry's rows back, newest first, and empty the journal. */ +void render_pass_journal_rollback(RenderPassJournal *j, uint16_t *vram, + int vram_w); +void render_pass_journal_free(RenderPassJournal *j); + #ifdef __cplusplus } #endif diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 45a27a6c..387582bf 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -381,10 +381,9 @@ static uint64_t s_idle_ticks_accum_fwd(uint64_t add); static int s_pass_active = 0; static int s_pass_x = 0, s_pass_y = 0, s_pass_w = 0, s_pass_h = 0; static uint32_t s_pass_leaks = 0; -static int pass_rect_contains(int x0, int y0, int x1, int y1) { - return x0 >= s_pass_x && y0 >= s_pass_y && - x1 < s_pass_x + s_pass_w && y1 < s_pass_y + s_pass_h; -} +/* Policy and CPU rows of the out-of-rect journal (render_pass_plan.c, + * render_pass_sandbox_test); the GPU copies live in s_pj below. */ +static RenderPassJournal s_pj_cpu; static int pass_journal_protect(int x, int y, int w, int h); /* A fill, copy or upload outside the pass rect (e.g. a game that moves a * few pixels of VRAM as part of every frame) is journaled -- its destination @@ -392,15 +391,14 @@ static int pass_journal_protect(int x, int y, int w, int h); * full or native-wide is active, refused and counted (the pass is then rolled * back without an image). */ static int pass_refuse_write(const char *what, int x, int y, int w, int h) { + int policy; if (!s_pass_active) return 0; - if (w <= 0 || h <= 0) return 0; - if (x < 0) { w += x; x = 0; } - if (y < 0) { h += y; y = 0; } - if (x + w > VRAM_W) w = VRAM_W - x; - if (y + h > VRAM_H) h = VRAM_H - y; - if (w <= 0 || h <= 0) return 0; - if (pass_rect_contains(x, y, x + w - 1, y + h - 1)) return 0; - if (pass_journal_protect(x, y, w, h)) return 0; + policy = render_pass_vram_policy(&s_pj_cpu, s_pass_x, s_pass_y, s_pass_w, + s_pass_h, VRAM_W, VRAM_H, 1, + &x, &y, &w, &h); + if (policy == RENDER_PASS_VRAM_ALLOW) return 0; + if (policy == RENDER_PASS_VRAM_JOURNAL && pass_journal_protect(x, y, w, h)) + return 0; if (s_pass_leaks < 8) fprintf(stderr, "psxrecomp: render pass refused %s %d,%d %dx%d outside " "%d,%d %dx%d\n", what, x, y, w, h, s_pass_x, s_pass_y, @@ -4616,16 +4614,12 @@ static void pass_verify_read(uint8_t **hr, size_t *hr_cap, uint8_t **raw, } /* VRAM journal for out-of-rect writes during a pass (see pass_refuse_write). */ -#define PASS_JOURNAL_MAX 16 +#define PASS_JOURNAL_MAX RENDER_PASS_JOURNAL_MAX typedef struct PassJournal { - int x, y, w, h; GLuint hr_tex, hr_rb, hr_fbo, raw_tex, raw_fbo; int hr_w, hr_h, raw_w, raw_h; - uint16_t *cpu; - size_t cpu_cap; } PassJournal; static PassJournal s_pj[PASS_JOURNAL_MAX]; -static int s_pj_n = 0; static uint64_t s_pj_total = 0; static void pass_free_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo, @@ -4657,21 +4651,16 @@ static void pass_resources_release(void) { pass_free_color_fbo(&e->hr_tex, &e->hr_rb, &e->hr_fbo, &e->hr_w, &e->hr_h); pass_free_color_fbo(&e->raw_tex, NULL, &e->raw_fbo, &e->raw_w, &e->raw_h); } - s_pj_n = 0; + render_pass_journal_free(&s_pj_cpu); s_pb_valid = 0; } static int pass_journal_protect(int x, int y, int w, int h) { - int S = s_scale; + int S = s_scale, i = s_pj_cpu.n; PassJournal *j; if (g_wide_w > 0) return 0; /* wide margins are not journaled */ - for (int i = 0; i < s_pj_n; i++) { - PassJournal *e = &s_pj[i]; - if (x >= e->x && y >= e->y && x + w <= e->x + e->w && y + h <= e->y + e->h) - return 1; /* already covered */ - } - if (s_pj_n >= PASS_JOURNAL_MAX) return 0; - j = &s_pj[s_pj_n]; + if (i >= PASS_JOURNAL_MAX) return 0; + j = &s_pj[i]; if (!pass_make_color_fbo(&j->hr_tex, &j->hr_rb, &j->hr_fbo, &j->hr_w, &j->hr_h, w * S, h * S, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE) || @@ -4679,40 +4668,29 @@ static int pass_journal_protect(int x, int y, int w, int h) { &j->raw_h, w, h, PSXGL_R16UI, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT)) return 0; - if (j->cpu_cap < (size_t)w * (size_t)h) { - free(j->cpu); - j->cpu = (uint16_t *)malloc((size_t)w * (size_t)h * sizeof(uint16_t)); - j->cpu_cap = j->cpu ? (size_t)w * (size_t)h : 0; - if (!j->cpu) return 0; - } flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - j->x = x; j->y = y; j->w = w; j->h = h; + if (render_pass_journal_add(&s_pj_cpu, s_vram, VRAM_W, x, y, w, h) != i) + return 0; pass_blit(s_hr_fbo, j->hr_fbo, x * S, y * S, 0, 0, w * S, h * S, GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); pass_blit(s_raw_fbo, j->raw_fbo, x, y, 0, 0, w, h, GL_COLOR_BUFFER_BIT); - for (int row = 0; row < h; row++) - memcpy(j->cpu + (size_t)row * w, s_vram + (size_t)(y + row) * VRAM_W + x, - (size_t)w * sizeof(uint16_t)); - s_pj_n++; s_pj_total++; return 1; } static void pass_journal_rollback(void) { int S = s_scale; - for (int i = s_pj_n - 1; i >= 0; i--) { + for (int i = s_pj_cpu.n - 1; i >= 0; i--) { PassJournal *j = &s_pj[i]; - pass_blit(j->hr_fbo, s_hr_fbo, 0, 0, j->x * S, j->y * S, j->w * S, - j->h * S, GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - pass_blit(j->raw_fbo, s_raw_fbo, 0, 0, j->x, j->y, j->w, j->h, - GL_COLOR_BUFFER_BIT); - for (int row = 0; row < j->h; row++) - memcpy(s_vram + (size_t)(j->y + row) * VRAM_W + j->x, - j->cpu + (size_t)row * j->w, (size_t)j->w * sizeof(uint16_t)); + int x = s_pj_cpu.x[i], y = s_pj_cpu.y[i]; + int w = s_pj_cpu.w[i], h = s_pj_cpu.h[i]; + pass_blit(j->hr_fbo, s_hr_fbo, 0, 0, x * S, y * S, w * S, h * S, + GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + pass_blit(j->raw_fbo, s_raw_fbo, 0, 0, x, y, w, h, GL_COLOR_BUFFER_BIT); } - s_pj_n = 0; + render_pass_journal_rollback(&s_pj_cpu, s_vram, VRAM_W); } int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, @@ -4807,7 +4785,7 @@ backed_up: s_pass_verify = (e && e[0] && e[0] != '0') ? 1 : 0; } if (s_pass_verify) pass_verify_read(&s_pv_hr, &s_pv_hr_cap, &s_pv_raw, &s_pv_raw_cap); - s_pj_n = 0; + s_pj_cpu.n = 0; s_pass_active = 1; return 1; } diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 8ae6a586..1330c348 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1717,35 +1717,26 @@ static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) { * counted instead. */ uint64_t g_render_pass_dropped_writes[RENDER_PASS_DROP_CLASSES]; +static void render_pass_mmio_write(uint32_t phys, uint32_t val, + uint32_t width) { + if (width == 4) mmio_write32(phys, val); + else if (width == 2) mmio_write16(phys, (uint16_t)val); + else mmio_write8(phys, (uint8_t)val); +} + +/* The policy is render_pass_store_to (render_pass_plan.c, unit-tested by + * render_pass_sandbox_test); this only wires it to memory.c's arrays. */ static void render_pass_store(uint32_t addr, uint32_t val, uint32_t width) { - uint32_t phys; - if (addr >= 0xC0000000u) { /* cache control, KSEG2 */ - g_render_pass_dropped_writes[RENDER_PASS_DROP_OTHER]++; - return; - } - if (sr_ptr && (*sr_ptr & 0x10000u)) return; /* IsC: cache-only store */ - phys = psx_phys_addr(addr); - if (phys < RAM_SIZE) { - for (uint32_t i = 0; i < width; i++) ram[phys + i] = (uint8_t)(val >> (8u * i)); - return; - } - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - for (uint32_t i = 0; i < width && off + i < SCRATCHPAD_SIZE; i++) - scratchpad[off + i] = (uint8_t)(val >> (8u * i)); - return; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - int cls = render_pass_mmio_class(phys, val, width); /* render_pass_plan.c */ - if (cls >= 0) { g_render_pass_dropped_writes[cls]++; return; } - if (width == 4) mmio_write32(phys, val); - else if (width == 2) mmio_write16(phys, (uint16_t)val); - else mmio_write8(phys, (uint8_t)val); - return; - } - /* Mod memory, expansion, ROM, unmapped: never written by a pass (mod - * arenas are not part of the pass restore). */ - g_render_pass_dropped_writes[RENDER_PASS_DROP_OTHER]++; + RenderPassStoreTarget t; + int cls; + t.ram = ram; + t.ram_size = RAM_SIZE; + t.scratchpad = scratchpad; + t.scratchpad_size = SCRATCHPAD_SIZE; + t.isolate_cache = (sr_ptr && (*sr_ptr & 0x10000u)) ? 1 : 0; + t.mmio_write = render_pass_mmio_write; + cls = render_pass_store_to(&t, addr, val, width); + if (cls >= 0) g_render_pass_dropped_writes[cls]++; } static void psx_write_word_raw(uint32_t addr, uint32_t val); diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index ae48aa43..df51a3e1 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -2,6 +2,8 @@ #include "render_pass.h" #include +#include +#include #define Q16 65536.0 @@ -145,3 +147,92 @@ int render_pass_mmio_class(uint32_t phys, uint32_t val, uint32_t width) { if (phys >= 0x1F801100u && phys <= 0x1F80112Fu) return RENDER_PASS_DROP_TIMER; return RENDER_PASS_DROP_OTHER; } + +int render_pass_store_to(const RenderPassStoreTarget *t, uint32_t addr, + uint32_t val, uint32_t width) { + uint32_t phys; + if (addr >= 0xC0000000u) return RENDER_PASS_DROP_OTHER; /* KSEG2 */ + if (t->isolate_cache) return -1; /* cache-only store */ + phys = addr & 0x1FFFFFFFu; + if (phys < 0x00800000u) phys &= t->ram_size - 1u; /* RAM mirrors */ + if (phys < t->ram_size) { + for (uint32_t i = 0; i < width; i++) + t->ram[phys + i] = (uint8_t)(val >> (8u * i)); + return -1; + } + if (phys >= 0x1F800000u && phys < 0x1F800000u + t->scratchpad_size) { + uint32_t off = phys - 0x1F800000u; + for (uint32_t i = 0; i < width && off + i < t->scratchpad_size; i++) + t->scratchpad[off + i] = (uint8_t)(val >> (8u * i)); + return -1; + } + if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { + int cls = render_pass_mmio_class(phys, val, width); + if (cls >= 0) return cls; + t->mmio_write(phys, val, width); + return -1; + } + /* Mod memory, expansion, ROM, unmapped: never written by a pass (mod + * arenas are not part of the pass restore). */ + return RENDER_PASS_DROP_OTHER; +} + +int render_pass_vram_policy(const RenderPassJournal *j, int px, int py, + int pw, int ph, int vram_w, int vram_h, + int can_journal, int *x, int *y, int *w, int *h) { + if (*w <= 0 || *h <= 0) return RENDER_PASS_VRAM_ALLOW; + if (*x < 0) { *w += *x; *x = 0; } + if (*y < 0) { *h += *y; *y = 0; } + if (*x + *w > vram_w) *w = vram_w - *x; + if (*y + *h > vram_h) *h = vram_h - *y; + if (*w <= 0 || *h <= 0) return RENDER_PASS_VRAM_ALLOW; + if (*x >= px && *y >= py && *x + *w <= px + pw && *y + *h <= py + ph) + return RENDER_PASS_VRAM_ALLOW; + if (!can_journal) return RENDER_PASS_VRAM_REFUSE; + for (int i = 0; i < j->n; i++) + if (*x >= j->x[i] && *y >= j->y[i] && *x + *w <= j->x[i] + j->w[i] && + *y + *h <= j->y[i] + j->h[i]) + return RENDER_PASS_VRAM_ALLOW; /* already covered */ + return j->n < RENDER_PASS_JOURNAL_MAX ? RENDER_PASS_VRAM_JOURNAL + : RENDER_PASS_VRAM_REFUSE; +} + +int render_pass_journal_add(RenderPassJournal *j, const uint16_t *vram, + int vram_w, int x, int y, int w, int h) { + int i = j->n; + size_t need = (size_t)w * (size_t)h; + if (i >= RENDER_PASS_JOURNAL_MAX || w <= 0 || h <= 0) return -1; + if (j->cap[i] < need) { + uint16_t *p = (uint16_t *)realloc(j->rows[i], need * sizeof(uint16_t)); + if (!p) return -1; + j->rows[i] = p; + j->cap[i] = need; + } + for (int row = 0; row < h; row++) + memcpy(j->rows[i] + (size_t)row * (size_t)w, + vram + (size_t)(y + row) * (size_t)vram_w + (size_t)x, + (size_t)w * sizeof(uint16_t)); + j->x[i] = x; j->y[i] = y; j->w[i] = w; j->h[i] = h; + j->n = i + 1; + return i; +} + +void render_pass_journal_rollback(RenderPassJournal *j, uint16_t *vram, + int vram_w) { + for (int i = j->n - 1; i >= 0; i--) + for (int row = 0; row < j->h[i]; row++) + memcpy(vram + (size_t)(j->y[i] + row) * (size_t)vram_w + + (size_t)j->x[i], + j->rows[i] + (size_t)row * (size_t)j->w[i], + (size_t)j->w[i] * sizeof(uint16_t)); + j->n = 0; +} + +void render_pass_journal_free(RenderPassJournal *j) { + for (int i = 0; i < RENDER_PASS_JOURNAL_MAX; i++) { + free(j->rows[i]); + j->rows[i] = NULL; + j->cap[i] = 0; + } + j->n = 0; +} diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 7d280c51..33332429 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -54,10 +54,22 @@ for fn in ("void psx_write_word(uint32_t addr, uint32_t val) {", lines = body(mem, fn).splitlines()[:6] assert any("render_pass_store" in l for l in lines), ( "pass stores must bypass live-timeline observers: " + fn) -assert "render_pass_mmio_class(phys, val, width)" in body( - mem, "static void render_pass_store("), ( - "pass MMIO stores must go through the tested store policy") +b = body(mem, "static void render_pass_store(") +assert "render_pass_store_to(&t, addr, val, width)" in b and \ + "g_render_pass_dropped_writes[cls]++" in b, ( + "pass stores must go through the tested store policy " + "(render_pass_store_to, render_pass_sandbox_test) and count drops") plan = (SRC / "render_pass_plan.c").read_text(encoding="utf-8") +assert "render_pass_mmio_class(phys, val, width)" in body( + plan, "int render_pass_store_to("), ( + "pass MMIO stores must go through the MMIO allow-list") +gl = (SRC / "gpu_gl_renderer.c").read_text(encoding="utf-8") +assert "render_pass_vram_policy(&s_pj_cpu" in body( + gl, "static int pass_refuse_write("), ( + "out-of-rect VRAM writes must go through the tested journal policy") +assert "render_pass_journal_rollback(&s_pj_cpu" in body( + gl, "static void pass_journal_rollback("), ( + "the journal rollback must restore the CPU VRAM rows") cls = body(plan, "int render_pass_mmio_class(") for dev in ("RENDER_PASS_DROP_SPU", "RENDER_PASS_DROP_CD", "RENDER_PASS_DROP_TIMER"): diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c new file mode 100644 index 00000000..0e111061 --- /dev/null +++ b/runtime/tests/test_render_pass_sandbox.c @@ -0,0 +1,483 @@ +/* Render-pass sandbox: what a pass may write and what it gets back. + * + * 1. Store policy (render_pass_plan.c render_pass_store_to, the function + * memory.c routes every store made inside a pass through): RAM and + * scratchpad are written directly; SPU, CD, timer, other-DMA, GP1 and + * everything else outside the allow-list is dropped under its class and + * never reaches the device; GP0, GP1 DMA mode, I_STAT/I_MASK, GPU/OTC DMA + * and DPCR/DICR reach it. + * 2. A real pass (render_pass.c, psx_cycles.c and the real timers.c) that + * runs 10^6 guest cycles, makes those stores, acknowledges and masks + * interrupts: the drops are counted per class, no device advances, no + * timer IRQ is raised, and RAM, scratchpad, I_STAT/I_MASK, timers and the + * clock are exactly as before. Status gates refuse the pass without + * running it. + * 3. VRAM journal (render_pass_vram_policy / _journal_add / _rollback, the + * CPU side of gpu_gl_renderer.c's out-of-rect journal): writes inside the + * pass rect are allowed, writes outside are backed up first and rolled + * back exactly, covered writes are not journaled twice, a full journal or + * a renderer mode that cannot journal refuses. + * + * The GPU, presenter and remaining devices are mocks. Build/run: + * ctest -R render_pass_sandbox_test */ + +#include "render_pass.h" +#include "render_pass_plan.h" + +#include +#include +#include +#include + +#include "cpu_state.h" +#include "dirty_ram_interp.h" +#include "mod_plugins.h" +#include "psx_cycles.h" +#include "timers.h" + +static int failures; +#define CHECK(c, m) do { if (!(c)) { fprintf(stderr, "FAIL: %s\n", m); failures++; } } while (0) + +/* ---- runtime globals the units share with the rest of the runtime ----- */ +int g_ls_replay_active = 0; +int g_ls_mode = 0; +int g_precise_mode = 0; +int g_psx_call_bail = 0; +int g_psx_dispatch_depth = 0; +uint32_t i_stat = 0, i_mask = 0; +uint64_t g_guest_store_count = 0; +uint64_t g_mmio_access_count = 0; +uint32_t g_debug_last_store_pc = 0; +uint32_t g_debug_current_func_addr = 0; +uint32_t g_psx_icache_tv[1024]; +uint64_t g_render_pass_dropped_writes[RENDER_PASS_DROP_CLASSES]; +void (*g_overlay_flush_pending_cycles)(void) = NULL; +int g_call_unit_depth = 0; +int g_dma_exec_depth = 0; +int g_dma_cur_ch = -1; +uint32_t g_dma_cur_madr = 0, g_dma_cur_bcr = 0, g_dma_initiator_pc = 0; +int g_dirty_interp_active = 0; +int g_exec_phase = 0; +uint32_t g_dirty_safe_resume_pc = 0; + +/* ---- memory and the MMIO devices a pass may reach ----------------------- */ +#define RAM_SIZE (2u * 1024u * 1024u) +static uint8_t s_ram[RAM_SIZE]; +static uint8_t s_spad[1024]; +static uint8_t s_dma_regs[64]; +static uint32_t s_csv = 1234; +static int s_mmio_calls, s_gp0_words, s_gp1_calls, s_irq_refresh; +static uint32_t s_last_mmio; + +static void mock_mmio_write(uint32_t phys, uint32_t val, uint32_t width) { + (void)width; + s_mmio_calls++; + s_last_mmio = phys; + if (phys == 0x1F801070u) i_stat &= val; /* acknowledge */ + else if (phys == 0x1F801074u) i_mask = val; + else if (phys == 0x1F801810u) s_gp0_words++; + else if (phys == 0x1F801814u) s_gp1_calls++; + else if (phys >= 0x1F801100u && phys <= 0x1F80112Fu) + timers_write(phys, val); /* would move a timer */ +} + +static RenderPassStoreTarget target(void) { + RenderPassStoreTarget t; + memset(&t, 0, sizeof t); + t.ram = s_ram; + t.ram_size = RAM_SIZE; + t.scratchpad = s_spad; + t.scratchpad_size = sizeof s_spad; + t.mmio_write = mock_mmio_write; + return t; +} + +/* What memory.c psx_write_word/half/byte do while a pass is active. */ +static void guest_store(uint32_t addr, uint32_t val, uint32_t width) { + RenderPassStoreTarget t = target(); + int cls; + if (!g_psx_render_pass_active) { + fprintf(stderr, "FAIL: guest_store outside a pass\n"); + failures++; + return; + } + cls = render_pass_store_to(&t, addr, val, width); + if (cls >= 0) g_render_pass_dropped_writes[cls]++; +} + +uint8_t *memory_get_ram_ptr(void) { return s_ram; } +uint8_t *memory_get_scratchpad_ptr(void) { return s_spad; } +uint32_t dma_snapshot_bytes(void) { return sizeof s_dma_regs; } +void dma_snapshot_write(uint8_t *p) { memcpy(p, s_dma_regs, sizeof s_dma_regs); } +int dma_snapshot_read(const uint8_t *p, uint32_t len) { + if (len != sizeof s_dma_regs) return 0; + memcpy(s_dma_regs, p, len); + return 1; +} +int dma_gpu_linked_list_active(void) { return 0; } +uint32_t interrupts_get_cycles_since_vblank(void) { return s_csv; } +void interrupts_set_cycles_since_vblank(uint32_t v) { s_csv = v; } +void psx_irq_refresh_cause_ip2(void) { s_irq_refresh++; } +static int s_irqs_raised; +void psx_irq_raise(uint32_t bit, uint32_t detail) { + (void)detail; + s_irqs_raised++; + i_stat |= 1u << bit; +} +void event_ring_record_aux(int ev, uint8_t src, uint32_t aux) { + (void)ev; (void)src; (void)aux; +} +uint32_t cdrom_snapshot_bytes(void) { return 0; } +void cdrom_snapshot_write(uint8_t *p) { (void)p; } +uint32_t spu_snapshot_bytes(void) { return 0; } +void spu_snapshot_write(uint8_t *p) { (void)p; } +uint32_t sio_snapshot_bytes(void) { return 0; } +void sio_snapshot_write(uint8_t *p) { (void)p; } +uint32_t mdec_snapshot_bytes(void) { return 0; } +void mdec_snapshot_write(uint8_t *p) { (void)p; } + +int psx_get_in_exception(void) { return 0; } +int psx_netplay_active(void) { return 0; } +int psx_netplay_is_resimulating(void) { return 0; } +int psx_selfcheck_resim_active(void) { return 0; } +int psx_selfcheck_enabled(void) { return 0; } +int psx_rewind_is_open(void) { return 0; } +static int s_fast_forward; +int psx_presentation_fast_forward(void) { return s_fast_forward; } + +/* psx_cycles.c device side (timers are the real ones). */ +static uint64_t s_dev_cycles; +void sio_advance(uint32_t c) { s_dev_cycles += c; } +void cdrom_advance(uint32_t c) { s_dev_cycles += c; } +void dma_advance(uint32_t c) { s_dev_cycles += c; } +void interrupts_advance_cycles(uint32_t c) { s_dev_cycles += c; } +void interrupts_service_scheduled_events(void) {} +uint32_t interrupts_cycles_to_vblank(void) { return 564480u; } +uint32_t cdrom_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; } +uint32_t dma_cycles_to_internal_event(void) { return UINT32_MAX; } +uint32_t dma_cycles_to_deliverable_irq(uint32_t m) { (void)m; return UINT32_MAX; } +uint32_t sio_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; } +uint32_t psx_spu_sample_event_cycles_to_next(void) { return UINT32_MAX; } +void psx_spu_sample_event_service(void) {} +void starvation_watchdog_check(void) {} +void starvation_ring_pc_sample(void) {} +void dirty_ram_irq_ambient_resync_after_restore(void) {} + +static DirtyRamLoadDelay s_ld; +void dirty_ram_ld_delay_discard(void) { memset(&s_ld, 0, sizeof s_ld); } +void dirty_ram_ld_delay_save(DirtyRamLoadDelay *o) { *o = s_ld; } +void dirty_ram_ld_delay_restore(const DirtyRamLoadDelay *i) { s_ld = *i; } +void overlay_loader_native_nesting(int *d, uint32_t *ip) { *d = 0; *ip = 0; } +void overlay_loader_set_native_nesting(int d, uint32_t ip) { (void)d; (void)ip; } + +/* GPU and presenter. */ +uint64_t gpu_pass_state_hash(void) { return 42; } +int gpu_pass_checkpoint_save(void) { return 1; } +void gpu_pass_checkpoint_restore(void) {} +static uint64_t s_ticks; +uint64_t gl_renderer_perf_ticks(void) { return s_ticks += 1000; } +uint64_t gl_renderer_perf_frequency(void) { return 1000000000u; } +static uint32_t s_gl_status = PSX_MOD_RENDER_PASS_READY; +uint32_t gl_renderer_pass_unavailable(void) { return s_gl_status; } +uint32_t gl_renderer_pass_plan(uint32_t p, uint32_t s, uint32_t *a, + uint32_t max, uint32_t *wanted) { + (void)p; (void)s; (void)a; (void)max; + if (wanted) *wanted = 0; + return 0; +} +static int s_open_passes, s_kept; +int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, + uint32_t period, int reuse_backup) { + (void)x; (void)y; (void)w; (void)h; (void)open_gen; (void)period; + (void)reuse_backup; + s_open_passes++; + return 1; +} +void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { + (void)alpha_q16; + s_open_passes--; + if (keep) s_kept++; +} +uint32_t gl_renderer_pass_leaks(void) { return 0; } +int gl_renderer_pass_verify_vram(void) { return 1; } +void gl_renderer_pass_note_cost(uint64_t t) { (void)t; } +void gl_renderer_pass_service_presents(void) {} + +/* ---- 1. store policy --------------------------------------------------- */ +static void test_store_policy(void) { + RenderPassStoreTarget t = target(); + int calls; + memset(s_ram, 0, sizeof s_ram); + memset(s_spad, 0, sizeof s_spad); + s_mmio_calls = 0; + + CHECK(render_pass_store_to(&t, 0x80001000u, 0x11223344u, 4) == -1 && + s_ram[0x1000] == 0x44 && s_ram[0x1003] == 0x11, "KSEG0 RAM word written"); + CHECK(render_pass_store_to(&t, 0xA0001004u, 0xBEEFu, 2) == -1 && + s_ram[0x1004] == 0xEF && s_ram[0x1005] == 0xBE, "KSEG1 RAM half written"); + CHECK(render_pass_store_to(&t, 0x00201008u, 0x5Au, 1) == -1 && + s_ram[0x1008] == 0x5A, "RAM mirror folds onto main RAM"); + CHECK(render_pass_store_to(&t, 0x1F800010u, 0xCAFEF00Du, 4) == -1 && + s_spad[0x10] == 0x0D && s_spad[0x13] == 0xCA, "scratchpad written"); + CHECK(s_mmio_calls == 0, "memory stores never touch MMIO"); + + /* Dropped, per class, and never delivered. */ + CHECK(render_pass_store_to(&t, 0x1F801D88u, 0xFFFFu, 2) == RENDER_PASS_DROP_SPU, + "SPU key-on dropped (SPU)"); + CHECK(render_pass_store_to(&t, 0x1F801C00u, 0x3FFFu, 2) == RENDER_PASS_DROP_SPU, + "SPU voice volume dropped (SPU)"); + CHECK(render_pass_store_to(&t, 0x1F801801u, 0x1Bu, 1) == RENDER_PASS_DROP_CD, + "CD command dropped (CD)"); + CHECK(render_pass_store_to(&t, 0x1F801104u, 0x58u, 4) == RENDER_PASS_DROP_TIMER, + "timer mode dropped (TIMER)"); + CHECK(render_pass_store_to(&t, 0x1F801128u, 100u, 2) == RENDER_PASS_DROP_TIMER, + "timer target dropped (TIMER)"); + CHECK(render_pass_store_to(&t, 0x1F8010C0u, 0x80100000u, 4) == RENDER_PASS_DROP_DMA, + "SPU DMA MADR dropped (DMA)"); + CHECK(render_pass_store_to(&t, 0x1F8010B8u, 0x11000000u, 4) == RENDER_PASS_DROP_DMA, + "CD DMA CHCR dropped (DMA)"); + CHECK(render_pass_store_to(&t, 0x1F801814u, 0x00000000u, 4) == RENDER_PASS_DROP_GPU, + "GP1 reset dropped (GPU)"); + CHECK(render_pass_store_to(&t, 0x1F801810u, 0xE1000000u, 2) == RENDER_PASS_DROP_GPU, + "a half-word GP0 store is dropped (GPU)"); + CHECK(render_pass_store_to(&t, 0x1F801040u, 0x11u, 1) == RENDER_PASS_DROP_OTHER, + "SIO dropped (OTHER)"); + CHECK(render_pass_store_to(&t, 0x1F801000u, 0x1F000000u, 4) == RENDER_PASS_DROP_OTHER, + "memory control dropped (OTHER)"); + CHECK(render_pass_store_to(&t, 0x1F801820u, 0u, 4) == RENDER_PASS_DROP_OTHER, + "MDEC dropped (OTHER)"); + CHECK(render_pass_store_to(&t, 0x1F000000u, 1u, 1) == RENDER_PASS_DROP_OTHER, + "expansion 1 dropped (OTHER)"); + CHECK(render_pass_store_to(&t, 0xFFFE0130u, 0x804u, 4) == RENDER_PASS_DROP_OTHER, + "KSEG2 cache control dropped (OTHER)"); + CHECK(s_mmio_calls == 0, "no dropped store reached a device"); + + /* Allowed: all restored after the pass. */ + calls = s_mmio_calls; + CHECK(render_pass_store_to(&t, 0x1F801810u, 0x28FFFFFFu, 4) == -1, "GP0 word allowed"); + CHECK(render_pass_store_to(&t, 0x1F801814u, 0x04000002u, 4) == -1, "GP1 DMA mode allowed"); + CHECK(render_pass_store_to(&t, 0x1F801814u, 0x10000007u, 4) == -1, "GP1 info allowed"); + CHECK(render_pass_store_to(&t, 0x1F801070u, 0xFFFFFFFEu, 4) == -1, "I_STAT ack allowed"); + CHECK(render_pass_store_to(&t, 0x1F801074u, 0x0000000Du, 4) == -1, "I_MASK allowed"); + CHECK(render_pass_store_to(&t, 0x1F8010A8u, 0x01000401u, 4) == -1, "GPU DMA allowed"); + CHECK(render_pass_store_to(&t, 0x1F8010E8u, 0x11000002u, 4) == -1, "OTC DMA allowed"); + CHECK(render_pass_store_to(&t, 0x1F8010F0u, 0x0F6F4321u, 4) == -1, "DPCR allowed"); + CHECK(s_mmio_calls == calls + 8, "allowed stores reached their device"); + + /* Isolated cache: nothing but the I-cache would change. */ + t.isolate_cache = 1; + s_ram[0x2000] = 0x77; + CHECK(render_pass_store_to(&t, 0x80002000u, 0u, 4) == -1 && s_ram[0x2000] == 0x77, + "IsC store absorbed, RAM untouched"); +} + +/* ---- 2. a real pass ------------------------------------------------------ */ +static int pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) { + (void)user; (void)alpha_q16; + cpu->gpr[8] = 0xDEADu; + guest_store(0x80010000u, 0x12345678u, 4); /* RAM */ + guest_store(0x1F800020u, 0x99u, 1); /* scratchpad */ + guest_store(0x1F801D88u, 0x00FFu, 2); /* SPU key-on */ + guest_store(0x1F801DAAu, 0xC000u, 2); /* SPU control */ + guest_store(0x1F801801u, 0x0Au, 1); /* CD: Init */ + guest_store(0x1F801124u, 0x0000u, 4); /* timer 2 mode */ + guest_store(0x1F8010C8u, 0x01000201u, 4); /* SPU DMA CHCR */ + guest_store(0x1F801814u, 0x03000001u, 4); /* GP1 display off */ + guest_store(0x1F801044u, 0x0001u, 2); /* SIO */ + guest_store(0x1F801810u, 0x02000000u, 4); /* GP0 fill: allowed */ + guest_store(0x1F801070u, 0u, 4); /* ack every IRQ */ + guest_store(0x1F801074u, 0u, 4); /* mask every IRQ */ + for (int i = 0; i < 1000; i++) psx_advance_cycles(1000u); /* 10^6 */ + /* A device-side change the store policy cannot see (as if some runtime + * path wrote a timer directly): the checkpoint must still undo it. */ + timers_write(0x1F801100u, 0x1234u); + return 1; +} + +static int never_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) { + (void)cpu; (void)user; (void)alpha_q16; + failures++; + fprintf(stderr, "FAIL: a refused pass ran its callback\n"); + return 1; +} + +static void test_pass(void) { + CPUState cpu; + PSXModRenderPass pass; + RenderPassStats st; + uint16_t tc0[3], tt0[3], tc1[3], tt1[3]; + uint32_t tm0[3], tf0[3], tm1[3], tf1[3]; + int32_t ti0[3], ti1[3]; + uint8_t ram0[64], spad0[64]; + uint64_t cyc0, dev0, next0; + uint32_t stat0, mask0; + int irqs0; + + memset(&cpu, 0, sizeof cpu); + cpu.gpr[8] = 0x55u; + memset(s_ram, 0xA5, sizeof s_ram); + memset(s_spad, 0x3C, sizeof s_spad); + psx_cycles_resync_after_restore(NULL); + + /* Live machine: timer 2 counts the system clock and interrupts on its + * target every 1000 cycles; timer 0 runs free. */ + timers_init(); + timers_write(0x1F801128u, 1000u); + timers_write(0x1F801124u, 0x0058u); + i_mask = 0x0040u; /* timer 2 unmasked */ + i_stat = 0x0001u; /* a pending VBlank */ + psx_advance_cycles(2500u); + CHECK(s_irqs_raised > 0, "sanity: timer 2 interrupts while time is live"); + + memset(&pass, 0, sizeof pass); + pass.struct_size = sizeof pass; + pass.w = 320; pass.h = 240; + pass.alpha_q16 = 32768u; + + timers_get_snapshot(tc0, tm0, tt0, ti0, tf0); + memcpy(ram0, s_ram + 0x10000, sizeof ram0); + memcpy(spad0, s_spad, sizeof spad0); + cyc0 = psx_cycle_count; + next0 = psx_next_service_cycle; + dev0 = s_dev_cycles; + stat0 = i_stat; + mask0 = i_mask; + irqs0 = s_irqs_raised; + s_mmio_calls = 0; + + CHECK(psx_mod_render_pass(&cpu, &pass, pass_fn, NULL) == 1, "the pass ran and was kept"); + render_pass_get_stats(&st); + CHECK(st.passes == 1 && s_kept == 1 && s_open_passes == 0, "one image, pass closed"); + CHECK(st.dropped[RENDER_PASS_DROP_SPU] == 2, "2 SPU stores dropped and counted"); + CHECK(st.dropped[RENDER_PASS_DROP_CD] == 1, "1 CD store dropped and counted"); + CHECK(st.dropped[RENDER_PASS_DROP_TIMER] == 1, "1 timer store dropped and counted"); + CHECK(st.dropped[RENDER_PASS_DROP_DMA] == 1, "1 other-DMA store dropped and counted"); + CHECK(st.dropped[RENDER_PASS_DROP_GPU] == 1, "1 GP1 store dropped and counted"); + CHECK(st.dropped[RENDER_PASS_DROP_OTHER] == 1, "1 SIO store dropped and counted"); + CHECK(s_mmio_calls == 3, "only GP0, I_STAT and I_MASK reached a device"); + CHECK(s_irqs_raised == irqs0, "no interrupt raised in 10^6 frozen cycles"); + CHECK(s_dev_cycles == dev0, "no device advanced"); + CHECK(psx_cycle_count == cyc0 && psx_next_service_cycle == next0, + "guest clock and service deadline restored"); + CHECK(i_stat == stat0 && i_mask == mask0, "I_STAT / I_MASK restored"); + CHECK(s_irq_refresh > 0, "CAUSE IP2 recomputed after the restore"); + timers_get_snapshot(tc1, tm1, tt1, ti1, tf1); + CHECK(memcmp(tc0, tc1, sizeof tc0) == 0 && memcmp(tm0, tm1, sizeof tm0) == 0 && + memcmp(tt0, tt1, sizeof tt0) == 0 && memcmp(ti0, ti1, sizeof ti0) == 0 && + memcmp(tf0, tf1, sizeof tf0) == 0, "timers exactly as before"); + CHECK(memcmp(ram0, s_ram + 0x10000, sizeof ram0) == 0, "RAM restored"); + CHECK(memcmp(spad0, s_spad, sizeof spad0) == 0, "scratchpad restored"); + CHECK(cpu.gpr[8] == 0x55u, "CPU registers restored"); + CHECK(!g_psx_render_pass_active, "time is live again"); + + /* Time is live again: the same cycles now move the timer and interrupt. */ + psx_advance_cycles(2500u); + CHECK(s_irqs_raised > irqs0, "timer 2 interrupts again after the pass"); + + /* Status gates refuse without running the callback. */ + s_fast_forward = 1; + CHECK(psx_mod_render_pass_status() == PSX_MOD_RENDER_PASS_FAST_FORWARD, + "fast-forward status"); + CHECK(psx_mod_render_pass(&cpu, &pass, never_fn, NULL) == 0, + "no pass during fast-forward"); + s_fast_forward = 0; + s_gl_status = PSX_MOD_RENDER_PASS_BACKEND; + CHECK(psx_mod_render_pass_status() == PSX_MOD_RENDER_PASS_BACKEND, + "backend status"); + CHECK(psx_mod_render_pass(&cpu, &pass, never_fn, NULL) == 0, + "no pass while the backend declines"); + s_gl_status = PSX_MOD_RENDER_PASS_READY; + CHECK(psx_mod_render_pass_status() == PSX_MOD_RENDER_PASS_READY, "ready again"); +} + +/* ---- 3. VRAM journal ----------------------------------------------------- */ +#define VW 1024 +#define VH 512 +static uint16_t s_vram[VW * VH], s_vram0[VW * VH]; + +static void paint(int x, int y, int w, int h, uint16_t v) { + for (int r = y; r < y + h; r++) + for (int c = x; c < x + w; c++) s_vram[r * VW + c] = v; +} + +static void test_journal(void) { + RenderPassJournal j; + int x, y, w, h, n; + memset(&j, 0, sizeof j); + for (int i = 0; i < VW * VH; i++) s_vram[i] = (uint16_t)(i * 2654435761u >> 16); + memcpy(s_vram0, s_vram, sizeof s_vram); + + /* The pass draws buffer 1 at (0, 240) 320x240. */ +#define POLICY(X, Y, W, H, CAN) \ + (x = (X), y = (Y), w = (W), h = (H), \ + render_pass_vram_policy(&j, 0, 240, 320, 240, VW, VH, (CAN), &x, &y, &w, &h)) + CHECK(POLICY(10, 250, 100, 100, 1) == RENDER_PASS_VRAM_ALLOW, "inside the rect: allowed"); + paint(10, 250, 100, 100, 0x1111); + CHECK(POLICY(0, 0, 0, 5, 1) == RENDER_PASS_VRAM_ALLOW, "empty write: allowed"); + + CHECK(POLICY(640, 0, 64, 32, 1) == RENDER_PASS_VRAM_JOURNAL, "outside: journal"); + CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 0, "journaled as entry 0"); + paint(640, 0, 64, 32, 0x2222); + CHECK(POLICY(650, 4, 16, 16, 1) == RENDER_PASS_VRAM_ALLOW, "covered: not journaled twice"); + paint(650, 4, 16, 16, 0x3333); + + CHECK(POLICY(300, 200, 40, 60, 1) == RENDER_PASS_VRAM_JOURNAL, "straddling the rect: journal"); + CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 1, "entry 1"); + paint(300, 200, 40, 60, 0x4444); + + /* Overlapping entry 0 partly: rolled back newest first. */ + CHECK(POLICY(600, 16, 64, 32, 1) == RENDER_PASS_VRAM_JOURNAL, "overlapping: journal"); + CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 2, "entry 2"); + paint(600, 16, 64, 32, 0x5555); + + /* Clipped to VRAM. */ + CHECK(POLICY(-8, -8, 24, 24, 1) == RENDER_PASS_VRAM_JOURNAL && x == 0 && y == 0 && + w == 16 && h == 16, "negative origin clipped"); + CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 3, "entry 3"); + paint(x, y, w, h, 0x6666); + CHECK(POLICY(1016, 508, 32, 32, 1) == RENDER_PASS_VRAM_JOURNAL && w == 8 && h == 4, + "far edge clipped"); + CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 4, "entry 4"); + paint(x, y, w, h, 0x7777); + + CHECK(POLICY(800, 300, 8, 8, 0) == RENDER_PASS_VRAM_REFUSE, + "a mode that cannot journal refuses"); + + n = j.n; + while (j.n < RENDER_PASS_JOURNAL_MAX) { + int k = j.n; + CHECK(POLICY(400 + 8 * k, 100, 4, 4, 1) == RENDER_PASS_VRAM_JOURNAL, "fill journal"); + render_pass_journal_add(&j, s_vram, VW, x, y, w, h); + paint(x, y, w, h, (uint16_t)(0x8000 + k)); + } + CHECK(j.n == RENDER_PASS_JOURNAL_MAX && n == 5, "journal full"); + CHECK(POLICY(900, 400, 4, 4, 1) == RENDER_PASS_VRAM_REFUSE, "full journal refuses"); + CHECK(render_pass_journal_add(&j, s_vram, VW, 900, 400, 4, 4) == -1, + "add refuses past the limit"); + + /* End of pass: roll back, then the pass rect itself is restored by the + * renderer's own backup; outside it VRAM must equal the original. */ + render_pass_journal_rollback(&j, s_vram, VW); + CHECK(j.n == 0, "journal emptied"); + paint(0, 240, 320, 240, 0); + { + int bad = 0; + uint16_t *a = s_vram, *b = s_vram0; + for (int r = 0; r < VH; r++) + for (int c = 0; c < VW; c++) { + if (r >= 240 && r < 480 && c < 320) continue; + if (a[r * VW + c] != b[r * VW + c]) bad++; + } + CHECK(bad == 0, "every out-of-rect write rolled back exactly"); + } + render_pass_journal_free(&j); +#undef POLICY +} + +int main(void) { + test_store_policy(); + test_pass(); + test_journal(); + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); + return failures ? 1 : 0; +}