mirror of
https://github.com/ARMSX2/ARMSX1.git
synced 2026-08-24 16:53:35 -07:00
1000 lines
36 KiB
C
1000 lines
36 KiB
C
/*
|
|
TEXREP_PARITY — texture dumping and replacement, checked BEHAVIOURALLY.
|
|
|
|
WHY THIS FILE EXISTS AND WHY GPU_PARITY IS NOT ENOUGH
|
|
----------------------------------------------------
|
|
tests/gpu_renderer_parity.c compares the two CPU rasterizers against each other, so it
|
|
cannot detect a shared mistake. These cases instead derive expected pixels directly from
|
|
the texture-replacement contract.
|
|
|
|
So nothing here is a rasterizer-versus-rasterizer comparison. Every case pins output
|
|
against a value derived from the FEATURE'S OWN CONTRACT (psx/texrep.h) and then checks
|
|
that each rasterizer independently produces it:
|
|
|
|
* round-trip identity dump a texture, feed the dump straight back as a replacement,
|
|
and the frame must be BIT-IDENTICAL to no replacement at all.
|
|
A wrong hash, a wrong rectangle, a wrong sub-texel, a wrong
|
|
alpha rule or a wrong colour expansion all break this, and
|
|
none of them can break it "the same way" in two rasterizers
|
|
because the reference is the ORIGINAL FRAME, not the other
|
|
rasterizer.
|
|
* substitution repaint the replacement and the frame must change to the
|
|
repainted colour — proving the hook is actually reached rather
|
|
than silently skipped.
|
|
* negative control the SAME repainted file under a wrong hash must change nothing,
|
|
which is what stops "everything is replaced by everything".
|
|
* sub-texel a 2x replacement rendered at internal scale 2 must produce a
|
|
non-uniform 2x2 block. Without the fractional UV every block is
|
|
flat and an upscaled pack looks exactly like the original — a
|
|
failure that is invisible to any per-texel comparison.
|
|
* GLSL agreement PSX_TEXREP_GLSL is the GLES rasterizer's only definition of the
|
|
atlas mapping and cannot be executed here. It is transcribed
|
|
into C by hand at the bottom of this file, from the macro text,
|
|
and psx_texrep_sample() must agree with it across a matrix of
|
|
scales, texture windows, rectangles and sub-texel positions.
|
|
Two independent transcriptions of one text is the strongest
|
|
check available without a GPU.
|
|
|
|
The PNG codec is exercised end to end by every one of those: the dumper writes it and the
|
|
loader reads it back.
|
|
*/
|
|
|
|
#include <stddef.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
|
|
#include "../psx/dev/gpu.h"
|
|
#include "../psx/dev/gpu_backend.h"
|
|
#include "../psx/texrep.h"
|
|
#include "../psx/texrep_png.h"
|
|
#include "../frontend/gpu_hw.h"
|
|
#include "../frontend/gpu_hw_rt.h"
|
|
|
|
/* ---- core stubs, as tests/gpu_renderer_parity.c does ------------------------------------ */
|
|
|
|
void log_log(int level, const char* file, int line, const char* format, ...) {
|
|
(void)level; (void)file; (void)line; (void)format;
|
|
}
|
|
|
|
void psx_ic_irq(psx_ic_t* ic, int id) { (void)ic; (void)id; }
|
|
|
|
void psx_sw_u8(psx_state_writer_t* w, uint8_t v) { (void)w; (void)v; }
|
|
void psx_sw_u16(psx_state_writer_t* w, uint16_t v) { (void)w; (void)v; }
|
|
void psx_sw_u32(psx_state_writer_t* w, uint32_t v) { (void)w; (void)v; }
|
|
void psx_sw_i32(psx_state_writer_t* w, int32_t v) { (void)w; (void)v; }
|
|
void psx_sw_f32(psx_state_writer_t* w, float v) { (void)w; (void)v; }
|
|
void psx_sw_u16_array(psx_state_writer_t* w, const uint16_t* v, size_t n) { (void)w; (void)v; (void)n; }
|
|
uint8_t psx_sr_u8(psx_state_reader_t* r) { (void)r; return 0; }
|
|
uint16_t psx_sr_u16(psx_state_reader_t* r) { (void)r; return 0; }
|
|
uint32_t psx_sr_u32(psx_state_reader_t* r) { (void)r; return 0; }
|
|
int32_t psx_sr_i32(psx_state_reader_t* r) { (void)r; return 0; }
|
|
float psx_sr_f32(psx_state_reader_t* r) { (void)r; return 0.0f; }
|
|
void psx_sr_u16_array(psx_state_reader_t* r, uint16_t* v, size_t n) { (void)r; (void)v; (void)n; }
|
|
|
|
static int g_failures = 0;
|
|
|
|
static void ok(const char* name) {
|
|
printf("TEXREP_PARITY passed case=%s\n", name);
|
|
}
|
|
|
|
static void fail(const char* name, const char* why) {
|
|
fprintf(stderr, "TEXREP_PARITY failed case=%s reason=%s\n", name, why);
|
|
g_failures++;
|
|
}
|
|
|
|
#define CHECK(name, cond, why) do { if (!(cond)) { fail((name), (why)); return; } } while (0)
|
|
|
|
/* ---- scratch directory ------------------------------------------------------------------ */
|
|
|
|
/* Under build/, which is already ignored and already where the test binaries live. */
|
|
#define TEXREP_TEST_DIR "build/tests/texrep"
|
|
#define TEXREP_DUMP_DIR TEXREP_TEST_DIR "/dump"
|
|
#define TEXREP_PACK_DIR TEXREP_TEST_DIR "/replacements"
|
|
|
|
/* Both directories start EMPTY every run. dump-writes-png asserts that exactly one file
|
|
appears, which only means anything if a previous run's leftovers are gone — and a stale
|
|
file under an old key would also make roundtrip-is-identical pass against the wrong image.
|
|
Shelling out is fine here: this binary runs on the build machine and nowhere else. */
|
|
static void reset_scratch(void) {
|
|
mkdir("build", 0755);
|
|
mkdir("build/tests", 0755);
|
|
mkdir(TEXREP_TEST_DIR, 0755);
|
|
mkdir(TEXREP_DUMP_DIR, 0755);
|
|
mkdir(TEXREP_PACK_DIR, 0755);
|
|
|
|
if (system("rm -f " TEXREP_DUMP_DIR "/*.png " TEXREP_PACK_DIR "/*.png") != 0)
|
|
fprintf(stderr, "TEXREP_PARITY note: could not clear the scratch directories\n");
|
|
}
|
|
|
|
/* ---- the corpus ------------------------------------------------------------------------- */
|
|
|
|
/*
|
|
ONE 16x16 4bpp texture at texture page (0, 0), CLUT at VRAM row 300, drawn as a textured
|
|
quad at (64, 64) and as a textured sprite at (128, 64).
|
|
|
|
4bpp is deliberate: it is the depth where the most can go wrong (four texels per VRAM
|
|
halfword, plus a CLUT indirection), and it is what PS1 games overwhelmingly use.
|
|
*/
|
|
#define TEX_PAGE_X 0
|
|
#define TEX_PAGE_Y 0
|
|
#define CLUT_X 0
|
|
#define CLUT_Y 300
|
|
#define TEX_W 16
|
|
#define TEX_H 16
|
|
#define QUAD_X 64
|
|
#define QUAD_Y 64
|
|
#define SPRITE_X 128
|
|
#define SPRITE_Y 64
|
|
|
|
/* CLUT word for GP0: bits 0-5 = x/16, bits 6-14 = y. */
|
|
#define CLUT_WORD ((uint16_t)(((CLUT_X >> 4) & 0x3f) | ((CLUT_Y & 0x1ff) << 6)))
|
|
/* Texpage word: bits 0-3 page x, bit 4 page y, bits 7-8 depth (0 = 4bpp). */
|
|
#define TEXPAGE_WORD ((uint16_t)(((TEX_PAGE_X >> 6) & 0xf) | (((TEX_PAGE_Y >> 8) & 1) << 4)))
|
|
|
|
static psx_gpu_t* make_gpu(void) {
|
|
psx_gpu_t* gpu = psx_gpu_create();
|
|
|
|
if (!gpu)
|
|
return NULL;
|
|
|
|
psx_gpu_init(gpu, NULL);
|
|
gpu->draw_x1 = 0;
|
|
gpu->draw_y1 = 0;
|
|
gpu->draw_x2 = PSX_GPU_FB_WIDTH - 1;
|
|
gpu->draw_y2 = PSX_GPU_FB_HEIGHT - 1;
|
|
|
|
return gpu;
|
|
}
|
|
|
|
/* A 16-entry palette and a 16x16 index pattern, both chosen so no two texels of the drawn
|
|
region share a colour by accident and so entry 0 (the transparent texel) is present. */
|
|
static void seed_texture(psx_gpu_t* gpu) {
|
|
int i, x, y;
|
|
|
|
memset(gpu->vram, 0, PSX_GPU_VRAM_SIZE);
|
|
|
|
for (i = 0; i < 16; i++) {
|
|
/* Spread across all three channels; entry 0 stays 0x0000, the transparent texel.
|
|
Entry 15 carries bit 15 so the semi-transparency half of the alpha contract is
|
|
exercised by the round trip as well. */
|
|
unsigned r = (unsigned)(i * 2) & 0x1f;
|
|
unsigned g = (unsigned)(31 - i) & 0x1f;
|
|
unsigned b = (unsigned)((i * 3) + 1) & 0x1f;
|
|
|
|
gpu->vram[CLUT_X + i + (CLUT_Y * 1024)] =
|
|
(uint16_t)(i == 0 ? 0 : (r | (g << 5) | (b << 10) | ((i == 15) ? 0x8000u : 0u)));
|
|
}
|
|
|
|
for (y = 0; y < TEX_H; y++) {
|
|
for (x = 0; x < TEX_W; x += 4) {
|
|
/* Four 4-bit indices per halfword, low nibble first — the packing
|
|
gpu_fetch_texel() decodes. */
|
|
unsigned w = 0;
|
|
int k;
|
|
|
|
for (k = 0; k < 4; k++) {
|
|
unsigned idx = (unsigned)((x + k + y) & 0xf);
|
|
|
|
w |= idx << (k * 4);
|
|
}
|
|
|
|
gpu->vram[TEX_PAGE_X + ((x >> 2)) + ((TEX_PAGE_Y + y) * 1024)] = (uint16_t)w;
|
|
}
|
|
}
|
|
}
|
|
|
|
static void gp0(psx_gpu_t* gpu, uint32_t word) {
|
|
psx_gpu_write32(gpu, 0x00, word);
|
|
}
|
|
|
|
/* GP0(2C): textured opaque quad, raw (no modulation) so the test compares texels rather
|
|
than a blend. Raw keeps the comparison about the SAMPLER, which is what is under test. */
|
|
static void draw_quad(psx_gpu_t* gpu) {
|
|
gp0(gpu, 0x2d000000u); /* cmd + (ignored) colour */
|
|
gp0(gpu, (uint32_t)((QUAD_Y << 16) | QUAD_X)); /* v0 */
|
|
gp0(gpu, (uint32_t)((CLUT_WORD << 16) | (0 << 8) | 0)); /* uv0 + clut */
|
|
gp0(gpu, (uint32_t)((QUAD_Y << 16) | (QUAD_X + TEX_W))); /* v1 */
|
|
gp0(gpu, (uint32_t)((TEXPAGE_WORD << 16) | (0 << 8) | (TEX_W - 1))); /* uv1 + texpage */
|
|
gp0(gpu, (uint32_t)(((QUAD_Y + TEX_H) << 16) | QUAD_X)); /* v2 */
|
|
gp0(gpu, (uint32_t)((0 << 8) | 0) | ((uint32_t)(TEX_H - 1) << 8)); /* uv2 */
|
|
gp0(gpu, (uint32_t)(((QUAD_Y + TEX_H) << 16) | (QUAD_X + TEX_W))); /* v3 */
|
|
gp0(gpu, (uint32_t)(((TEX_H - 1) << 8) | (TEX_W - 1))); /* uv3 */
|
|
}
|
|
|
|
/* GP0(65): textured opaque sprite, variable size, raw. Exercises the OTHER live rasterizer
|
|
entry point — gpu_render_rect(), whose UV walk and bind are separate code from the
|
|
polygon path's. */
|
|
static void draw_sprite(psx_gpu_t* gpu) {
|
|
gp0(gpu, 0x65808080u);
|
|
gp0(gpu, (uint32_t)((SPRITE_Y << 16) | SPRITE_X));
|
|
gp0(gpu, (uint32_t)((CLUT_WORD << 16) | (0 << 8) | 0));
|
|
gp0(gpu, (uint32_t)((TEX_H << 16) | TEX_W));
|
|
}
|
|
|
|
/* Sprites read the LATCHED texpage, not a per-primitive word, exactly as hardware does. */
|
|
static void latch_texpage(psx_gpu_t* gpu) {
|
|
gp0(gpu, 0xe1000000u | (uint32_t)TEXPAGE_WORD);
|
|
}
|
|
|
|
static void draw_all(psx_gpu_t* gpu) {
|
|
latch_texpage(gpu);
|
|
draw_quad(gpu);
|
|
draw_sprite(gpu);
|
|
}
|
|
|
|
/* ---- frame capture ---------------------------------------------------------------------- */
|
|
|
|
#define CAP_X0 (QUAD_X - 2)
|
|
#define CAP_Y0 (QUAD_Y - 2)
|
|
#define CAP_W (SPRITE_X + TEX_W + 2 - CAP_X0)
|
|
#define CAP_H (TEX_H + 4)
|
|
|
|
typedef struct { uint16_t px[CAP_W * CAP_H]; } frame_t;
|
|
|
|
static void capture_vram(const psx_gpu_t* gpu, frame_t* out) {
|
|
int x, y;
|
|
|
|
for (y = 0; y < CAP_H; y++)
|
|
for (x = 0; x < CAP_W; x++)
|
|
out->px[y * CAP_W + x] = gpu->vram[(CAP_X0 + x) + ((CAP_Y0 + y) * 1024)];
|
|
}
|
|
|
|
static uint16_t rt_texel(psx_gpu_backend_t* be, int scale, int x, int y) {
|
|
uint32_t stride_bytes = 0;
|
|
const uint16_t* base = (const uint16_t*)be->display_buffer(be, 0, 0, &stride_bytes);
|
|
const size_t stride_px = stride_bytes / sizeof(uint16_t);
|
|
|
|
return base[(size_t)x * scale + ((size_t)y * scale * stride_px)];
|
|
}
|
|
|
|
static void capture_rt(psx_gpu_backend_t* be, int scale, frame_t* out) {
|
|
int x, y;
|
|
|
|
for (y = 0; y < CAP_H; y++)
|
|
for (x = 0; x < CAP_W; x++)
|
|
out->px[y * CAP_W + x] = rt_texel(be, scale, CAP_X0 + x, CAP_Y0 + y);
|
|
}
|
|
|
|
static int frames_equal(const frame_t* a, const frame_t* b) {
|
|
return memcmp(a->px, b->px, sizeof(a->px)) == 0;
|
|
}
|
|
|
|
static int frame_nonblank(const frame_t* f) {
|
|
size_t i;
|
|
|
|
for (i = 0; i < (size_t)(CAP_W * CAP_H); i++)
|
|
if (f->px[i] != 0)
|
|
return 1;
|
|
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
Renders the corpus with the given texrep configuration and returns BOTH rasterizers'
|
|
output. `soft` is psx/dev/gpu.c's VRAM; `hw` is frontend/gpu_hw_rt.c's render target at
|
|
`scale`. The backend runs with PSX_GPU_BACKEND_SOFTWARE_SHADOW, so one pass produces
|
|
both — which is exactly the arrangement that makes "did they both replace the same
|
|
texels" answerable at all.
|
|
*/
|
|
static int render(int dump, int replace, const char* dir, int scale,
|
|
frame_t* soft, frame_t* hw) {
|
|
psx_gpu_t* gpu = make_gpu();
|
|
psx_gpu_backend_t* be;
|
|
|
|
if (!gpu)
|
|
return 0;
|
|
|
|
seed_texture(gpu);
|
|
psx_texrep_configure(gpu, dump, replace, dir);
|
|
|
|
be = armsx_hw_rt_create(gpu, scale);
|
|
|
|
if (!be) {
|
|
psx_gpu_destroy(gpu);
|
|
|
|
return 0;
|
|
}
|
|
|
|
psx_gpu_set_backend(gpu, be);
|
|
draw_all(gpu);
|
|
|
|
if (soft)
|
|
capture_vram(gpu, soft);
|
|
|
|
if (hw)
|
|
capture_rt(be, scale, hw);
|
|
|
|
psx_gpu_set_backend(gpu, NULL);
|
|
armsx_hw_rt_destroy(be);
|
|
psx_gpu_destroy(gpu);
|
|
|
|
return 1;
|
|
}
|
|
|
|
/* ---- dump-file helpers ------------------------------------------------------------------ */
|
|
|
|
/* Fills `out` with the single .png the dumper wrote for the 16x16 4bpp texture. The name is
|
|
the CONTRACT (psx/texrep.h), so it is reconstructed here from the key the loader would
|
|
parse rather than by listing the directory — a test that globs would pass even if the
|
|
naming scheme changed underneath it. */
|
|
static int find_dump(char* out, size_t cap) {
|
|
/* The 16-hex key is not known a priori, so this is the one place a directory listing is
|
|
the honest tool. Done with popen(ls) rather than dirent so the test file stays free of
|
|
platform ifdefs; it runs on the build machine only. */
|
|
FILE* p = popen("ls -1 " TEXREP_DUMP_DIR "/*.png 2>/dev/null | head -n 2", "r");
|
|
char line[512];
|
|
int n = 0;
|
|
|
|
if (!p)
|
|
return 0;
|
|
|
|
out[0] = '\0';
|
|
|
|
while (fgets(line, sizeof(line), p)) {
|
|
size_t len = strlen(line);
|
|
|
|
while (len && (line[len - 1] == '\n' || line[len - 1] == '\r'))
|
|
line[--len] = '\0';
|
|
|
|
if (!len)
|
|
continue;
|
|
|
|
if (n == 0 && len < cap)
|
|
memcpy(out, line, len + 1u);
|
|
|
|
n++;
|
|
}
|
|
|
|
pclose(p);
|
|
|
|
return n;
|
|
}
|
|
|
|
static int copy_file(const char* src, const char* dst) {
|
|
FILE* a = fopen(src, "rb");
|
|
FILE* b;
|
|
char buf[4096];
|
|
size_t got;
|
|
|
|
if (!a)
|
|
return 0;
|
|
|
|
b = fopen(dst, "wb");
|
|
|
|
if (!b) {
|
|
fclose(a);
|
|
|
|
return 0;
|
|
}
|
|
|
|
while ((got = fread(buf, 1, sizeof(buf), a)) > 0)
|
|
fwrite(buf, 1, got, b);
|
|
|
|
fclose(a);
|
|
|
|
return fclose(b) == 0;
|
|
}
|
|
|
|
static const char* basename_of(const char* path) {
|
|
const char* slash = strrchr(path, '/');
|
|
|
|
return slash ? (slash + 1) : path;
|
|
}
|
|
|
|
/* ---- cases ------------------------------------------------------------------------------- */
|
|
|
|
static void case_disabled_is_inert(void) {
|
|
const char* name = "disabled-is-inert";
|
|
frame_t off_s, off_h, cfg_s, cfg_h;
|
|
|
|
CHECK(name, render(0, 0, TEXREP_TEST_DIR, 1, &off_s, &off_h), "render");
|
|
CHECK(name, frame_nonblank(&off_s), "corpus drew nothing");
|
|
/* Configuring with both flags off must be indistinguishable from never configuring. */
|
|
CHECK(name, render(0, 0, "", 1, &cfg_s, &cfg_h), "render");
|
|
CHECK(name, frames_equal(&off_s, &cfg_s), "software output moved with the feature off");
|
|
CHECK(name, frames_equal(&off_h, &cfg_h), "hardware output moved with the feature off");
|
|
CHECK(name, frames_equal(&off_s, &off_h), "1x hardware differs from software");
|
|
|
|
ok(name);
|
|
}
|
|
|
|
static void case_dump_writes_png(void) {
|
|
const char* name = "dump-writes-png";
|
|
frame_t base_s, dump_s;
|
|
char path[512];
|
|
uint8_t* rgba;
|
|
int w = 0, h = 0;
|
|
const char* why = NULL;
|
|
FILE* f;
|
|
long size;
|
|
uint8_t* bytes;
|
|
int count;
|
|
|
|
CHECK(name, render(0, 0, TEXREP_TEST_DIR, 1, &base_s, NULL), "render");
|
|
CHECK(name, render(1, 0, TEXREP_TEST_DIR, 1, &dump_s, NULL), "render");
|
|
|
|
/* Dumping must not change a single pixel: it only observes. */
|
|
CHECK(name, frames_equal(&base_s, &dump_s), "dumping altered the frame");
|
|
|
|
count = find_dump(path, sizeof(path));
|
|
CHECK(name, count >= 1, "no PNG was written");
|
|
/* The quad and the sprite sample the SAME rectangle of the SAME page with the SAME CLUT
|
|
and the same texture window, so they must hash to one key and produce one file. Two
|
|
files here would mean the key depends on the primitive rather than on the texels. */
|
|
CHECK(name, count == 1, "the quad and the sprite produced different keys");
|
|
|
|
/* The name carries the size and depth the contract specifies. */
|
|
CHECK(name, strstr(basename_of(path), "ps1-") == basename_of(path), "name lacks the ps1- prefix");
|
|
CHECK(name, strstr(path, "-16x16-4.png") != NULL, "name lacks the -WxH-bpp tail");
|
|
|
|
f = fopen(path, "rb");
|
|
CHECK(name, f != NULL, "dump not readable");
|
|
fseek(f, 0, SEEK_END);
|
|
size = ftell(f);
|
|
rewind(f);
|
|
bytes = (uint8_t*)malloc((size_t)size);
|
|
CHECK(name, bytes && fread(bytes, 1, (size_t)size, f) == (size_t)size, "dump read failed");
|
|
fclose(f);
|
|
|
|
rgba = psx_png_decode(bytes, (size_t)size, &w, &h, &why);
|
|
free(bytes);
|
|
CHECK(name, rgba != NULL, why ? why : "dump did not decode");
|
|
CHECK(name, w == TEX_W && h == TEX_H, "dump is the wrong size");
|
|
|
|
/* The alpha contract, spot-checked against the palette this test seeded:
|
|
index 0 -> transparent texel -> A = 0
|
|
index 15 -> STP set -> A = 128
|
|
anything else -> A = 255 */
|
|
{
|
|
int x, y, saw_zero = 0, saw_stp = 0, saw_opaque = 0, bad = 0;
|
|
|
|
for (y = 0; y < TEX_H; y++) {
|
|
for (x = 0; x < TEX_W; x++) {
|
|
const uint8_t* p = rgba + ((size_t)y * TEX_W + (size_t)x) * 4u;
|
|
unsigned idx = (unsigned)((x + y) & 0xf);
|
|
|
|
if (idx == 0) {
|
|
saw_zero = 1;
|
|
if (p[3] != 0) bad = 1;
|
|
} else if (idx == 15) {
|
|
saw_stp = 1;
|
|
if (p[3] != 128) bad = 1;
|
|
} else {
|
|
saw_opaque = 1;
|
|
if (p[3] != 255) bad = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
free(rgba);
|
|
CHECK(name, saw_zero && saw_stp && saw_opaque, "corpus did not cover all three alphas");
|
|
CHECK(name, !bad, "alpha does not follow the transparent/STP/opaque contract");
|
|
}
|
|
|
|
ok(name);
|
|
}
|
|
|
|
static void case_roundtrip_is_identical(void) {
|
|
const char* name = "roundtrip-is-identical";
|
|
frame_t base_s, base_h, rep_s, rep_h;
|
|
char path[512];
|
|
char dst[640];
|
|
|
|
CHECK(name, render(0, 0, TEXREP_TEST_DIR, 1, &base_s, &base_h), "render");
|
|
CHECK(name, find_dump(path, sizeof(path)) == 1, "dump missing (run dump-writes-png first)");
|
|
|
|
snprintf(dst, sizeof(dst), "%s/%s", TEXREP_PACK_DIR, basename_of(path));
|
|
CHECK(name, copy_file(path, dst), "could not stage the pack file");
|
|
|
|
CHECK(name, render(0, 1, TEXREP_TEST_DIR, 1, &rep_s, &rep_h), "render");
|
|
|
|
/*
|
|
THE central assertion. An untouched dump put back as a replacement has to be
|
|
pixel-for-pixel what the game would have drawn. It fails if the hash names a
|
|
different texture, if the rectangle is off by a texel, if the alpha rule is wrong,
|
|
if 5->8->5 colour expansion is lossy, or if the sub-texel lands anywhere but 0 at
|
|
scale 1 — and it cannot pass "by accident in both rasterizers", because the
|
|
reference is the ORIGINAL FRAME rather than the other rasterizer.
|
|
*/
|
|
CHECK(name, frames_equal(&base_s, &rep_s), "software round trip is not identical");
|
|
CHECK(name, frames_equal(&base_h, &rep_h), "hardware round trip is not identical");
|
|
|
|
ok(name);
|
|
}
|
|
|
|
/* Repaints the staged replacement a flat colour and returns the BGR555 the sampler must
|
|
produce for it. Alpha 255 everywhere, so every texel of the region becomes opaque —
|
|
including the ones that were transparent, which is the strongest evidence the replacement
|
|
is being read rather than the original. */
|
|
static uint16_t repaint_pack(const char* file, uint8_t r, uint8_t g, uint8_t b) {
|
|
uint8_t rgba[TEX_W * TEX_H * 4];
|
|
int i;
|
|
|
|
for (i = 0; i < (TEX_W * TEX_H); i++) {
|
|
rgba[i * 4 + 0] = r;
|
|
rgba[i * 4 + 1] = g;
|
|
rgba[i * 4 + 2] = b;
|
|
rgba[i * 4 + 3] = 255;
|
|
}
|
|
|
|
if (!psx_png_write(file, rgba, TEX_W, TEX_H))
|
|
return 0;
|
|
|
|
return (uint16_t)((r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10));
|
|
}
|
|
|
|
static void case_substitution_reaches_both(void) {
|
|
const char* name = "substitution-reaches-both";
|
|
frame_t base_s, rep_s, rep_h;
|
|
char path[512];
|
|
char dst[640];
|
|
uint16_t want;
|
|
int x, y, soft_hits = 0, hw_hits = 0, soft_wrong = 0, hw_wrong = 0;
|
|
|
|
CHECK(name, render(0, 0, TEXREP_TEST_DIR, 1, &base_s, NULL), "render");
|
|
CHECK(name, find_dump(path, sizeof(path)) == 1, "dump missing");
|
|
snprintf(dst, sizeof(dst), "%s/%s", TEXREP_PACK_DIR, basename_of(path));
|
|
|
|
want = repaint_pack(dst, 0xff, 0x00, 0x88);
|
|
CHECK(name, want != 0, "could not write the repainted pack");
|
|
|
|
CHECK(name, render(0, 1, TEXREP_TEST_DIR, 1, &rep_s, &rep_h), "render");
|
|
|
|
/* Every pixel the corpus covers must now be the flat colour, in BOTH rasterizers. The
|
|
original had transparent texels that drew nothing, so this also proves the alpha of
|
|
the REPLACEMENT decides transparency rather than the original texel's. */
|
|
for (y = 2; y < (2 + TEX_H); y++) {
|
|
for (x = 2; x < (2 + TEX_W); x++) {
|
|
uint16_t s = rep_s.px[y * CAP_W + x];
|
|
uint16_t h = rep_h.px[y * CAP_W + x];
|
|
|
|
if (s == want) soft_hits++; else soft_wrong++;
|
|
if (h == want) hw_hits++; else hw_wrong++;
|
|
}
|
|
}
|
|
|
|
CHECK(name, soft_hits == (TEX_W * TEX_H), "software did not take the replacement everywhere");
|
|
CHECK(name, hw_hits == (TEX_W * TEX_H), "hardware did not take the replacement everywhere");
|
|
CHECK(name, soft_wrong == 0 && hw_wrong == 0, "stray texels");
|
|
CHECK(name, !frames_equal(&base_s, &rep_s), "replacement changed nothing");
|
|
CHECK(name, frames_equal(&rep_s, &rep_h), "the two rasterizers replaced differently");
|
|
|
|
ok(name);
|
|
}
|
|
|
|
static void case_wrong_hash_is_ignored(void) {
|
|
const char* name = "wrong-hash-is-ignored";
|
|
frame_t base_s, rep_s, rep_h;
|
|
char path[512];
|
|
char dst[640];
|
|
char wrong[640];
|
|
|
|
/* Negative control. The SAME repainted image under a key that names nothing must be
|
|
inert — otherwise "matched" would mean "a file exists", and every texture in the game
|
|
would take the first replacement in the folder. */
|
|
CHECK(name, find_dump(path, sizeof(path)) == 1, "dump missing");
|
|
snprintf(dst, sizeof(dst), "%s/%s", TEXREP_PACK_DIR, basename_of(path));
|
|
remove(dst);
|
|
|
|
snprintf(wrong, sizeof(wrong), "%s/ps1-0123456789abcdef-16x16-4.png", TEXREP_PACK_DIR);
|
|
CHECK(name, repaint_pack(wrong, 0x00, 0xff, 0x00) != 0, "could not write the decoy");
|
|
|
|
CHECK(name, render(0, 0, TEXREP_TEST_DIR, 1, &base_s, NULL), "render");
|
|
CHECK(name, render(0, 1, TEXREP_TEST_DIR, 1, &rep_s, &rep_h), "render");
|
|
|
|
CHECK(name, frames_equal(&base_s, &rep_s), "a non-matching key was applied (software)");
|
|
CHECK(name, frames_equal(&base_s, &rep_h), "a non-matching key was applied (hardware)");
|
|
|
|
remove(wrong);
|
|
ok(name);
|
|
}
|
|
|
|
static void case_subtexel_at_scale(void) {
|
|
const char* name = "subtexel-at-scale";
|
|
char path[512];
|
|
char dst[640];
|
|
uint8_t rgba[TEX_W * 2 * TEX_H * 2 * 4];
|
|
frame_t rep_h;
|
|
psx_gpu_t* gpu;
|
|
psx_gpu_backend_t* be;
|
|
int x, y;
|
|
int varied = 0;
|
|
|
|
CHECK(name, find_dump(path, sizeof(path)) == 1, "dump missing");
|
|
snprintf(dst, sizeof(dst), "%s/%s", TEXREP_PACK_DIR, basename_of(path));
|
|
|
|
/* A 2x replacement whose four sub-texels differ inside every native texel. If the
|
|
fractional UV is dropped, each 2x2 render-target block collapses to one of them and
|
|
the whole image is flat per texel — exactly what an upscaled pack looks like when the
|
|
sub-texel path is missing, and invisible to a per-texel comparison. */
|
|
for (y = 0; y < (TEX_H * 2); y++) {
|
|
for (x = 0; x < (TEX_W * 2); x++) {
|
|
uint8_t* p = rgba + ((size_t)y * (TEX_W * 2) + (size_t)x) * 4u;
|
|
unsigned sub = (unsigned)((x & 1) | ((y & 1) << 1)); /* 0..3 within the texel */
|
|
|
|
p[0] = (uint8_t)(8 + sub * 56);
|
|
p[1] = (uint8_t)(8 + sub * 56);
|
|
p[2] = (uint8_t)(8 + sub * 56);
|
|
p[3] = 255;
|
|
}
|
|
}
|
|
|
|
CHECK(name, psx_png_write(dst, rgba, TEX_W * 2, TEX_H * 2), "could not write the 2x pack");
|
|
|
|
gpu = make_gpu();
|
|
CHECK(name, gpu != NULL, "gpu");
|
|
seed_texture(gpu);
|
|
psx_texrep_configure(gpu, 0, 1, TEXREP_TEST_DIR);
|
|
|
|
be = armsx_hw_rt_create(gpu, 2);
|
|
CHECK(name, be != NULL, "backend");
|
|
psx_gpu_set_backend(gpu, be);
|
|
draw_all(gpu);
|
|
|
|
/* Read the render target at its OWN resolution: the 2x2 block behind one native texel
|
|
must contain more than one value. */
|
|
{
|
|
uint32_t stride_bytes = 0;
|
|
const uint16_t* base = (const uint16_t*)be->display_buffer(be, 0, 0, &stride_bytes);
|
|
const size_t stride_px = stride_bytes / sizeof(uint16_t);
|
|
|
|
for (y = 0; y < TEX_H && !varied; y++) {
|
|
for (x = 0; x < TEX_W; x++) {
|
|
size_t bx = (size_t)(QUAD_X + x) * 2u;
|
|
size_t by = (size_t)(QUAD_Y + y) * 2u;
|
|
uint16_t a = base[bx + by * stride_px];
|
|
uint16_t b = base[bx + 1 + by * stride_px];
|
|
uint16_t c = base[bx + (by + 1) * stride_px];
|
|
|
|
if (a != b || a != c) {
|
|
varied = 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
(void)rep_h;
|
|
psx_gpu_set_backend(gpu, NULL);
|
|
armsx_hw_rt_destroy(be);
|
|
psx_gpu_destroy(gpu);
|
|
|
|
CHECK(name, varied, "a 2x replacement rendered flat: the sub-texel is being dropped");
|
|
|
|
remove(dst);
|
|
ok(name);
|
|
}
|
|
|
|
/* ---- the texture-window fold ------------------------------------------------------------- */
|
|
|
|
static void case_fold_axis(void) {
|
|
const char* name = "fold-axis";
|
|
uint16_t lo, n;
|
|
|
|
/* No window: the raw range passes through. */
|
|
psx_texrep_fold_axis(4, 12, 0, 0, &lo, &n);
|
|
CHECK(name, lo == 4 && n == 12, "unwindowed range moved");
|
|
|
|
/* A raw range that runs past 255 wraps and therefore covers everything. */
|
|
psx_texrep_fold_axis(250, 20, 0, 0, &lo, &n);
|
|
CHECK(name, lo == 0 && n == 256, "wrapped range not widened to the full axis");
|
|
|
|
/* mask = 31*8 = 0xF8 keeps the low three bits: an 8-wide window at the offset. A polygon
|
|
tiling 0..255 through it samples exactly eight columns, and the key must say so —
|
|
otherwise the same wall texture gets a different key in every game that tiles it over
|
|
a different span. */
|
|
psx_texrep_fold_axis(0, 256, 0xf8, 0x10, &lo, &n);
|
|
CHECK(name, lo == 0x10 && n == 8, "8-texel window not folded to 8");
|
|
|
|
/* mask = 1*8 = 0x08 forces bit 3 only, which frees a NON-CONTIGUOUS set of bits. The
|
|
fold enumerates rather than reasons, so this is exact where a closed-form derivation
|
|
would be wrong. */
|
|
psx_texrep_fold_axis(0, 256, 0x08, 0x08, &lo, &n);
|
|
CHECK(name, lo == 8 && n == 248, "non-contiguous mask folded wrongly");
|
|
|
|
ok(name);
|
|
}
|
|
|
|
/* ---- the GLSL transcription ---------------------------------------------------------------
|
|
|
|
Hand-transcribed from PSX_TEXREP_GLSL in psx/texrep.h. It is deliberately written from the
|
|
MACRO TEXT and not from psx_texrep_sample(), because the whole point is to be an
|
|
independent second reading of the one definition the GLES rasterizer compiles. */
|
|
|
|
static int glsl_clamp(int v, int hi) { return v < 0 ? 0 : (v > hi ? hi : v); }
|
|
|
|
static void glsl_at(int itx, int ity, int u0, int v0, int nw, int nh,
|
|
int s, int ax, int ay, int fx, int fy, int* ox, int* oy) {
|
|
int lx = glsl_clamp(itx - u0, nw - 1);
|
|
int ly = glsl_clamp(ity - v0, nh - 1);
|
|
|
|
*ox = ax + lx * s + glsl_clamp(fx, s - 1);
|
|
*oy = ay + ly * s + glsl_clamp(fy, s - 1);
|
|
}
|
|
|
|
static uint16_t glsl_pack(const uint8_t* rgba) {
|
|
int a = rgba[3];
|
|
unsigned r, g, b, v;
|
|
|
|
if (a == 0)
|
|
return 0;
|
|
|
|
r = (unsigned)(rgba[0] >> 3);
|
|
g = (unsigned)(rgba[1] >> 3);
|
|
b = (unsigned)(rgba[2] >> 3);
|
|
v = r | (g << 5) | (b << 10);
|
|
|
|
if (a != 255)
|
|
v |= 0x8000u;
|
|
|
|
return (uint16_t)v;
|
|
}
|
|
|
|
/*
|
|
Drives psx_texrep_sample() directly against the transcription above, over a matrix of
|
|
scales, texture windows, rectangle origins and sub-texel positions. Reaching the sampler
|
|
needs a bound image, which is built by hand rather than by loading a PNG — the point here
|
|
is the ARITHMETIC, and going through the loader would only add a decode to the failure
|
|
surface.
|
|
*/
|
|
static void case_glsl_matrix(void) {
|
|
const char* name = "glsl-matrix";
|
|
static const int scales[] = { 1, 2, 4 };
|
|
static const unsigned masks[] = { 0x00, 0xf8, 0x08 };
|
|
psx_gpu_t* gpu = make_gpu();
|
|
psx_texrep_image_t im;
|
|
int si, mi, checked = 0;
|
|
|
|
CHECK(name, gpu != NULL, "gpu");
|
|
|
|
memset(&im, 0, sizeof(im));
|
|
|
|
for (si = 0; si < 3; si++) {
|
|
const int s = scales[si];
|
|
const int nw = 16, nh = 8;
|
|
const int pw = nw * s, ph = nh * s;
|
|
uint8_t* pix = (uint8_t*)malloc((size_t)pw * (size_t)ph * 4u);
|
|
int i;
|
|
|
|
CHECK(name, pix != NULL, "alloc");
|
|
|
|
/* Every pixel distinct, so a mis-mapped coordinate cannot land on a lookalike. */
|
|
for (i = 0; i < (pw * ph); i++) {
|
|
pix[i * 4 + 0] = (uint8_t)((i * 7) & 0xff);
|
|
pix[i * 4 + 1] = (uint8_t)((i * 13) & 0xff);
|
|
pix[i * 4 + 2] = (uint8_t)((i * 29) & 0xff);
|
|
pix[i * 4 + 3] = (uint8_t)((i % 5) == 0 ? 0 : ((i % 3) == 0 ? 128 : 255));
|
|
}
|
|
|
|
im.nw = (uint16_t)nw;
|
|
im.nh = (uint16_t)nh;
|
|
im.pw = (uint16_t)pw;
|
|
im.ph = (uint16_t)ph;
|
|
im.scale = (uint8_t)s;
|
|
im.rgba = pix;
|
|
|
|
for (mi = 0; mi < 3; mi++) {
|
|
const unsigned mask = masks[mi];
|
|
const unsigned off = 0x18;
|
|
int u0, v0, t, sub;
|
|
|
|
gpu->texw_mx = mask;
|
|
gpu->texw_my = mask;
|
|
gpu->texw_ox = off;
|
|
gpu->texw_oy = off;
|
|
|
|
for (u0 = 0; u0 <= 32; u0 += 16) {
|
|
v0 = u0 >> 1;
|
|
|
|
gpu->texrep_bind.img = &im;
|
|
gpu->texrep_bind.u0 = (uint16_t)u0;
|
|
gpu->texrep_bind.v0 = (uint16_t)v0;
|
|
|
|
for (t = -3; t < 40; t++) {
|
|
for (sub = 0; sub < s; sub++) {
|
|
/* Land the fraction squarely inside sub-texel `sub`, away from the
|
|
bin edge, so this compares the mapping rather than float rounding
|
|
at an exact boundary. */
|
|
const float frac = ((float)sub + 0.5f) / (float)s;
|
|
const float tx = (float)t + frac;
|
|
const float ty = (float)(t + 1) + frac;
|
|
|
|
/* Truncate-then-mask, matching psx_texrep_sample()'s note about
|
|
float -> unsigned being undefined for negatives. */
|
|
unsigned itx = ((((unsigned)((int)tx & 0xffff)) & ~mask) | (off & mask)) & 0xffu;
|
|
unsigned ity = ((((unsigned)((int)ty & 0xffff)) & ~mask) | (off & mask)) & 0xffu;
|
|
int ox = 0, oy = 0;
|
|
uint16_t got, want;
|
|
|
|
glsl_at((int)itx, (int)ity, u0, v0, nw, nh, s, 0, 0, sub, sub, &ox, &oy);
|
|
want = glsl_pack(pix + (((size_t)oy * (size_t)pw) + (size_t)ox) * 4u);
|
|
got = psx_texrep_sample(gpu, tx, ty);
|
|
checked++;
|
|
|
|
if (got != want) {
|
|
char why[160];
|
|
|
|
snprintf(why, sizeof(why),
|
|
"s=%d mask=%02x u0=%d t=%d sub=%d got=%04x want=%04x",
|
|
s, mask, u0, t, sub, got, want);
|
|
fail(name, why);
|
|
free(pix);
|
|
psx_gpu_destroy(gpu);
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
free(pix);
|
|
}
|
|
|
|
gpu->texrep_bind.img = NULL;
|
|
psx_gpu_destroy(gpu);
|
|
|
|
CHECK(name, checked > 1000, "matrix did not run");
|
|
printf("TEXREP_PARITY passed case=%s (%d samples)\n", name, checked);
|
|
}
|
|
|
|
/* ---- PNG codec round trip ----------------------------------------------------------------- */
|
|
|
|
static void case_png_roundtrip(void) {
|
|
const char* name = "png-roundtrip";
|
|
const int w = 37, h = 19; /* deliberately not a multiple of anything */
|
|
uint8_t* src = (uint8_t*)malloc((size_t)w * (size_t)h * 4u);
|
|
uint8_t* back;
|
|
int i, gw = 0, gh = 0, bad = 0;
|
|
const char* why = NULL;
|
|
FILE* f;
|
|
long size;
|
|
uint8_t* bytes;
|
|
const char* path = TEXREP_TEST_DIR "/codec.png";
|
|
|
|
CHECK(name, src != NULL, "alloc");
|
|
|
|
for (i = 0; i < (w * h); i++) {
|
|
src[i * 4 + 0] = (uint8_t)(i * 3);
|
|
src[i * 4 + 1] = (uint8_t)(255 - i);
|
|
src[i * 4 + 2] = (uint8_t)(i ^ 0x5a);
|
|
src[i * 4 + 3] = (uint8_t)((i % 7) == 0 ? 0 : ((i % 4) == 0 ? 128 : 255));
|
|
}
|
|
|
|
CHECK(name, psx_png_write(path, src, w, h), "encode failed");
|
|
|
|
f = fopen(path, "rb");
|
|
CHECK(name, f != NULL, "written file not readable");
|
|
fseek(f, 0, SEEK_END);
|
|
size = ftell(f);
|
|
rewind(f);
|
|
bytes = (uint8_t*)malloc((size_t)size);
|
|
CHECK(name, bytes && fread(bytes, 1, (size_t)size, f) == (size_t)size, "read failed");
|
|
fclose(f);
|
|
|
|
back = psx_png_decode(bytes, (size_t)size, &gw, &gh, &why);
|
|
free(bytes);
|
|
CHECK(name, back != NULL, why ? why : "decode failed");
|
|
CHECK(name, gw == w && gh == h, "size changed");
|
|
|
|
for (i = 0; i < (w * h * 4); i++)
|
|
if (back[i] != src[i])
|
|
bad = 1;
|
|
|
|
free(back);
|
|
free(src);
|
|
remove(path);
|
|
|
|
CHECK(name, !bad, "pixels changed");
|
|
ok(name);
|
|
}
|
|
|
|
/* Truncated, corrupt and hostile input must be REJECTED, not crash: this codec parses files
|
|
a stranger wrote and hands the result to the hottest loop in the emulator. */
|
|
static void case_png_rejects_garbage(void) {
|
|
const char* name = "png-rejects-garbage";
|
|
const int w = 24, h = 24;
|
|
uint8_t* src = (uint8_t*)malloc((size_t)w * (size_t)h * 4u);
|
|
uint8_t* bytes;
|
|
long size;
|
|
FILE* f;
|
|
const char* path = TEXREP_TEST_DIR "/fuzz.png";
|
|
int gw, gh, cut, seed;
|
|
const char* why;
|
|
|
|
CHECK(name, src != NULL, "alloc");
|
|
memset(src, 0xa5, (size_t)w * (size_t)h * 4u);
|
|
CHECK(name, psx_png_write(path, src, w, h), "encode failed");
|
|
free(src);
|
|
|
|
f = fopen(path, "rb");
|
|
CHECK(name, f != NULL, "read");
|
|
fseek(f, 0, SEEK_END);
|
|
size = ftell(f);
|
|
rewind(f);
|
|
bytes = (uint8_t*)malloc((size_t)size);
|
|
CHECK(name, bytes && fread(bytes, 1, (size_t)size, f) == (size_t)size, "read");
|
|
fclose(f);
|
|
remove(path);
|
|
|
|
/*
|
|
Every truncation. The requirement is NOT "must fail" -- a cut that lands inside the
|
|
trailing IEND still leaves a complete IHDR and IDAT, and decoding that is the lenient
|
|
behaviour every image tool has. The requirement is that a short file is never read
|
|
past its end: whatever comes back must be NULL or a correctly sized buffer, which the
|
|
sanitizer build turns into an assertion rather than a hope.
|
|
*/
|
|
for (cut = 1; cut < (int)size; cut++) {
|
|
uint8_t* got = psx_png_decode(bytes, (size_t)cut, &gw, &gh, &why);
|
|
|
|
if (got) {
|
|
int wrong = (gw != w) || (gh != h);
|
|
|
|
free(got);
|
|
|
|
if (wrong) {
|
|
free(bytes);
|
|
fail(name, "a truncated file decoded to the wrong size");
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Single-byte corruption anywhere. Whatever comes back must be either NULL or a
|
|
correctly sized buffer; the point is that nothing reads or writes out of bounds, which
|
|
the sanitizer build makes an assertion rather than a hope. */
|
|
for (seed = 0; seed < (int)size; seed++) {
|
|
uint8_t saved = bytes[seed];
|
|
uint8_t* got;
|
|
|
|
bytes[seed] = (uint8_t)(saved ^ 0xff);
|
|
got = psx_png_decode(bytes, (size_t)size, &gw, &gh, &why);
|
|
|
|
if (got) {
|
|
if (gw <= 0 || gh <= 0 || gw > 8192 || gh > 8192) {
|
|
free(got);
|
|
free(bytes);
|
|
fail(name, "corrupt file yielded implausible dimensions");
|
|
|
|
return;
|
|
}
|
|
|
|
free(got);
|
|
}
|
|
|
|
bytes[seed] = saved;
|
|
}
|
|
|
|
free(bytes);
|
|
ok(name);
|
|
}
|
|
|
|
int main(void) {
|
|
reset_scratch();
|
|
|
|
case_disabled_is_inert();
|
|
case_fold_axis();
|
|
case_glsl_matrix();
|
|
case_png_roundtrip();
|
|
case_png_rejects_garbage();
|
|
case_dump_writes_png();
|
|
case_roundtrip_is_identical();
|
|
case_substitution_reaches_both();
|
|
case_wrong_hash_is_ignored();
|
|
case_subtexel_at_scale();
|
|
|
|
if (g_failures) {
|
|
fprintf(stderr, "TEXREP_PARITY %d case(s) failed\n", g_failures);
|
|
|
|
return 1;
|
|
}
|
|
|
|
printf("TEXREP_PARITY all cases passed\n");
|
|
|
|
return 0;
|
|
}
|