Page-Aligned Texture Dump + Replacement (experimental)

July 4, 2026 · View on GitHub

An opt-in alternative dumping/replacement mode for the Vulkan ("Beetle PSX HW") renderer that works at whole VRAM texture-page granularity instead of per-upload rectangles. It targets the fragmented-"sections" problem with the existing dumper (libretro/beetle-psx #918): the upload-rect dumper emits many small, oddly-cropped fragments of a page, whereas page-aligned mode emits one clean 256×256 tile per page — much friendlier for an artist to replace.

It is layered on top of the existing HD texture-replacement system (see HD_TEXTURE_CACHE.md) and reuses its three-tier cache, IO worker pool, priority queue, caching-method option, and budgets unchanged. Default behaviour is the classic upload-rect mode, so existing packs are completely unaffected.

All code is in rhi/rhi_lib_vulkan.c + libretro_core_options.h (Vulkan-only, built under TEXTURE_DUMPING_ENABLED). It is implemented in C on top of upstream's C/C89 rhi_lib_vulkan.c (the renderer's MSVC-C89 target: declarations at block top, -Werror=declaration-after-statement), reusing the C HdImageCache/HdGpuCache LRU caches, HdKeySet, the IOThread worker pool and the manual-refcount ImageHandle. The page vs upload-rect cache entries are namespaced by a pages flag folded into the packed cache key, so both pack types share one cache + budget without aliasing.

Compatibility model

Two independent core options:

  • beetle_psx_hw_hd_dump_mode — how textures are dumped: {upload_rect (default), page_aligned, both}.
  • beetle_psx_hw_hd_replacement_mode — how replacements are looked up: {upload_rect (default), page_aligned}.

hd_dump_mode = both writes both pack types in a single playthrough (to their two separate folders below), so you don't have to replay the game once per mode. The two dumps are independent and deduplicated; the decode/encode runs on the IO worker pool, so dumping both at once doesn't stall the render thread.

Each mode uses its own folders so the two pack types never collide (the same filename does not mean the same texture between them):

dumpreplacements
upload-rect (default)<cd>-texture-dump/<cd>-texture-replacements/
page-aligned<cd>-texture-dump-pages/<cd>-texture-replacements-pages/

The two sides are independent — you can dump page-aligned while still replacing from an existing upload-rect pack, or vice versa. Page packs and upload-rect packs are NOT interchangeable and cannot be converted offline (a re-dump is required): the hash is computed over a different region of VRAM.

The folder location honours the HD Texture Folder core option (beetle_psx_hw_texture_directory: content / system / save directory), and the folders are created automatically when dumping/replacement is enabled.

Cross-mode replacement fallback

beetle_psx_hw_hd_replacement_fallback ({disabled (default), enabled}) lets the two replacement modes back each other up rather than being strictly one-or-the-other. When the active hd_replacement_mode finds no match for a draw, the renderer checks the other mode's pack before falling back to native:

  • in upload-rect mode, a miss is retried against the page-aligned (-pages) pack;
  • in page-aligned mode, a miss is retried against the upload-rect pack.

So you can pick whichever mode fits the bulk of a game and let the other cover the exceptions — e.g. a mostly page-aligned pack for the static art, with a handful of upload-rect textures filling in animated sprites — without converting anything. It is off by default, costs nothing on a hit, and only does the extra lookup on a genuine miss (best paired with Lazy caching, so the fallback's loads stay on demand).

Internally both packs share the one three-tier cache; a 1-bit discriminator on the cache key namespaces page vs upload-rect entries so they can never alias, even on a 32-bit hash collision.

How it works

Page geometry

A PS1 texture page, in VRAM 16-bit coordinates, is {page_x, page_y, page_w, 256} where page_w = 64 (4bpp) / 128 (8bpp) / 256 (16bpp). The draw path already knows page_x, page_y (from render_state.texture_offset_x/y). Every page decodes to a 256×256-texel image regardless of bit depth.

CPU VRAM mirror

Page hashing/dumping needs the live page bytes, but the draw path carries no CPU VRAM pointer. The tracker therefore keeps a vram_mirror (1024×512 ×16-bit = 1 MB) updated in every VRAM mutation hook (upload, blit, clearRegion/fill, notifyReadback). All page reads come from this mirror. (Correctness depends on every VRAM write going through one of those hooks.)

Page hash

hash_page() is a CRC32 over the page rect read from vram_mirror. The palette hash is unchanged from upload-rect mode. A page hash is just a uint32_t, so a page slots into the existing HdTextureId{hash, palette} keyspace and reuses all of the cache machinery as-is.

Dump

When hd_dump_mode = page_aligned (or both), a textured draw snapshots the full page's raw VRAM words + palette and queues a low-priority dump request for <cd>-texture-dump-pages/<pagehash>-<palettehash>.png, deduplicated per (page_hash, palette_hash). The palette→RGBA decode (same conversion as the upload-rect dumper) and the PNG encode then run on the IO worker pool, not the render thread, so a burst of first-seen pages — or dumping both pack types at once under both — doesn't stall input. Files named …-missing.png mean the palette wasn't resident when the page was captured (grayscale index data, not a usable replacement); …-0.png is a genuine palette-less (ABGR1555) texture.

Match + binding

When hd_replacement_mode = page_aligned, the draw path computes the page hash, looks it up through the same three-tier cache / loader / IO pool (sourced from the -pages folder and a separate known_files_pages listing), and binds the resulting page image. No shader changes are needed: binding returns an HdTexture with vram_rect = page_rect and texel_rect = {0, 0, img_w, img_h}, which the existing sampling push-constants map correctly for any sub-rect draw — the same shape the renderer's fused-page path already produces. Eager / Lazy / Lazy-synchronous caching methods and the VRAM/RAM budgets all apply unchanged.

Performance note

Page match has no per-draw "same as last time" short-circuit, so it would otherwise hash a 32–128 KB page on every textured draw (hundreds per frame in busy 2D scenes). A memo (hash_page_cached) avoids this: a VRAM write only marks overlapping pages dirty, and any page is re-hashed at most once per frame. Steady-state cost is ~1 page CRC per frame. The [hdcache] INFO log gains a page line (binds / native-misses / page-CRCs / memo entries) for tuning — page-CRCs should sit near 1/frame; a value near the bind count means the memo has regressed.

When to use it

Page-aligned mode shines on static, reused art — backgrounds, UI, tilemaps, and (by extension) 3D games, where a page is uploaded once and drawn many times.

It is a poor fit for 2D animated multi-palette sprites (e.g. Alucard in Symphony of the Night): each animation frame uploads new index data, and because the page hash also folds in the surrounding VRAM context, one logical sprite explodes into many distinct page hashes. That needs far more replacement files than the upload-rect equivalent and churns the cache, so such sprites mostly fall back to native. Use upload-rect replacement for that kind of content. Since the two modes are independent, you can choose per game.

Usage

Build the HW core as usual (make HAVE_HW=1, then optionally strip).

Dump page tiles: enable Track Textures + Dump Textures, set HD Dump Mode = Page-aligned, run the game, and inspect <cd>-texture-dump-pages/ (compare the clean tiles against the old <cd>-texture-dump/ fragments).

Dump both at once: set HD Dump Mode = Both to populate <cd>-texture-dump/ and <cd>-texture-dump-pages/ in one playthrough, then decide which to work from.

Mix packs: to use a page-aligned pack but cover its gaps from an upload-rect pack (or vice versa), enable HD Replacement Cross-Mode Fallback alongside your chosen HD Replacement Mode.

Use replacements: put edited tiles in <cd>-texture-replacements-pages/ (keep dimensions a power-of-two multiple of 256, e.g. 256/512/1024 square), enable Track Textures + Replace Textures, and set HD Replacement Mode = Page-aligned. The [hdcache] log shows replace=page and the page bind counts. First appearance is native for one frame under Lazy; use HD Texture Caching Method = Lazy (synchronous) for no pop-in while evaluating.