Merge pull request #736: bounded HD replacement preload

Current-master rebase retains both source-retirement and preload test entry points. Full native runtime build and eight relevant checks pass. Prior combined title regression and owner visual acceptance recorded; owner explicitly authorized this merge.
This commit is contained in:
Matt Stanley
2026-10-24 12:37:16 -07:00
committed by GitHub
9 changed files with 603 additions and 42 deletions
@@ -170,6 +170,12 @@ int duck_texture_pack_match_parts(DuckTexturePack* pack,
* Release the lease even after pack destruction. */
void duck_texture_pack_set_decode_budget(DuckTexturePack* pack, size_t bytes);
int duck_texture_pack_request_decode(DuckTexturePack* pack, uint64_t entry_id);
/* Elective full preload with the hd_texture_pack_preload contract: bounded
* aggregate decoded bytes and concurrency, pinned images outside the decode
* budget, everything not preloaded left to the asynchronous decode path.
* Returns 1 complete, 0 partial (see out), -1 on error. */
int duck_texture_pack_preload(DuckTexturePack* pack, size_t max_bytes,
unsigned max_threads, HdTexturePreloadResult* out);
int duck_texture_pack_acquire_decoded(DuckTexturePack* pack, uint64_t entry_id,
DuckTexturePixels* out_pixels);
void duck_texture_pixels_release(DuckTexturePixels* pixels);
+21
View File
@@ -189,6 +189,27 @@ typedef struct HdTexturePixels {
void hd_texture_pack_set_decode_budget(HdTexturePack* pack,
size_t budget_bytes);
/* Elective full preload: decode unambiguous replacements up front and pin
* them, bounded by max_bytes of decoded RGBA in aggregate and by max_threads
* concurrent decodes (the caller's thread included). Pinned images are kept
* outside the on-demand decode budget, which stays as configured. Images not
* preloaded (over the cap, unreadable, malformed, a worker failure) are left
* to the normal asynchronous, budgeted decode path; nothing is marked failed
* by the preload. Returns 1 when every image was preloaded, 0 for a partial
* preload (see out), -1 on error. Repeated calls count already pinned images
* without reading/decoding them again; max_bytes caps the pack's total pinned
* residency. A cap smaller than existing residency is rejected with -1 and
* the existing residency reported; it does not discard live pinned images. */
typedef struct HdTexturePreloadResult {
int requested; /* unambiguous replacements considered */
int loaded; /* resident and pinned, including earlier calls */
int over_budget; /* skipped: the aggregate max_bytes was reached */
int failed; /* unreadable, malformed, too large, or worker error */
int worker_errors; /* of failed: a decode that threw (caught) */
size_t bytes; /* total decoded RGBA pinned by this pack */
} HdTexturePreloadResult;
int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes,
unsigned max_threads, HdTexturePreloadResult* out);
int hd_texture_pack_request_decode(HdTexturePack* pack,
uint32_t texture_hash,
uint32_t palette_hash);
+100
View File
@@ -0,0 +1,100 @@
#ifndef PSX_TEXTURE_PACK_PRELOAD_HPP
#define PSX_TEXTURE_PACK_PRELOAD_HPP
/* Shared machinery for the elective full texture-pack preload
* (hd_texture_pack_preload, duck_texture_pack_preload): an aggregate
* decoded-byte reservation and a bounded worker pool that can neither leak a
* joinable std::thread nor let an exception escape a thread entry. */
#include <algorithm>
#include <atomic>
#include <cstddef>
#include <new>
#include <system_error>
#include <thread>
#include <vector>
namespace psx_texpreload {
/* Test-only fault injection. 1: the first item's decode throws
* std::bad_alloc. 2: creating the second pool thread throws std::system_error.
* 3: publication fails after the first pinned image. */
inline std::atomic<int>& fault_mode() {
static std::atomic<int> mode{0};
return mode;
}
inline void after_publish(size_t count) {
if (count == 1 && fault_mode().load() == 3) throw std::bad_alloc();
}
/* Aggregate decoded bytes, reserved before an image is decoded. */
class Budget {
public:
explicit Budget(size_t cap) : cap_(cap) {}
bool reserve(size_t bytes) {
size_t cur = used_.load();
do {
if (bytes > cap_ || cur > cap_ - bytes) return false;
} while (!used_.compare_exchange_weak(cur, cur + bytes));
return true;
}
void release(size_t bytes) { used_.fetch_sub(bytes); }
private:
size_t cap_;
std::atomic<size_t> used_{0};
};
/* Per-item outcome, written by exactly one worker. */
enum ItemStatus : unsigned char { kPending = 0, kLoaded, kFailed, kOverBudget };
/* Joins every started thread when the pool goes out of scope, including while
* unwinding from a failed std::thread construction. */
struct Joiner {
std::vector<std::thread>& threads;
~Joiner() {
for (auto& t : threads)
if (t.joinable()) t.join();
}
};
/* Runs item(k) for every k < n on at most max_threads threads, the caller
* being one of them. An exception from item(k) is caught and counted, never
* propagated out of a thread entry. A failure to start a thread only lowers
* concurrency. Returns the number of items whose item(k) threw. */
template <class F>
size_t run(size_t n, unsigned max_threads, F&& item) {
std::atomic<size_t> next{0}, errors{0};
auto work = [&]() noexcept {
for (size_t k; (k = next.fetch_add(1)) < n;) {
try {
if (k == 0 && fault_mode().load() == 1) throw std::bad_alloc();
item(k);
} catch (...) {
errors.fetch_add(1);
}
}
};
unsigned hw = std::thread::hardware_concurrency();
if (hw == 0) hw = 1;
const unsigned threads = std::max(1u, std::min(max_threads ? max_threads : 1u, hw));
std::vector<std::thread> pool;
{
Joiner joiner{pool};
for (unsigned t = 1; t < threads && t < n; ++t) {
try {
if (t == 1 && fault_mode().load() == 2)
throw std::system_error(
std::make_error_code(std::errc::resource_unavailable_try_again));
pool.emplace_back(work);
} catch (...) {
break;
}
}
work();
}
return errors.load();
}
} // namespace psx_texpreload
#endif
+105 -3
View File
@@ -1,5 +1,6 @@
#include "duckstation_texture_pack.h"
#include "texture_image_decode.h"
#include "texture_pack_preload.hpp"
#define XXH_INLINE_ALL
#include "../third_party/xxhash.h"
#include "png_write.h"
@@ -183,6 +184,7 @@ struct Decode {
std::string path;
std::shared_ptr<Image> image;
uint64_t use = 0;
bool pinned = false; /* preloaded: never evicted */
};
struct Dump { fs::path path; std::shared_ptr<Image> image; };
struct DumpHistory { std::unordered_set<std::string> names; std::deque<std::string> order; };
@@ -231,6 +233,7 @@ struct Worker {
size_t dump_bytes = 0;
uint64_t tick = 0;
uint64_t decoded_images = 0, decode_evictions = 0;
size_t pinned_count = 0; /* preloaded entries: outside the 512-entry cap */
bool stop = false;
bool dump_busy = false;
std::string dump_error;
@@ -243,7 +246,7 @@ struct Worker {
while (used + incoming > budget) {
auto candidate = decoded.end();
for (auto it = decoded.begin(); it != decoded.end(); ++it)
if (it->second.status == DecodeStatus::Ready &&
if (it->second.status == DecodeStatus::Ready && !it->second.pinned &&
(candidate == decoded.end() || it->second.use < candidate->second.use)) candidate = it;
if (candidate == decoded.end()) break;
used -= candidate->second.image->rgba.size();
@@ -1141,11 +1144,11 @@ int duck_texture_pack_request_decode(DuckTexturePack* p, uint64_t id) {
}
if (!w.budget) return HD_TEXTURE_LOOKUP_ERROR;
if (w.decode_queue.size() >= kMaxDecodeQueue) return HD_TEXTURE_LOOKUP_NONE;
if (w.decoded.size() >= 512) {
if (w.decoded.size() - w.pinned_count >= 512) {
auto victim = w.decoded.end();
for (auto item = w.decoded.begin(); item != w.decoded.end(); ++item)
if ((item->second.status == DecodeStatus::Ready || item->second.status == DecodeStatus::Failed) &&
(victim == w.decoded.end() || item->second.use < victim->second.use)) victim = item;
!item->second.pinned && (victim == w.decoded.end() || item->second.use < victim->second.use)) victim = item;
if (victim == w.decoded.end()) return HD_TEXTURE_LOOKUP_NONE;
if (victim->second.image) w.used -= victim->second.image->rgba.size();
w.decoded.erase(victim);
@@ -1155,6 +1158,105 @@ int duck_texture_pack_request_decode(DuckTexturePack* p, uint64_t id) {
w.wake.notify_one(); return HD_TEXTURE_LOOKUP_NONE;
} catch (...) { return HD_TEXTURE_LOOKUP_ERROR; }
}
int duck_texture_pack_preload(DuckTexturePack* p, size_t max_bytes, unsigned max_threads,
HdTexturePreloadResult* out) {
HdTexturePreloadResult r{};
if (out) *out = r;
if (!p) return -1;
try {
namespace tp = psx_texpreload;
Worker& w = p->worker;
std::vector<uint64_t> ids;
size_t per_image = 0;
{
std::lock_guard<std::mutex> lock(w.mutex);
per_image = w.budget;
/* The cap and result describe the pack's total pinned residency,
* including earlier successful calls or publication prefixes. */
for (const auto& item : w.decoded)
if (item.second.pinned && item.second.image)
r.bytes += item.second.image->rgba.size();
for (size_t i = 0; i < p->entries.size(); ++i) {
if (p->entries[i].ambiguous) continue;
++r.requested;
const auto it = w.decoded.find(i + 1);
if (it != w.decoded.end() && it->second.pinned) ++r.loaded;
}
if (r.bytes > max_bytes) {
r.failed = r.requested - r.loaded;
if (out) *out = r;
return -1; /* A smaller cap cannot discard live pinned data. */
}
for (size_t i = 0; i < p->entries.size(); ++i) {
if (p->entries[i].ambiguous) continue;
const auto it = w.decoded.find(i + 1);
if (it == w.decoded.end() || !it->second.pinned) ids.push_back(i + 1);
}
}
std::vector<std::shared_ptr<Image>> images(ids.size());
std::vector<unsigned char> status(ids.size(), tp::kPending);
tp::Budget budget(max_bytes - r.bytes);
r.worker_errors = static_cast<int>(tp::run(ids.size(), max_threads, [&](size_t k) {
status[k] = tp::kFailed;
const std::string& path = p->entries[ids[k] - 1].path;
std::ifstream input(fs::u8path(path), std::ios::binary | std::ios::ate);
const auto length = input ? input.tellg() : std::streampos(-1);
if (length <= 0 || static_cast<uint64_t>(length) > kMaxEncoded) return;
std::vector<uint8_t> encoded(static_cast<size_t>(length));
input.seekg(0);
if (!input.read(reinterpret_cast<char*>(encoded.data()), length)) return;
TextureImageProbe probe; std::string error;
if (!texture_image_probe(encoded.data(), encoded.size(), per_image, probe, error)) return;
const size_t need = static_cast<size_t>(probe.width) * probe.height * 4u;
if (!budget.reserve(need)) { status[k] = tp::kOverBudget; return; }
struct Reservation {
tp::Budget& b; size_t n; bool keep = false;
~Reservation() { if (!keep) b.release(n); }
} reservation{budget, need};
DecodedTextureImage decoded;
if (!texture_image_decode(encoded.data(), encoded.size(), need, decoded, error) ||
decoded.rgba.size() != need) return;
auto image = std::make_shared<Image>(); image->w = decoded.width; image->h = decoded.height;
image->rgba = std::move(decoded.rgba);
images[k] = std::move(image);
reservation.keep = true;
status[k] = tp::kLoaded;
}));
std::lock_guard<std::mutex> lock(w.mutex);
for (size_t k = 0; k < ids.size(); ++k) {
if (status[k] == tp::kOverBudget) { ++r.over_budget; continue; }
if (status[k] != tp::kLoaded) continue;
auto it = w.decoded.find(ids[k]);
if (it != w.decoded.end()) {
/* Already pinned, or an on-demand decode in flight: leave it. */
if (it->second.pinned || it->second.status == DecodeStatus::Queued ||
it->second.status == DecodeStatus::Loading) continue;
}
const size_t previous_bytes = it != w.decoded.end() && it->second.image
? it->second.image->rgba.size() : 0;
Decode entry;
/* Prepare throwing metadata before touching an existing entry. */
entry.path = p->entries[ids[k] - 1].path;
entry.image = std::move(images[k]); entry.status = DecodeStatus::Ready;
entry.pinned = true; entry.use = ++w.tick;
const size_t bytes = entry.image->rgba.size();
if (it == w.decoded.end()) w.decoded.emplace(ids[k], std::move(entry));
else it->second = std::move(entry);
w.used -= previous_bytes;
r.bytes += bytes;
++w.pinned_count; ++r.loaded;
++w.decoded_images;
tp::after_publish(r.loaded);
}
r.failed = r.requested - r.loaded - r.over_budget;
if (out) *out = r;
return r.loaded == r.requested ? 1 : 0;
} catch (...) {
r.failed = r.requested - r.loaded - r.over_budget;
if (out) *out = r; /* Account the pinned prefix even on publication failure. */
return -1;
}
}
int duck_texture_pack_acquire_decoded(DuckTexturePack* p, uint64_t id, DuckTexturePixels* out) {
if (out) *out = {};
if (!p || !out) return HD_TEXTURE_LOOKUP_ERROR;
+32
View File
@@ -7,6 +7,7 @@
#include <array>
#include <cmath>
#include <vector>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -25,6 +26,11 @@ constexpr size_t kCompositionBudget = 64u * 1024u * 1024u;
constexpr size_t kCompositionCount = 256;
constexpr uint32_t kCompositionDimension = 8192;
constexpr size_t kMaxParts = 64;
/* Elective full preload of an installed pack: at most this much decoded RGBA
* pinned across both formats, decoded by at most this many threads. Anything
* beyond decodes on demand under the normal budget. */
constexpr size_t kPreloadMaxBytes = size_t{512} * 1024u * 1024u;
constexpr unsigned kPreloadThreads = 4;
struct Composition {
std::vector<uint64_t> signature;
std::vector<uint8_t> rgba;
@@ -289,6 +295,32 @@ extern "C" int gpu_hd_textures_configure(const char* root, int replacements,
std::filesystem::create_directories(directory / "dumps");
}
if (!duck_texture_pack_create(root, &next->duck, error, capacity)) return 0;
if (next->replacements) {
/* Replacements decoded and resident before the game draws, within
* kPreloadMaxBytes; the rest stays on the budgeted async path. */
const auto t0 = std::chrono::steady_clock::now();
HdTexturePreloadResult dr{}, br{};
const int dstatus = duck_texture_pack_preload(next->duck, kPreloadMaxBytes,
kPreloadThreads, &dr);
int bstatus = 1;
if (next->beetle) {
const size_t left = dr.bytes < kPreloadMaxBytes ? kPreloadMaxBytes - dr.bytes : 0;
bstatus = hd_texture_pack_preload(next->beetle, left, kPreloadThreads, &br);
}
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
const int requested = dr.requested + br.requested;
if (requested > 0 || dstatus < 0 || bstatus < 0)
std::fprintf(stdout, "psxrecomp: texture pack: preloaded %d/%d images (%.1f MiB) in %lld ms"
"%s; %d over the %u MiB preload cap, %d failed (%d worker errors);"
" the rest decode on demand\n",
dr.loaded + br.loaded, requested, (dr.bytes + br.bytes) / 1048576.0,
static_cast<long long>(ms),
(dstatus < 0 || bstatus < 0) ? " (preload error)" :
(dstatus == 1 && bstatus == 1) ? " (complete)" : " (partial)",
dr.over_budget + br.over_budget, static_cast<unsigned>(kPreloadMaxBytes / (1024u * 1024u)),
dr.failed + br.failed, dr.worker_errors + br.worker_errors);
}
/* Quiesce renderer users (GL render thread: dump_draw appends to the
* old session's sources) before flushing its dumps or reading its
* residency; the cache is rebuilt from the new session. */
+155 -30
View File
@@ -1,10 +1,12 @@
#include "hd_texture_pack.h"
#include "texture_pack_preload.hpp"
#ifndef HD_TEXTURE_PACK_DISABLE_PNG_DECODE
#include "../third_party/stb_image.h" /* declarations only; implementation is shared */
#endif
#include <algorithm>
#include <atomic>
#include <array>
#include <condition_variable>
#include <cctype>
@@ -452,6 +454,7 @@ struct DecodeItem {
std::shared_ptr<DecodedImage> image;
size_t bytes = 0;
uint64_t last_used = 0;
bool pinned = false; /* preloaded: never evicted */
};
struct DecodeCache {
@@ -484,7 +487,7 @@ void evict_decode_cache(DecodeCache& cache, size_t incoming = 0) {
while (cache.used + incoming > cache.budget) {
auto victim = cache.items.end();
for (auto it = cache.items.begin(); it != cache.items.end(); ++it) {
if (it->second.state != DecodeState::Ready) continue;
if (it->second.state != DecodeState::Ready || it->second.pinned) continue;
if (victim == cache.items.end() ||
it->second.last_used < victim->second.last_used)
victim = it;
@@ -515,38 +518,46 @@ void decode_worker(DecodeCache* cache) {
path = it->second.path;
}
std::vector<uint8_t> encoded;
{
std::ifstream input(path, std::ios::binary | std::ios::ate);
std::streamoff length = -1;
if (input) length = static_cast<std::streamoff>(input.tellg());
if (length > 0 && static_cast<uint64_t>(length) <= kMaxPngFileBytes &&
static_cast<uint64_t>(length) <=
static_cast<uint64_t>(std::numeric_limits<int>::max())) {
encoded.resize(static_cast<size_t>(length));
input.seekg(0);
if (!input.read(reinterpret_cast<char*>(encoded.data()), length))
encoded.clear();
}
}
int width = 0;
int height = 0;
int components = 0;
stbi_uc* loaded = encoded.empty() ? nullptr : stbi_load_from_memory(
encoded.data(), static_cast<int>(encoded.size()),
&width, &height, &components, 4);
std::shared_ptr<DecodedImage> decoded;
size_t bytes = 0;
if (loaded && width > 0 && height > 0 &&
static_cast<uint64_t>(width) * static_cast<uint64_t>(height) <=
std::numeric_limits<size_t>::max() / 4) {
bytes = static_cast<size_t>(width) * static_cast<size_t>(height) * 4;
decoded = std::make_shared<DecodedImage>();
decoded->width = static_cast<uint32_t>(width);
decoded->height = static_cast<uint32_t>(height);
decoded->rgba.assign(loaded, loaded + bytes);
/* Allocation failure (encoded buffer, RGBA copy) must not escape the
* thread entry: the item fails like an undecodable image. */
try {
std::vector<uint8_t> encoded;
{
std::ifstream input(path, std::ios::binary | std::ios::ate);
std::streamoff length = -1;
if (input) length = static_cast<std::streamoff>(input.tellg());
if (length > 0 && static_cast<uint64_t>(length) <= kMaxPngFileBytes &&
static_cast<uint64_t>(length) <=
static_cast<uint64_t>(std::numeric_limits<int>::max())) {
encoded.resize(static_cast<size_t>(length));
input.seekg(0);
if (!input.read(reinterpret_cast<char*>(encoded.data()), length))
encoded.clear();
}
}
int width = 0;
int height = 0;
int components = 0;
std::unique_ptr<stbi_uc, void (*)(void*)> loaded(
encoded.empty() ? nullptr : stbi_load_from_memory(
encoded.data(), static_cast<int>(encoded.size()),
&width, &height, &components, 4),
stbi_image_free);
if (loaded && width > 0 && height > 0 &&
static_cast<uint64_t>(width) * static_cast<uint64_t>(height) <=
std::numeric_limits<size_t>::max() / 4) {
bytes = static_cast<size_t>(width) * static_cast<size_t>(height) * 4;
decoded = std::make_shared<DecodedImage>();
decoded->width = static_cast<uint32_t>(width);
decoded->height = static_cast<uint32_t>(height);
decoded->rgba.assign(loaded.get(), loaded.get() + bytes);
}
} catch (...) {
decoded.reset();
bytes = 0;
}
if (loaded) stbi_image_free(loaded);
std::lock_guard<std::mutex> lock(cache->mutex);
auto it = cache->items.find(key);
@@ -1317,6 +1328,120 @@ void hd_texture_pack_set_decode_budget(HdTexturePack* pack,
evict_decode_cache(pack->decode);
}
int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, unsigned max_threads,
HdTexturePreloadResult* out) {
HdTexturePreloadResult r{};
if (out) *out = r;
#ifdef HD_TEXTURE_PACK_DISABLE_PNG_DECODE
(void)pack; (void)max_bytes; (void)max_threads;
return -1;
#else
if (!pack) return -1;
try {
namespace tp = psx_texpreload;
std::vector<std::pair<uint64_t, std::string>> work_items;
size_t per_image = 0;
{
std::lock_guard<std::mutex> lock(pack->decode.mutex);
per_image = pack->decode.budget;
for (const auto& item : pack->decode.items)
if (item.second.pinned) r.bytes += item.second.bytes;
for (const auto& entry : pack->entries) {
if (entry.second.ambiguous) continue;
++r.requested;
const auto it = pack->decode.items.find(entry.first);
if (it != pack->decode.items.end() && it->second.pinned) ++r.loaded;
}
if (r.bytes > max_bytes) {
r.failed = r.requested - r.loaded;
if (out) *out = r;
return -1;
}
for (const auto& entry : pack->entries) {
if (entry.second.ambiguous) continue;
const auto it = pack->decode.items.find(entry.first);
if (it == pack->decode.items.end() || !it->second.pinned)
work_items.emplace_back(entry.first, entry.second.replacement_path);
}
}
std::vector<std::shared_ptr<DecodedImage>> images(work_items.size());
std::vector<unsigned char> status(work_items.size(), tp::kPending);
tp::Budget budget(max_bytes - r.bytes);
r.worker_errors = static_cast<int>(tp::run(work_items.size(), max_threads, [&](size_t k) {
status[k] = tp::kFailed;
std::ifstream input(work_items[k].second, std::ios::binary | std::ios::ate);
const std::streamoff length = input ? static_cast<std::streamoff>(input.tellg()) : -1;
if (length <= 0 || static_cast<uint64_t>(length) > kMaxPngFileBytes ||
static_cast<uint64_t>(length) > static_cast<uint64_t>(std::numeric_limits<int>::max()))
return;
std::vector<uint8_t> encoded(static_cast<size_t>(length));
input.seekg(0);
if (!input.read(reinterpret_cast<char*>(encoded.data()), length)) return;
int width = 0, height = 0, components = 0;
if (!stbi_info_from_memory(encoded.data(), static_cast<int>(encoded.size()),
&width, &height, &components) ||
width <= 0 || height <= 0 ||
static_cast<uint64_t>(width) * static_cast<uint64_t>(height) >
static_cast<uint64_t>(per_image) / 4)
return;
const size_t need = static_cast<size_t>(width) * static_cast<size_t>(height) * 4;
if (!budget.reserve(need)) { status[k] = tp::kOverBudget; return; }
struct Reservation {
tp::Budget& b; size_t n; bool keep = false;
~Reservation() { if (!keep) b.release(n); }
} reservation{budget, need};
int lw = 0, lh = 0, lc = 0;
std::unique_ptr<stbi_uc, void (*)(void*)> loaded(
stbi_load_from_memory(encoded.data(), static_cast<int>(encoded.size()),
&lw, &lh, &lc, 4),
stbi_image_free);
if (!loaded || lw != width || lh != height) return;
auto image = std::make_shared<DecodedImage>();
image->width = static_cast<uint32_t>(width);
image->height = static_cast<uint32_t>(height);
image->rgba.assign(loaded.get(), loaded.get() + need);
images[k] = std::move(image);
reservation.keep = true;
status[k] = tp::kLoaded;
}));
std::lock_guard<std::mutex> lock(pack->decode.mutex);
for (size_t k = 0; k < work_items.size(); ++k) {
if (status[k] == tp::kOverBudget) { ++r.over_budget; continue; }
if (status[k] != tp::kLoaded) continue;
auto it = pack->decode.items.find(work_items[k].first);
if (it != pack->decode.items.end()) {
/* Already pinned, or an on-demand decode in flight: leave it. */
if (it->second.pinned || it->second.state == DecodeState::Queued ||
it->second.state == DecodeState::Loading) continue;
}
const size_t previous_bytes = it != pack->decode.items.end() &&
it->second.state == DecodeState::Ready ? it->second.bytes : 0;
DecodeItem item;
/* Prepare throwing metadata before touching an existing entry. */
item.path = work_items[k].second;
item.bytes = images[k]->rgba.size();
item.image = std::move(images[k]);
item.state = DecodeState::Ready; item.pinned = true;
item.last_used = ++pack->decode.tick;
const size_t bytes = item.bytes;
if (it == pack->decode.items.end())
pack->decode.items.emplace(work_items[k].first, std::move(item));
else it->second = std::move(item);
pack->decode.used -= previous_bytes;
r.bytes += bytes; ++r.loaded;
tp::after_publish(r.loaded);
}
r.failed = r.requested - r.loaded - r.over_budget;
if (out) *out = r;
return r.loaded == r.requested ? 1 : 0;
} catch (...) {
r.failed = r.requested - r.loaded - r.over_budget;
if (out) *out = r; /* Account the pinned prefix even on publication failure. */
return -1;
}
#endif
}
int hd_texture_pack_request_decode(HdTexturePack* pack,
uint32_t texture_hash,
uint32_t palette_hash) {
@@ -1,6 +1,9 @@
#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>
@@ -109,6 +112,98 @@ bool decode_fails(DuckTexturePack* pack, uint64_t id) {
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_preload(pack.get(),256,1,&r) == 1 && r.loaded == 1 &&
r.failed == 0 && r.bytes == 256 && r.worker_errors == 0,
"repeat counts existing residency without decoding under the reduced budget");
check(duck_texture_pack_preload(pack.get(),255,1,&r) == -1 && r.bytes == 256,
"a smaller lifetime cap rejects without hiding pinned residency");
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");
}
{ /* Publication can fail after retaining a pinned prefix. */
auto pack = create(root);
tp::fault_mode() = 3;
HdTexturePreloadResult r{};
const int status = duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r);
tp::fault_mode() = 0;
check(status == -1 && r.loaded == 1 && r.bytes == 16*4*4,
"publication failure still reports every retained byte");
check(duck_texture_pack_preload(pack.get(),256,1,&r) == 1 &&
r.loaded == 1 && r.failed == 0 && r.bytes == 256,
"publication retry accounts the retained prefix without decoding again");
}
{ /* 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. */
@@ -624,7 +719,8 @@ int main(int argc, char** argv) {
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"); release_while_another_source_splits(root/"release-split");
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());
+15 -8
View File
@@ -425,7 +425,7 @@ static void composition_scene(const char* root) {
}
}
static void cache_eviction_scene(const char* root) {
static void cache_pressure_scene(const char* root) {
uint16_t pressure_words[16]; for(int i=0;i<16;++i) pressure_words[i]=0x1234;
DuckTextureKey key={0}; key.kind=DUCK_TEXTURE_UPLOAD;key.depth=HD_TEXTURE_DEPTH_16BPP;
key.source_hash=duck_texture_hash_words_le(pressure_words,16);
@@ -446,7 +446,7 @@ static void cache_eviction_scene(const char* root) {
composed_key=image.cache_key;
gpu_hd_textures_release_image(&image);if(!composed_key) SDL_Delay(1);
}
check(composed_key!=0,"complete composition ready before decoder eviction");
check(composed_key!=0,"complete composition ready before cache pressure");
gr_fill_rect(0,0,16,16,0x03e0);gr_draw_textured_rect(2,2,4,4,0,0,0,0,texture_page);capture();
GpuHdTextureDiag before,after;gpu_hd_textures_get_diag(&before);
gr_vram_transfer_in(640,64,16,1,pressure_words);int ready=0;
@@ -455,17 +455,24 @@ static void cache_eviction_scene(const char* root) {
gpu_hd_textures_release_image(&image);if(!ready) SDL_Delay(1);
}
check(ready,"full CPU budget pressure image decoded");gpu_hd_textures_get_diag(&after);
check(after.decoded_bytes==1024*1024 && after.decode_evictions>before.decode_evictions,
"pressure image evicts original decoded replacements");
/* All fixture images fit in the bounded preload and remain pinned.
* The configured 1 MiB on-demand budget must not evict that preload.
* decoded_bytes accounts only the on-demand cache; pinned bytes are
* separately bounded and are not charged against this budget. */
check(before.decoded_images>=7 && before.decoded_bytes==0 &&
after.decoded_bytes==before.decoded_bytes &&
after.decoded_images==before.decoded_images &&
after.decode_evictions==before.decode_evictions,
"cache pressure retains pinned preloaded replacements");
check(gpu_hd_textures_acquire_gl_draw(texture_page,0,0,bounds,0,0,&image) && !image.rgba,
"GPU-resident replacement remains ready after CPU eviction");gpu_hd_textures_release_image(&image);
"GPU-resident replacement remains ready under cache pressure");gpu_hd_textures_release_image(&image);
check(gpu_hd_textures_acquire_draw(tp,0,0,parts,0,1,&image) && image.cache_key==composed_key,
"complete composition survives eviction of all source PNGs");gpu_hd_textures_release_image(&image);
"complete composition remains warm under cache pressure");gpu_hd_textures_release_image(&image);
gr_draw_textured_rect(2,2,4,4,0,0,0,0,texture_page);capture();
check(sample(8,8)[0]>200,"GPU-resident replacement still draws original HD detail");
SDL_Delay(20);gpu_hd_textures_get_diag(&before);
check(before.decoded_images==after.decoded_images && before.gl_uploads==after.gl_uploads,
"warm draws do not decode or upload again after CPU eviction");
"warm draws do not decode or upload again under cache pressure");
pack_config(root,0);check(gpu_hd_textures_reload(error,sizeof(error)),"eviction fixture cleanup reload");
state();gr_vram_transfer_in(512,0,4,4,source_words);wait_ready(0);wait_ready(1);
}
@@ -571,7 +578,7 @@ int main(int argc,char** argv) {
"ST black alpha127 remains occupied semitransparent black");
gr_set_semi_transparency(0,0);
composition_scene(argv[1]);state();wait_ready(0);wait_ready(1);
cache_eviction_scene(argv[1]);
cache_pressure_scene(argv[1]);
/* A queued draw must consume its source before a later source overwrite.
* The next draw falls back because upload identity has been invalidated. */
+72
View File
@@ -1,6 +1,7 @@
#define STB_IMAGE_IMPLEMENTATION
#include "../third_party/stb_image.h"
#include "hd_texture_pack.h"
#include "texture_pack_preload.hpp"
#include <chrono>
#include <cstdio>
@@ -530,6 +531,76 @@ void test_bounded_async_decode(Fixture& fixture) {
hd_texture_pixels_release(&pixels);
}
void test_bounded_preload(Fixture& fixture) {
namespace tp = psx_texpreload;
{
HdTexturePack* pack = open_pack(fixture.root);
if (!pack) return;
HdTexturePreloadResult r{};
/* One valid 64x8 PNG; the other keys are empty (malformed) files. */
const int status = hd_texture_pack_preload(pack, size_t{64} << 20, 4, &r);
check(status >= 0 && r.loaded == 1 && r.bytes == 64u * 8u * 4u,
"preload pins the valid replacement");
check(r.requested == r.loaded + r.failed + r.over_budget &&
(r.requested == 1 ? status == 1 : (status == 0 && r.failed >= 1)),
"malformed replacements are reported, not counted as preloaded");
hd_texture_pack_set_decode_budget(pack, 1);
HdTexturePreloadResult repeat{};
check(hd_texture_pack_preload(pack,64u*8u*4u,1,&repeat) >= 0 &&
repeat.loaded == 1 && repeat.bytes == 64u*8u*4u &&
repeat.failed == repeat.requested - 1,
"repeat counts pinned images without decoding them under a reduced budget");
check(hd_texture_pack_preload(pack,64u*8u*4u-1,1,&repeat) == -1 &&
repeat.bytes == 64u*8u*4u,
"a smaller lifetime cap rejects and reports retained residency");
check(hd_texture_pack_track_upload(pack, 10, 20, 8, 4,
fixture.upload_words.data(),
fixture.upload_words.size(), nullptr) == 1,
"preload fixture upload tracks");
HdTexturePixels pixels{};
check(hd_texture_pack_acquire_decoded(pack, fixture.upload_hash, fixture.palette4_hash,
&pixels) == HD_TEXTURE_LOOKUP_FOUND &&
pixels.width == 64,
"a preloaded image is resident and survives budget eviction");
hd_texture_pixels_release(&pixels);
hd_texture_pack_destroy(pack);
}
{
HdTexturePack* pack = open_pack(fixture.root);
if (!pack) return;
HdTexturePreloadResult r{};
check(hd_texture_pack_preload(pack, 64, 4, &r) == 0 && r.loaded == 0 &&
r.over_budget == 1 && r.bytes == 0,
"the aggregate cap skips an oversized image");
hd_texture_pack_destroy(pack);
}
{
HdTexturePack* pack = open_pack(fixture.root);
if (!pack) return;
tp::fault_mode() = 1;
HdTexturePreloadResult r{};
const int status = hd_texture_pack_preload(pack, size_t{64} << 20, 4, &r);
tp::fault_mode() = 0;
check(status == 0 && r.worker_errors == 1 && r.loaded + 1 + r.over_budget <= r.requested,
"an injected worker failure is caught and reported as partial");
hd_texture_pack_destroy(pack);
}
{
HdTexturePack* pack = open_pack(fixture.root);
if (!pack) return;
tp::fault_mode() = 3;
HdTexturePreloadResult r{};
const int status = hd_texture_pack_preload(pack, size_t{64} << 20, 4, &r);
tp::fault_mode() = 0;
check(status == -1 && r.loaded == 1 && r.bytes == 64u * 8u * 4u,
"publication failure still accounts the retained pinned image");
check(hd_texture_pack_preload(pack,64u*8u*4u,1,&r) >= 0 &&
r.loaded == 1 && r.bytes == 64u*8u*4u && r.failed == r.requested-1,
"publication retry accounts existing pinned residency");
hd_texture_pack_destroy(pack);
}
}
} // namespace
int main() {
@@ -542,6 +613,7 @@ int main() {
test_wrapping(fixture);
test_ambiguity_fallback();
test_bounded_async_decode(fixture);
test_bounded_preload(fixture);
if (failures) {
std::fprintf(stderr, "test_hd_texture_pack: %d failure(s)\n", failures);