Files
psxrecomp/runtime/tests/test_duckstation_texture_pack.cpp
T
Shokunin 954e28b051 HD preload: bounded contract, safe threads, explicit partial result
Both preload functions now take an aggregate decoded-byte cap and a thread
cap (runtime: 512 MiB, 4 threads). Each image is probed and its RGBA
reserved against the cap before decoding; per-image limits follow the
pack's decode budget, which is no longer grown to usage (pinned images sit
outside it, and outside the Duck 512-entry cap). The pool (texture_pack_
preload.hpp) joins every started thread on unwinding, survives thread
creation failure, and catches worker exceptions. Images not preloaded are
left to the asynchronous budgeted path instead of being marked failed, and
HdTexturePreloadResult reports loaded / over-cap / failed / worker errors
with a complete-or-partial status. The Beetle async worker no longer lets
allocation failures escape its thread entry.

Tests cover both formats: complete preload, over-cap skip with on-demand
fallback, malformed images, injected worker and thread-creation failures,
and budget reservation bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:36:08 -07:00

716 lines
48 KiB
C++

#define STB_IMAGE_IMPLEMENTATION
#include "../third_party/stb_image.h"
#include "duckstation_texture_pack.h"
#include "texture_pack_preload.hpp"
#include <stdexcept>
#include <atomic>
#include "../src/png_write.h"
#include <array>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <memory>
#include <set>
#include <string>
#include <thread>
#include <vector>
namespace fs = std::filesystem;
namespace {
int failures = 0;
void check(bool ok, const char* what) {
if (!ok) { std::fprintf(stderr,"FAIL: %s\n",what); ++failures; }
}
using Pack = std::unique_ptr<DuckTexturePack,decltype(&duck_texture_pack_destroy)>;
Pack create(const fs::path& root) {
DuckTexturePack* pack = nullptr; char error[512]{};
check(duck_texture_pack_create(root.u8string().c_str(),&pack,error,sizeof(error)) == 1,error);
return Pack(pack,&duck_texture_pack_destroy);
}
std::vector<uint16_t> words(size_t n) {
std::vector<uint16_t> result(n);
for (size_t i = 0; i < n; ++i) result[i] = static_cast<uint16_t>((i*7919)^0xa51c);
return result;
}
void place(std::vector<uint16_t>& vram, unsigned x, unsigned y, unsigned w,
const std::vector<uint16_t>& source) {
for (size_t i = 0; i < source.size(); ++i) vram[(y+i/w)*1024+x+i%w] = source[i];
}
HdTextureDrawQuery query(std::vector<uint16_t>& vram, uint16_t page_x, uint16_t page_y,
uint8_t depth, uint8_t u0, uint8_t u1, uint8_t v0, uint8_t v1,
uint16_t clut_x = 512, uint16_t clut_y = 400) {
return {page_x,page_y,depth,u0,u1,v0,v1,clut_x,clut_y,vram.data(),vram.size()};
}
void png(const fs::path& path, unsigned w = 4, unsigned h = 4) {
fs::create_directories(path.parent_path());
std::vector<uint8_t> pixels(size_t(w)*h*4);
constexpr uint8_t alpha[] = {0,127,128,242,243,255};
for (size_t i = 0; i < size_t(w)*h; ++i) {
pixels[i*4] = 23; pixels[i*4+1] = 57; pixels[i*4+2] = 91; pixels[i*4+3] = alpha[i%6];
}
#ifdef _WIN32
FILE* file = _wfopen(path.c_str(),L"wb");
#else
FILE* file = std::fopen(path.c_str(),"wb");
#endif
check(file != nullptr,"open original test PNG");
if (file) { check(png_write_rgba(file,pixels.data(),w,h) == 1,"encode original test PNG"); std::fclose(file); }
}
void config(const fs::path& root, const char* contents) {
fs::create_directories(root);
std::ofstream file(root/"config.yaml"); file << contents;
}
std::vector<fs::path> dumps(const fs::path& root) {
std::vector<fs::path> result;
if (fs::exists(root/"dumps")) for (const auto& file : fs::directory_iterator(root/"dumps"))
if (file.path().extension() == ".png") result.push_back(file.path());
std::sort(result.begin(),result.end()); return result;
}
std::vector<uint8_t> read_png(const fs::path& file, unsigned w, unsigned h) {
std::ifstream input(file,std::ios::binary);
std::vector<uint8_t> encoded((std::istreambuf_iterator<char>(input)),{}), result;
int width = 0, height = 0, channels = 0;
auto* pixels = encoded.empty() ? nullptr : stbi_load_from_memory(encoded.data(),static_cast<int>(encoded.size()),&width,&height,&channels,4);
check(pixels && width == int(w) && height == int(h),"dump PNG has expected dimensions");
if (pixels) { result.assign(pixels,pixels+size_t(width)*height*4); stbi_image_free(pixels); }
return result;
}
void flush(DuckTexturePack* pack) {
char error[512]{};
check(duck_texture_pack_flush_dumps(pack,error,sizeof(error)) == HD_TEXTURE_LOOKUP_FOUND,error);
}
std::string filename(const DuckTextureKey& key) {
char name[192]{}; check(duck_texture_format_name(&key,name,sizeof(name)),"format fixture name");
return std::string(name)+".png";
}
DuckTexturePixels decoded(DuckTexturePack* pack, uint64_t id) {
DuckTexturePixels pixels{};
check(duck_texture_pack_request_decode(pack,id) >= 0,"queue PNG decode");
const auto end = std::chrono::steady_clock::now()+std::chrono::seconds(5);
int status = HD_TEXTURE_LOOKUP_NONE;
do {
status = duck_texture_pack_acquire_decoded(pack,id,&pixels);
if (status != HD_TEXTURE_LOOKUP_NONE) break;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
} while (std::chrono::steady_clock::now() < end);
check(status == HD_TEXTURE_LOOKUP_FOUND,"background PNG decode becomes ready");
return pixels;
}
bool decode_fails(DuckTexturePack* pack, uint64_t id) {
int status = duck_texture_pack_request_decode(pack,id);
const auto end = std::chrono::steady_clock::now()+std::chrono::seconds(5);
while (status == HD_TEXTURE_LOOKUP_NONE && std::chrono::steady_clock::now() < end) {
DuckTexturePixels pixels{};
status = duck_texture_pack_acquire_decoded(pack,id,&pixels);
duck_texture_pixels_release(&pixels);
if (status == HD_TEXTURE_LOOKUP_NONE) std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
return status == HD_TEXTURE_LOOKUP_ERROR;
}
void preload(const fs::path& root) {
namespace tp = psx_texpreload;
const char* name = "texpage-P4-3631D2A04EC85311-9CBCDEA9305D7528-64x256-4-2-8x2-P0-15.png";
png(root/"replacements"/name,16,4);
std::vector<uint16_t> vram(1024*512);
place(vram,65,258,2,words(4)); place(vram,512,400,16,words(16));
{
auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3);
HdTexturePreloadResult r{};
check(duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r) == 1 && r.loaded == 1 &&
r.requested == 1 && r.bytes == 16*4*4 && r.failed == 0,"preload decodes the pack");
DuckTextureMatch match{};
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"preloaded fixture matches");
DuckTexturePixels pixels{};
check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND &&
pixels.width == 16,"preloaded image is resident without a decode request");
duck_texture_pixels_release(&pixels);
duck_texture_pack_set_decode_budget(pack.get(),1);
check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND,
"a preloaded image survives budget eviction");
duck_texture_pixels_release(&pixels);
}
{ /* Over the aggregate cap: partial, nothing pinned, async path still decodes. */
auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3);
HdTexturePreloadResult r{};
check(duck_texture_pack_preload(pack.get(),16,4,&r) == 0 && r.loaded == 0 && r.over_budget == 1 &&
r.bytes == 0,"oversized image is skipped by the preload cap");
DuckTextureMatch match{};
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"capped fixture matches");
DuckTexturePixels pixels = decoded(pack.get(),match.entry_id);
check(pixels.width == 16,"an image the preload skipped decodes on demand");
duck_texture_pixels_release(&pixels);
}
{ /* Injected worker failure: caught, reported, image left to the async path. */
auto pack = create(root);
tp::fault_mode() = 1;
HdTexturePreloadResult r{};
const int status = duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r);
tp::fault_mode() = 0;
check(status == 0 && r.worker_errors == 1 && r.failed == 1 && r.loaded == 0,
"a throwing decode is caught and reported as a partial preload");
}
{ /* Malformed image: failed, not pinned, async decode reports failure. */
std::ofstream(root/"replacements"/name,std::ios::binary|std::ios::trunc) << "not a png";
auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3);
HdTexturePreloadResult r{};
check(duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r) == 0 && r.failed == 1 &&
r.worker_errors == 0,"malformed image fails the preload item");
DuckTextureMatch match{};
if (duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND)
check(decode_fails(pack.get(),match.entry_id),"malformed image still fails on demand");
}
}
void preload_pool() {
namespace tp = psx_texpreload;
std::atomic<int> ran{0};
tp::fault_mode() = 2; /* the second thread cannot be created */
size_t errors = tp::run(32,8,[&](size_t){ ++ran; });
check(errors == 0 && ran == 32,"thread creation failure lowers concurrency, all items still run");
tp::fault_mode() = 1; /* the first item throws bad_alloc */
ran = 0;
errors = tp::run(32,8,[&](size_t){ ++ran; });
tp::fault_mode() = 0;
check(errors == 1 && ran == 31,"a throwing item is caught and counted");
ran = 0;
errors = tp::run(32,8,[&](size_t k){ if (k % 4 == 0) throw std::runtime_error("x"); ++ran; });
check(errors == 8 && ran == 24,"item exceptions never escape a pool thread");
tp::Budget budget(100);
check(budget.reserve(60) && !budget.reserve(41) && budget.reserve(40) && !budget.reserve(1),
"aggregate budget reservations are bounded");
budget.release(50);
check(budget.reserve(50),"released bytes are reusable");
}
void fixed_hash_vectors() {
/* Independently produced by Python xxhash 3.6.0's native wheel. These
* literal expectations cover XXH3's short/medium/long-input boundaries. */
const std::pair<size_t,uint64_t> vectors[] = {
{0,0x2d06800538d394c2ULL},{1,0xc5779b2f56fbe2e1ULL},{2,0x0edb68960f55aef6ULL},
{3,0xfce9d54aec3f9d32ULL},{4,0x3631d2a04ec85311ULL},{8,0x000ca76cb9b329dcULL},
{9,0xab6a9091cf532d8aULL},{16,0x9cbcdea9305d7528ULL},{17,0x2f133c3e193cbe48ULL},
{32,0x3e4a087da5cd5990ULL},{64,0xc3e3ef9ad7f08dacULL},{65,0x740c4e7d1690d606ULL},
{120,0x5942d561231419b2ULL},{121,0x152cb5857c4535f3ULL},{128,0x351cd56a8fe50e66ULL},
{129,0x38a45de7ae84906eULL},{256,0x0005d0b83a005f4eULL},{8192,0xc68a00dcca652920ULL}
};
for (const auto& v : vectors) {
const auto input = words(v.first);
check(duck_texture_hash_words_le(input.data(),input.size()) == v.second,"fixed independent LE16 XXH3 vector");
}
std::vector<uint16_t> vram(1024*512,0xf00d);
place(vram,101,39,2,words(8));
check(duck_texture_hash_rect(vram.data(),vram.size(),101,39,2,4) == 0x000ca76cb9b329dcULL,
"strided native rectangle excludes unrelated VRAM words");
check(duck_texture_hash_rect(vram.data(),vram.size(),1023,0,2,1) == 0,"wrapped hash rectangle rejected");
}
void names() {
DuckTextureKey key{};
check(duck_texture_parse_name("texupload-P4-0123456789abcdef-FEDCBA9876543210-64x256-0-192-64x64-P0-14.png",&key) == 1,
"literal upstream documented upload filename");
check(key.source_hash == 0x0123456789abcdefULL && key.palette_hash == 0xfedcba9876543210ULL &&
key.source_width_words == 64 && key.offset_y == 192 && key.width == 64 && key.palette_max == 14,
"filename dimensions distinguish source words from expanded texels");
check(duck_texture_parse_name("texpage-STC16-0123456789ABCDEF-256x256-8-16-32x64.PNG",&key) == 1 &&
key.kind == DUCK_TEXTURE_PAGE && key.semitransparent && key.depth == 2 && !key.palette_hash,
"direct ST filename omits palette fields");
for (const char* extension : {"jpg","jpeg","webp"})
check(duck_texture_parse_name((std::string("texupload-C16-0123456789ABCDEF-4x2-0-0-4x2.")+extension).c_str(),&key),
"JPEG and WebP canonical payload extensions accepted");
const char* invalid[] = {
"texpage-P4-12345678-0123456789ABCDEF-64x256-0-0-4x1-P0-15.png",
"texpage-P4-0123456789ABCDEF-0123456789ABCDEF-64x256-0-0-3x1-P0-15.png",
"texpage-P4-0123456789ABCDEF-0123456789ABCDEF-64x256-0-0-4x1-P0-16.png",
"texpage-P8-0123456789ABCDEF-0123456789ABCDEF-128x256-0-0-4x1-P15-0.png",
"texupload-C16-0123456789ABCDEF-0x256-0-0-4x1.png",
"texupload-C16-0123456789ABCDEF-65536x1-0-0-4x1.png",
"texpage-C16-0123456789ABCDEF-256x256-255-0-2x1.png",
"texpage-C16-0123456789ABCDEF-256x256-0-0-4x1.png.old",
"../texpage-C16-0123456789ABCDEF-256x256-0-0-4x1.png"
};
for (const char* name : invalid) check(!duck_texture_parse_name(name,&key),"targeted malformed/unsupported name rejected");
}
void page_and_decode(const fs::path& root) {
const char* name = "texpage-P4-3631D2A04EC85311-9CBCDEA9305D7528-64x256-4-2-8x2-P0-15.png";
png(root/"replacements"/"nested"/name,16,4);
std::vector<uint16_t> vram(1024*512);
place(vram,65,258,2,words(4)); place(vram,512,400,16,words(16));
auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3);
DuckTextureMatch match{};
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"fixed subpage fixture matches");
check(match.origin_u == 4 && match.origin_v == 2 && match.source_width == 8 && match.source_height == 2,
"crop extent and origin are page-space texels");
auto pixels = decoded(pack.get(),match.entry_id);
check(pixels.width == 16 && pixels.height == 4,"arbitrary PNG scaling uses image dimensions");
constexpr uint8_t alpha[] = {0,127,128,242,243,255};
if (pixels.rgba) for (unsigned i = 0; i < 6; ++i)
check(pixels.rgba[i*4+3] == alpha[i],"decode retains raw alpha boundary bytes");
const uint16_t old = vram[258*1024+65];
vram[258*1024+65] ^= 1; duck_texture_pack_invalidate(pack.get(),65,258,1,1);
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"write invalidates positive page cache");
vram[258*1024+65] = old; duck_texture_pack_invalidate(pack.get(),65,258,1,1);
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"write invalidates negative page cache");
vram[400*1024+512] ^= 1; duck_texture_pack_invalidate(pack.get(),512,400,1,1);
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"CLUT write invalidates cached match");
pack.reset();
check(pixels.rgba && pixels.rgba[0] == 23,"pixel lease outlives pack and decode worker");
duck_texture_pixels_release(&pixels);
}
void upload(const fs::path& root) {
png(root/"replacements"/"texupload-C16-000CA76CB9B329DC-4x2-2-0-2x2.png",4,8);
std::vector<uint16_t> vram(1024*512); place(vram,66,257,4,words(8));
auto pack = create(root); auto q = query(vram,64,256,2,4,5,1,2);
DuckTextureMatch match{};
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"upload names require original source identity");
check(duck_texture_pack_track_upload(pack.get(),66,257,4,2,vram.data(),vram.size()),"track post-write native upload");
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND && match.origin_u == 4 && match.origin_v == 1,
"upload crop has correct source origin");
duck_texture_pack_invalidate(pack.get(),66,257,1,1); /* outside replacement crop */
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"partial source overwrite drops entire upload identity");
duck_texture_pack_track_upload(pack.get(),66,257,4,2,vram.data(),vram.size());
duck_texture_pack_reset_tracking(pack.get());
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"savestate/reset clears upload identity");
check(!duck_texture_pack_track_upload(pack.get(),1023,0,2,1,vram.data(),vram.size()),"wrapped upload explicitly falls back");
}
void reload_tracking(const fs::path& root) {
std::vector<uint16_t> vram(1024*512); place(vram,66,257,4,words(8));
auto old = create(root);
check(duck_texture_pack_track_upload(old.get(),66,257,4,2,vram.data(),vram.size()),"capture upload before replacement file exists");
png(root/"replacements"/"texupload-C16-000CA76CB9B329DC-4x2-2-0-2x2.png");
auto refreshed = create(root); auto q = query(vram,64,256,2,4,5,1,2); DuckTextureMatch match{};
check(duck_texture_pack_match(refreshed.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"newly indexed replacement initially has no upload identity");
check(duck_texture_pack_copy_tracking(refreshed.get(),old.get()),"same-session reload copies upload tracking");
check(duck_texture_pack_match(refreshed.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"copied tracking makes newly indexed replacement immediately match");
duck_texture_pack_invalidate(refreshed.get(),66,257,1,1);
check(duck_texture_pack_match(refreshed.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"destination tracking invalidates independently");
auto second = create(root); duck_texture_pack_copy_tracking(second.get(),old.get());
check(duck_texture_pack_match(second.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"destination invalidation leaves original tracker intact");
}
void palettes(const fs::path& root) {
std::vector<uint16_t> vram(1024*512); place(vram,128,0,1,words(2));
place(vram,1008,400,16,words(16));
const char* edge = "texpage-P8-0EDB68960F55AEF6-9CBCDEA9305D7528-128x256-0-0-2x2-P0-255.png";
png(root/"edge"/"replacements"/edge);
auto pack = create(root/"edge"); auto q = query(vram,128,0,1,0,1,0,1,1008,400); DuckTextureMatch match{};
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"full P8 edge CLUT hashes remaining 16 words");
vram[400*1024] = 42; duck_texture_pack_invalidate(pack.get(),0,400,1,1);
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"full P8 CLUT hash does not wrap into row start");
const char* partial = "texpage-P8-0EDB68960F55AEF6-3631D2A04EC85311-128x256-0-0-2x2-P8-11.png";
png(root/"partial"/"replacements"/partial);
place(vram,512,400,16,words(16)); q.clut_x = 512;
pack = create(root/"partial");
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"reduced P8 min8-max11 hashes prefix4 per upstream pin");
vram[400*1024+520] ^= 1; duck_texture_pack_invalidate(pack.get(),520,400,1,1);
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"pinned prefix quirk ignores palette8 despite declared minimum8");
vram[400*1024+512] ^= 1; duck_texture_pack_invalidate(pack.get(),512,400,1,1);
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"reduced P8 prefix change stops replacement");
q.clut_x = 1023;
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"misaligned/edge reduced CLUT rejected");
}
void st_alias_duplicates(const fs::path& root) {
const char* ordinary = "texpage-C16-000CA76CB9B329DC-256x256-0-0-4x2.png";
const char* st = "texpage-STC16-000CA76CB9B329DC-256x256-0-0-4x2.png";
png(root/"paired"/"replacements"/ordinary); png(root/"paired"/"replacements"/st);
std::vector<uint16_t> vram(1024*512); place(vram,64,0,4,words(8)); auto q = query(vram,64,0,2,0,3,0,1);
auto pack = create(root/"paired"); DuckTextureMatch match{};
check(duck_texture_pack_match_draw(pack.get(),&q,0,&match) == HD_TEXTURE_LOOKUP_FOUND && !match.key.semitransparent,
"ordinary primitive prefers ordinary filename");
check(duck_texture_pack_match_draw(pack.get(),&q,1,&match) == HD_TEXTURE_LOOKUP_FOUND && match.key.semitransparent,
"semitransparent primitive prefers ST filename");
png(root/"alias"/"replacements"/"art"/"sample.png");
fs::create_directories(root/"alias");
std::ofstream config(root/"alias"/"config.yaml");
config << "MaxVRAMWriteCoalesceWidth: 1\nBogusOption: true\nAliases:\n " << std::string(ordinary).substr(0,std::strlen(ordinary)-4)
<< ": |\n art/sample.png\n"; config.close();
pack = create(root/"alias");
check(duck_texture_pack_match_draw(pack.get(),&q,1,&match) == HD_TEXTURE_LOOKUP_FOUND && !match.key.semitransparent,
"bounded literal alias and opposite-convention fallback");
DuckTexturePackInfo info{}; duck_texture_pack_get_info(pack.get(),&info);
DuckTextureConfig settings{}; duck_texture_pack_get_config(pack.get(),&settings);
check(settings.max_vram_write_coalesce_width == 1,"exact root-level coalesce option recognized");
check(info.ignored_count && std::strstr(info.diagnostic,"BogusOption"),"unknown config option explicitly diagnosed");
png(root/"duplicate"/"replacements"/"a"/ordinary); png(root/"duplicate"/"replacements"/"b"/ordinary);
pack = create(root/"duplicate");
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_AMBIGUOUS,"recursive duplicate keys fall back deterministically");
q.u_first = 254; q.u_last = 1;
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_NONE,"wrapped UV interval falls back");
}
void failed_payloads(const fs::path& root) {
const char* name = "texpage-C16-000CA76CB9B329DC-256x256-0-0-4x2.png";
png(root/"budget"/"replacements"/name);
std::vector<uint16_t> vram(1024*512); place(vram,64,0,4,words(8)); auto q = query(vram,64,0,2,0,3,0,1);
auto pack = create(root/"budget"); DuckTextureMatch match{};
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"over-budget payload identity remains valid metadata");
duck_texture_pack_set_decode_budget(pack.get(),8);
check(decode_fails(pack.get(),match.entry_id),"over-budget decoded image fails before pixel allocation");
fs::create_directories(root/"malformed"/"replacements");
std::ofstream invalid(root/"malformed"/"replacements"/name,std::ios::binary); invalid << "not PNG"; invalid.close();
pack = create(root/"malformed");
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"malformed PNG identity can be indexed without render-thread decode");
check(decode_fails(pack.get(),match.entry_id),"malformed PNG fails asynchronously for native fallback");
const fs::path oversized = root/"dimension"/"replacements"/name;
png(oversized);
std::ifstream file(oversized,std::ios::binary); std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(file)),{}); file.close();
/* Forge a valid IHDR width above the decode dimension bound. Keeping the
* tiny original IDAT makes the fixture inexpensive and verifies rejection
* using image metadata before an enormous allocation could happen. */
bytes[16] = 0; bytes[17] = 0; bytes[18] = 0x23; bytes[19] = 0x28; /* 9000 */
const uint32_t crc = png_crc_update(0xffffffffu,bytes.data()+12,17)^0xffffffffu;
for (unsigned i = 0; i < 4; ++i) bytes[29+i] = static_cast<uint8_t>(crc >> (24-8*i));
std::ofstream changed(oversized,std::ios::binary|std::ios::trunc);
changed.write(reinterpret_cast<const char*>(bytes.data()),bytes.size()); changed.close();
pack = create(root/"dimension"); duck_texture_pack_match(pack.get(),&q,&match);
check(decode_fails(pack.get(),match.entry_id),"oversized PNG IHDR fails within native fallback bounds");
}
void dumping(const fs::path& root) {
config(root,"DumpTexturePages: true\nDumpTextureWidthThreshold: 1\nDumpTextureHeightThreshold: 1\nReducePaletteRange: false\n");
auto pack = create(root); std::vector<uint16_t> vram(1024*512);
vram[0] = 0x3210; vram[400*1024+1] = 0x001f; vram[400*1024+2] = 0x8000; vram[400*1024+3] = 0xffff;
auto q = query(vram,0,0,0,0,3,0,0,0,400); char error[512]{};
check(duck_texture_pack_dump_draw(pack.get(),&q,1,error,sizeof(error)) == HD_TEXTURE_LOOKUP_FOUND,"queue native ST page dump");
check(duck_texture_pack_dump_draw(pack.get(),&q,1,error,sizeof(error)) == HD_TEXTURE_LOOKUP_NONE,"dump session deduplicates without filesystem access");
pack.reset(); /* required to finish queued background writes */
const fs::path file = root/"dumps"/"texpage-STP4-A92BF769517E0361-59786C612FB45363-64x256-0-0-4x1-P0-15.png";
check(fs::is_regular_file(file),"fixed independent hash filename published at worker shutdown");
int width = 0, height = 0, channels = 0;
std::ifstream encoded_file(file,std::ios::binary);
std::vector<uint8_t> encoded((std::istreambuf_iterator<char>(encoded_file)),{});
auto* image = encoded.empty() ? nullptr : stbi_load_from_memory(encoded.data(),static_cast<int>(encoded.size()),&width,&height,&channels,4);
constexpr uint8_t expected[] = {0,0,0,0,255,0,0,255,0,0,0,143,255,255,255,143};
check(image && width == 4 && height == 1 && !std::memcmp(image,expected,sizeof(expected)),"dump decodes native indices/CLUT and ST alpha");
if (image) stbi_image_free(image);
std::ofstream owner(file,std::ios::binary|std::ios::trunc); owner << "owner edit"; owner.close();
pack = create(root); duck_texture_pack_dump_draw(pack.get(),&q,1,error,sizeof(error)); pack.reset();
std::ifstream edited(file,std::ios::binary); std::string payload((std::istreambuf_iterator<char>(edited)),{});
check(payload == "owner edit","existing owner dump is never overwritten");
config(root/"upload","DumpTextureWidthThreshold: 1\nDumpTextureHeightThreshold: 1\nReducePaletteRange: false\n");
pack = create(root/"upload");
duck_texture_pack_track_upload(pack.get(),0,0,1,1,vram.data(),vram.size());
check(duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error)) == HD_TEXTURE_LOOKUP_FOUND,"tracked source emits texupload dump");
pack.reset();
check(fs::is_regular_file(root/"upload"/"dumps"/"texupload-P4-A92BF769517E0361-59786C612FB45363-1x1-0-0-4x1-P0-15.png"),
"upload dump retains original source dimensions");
/* PNG fixtures remain independent of the VRAM pointer used by replacement
* matching, so dumping never reads already-replaced pixels. */
}
void official_binary_oracle(const fs::path& root) {
/* Literal oracle from the official DuckStation 697599c47 binary, driven
* by a separately authored synthetic PS-X EXE. No source-derived expected
* filename or hash is used here. Both normal/semi uses must aggregate. */
std::vector<uint16_t> vram(1024*512), source(10*56);
constexpr uint16_t pattern[]{0x3210,0x7654,0xba98,0xfedc};
for (size_t i = 0; i < source.size(); ++i) source[i] = pattern[i%4];
place(vram,640,0,10,source);
for (unsigned i = 1; i < 16; ++i) {
vram[256*1024+i] = static_cast<uint16_t>((i*1537)&0x7fff);
vram[257*1024+i] = static_cast<uint16_t>(((i*293)&0x7fff)|0x8000);
}
auto pack = create(root); char error[512]{};
check(duck_texture_pack_track_upload(pack.get(),640,0,10,56,vram.data(),vram.size()),"oracle immutable upload tracked");
auto a = query(vram,640,0,0,0,31,0,31,0,256);
check(duck_texture_pack_dump_draw(pack.get(),&a,0,error,sizeof(error)) == HD_TEXTURE_LOOKUP_FOUND,"oracle first ordinary use recorded");
a.u_last = 35;
check(duck_texture_pack_dump_draw(pack.get(),&a,1,error,sizeof(error)) == HD_TEXTURE_LOOKUP_FOUND,"oracle nested semi use expands same record");
auto b = query(vram,640,0,0,0,39,32,55,0,257);
check(duck_texture_pack_dump_draw(pack.get(),&b,0,error,sizeof(error)) == HD_TEXTURE_LOOKUP_FOUND,"oracle second palette crop recorded");
DuckTexturePackInfo info{}; duck_texture_pack_get_info(pack.get(),&info);
check(info.pending_dump_sources == 1 && info.pending_palette_records == 2 && info.queued_dump_count == 0,
"authoring observes one source/two palettes and writes nothing before retirement");
check(dumps(root).empty(),"oracle draws do not emit per-draw files");
place(vram,640,0,10,std::vector<uint16_t>(560));
check(duck_texture_pack_track_upload(pack.get(),640,0,10,56,vram.data(),vram.size()),"oracle full overwrite retires original snapshot");
flush(pack.get()); duck_texture_pack_get_info(pack.get(),&info);
check(info.queued_dump_count == 2 && info.pending_palette_records == 0,"oracle retirement queues exactly two identities");
const char* names[]{
"texupload-STP4-18DDF9B731440D20-0DAE5F73C88719B1-10x56-0-0-36x32-P0-15.png",
"texupload-P4-18DDF9B731440D20-AF506869B80F5CE7-10x56-0-32-40x24-P0-15.png"};
check(dumps(root).size() == 2,"official binary golden fixture produces only its two PNGs");
for (unsigned variant = 0; variant < 2; ++variant) {
const unsigned width = variant ? 40 : 36, height = variant ? 24 : 32, y0 = variant ? 32 : 0;
const auto pixels = read_png(root/"dumps"/names[variant],width,height);
bool correct = pixels.size() == size_t(width)*height*4;
for (unsigned y = 0; correct && y < height; ++y) for (unsigned x = 0; x < width; ++x) {
const unsigned index = (pattern[((y+y0)*10+x/4)%4]>>((x%4)*4))&15;
const uint16_t color = index ? static_cast<uint16_t>(variant ? ((index*293)&0x7fff)|0x8000 : (index*1537)&0x7fff) : 0;
const size_t at = (size_t(y)*width+x)*4;
for (unsigned c = 0; c < 3; ++c) correct &= pixels[at+c] == (((color>>(5*c))&31)*255+15)/31;
correct &= pixels[at+3] == (color ? 255 : 0);
}
check(correct,"official binary golden crop retains original pixels and captured palette");
}
}
void defaults_and_options(const fs::path& root) {
auto pack = create(root/"defaults"); DuckTextureConfig c{};
duck_texture_pack_get_config(pack.get(),&c);
check(!c.dump_texture_pages && !c.dump_full_texture_pages && !c.dump_c16_textures && c.reduce_palette_range &&
c.texture_dump_width_threshold == 16 && c.texture_dump_height_threshold == 16 &&
!c.max_vram_write_splits && !c.max_vram_write_coalesce_width && !c.max_vram_write_coalesce_height &&
!c.convert_copies_to_writes && !c.replacement_scale_linear_filter && c.dump_vram_write_force_alpha_channel &&
c.vram_write_dump_width_threshold == 128 && c.vram_write_dump_height_threshold == 128,
"pinned native authoring defaults are exact");
std::vector<uint16_t> vram(1024*512); char error[512]{};
auto q = query(vram,0,0,0,0,63,0,31,0,400);
check(duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error)) == HD_TEXTURE_LOOKUP_NONE,"default no upload means no page fallback");
duck_texture_pack_track_upload(pack.get(),0,0,4,15,vram.data(),vram.size()); q.u_last = 15; q.v_last = 14;
duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error)); flush(pack.get());
check(dumps(root/"defaults").empty(),"height below minimum suppresses dump even when width meets threshold");
duck_texture_pack_track_upload(pack.get(),0,0,16,16,vram.data(),vram.size()); q.depth = 2; q.v_last = 15;
check(duck_texture_pack_dump_draw(pack.get(),&q,1,error,sizeof(error)) == HD_TEXTURE_LOOKUP_NONE,"C16 dumps disabled by default");
flush(pack.get()); check(dumps(root/"defaults").empty(),"default C16 has no delayed output");
config(root/"options","DumpTexturePages: true\nDumpFullTexturePages: true\nDumpTextureForceAlphaChannel: true\n"
"DumpC16Textures: true\nReducePaletteRange: false\nDumpTextureWidthThreshold: 1\nDumpTextureHeightThreshold: 1\n"
"MaxVRAMWriteSplits: 3\nMaxVRAMWriteCoalesceWidth: 9\nMaxVRAMWriteCoalesceHeight: 11\nConvertCopiesToWrites: true\n"
"ReplacementScaleLinearFilter: true\nDumpVRAMWriteForceAlphaChannel: false\nDumpVRAMWriteWidthThreshold: 3\nDumpVRAMWriteHeightThreshold: 5\n"
"Options:\n DumpTexturePages: false\nUnknownOption: true\n");
pack = create(root/"options"); duck_texture_pack_get_config(pack.get(),&c);
check(c.dump_texture_pages && c.dump_full_texture_pages && c.dump_texture_force_alpha_channel && c.dump_c16_textures &&
!c.reduce_palette_range && c.max_vram_write_splits == 3 && c.max_vram_write_coalesce_width == 9 &&
c.max_vram_write_coalesce_height == 11 && c.convert_copies_to_writes && duck_texture_pack_linear_filter(pack.get()) &&
!c.dump_vram_write_force_alpha_channel && c.vram_write_dump_width_threshold == 3 && c.vram_write_dump_height_threshold == 5,
"exact root-level scalar options parsed without nested Options override");
DuckTexturePackInfo info{}; duck_texture_pack_get_info(pack.get(),&info);
check(std::strstr(info.diagnostic,"Options") && std::strstr(info.diagnostic,"UnknownOption"),"unsupported nested and unknown options diagnosed");
q = query(vram,0,0,2,0,0,0,0);
duck_texture_pack_dump_draw(pack.get(),&q,1,error,sizeof(error)); flush(pack.get());
const auto files = dumps(root/"options"); check(files.size() == 1,"full page force-alpha emits one page");
if (!files.empty()) {
DuckTextureKey key{}; duck_texture_parse_name(files[0].filename().u8string().c_str(),&key);
check(key.width == 256 && key.height == 256 && !key.semitransparent,"fullpage and force-alpha filename conventions");
const auto pixels = read_png(files[0],256,256);
check(pixels.size() == 256*256*4 && pixels[0] == 0 && pixels[3] == 255,"force-alpha preserves opaque native zero");
}
config(root/"zero","MaxHashCacheVRAMUsageMB: 0\n"); pack = create(root/"zero");
check(!duck_texture_pack_track_upload(pack.get(),0,0,16,16,vram.data(),vram.size()),"zero snapshot budget falls back without blocking");
duck_texture_pack_get_info(pack.get(),&info); check(std::strstr(info.diagnostic,"budget"),"snapshot budget fallback diagnosed");
flush(pack.get());
}
void immutable_palettes_and_pages(const fs::path& root) {
std::vector<uint16_t> vram(1024*512);
place(vram,0,0,8,std::vector<uint16_t>(8*16,0x3210)); vram[400*1024+1] = 0x001f;
auto pack = create(root/"upload"); auto q = query(vram,0,0,0,0,15,0,15,0,400); char error[512]{};
duck_texture_pack_track_upload(pack.get(),0,0,8,16,vram.data(),vram.size());
duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error)); q.u_last = 31;
duck_texture_pack_dump_draw(pack.get(),&q,1,error,sizeof(error));
vram[400*1024+1] = 0x83e0; duck_texture_pack_invalidate(pack.get(),1,400,1,1);
duck_texture_pack_dump_draw(pack.get(),&q,1,error,sizeof(error));
/* Overwrite both source and CLUT before retirement; neither may be read
* by the writer instead of the saved original words/palettes. */
place(vram,0,0,8,std::vector<uint16_t>(8*16)); vram[400*1024+1] = 0x7c00;
duck_texture_pack_invalidate(pack.get(),0,0,8,16); flush(pack.get());
const auto files = dumps(root/"upload"); check(files.size() == 2,"palette versions keep separate lifetime records");
bool old_red = false, saved_green = false;
for (const auto& file : files) {
DuckTextureKey key{}; duck_texture_parse_name(file.filename().u8string().c_str(),&key);
check(key.width == 32 && key.height == 16 && key.semitransparent && key.palette_min == 0 && key.palette_max == 3,
"nested crops union, ordinary+semi aggregate, palette bounds reduced");
const auto pixels = read_png(file,32,16);
if (pixels.size() >= 8) { old_red |= pixels[4] == 255 && pixels[5] == 0 && pixels[7] == 255;
saved_green |= pixels[4] == 0 && pixels[5] == 255 && pixels[7] == 143; }
}
check(old_red && saved_green,"captured palette versions retain original colors and ST flags");
config(root/"page","DumpTexturePages: true\nReducePaletteRange: false\n");
vram.assign(vram.size(),0); place(vram,0,0,8,std::vector<uint16_t>(8*16,0x1111));
vram[400*1024+1] = 0x001f; vram[401*1024+1] = 0x03e0;
pack = create(root/"page"); q = query(vram,0,0,0,0,15,0,15,0,400);
duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error)); auto b = q; b.clut_y = 401;
duck_texture_pack_dump_draw(pack.get(),&b,0,error,sizeof(error));
vram[401*1024+1] = 0x7c00; duck_texture_pack_invalidate(pack.get(),1,401,1,1);
q.u_last = 31; duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error)); flush(pack.get());
const auto pagefiles = dumps(root/"page"); check(pagefiles.size() == 2,"changing CLUT B does not fragment CLUT A lifetime");
bool a_union = false, b_original = false;
for (const auto& file : pagefiles) {
DuckTextureKey key{}; duck_texture_parse_name(file.filename().u8string().c_str(),&key);
const auto pixels = read_png(file,key.width,key.height);
if (!pixels.empty()) { a_union |= key.width == 32 && pixels[0] == 255;
b_original |= key.width == 16 && pixels[1] == 255 && pixels[2] == 0; }
}
check(a_union && b_original,"page authoring residency is isolated per CLUT and snapshots old palettes");
}
void split_coalesce_copy_parts(const fs::path& root) {
std::vector<uint16_t> vram(1024*512); place(vram,0,0,4,std::vector<uint16_t>(4*16,0x3210));
vram[400*1024+1] = 0x1f; vram[400*1024+2] = 0x3e0; vram[400*1024+3] = 0x7c00;
DuckTextureKey key{}; key.depth = 0; key.source_width_words = 4; key.source_height = 16; key.width = 16; key.height = 16;
key.source_hash = duck_texture_hash_rect(vram.data(),vram.size(),0,0,4,16);
key.palette_hash = duck_texture_hash_words_le(vram.data()+400*1024,4); key.palette_max = 3;
config(root/"split","MaxVRAMWriteSplits: 2\n"); png(root/"split"/"replacements"/filename(key),16,16);
auto pack = create(root/"split"); duck_texture_pack_track_upload(pack.get(),0,0,4,16,vram.data(),vram.size());
auto q = query(vram,0,0,0,0,15,0,15,0,400); char error[512]{};
duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error));
vram[1024] = 0; duck_texture_pack_invalidate(pack.get(),0,1,1,1);
std::array<DuckTextureMatch,64> parts{}; size_t count = 0;
check(duck_texture_pack_match_parts(pack.get(),&q,0,parts.data(),parts.size(),&count) == HD_TEXTURE_LOOKUP_FOUND && count == 3,
"partial overwrite splits original identity into three surviving clips");
unsigned area = 0; for (size_t i = 0; i < count; ++i) area += unsigned(parts[i].clip_width)*parts[i].clip_height;
check(area == 252,"split clips exclude exactly four overwritten native texels");
check(duck_texture_pack_match_parts(pack.get(),&q,0,parts.data(),1,&count) == HD_TEXTURE_LOOKUP_ERROR && count == 0,
"insufficient multi-part output capacity never truncates composition");
auto cut = q; cut.u_last = 3; cut.v_first = cut.v_last = 1;
DuckTextureMatch match{}; check(duck_texture_pack_match(pack.get(),&cut,&match) == HD_TEXTURE_LOOKUP_NONE,"overwritten region has native fallback");
flush(pack.get()); const auto files = dumps(root/"split"); check(files.size() == 1,"split preserves pending original upload observation");
if (!files.empty()) { const auto pixels = read_png(files[0],16,16); check(pixels.size() >= 68 && pixels[68] == 255,"split dump uses immutable preoverwrite words"); }
config(root/"coalesce","MaxVRAMWriteCoalesceHeight: 8\n");
vram.assign(vram.size(),0); place(vram,0,0,4,std::vector<uint16_t>(4*16,0x1111)); vram[400*1024+1] = 31;
pack = create(root/"coalesce"); duck_texture_pack_track_upload(pack.get(),0,0,4,8,vram.data(),vram.size());
duck_texture_pack_track_upload(pack.get(),0,8,4,8,vram.data(),vram.size());
q = query(vram,0,0,0,0,15,0,15,0,400); duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error)); flush(pack.get());
const auto coalesced = dumps(root/"coalesce"); check(coalesced.size() == 1,"unused adjacent writes coalesce before authoring use");
if (!coalesced.empty()) { DuckTextureKey k{}; duck_texture_parse_name(coalesced[0].filename().u8string().c_str(),&k);
check(k.source_width_words == 4 && k.source_height == 16,"coalesced original extent retained in filename"); }
config(root/"copy","ConvertCopiesToWrites: true\nDumpC16Textures: true\nDumpTextureWidthThreshold: 1\nDumpTextureHeightThreshold: 1\n");
vram.assign(vram.size(),0); place(vram,64,0,4,words(64)); vram[0] = 0x8001;
const uint64_t oldhash = duck_texture_hash_rect(vram.data(),vram.size(),64,0,4,16);
auto changed = vram; changed[64+1024] = vram[0];
DuckTextureKey updated{}; updated.depth = 2; updated.source_width_words = 4; updated.source_height = 16;
updated.width = 4; updated.height = 16; updated.source_hash = duck_texture_hash_rect(changed.data(),changed.size(),64,0,4,16);
png(root/"copy"/"replacements"/filename(updated));
pack = create(root/"copy"); duck_texture_pack_track_upload(pack.get(),64,0,4,16,vram.data(),vram.size());
q = query(vram,64,0,2,0,3,0,15); duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error));
check(duck_texture_pack_begin_copy(pack.get(),0,0,64,1,1,1,vram.data(),vram.size()),"copy begins before native write and retires old observation");
vram[64+1024] = 0x8001;
check(duck_texture_pack_end_copy(pack.get(),vram.data(),vram.size()),"copy commit records postmask native destination");
check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND && match.key.source_hash == updated.source_hash && updated.source_hash != oldhash,
"copy-to-write rehashes whole ORIGINAL destination extent");
flush(pack.get()); const auto copyfiles = dumps(root/"copy"); check(copyfiles.size() == 1,"copy flush preserves old destination authored texture");
pack = create(root/"copy_default"); duck_texture_pack_track_upload(pack.get(),64,0,4,16,vram.data(),vram.size());
duck_texture_pack_begin_copy(pack.get(),0,0,64,1,1,1,vram.data(),vram.size());
duck_texture_pack_end_copy(pack.get(),vram.data(),vram.size());
DuckTexturePackInfo info{}; duck_texture_pack_get_info(pack.get(),&info);
check(info.snapshot_bytes == 0,"default copy drops destination identity rather than cloning source provenance");
config(root/"parts","MaxVRAMWriteSplits: 2\n");
vram.assign(vram.size(),0); place(vram,0,0,8,std::vector<uint16_t>(8*16,0x3210));
key.source_width_words = 8; key.source_hash = duck_texture_hash_rect(vram.data(),vram.size(),0,0,8,16);
key.palette_hash = duck_texture_hash_words_le(vram.data()+400*1024,4); key.width = 16;
png(root/"parts"/"replacements"/filename(key)); key.semitransparent = 1;
png(root/"parts"/"replacements"/filename(key)); key.offset_x = 16;
png(root/"parts"/"replacements"/filename(key));
pack = create(root/"parts"); duck_texture_pack_track_upload(pack.get(),0,0,8,16,vram.data(),vram.size());
q = query(vram,0,0,0,0,31,0,15,0,400);
check(duck_texture_pack_match_parts(pack.get(),&q,0,parts.data(),parts.size(),&count) == HD_TEXTURE_LOOKUP_FOUND && count == 2,
"adjacent replacement rectangles compose across one source");
bool left_ordinary = false, right_st = false;
for (size_t i = 0; i < count; ++i) { left_ordinary |= parts[i].clip_u == 0 && !parts[i].key.semitransparent;
right_st |= parts[i].clip_u == 16 && parts[i].key.semitransparent; }
check(left_ordinary && right_st,"same-coverage ST preference does not remove another rectangle's only ST asset");
check(duck_texture_pack_match_draw(pack.get(),&q,0,&match) == HD_TEXTURE_LOOKUP_NONE,"single-cover API preserves native fallback for composed draw");
}
void release_while_another_source_splits(const fs::path& root) {
config(root,"MaxVRAMWriteSplits: 4\nDumpC16Textures: true\nDumpTextureWidthThreshold: 1\nDumpTextureHeightThreshold: 1\n");
std::vector<uint16_t> vram(1024*512);
place(vram,0,0,4,words(16)); place(vram,8,0,8,words(64));
auto pack=create(root); char error[512]{};
duck_texture_pack_track_upload(pack.get(),0,0,4,4,vram.data(),vram.size());
duck_texture_pack_track_upload(pack.get(),8,0,8,8,vram.data(),vram.size());
auto first=query(vram,0,0,2,0,3,0,3);
auto second=query(vram,0,0,2,8,15,0,7);
duck_texture_pack_dump_draw(pack.get(),&first,0,error,sizeof error);
duck_texture_pack_dump_draw(pack.get(),&second,0,error,sizeof error);
DuckTexturePackInfo before{},after{}; duck_texture_pack_get_info(pack.get(),&before);
check(before.pending_dump_sources==2,"both independent source observations are pending");
// Retires the first upload entirely, while the second keeps right and lower clips.
duck_texture_pack_invalidate(pack.get(),0,0,12,4);
duck_texture_pack_get_info(pack.get(),&after);
check(after.pending_dump_sources==1 && after.queued_dump_count==1,
"released source finalizes even when surviving upload splits keep the total count steady");
}
void resident_checkpoint(const fs::path& root) {
std::vector<uint16_t> vram(1024*512);
place(vram,0,0,8,std::vector<uint16_t>(8*16,0x3210));
vram[400*1024+1] = 0x001f;
auto pack = create(root); char error[512]{};
duck_texture_pack_track_upload(pack.get(),0,0,8,16,vram.data(),vram.size());
auto q = query(vram,0,0,0,0,15,0,15,0,400);
duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error));
check(duck_texture_pack_checkpoint_dumps(pack.get(),error,sizeof(error)) == HD_TEXTURE_LOOKUP_FOUND,
"resident background checkpoint queues without retirement");
DuckTexturePackInfo info{}; duck_texture_pack_get_info(pack.get(),&info);
check(info.queued_dump_count == 1 && info.pending_dump_sources == 0,
"checkpoint publishes a still-resident source");
duck_texture_pack_dump_draw(pack.get(),&q,0,error,sizeof(error));
duck_texture_pack_checkpoint_dumps(pack.get(),error,sizeof(error));
duck_texture_pack_get_info(pack.get(),&info);
check(info.queued_dump_count == 1,"unchanged resident draws do not requeue PNGs");
/* A later disjoint crop must retain the previous crop and captured CLUT.
* Its union is 32 wide, rather than a separate right-hand 16-wide image. */
q.u_first = 16; q.u_last = 31;
duck_texture_pack_dump_draw(pack.get(),&q,1,error,sizeof(error));
vram[400*1024+1] = 0x7c00;
duck_texture_pack_checkpoint_dumps(pack.get(),error,sizeof(error));
flush(pack.get());
const auto files = dumps(root);
check(files.size() == 2,"resident checkpoint and expanded final crop are durable");
bool union_found = false;
for (const auto& file : files) {
DuckTextureKey key{}; duck_texture_parse_name(file.filename().u8string().c_str(),&key);
if (key.width == 32) {
const auto pixels = read_png(file,32,16);
union_found = key.offset_x == 0 && key.semitransparent && pixels.size() > 8 && pixels[4] == 255 && pixels[6] == 0;
}
}
check(union_found,"checkpoint preserves palette originals and earlier crop union");
}
void copied_pack_ownership(const fs::path& root) {
std::vector<uint16_t> vram(1024*512); place(vram,0,0,8,std::vector<uint16_t>(8*16,0x3210));
vram[400*1024+1] = 31;
auto original = create(root/"same"); duck_texture_pack_track_upload(original.get(),0,0,8,16,vram.data(),vram.size());
auto q = query(vram,0,0,0,0,15,0,15,0,400); char error[512]{};
duck_texture_pack_dump_draw(original.get(),&q,0,error,sizeof(error));
{
auto copied = create(root/"same"); check(duck_texture_pack_copy_tracking(copied.get(),original.get()),"same-root tracking clone with pending observations");
DuckTexturePackInfo info{}; duck_texture_pack_get_info(copied.get(),&info);
check(info.pending_dump_sources == 1 && info.pending_palette_records == 1,"cloned pack owns independent palette record count");
} /* Copy destroyed FIRST: original still has valid owned observations. */
q.u_last = 31;
check(duck_texture_pack_dump_draw(original.get(),&q,1,error,sizeof(error)) == HD_TEXTURE_LOOKUP_FOUND,
"original remains valid and expands union after copy destruction");
flush(original.get()); check(dumps(root/"same").size() == 2,"copy destruction does not steal original larger crop");
duck_texture_pack_dump_draw(original.get(),&q,1,error,sizeof(error));
auto elsewhere = create(root/"different"); check(duck_texture_pack_copy_tracking(elsewhere.get(),original.get()),"different-root clone retains provenance");
original.reset(); flush(elsewhere.get());
check(dumps(root/"different").size() == 1,"different root does not share filename deduplication suppression");
}
} // namespace
int main(int argc, char** argv) {
const fs::path root = fs::temp_directory_path()/fs::u8path("psx-duck-texture-\xCE\xA9-"+
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
try {
fixed_hash_vectors(); names(); page_and_decode(root/"page"); upload(root/"upload"); reload_tracking(root/"reload");
palettes(root/"palettes"); st_alias_duplicates(root/"keys"); failed_payloads(root/"invalid"); dumping(root/"dump");
official_binary_oracle(root/"official-oracle"); defaults_and_options(root/"defaults");
immutable_palettes_and_pages(root/"snapshots"); split_coalesce_copy_parts(root/"lifecycle"); copied_pack_ownership(root/"ownership");
resident_checkpoint(root/"resident"); preload(root/"preload"); preload_pool();
release_while_another_source_splits(root/"release-split");
/* Every artifact is created beneath this fresh, explicitly named test
* root. No user-provided paths participate in recursive cleanup. */
if (argc > 1 && !std::strcmp(argv[1],"--keep-artifacts")) std::printf("ARTIFACT_ROOT: %s\n",root.u8string().c_str());
else fs::remove_all(root);
} catch (const std::exception& e) { check(false,e.what()); }
if (failures) { std::fprintf(stderr,"test_duckstation_texture_pack: %d failure(s)\n",failures); return 1; }
std::puts("PASS: DuckStation format, independent hashes, native matching, decode and dumps");
return 0;
}