// Task 19: Visual validation tests (headless-safe subset)
//
// Tests that can run without a GPU to validate data pipeline correctness:
// 1. Tile seam check: adjacent tiles share edge pixels correctly
// 2. Fallback persistence: evicted tiles keep parent fallback
// 3. Page table GPU data format correctness
// 4. Coordinate conversion round-trip (Godot ↔ Blender)
// 5. Export/import data integrity (write, read back, compare)
// 6. Invalidation cascade correctness
//
// Compile: g++ -std=c++17 -I../src test/test_validation.cpp -o test/test_validation -lrt && ./test/test_validation
#include "../src/page_table.h"
#include "../src/shm_protocol.h"
#include "../src/tile_cache.h"
#include "../src/tile_request_queue.h"
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstring>
static int passed = 0;
static int failed = 0;
static void check(const char *name, bool condition) {
if (condition) {
printf(" %-50s PASS\n", name);
passed++;
} else {
printf(" %-50s FAIL\n", name);
failed++;
}
}
// ── Test 1: Tile seam check ──────────────────────────────────────────────────
// Validate that adjacent tiles in the page table map to valid physical
// locations that are spatially coherent (no gaps in coverage).
void test_tile_seam_coverage() {
PageTable pt;
pt.init(4, 8); // 4 mips, 8×8 base
TileCache cache;
cache.init(16, 16); // 256 slots, plenty of room
// Map all 8×8 base tiles
uint64_t frame = 1;
for (int y = 0; y < 8; y++) {
for (int x = 0; x < 8; x++) {
TileId tile = { 1, x, y, 0 };
auto result = cache.alloc(tile, frame);
assert(result.success);
pt.map(0, x, y, result.location);
}
}
// Verify every tile has a valid mapping — no seams (unmapped gaps)
bool all_valid = true;
for (int y = 0; y < 8; y++) {
for (int x = 0; x < 8; x++) {
auto look = pt.lookup(0, x, y);
if (!look.valid) {
all_valid = false;
}
}
}
check("seam_full_coverage", all_valid);
// Verify no two adjacent tiles map to the same physical location
// (which would mean they share data incorrectly)
bool no_duplicates = true;
for (int y = 0; y < 8; y++) {
for (int x = 0; x < 7; x++) {
auto a = pt.lookup(0, x, y);
auto b = pt.lookup(0, x + 1, y);
if (a.location.x == b.location.x && a.location.y == b.location.y) {
no_duplicates = false;
}
}
}
check("seam_no_horizontal_duplicates", no_duplicates);
// Same for vertical adjacency
no_duplicates = true;
for (int y = 0; y < 7; y++) {
for (int x = 0; x < 8; x++) {
auto a = pt.lookup(0, x, y);
auto b = pt.lookup(0, x, y + 1);
if (a.location.x == b.location.x && a.location.y == b.location.y) {
no_duplicates = false;
}
}
}
check("seam_no_vertical_duplicates", no_duplicates);
}
// ── Test 2: Fallback persistence ─────────────────────────────────────────────
// After evicting a fine mip tile, the parent fallback should still be valid.
void test_fallback_persistence() {
PageTable pt;
pt.init(4, 8); // 4 mips, 8×8 base
// Map parent at mip 2, tile (1,1) — this fills children via fallback
PhysicalLocation parent_loc = { 10, 20 };
pt.map_with_fallback(2, 1, 1, parent_loc);
// Children at mip 1: (2,2), (2,3), (3,2), (3,3) should have fallback
auto child = pt.lookup(1, 2, 2);
check("fallback_child_valid", child.valid);
check("fallback_child_points_to_parent",
child.location.x == 10 && child.location.y == 20);
// Grandchildren at mip 0: (4,4)..(5,5)
auto grandchild = pt.lookup(0, 4, 4);
check("fallback_grandchild_valid", grandchild.valid);
// Now map a fine tile at mip 0, (4,4)
PhysicalLocation fine_loc = { 30, 40 };
pt.map(0, 4, 4, fine_loc);
auto fine = pt.lookup(0, 4, 4);
check("fine_tile_overrides_fallback",
fine.location.x == 30 && fine.location.y == 40);
// Evict the fine tile — parent fallback should NOT be restored automatically
// (page table evict just clears, doesn't re-establish fallback)
pt.evict(0, 4, 4);
auto evicted = pt.lookup(0, 4, 4);
check("evicted_tile_invalid", !evicted.valid);
// But (4,5) should still have fallback from grandparent
auto sibling = pt.lookup(0, 4, 5);
check("sibling_fallback_intact", sibling.valid);
check("sibling_fallback_correct",
sibling.location.x == 10 && sibling.location.y == 20);
}
// ── Test 3: Page table GPU data format ───────────────────────────────────────
// Verify the RGBA8 format matches what the shader expects.
void test_page_table_gpu_format() {
PageTable pt;
pt.init(3, 4); // 3 mips, 4×4 base
// Map a few tiles
pt.map(0, 0, 0, { 5, 10 });
pt.map(0, 3, 3, { 15, 20 });
pt.map(1, 1, 0, { 7, 8 });
// Extract GPU data for mip 0
uint8_t buf[256];
int bytes = pt.get_gpu_data(0, buf, sizeof(buf));
check("gpu_data_size_mip0", bytes == 4 * 4 * 4); // 16 entries × 4 bytes
// Entry (0,0): x=5, y=10, valid=1, pad=0
check("gpu_entry_0_0_x", buf[0] == 5);
check("gpu_entry_0_0_y", buf[1] == 10);
check("gpu_entry_0_0_valid", buf[2] == 1);
check("gpu_entry_0_0_pad", buf[3] == 0);
// Entry (1,0): should be unmapped
check("gpu_entry_1_0_invalid", buf[4 + 2] == 0);
// Entry (3,3) at index 15: x=15, y=20, valid=1
int idx_33 = (3 * 4 + 3) * 4;
check("gpu_entry_3_3_x", buf[idx_33] == 15);
check("gpu_entry_3_3_y", buf[idx_33 + 1] == 20);
check("gpu_entry_3_3_valid", buf[idx_33 + 2] == 1);
// Mip 1 data (2×2)
bytes = pt.get_gpu_data(1, buf, sizeof(buf));
check("gpu_data_size_mip1", bytes == 2 * 2 * 4);
// Entry (1,0) at mip 1
check("gpu_mip1_entry_1_0_x", buf[(0 * 2 + 1) * 4] == 7);
check("gpu_mip1_entry_1_0_y", buf[(0 * 2 + 1) * 4 + 1] == 8);
}
// ── Test 4: Coordinate conversion round-trip ─────────────────────────────────
// Godot Y-up → Blender Z-up → Godot Y-up should be identity.
void test_coordinate_roundtrip() {
// Godot Y-up: (x, y, z)
float godot[3] = { 1.0f, 2.0f, 3.0f };
// Godot → Blender: (x, -z, y) = (1, -3, 2)
float blender[3];
blender[0] = godot[0];
blender[1] = -godot[2];
blender[2] = godot[1];
check("g2b_x", blender[0] == 1.0f);
check("g2b_y", blender[1] == -3.0f);
check("g2b_z", blender[2] == 2.0f);
// Blender → Godot: (x, z, -y) = (1, 2, 3)
float godot2[3];
godot2[0] = blender[0];
godot2[1] = blender[2];
godot2[2] = -blender[1];
check("roundtrip_x", fabsf(godot2[0] - godot[0]) < 1e-6f);
check("roundtrip_y", fabsf(godot2[1] - godot[1]) < 1e-6f);
check("roundtrip_z", fabsf(godot2[2] - godot[2]) < 1e-6f);
}
// ── Test 5: Export/import SHM data integrity ─────────────────────────────────
// Write a known object to SHM, read it back, verify all fields match.
void test_shm_data_integrity() {
ShmRegion shm;
assert(shm.create("/test_validation", 64 * 1024));
uint8_t *base = static_cast<uint8_t *>(shm.ptr());
// Write header
BridgeHeader hdr = {};
memcpy(hdr.magic, "BGOD", 4);
hdr.version = 1;
hdr.direction = 0;
hdr.sequence = 42;
hdr.object_count = 1;
hdr.frame_number = 100;
write_header(base, hdr);
// Write an object entry
ObjectEntry entry = {};
entry.object_id = 7;
entry.object_type = OBJ_MESH;
entry.update_type = UPDATE_FULL;
entry.data_offset = data_blob_offset(256);
entry.data_size = 12; // 3 floats
entry.sequence = 42;
write_object_entry(base, 0, entry);
// Write blob data (3 floats for a position)
float pos[3] = { 1.5f, 2.5f, 3.5f };
memcpy(base + entry.data_offset, pos, 12);
// Read everything back and verify
BridgeHeader hdr2 = read_header(base);
check("shm_magic", memcmp(hdr2.magic, "BGOD", 4) == 0);
check("shm_version", hdr2.version == 1);
check("shm_direction", hdr2.direction == 0);
check("shm_sequence", hdr2.sequence == 42);
check("shm_object_count", hdr2.object_count == 1);
check("shm_frame", hdr2.frame_number == 100);
ObjectEntry entry2 = read_object_entry(base, 0);
check("shm_obj_id", entry2.object_id == 7);
check("shm_obj_type", entry2.object_type == OBJ_MESH);
check("shm_update_type", entry2.update_type == UPDATE_FULL);
check("shm_data_offset", entry2.data_offset == data_blob_offset(256));
check("shm_data_size", entry2.data_size == 12);
float pos2[3];
memcpy(pos2, base + entry2.data_offset, 12);
check("shm_blob_x", fabsf(pos2[0] - 1.5f) < 1e-6f);
check("shm_blob_y", fabsf(pos2[1] - 2.5f) < 1e-6f);
check("shm_blob_z", fabsf(pos2[2] - 3.5f) < 1e-6f);
shm.destroy();
}
// ── Test 6: Invalidation cascade ─────────────────────────────────────────────
// Invalidating a region at a coarse mip should cascade to all finer mips.
void test_invalidation_cascade() {
PageTable pt;
pt.init(4, 8); // 4 mips, 8×8 base
// Fill all mip 0 tiles
for (int y = 0; y < 8; y++) {
for (int x = 0; x < 8; x++) {
pt.map(0, x, y, { (uint8_t)x, (uint8_t)y });
}
}
// Fill mip 1 tiles (4×4)
for (int y = 0; y < 4; y++) {
for (int x = 0; x < 4; x++) {
pt.map(1, x, y, { (uint8_t)(x + 8), (uint8_t)y });
}
}
// Invalidate mip 2, tile (0,0) — region 1×1
// This should cascade: mip 2 (0,0), mip 1 (0,0)..(1,1), mip 0 (0,0)..(3,3)
pt.invalidate_region(2, 0, 0, 1, 1);
// Mip 2 (0,0) should be evicted
auto m2 = pt.lookup(2, 0, 0);
check("cascade_mip2_evicted", !m2.valid);
// Mip 1 children: (0,0), (0,1), (1,0), (1,1) should all be evicted
check("cascade_mip1_00", !pt.lookup(1, 0, 0).valid);
check("cascade_mip1_01", !pt.lookup(1, 0, 1).valid);
check("cascade_mip1_10", !pt.lookup(1, 1, 0).valid);
check("cascade_mip1_11", !pt.lookup(1, 1, 1).valid);
// Mip 1 (2,0) should NOT be affected
check("cascade_mip1_20_intact", pt.lookup(1, 2, 0).valid);
// Mip 0 grandchildren: (0,0)..(3,3) should be evicted
for (int y = 0; y < 4; y++) {
for (int x = 0; x < 4; x++) {
auto r = pt.lookup(0, x, y);
if (r.valid) {
char buf[64];
snprintf(buf, sizeof(buf), "cascade_mip0_%d_%d_should_be_evicted", x, y);
check(buf, false);
}
}
}
check("cascade_mip0_quadrant_evicted", true); // if we got here, all were evicted
// Mip 0 (4,0) should NOT be affected
check("cascade_mip0_40_intact", pt.lookup(0, 4, 0).valid);
check("cascade_mip0_70_intact", pt.lookup(0, 7, 0).valid);
}
// ── Test 7: Cache stale tile persistence ─────────────────────────────────────
// After "disconnecting" (no new tiles arriving), stale tiles should persist
// in the cache — they should NOT be automatically freed.
void test_stale_tile_persistence() {
TileCache cache;
cache.init(4, 4); // 16 slots
// Allocate some tiles
TileId tiles[4] = {
{ 1, 0, 0, 0 },
{ 1, 1, 0, 0 },
{ 1, 0, 1, 0 },
{ 1, 1, 1, 0 },
};
for (int i = 0; i < 4; i++) {
auto r = cache.alloc(tiles[i], 100);
assert(r.success);
}
check("stale_initial_count", cache.occupied_count() == 4);
// Simulate "disconnection" — advance frame counter significantly
// without touching any tiles. They should still be in the cache.
// (In real usage, _process() would call touch() for active tiles)
// Verify all tiles are still findable after 1000 frames of inactivity
for (int i = 0; i < 4; i++) {
auto r = cache.find(tiles[i]);
check("stale_tile_persists", r.success);
}
check("stale_count_unchanged", cache.occupied_count() == 4);
// Only a new alloc that triggers eviction would remove them
// Allocate 12 more tiles to fill the cache
for (int i = 0; i < 12; i++) {
TileId t = { 2, i, 0, 0 };
cache.alloc(t, 200);
}
check("cache_full", cache.occupied_count() == 16);
// Now allocate one more — should evict the oldest (frame=100) tile
TileId overflow = { 3, 0, 0, 0 };
auto result = cache.alloc(overflow, 300);
check("eviction_triggered", result.success && result.was_eviction);
check("evicted_from_old_frame", result.evicted_tile.texture_id == 1);
}
// ── Main ─────────────────────────────────────────────────────────────────────
int main() {
printf("=== Validation Tests ===\n");
test_tile_seam_coverage();
test_fallback_persistence();
test_page_table_gpu_format();
test_coordinate_roundtrip();
test_shm_data_integrity();
test_invalidation_cascade();
test_stale_tile_persistence();
printf("\n");
if (failed == 0) {
printf("All %d tests passed.\n", passed);
} else {
printf("%d passed, %d FAILED.\n", passed, failed);
}
return failed > 0 ? 1 : 0;
}