Files
psxrecomp/docs/DUCKSTATION_TEXTURE_FORMAT.md
T
Matt Stanley f9db2561d0 Fix persistent HD texture cache churn and missing live background dumps (#626)
* fix: reuse resident HD textures and publish live background dumps

* test: keep residency stubs local to the filtering fixture
2026-10-10 12:55:54 -07:00

288 lines
17 KiB
Markdown

# DuckStation texture-pack interoperability
psxrecomp implements a bounded subset of modern DuckStation texture packs.
The parser, matcher, cache and dumper are independently written here. The
reference version is upstream commit
`697599c47a646a6cfcc4d246018adf4904d55001`, observed on 2026-10-06.
This pin matters for palette-range and alpha behavior.
## Provenance
At the reference pin, DuckStation's
[license](https://github.com/stenzek/duckstation/blob/697599c47a646a6cfcc4d246018adf4904d55001/LICENSE)
is CC-BY-NC-ND-4.0; its texture-cache and shader files carry the same SPDX
identifier. This project includes no DuckStation implementation, translated
implementation, patch, or binary. Its own code retains the project's
PolyForm Noncommercial terms. Reading format behavior does not provide a
license to incorporate DuckStation's source. Pack artwork has its own rights;
users supply it separately.
The hash implementation comes directly from
[xxHash v0.8.3](https://github.com/Cyan4973/xxHash/tree/v0.8.3), under
[BSD-2-Clause](https://github.com/Cyan4973/xxHash/blob/v0.8.3/LICENSE).
The unmodified `runtime/third_party/xxhash.h` has SHA-256
`17973c0dc49d9854ca26caa191f0e12f7a424b68858d9a78de3860d959d85e4b`.
Its license ships as `runtime/licenses/xxhash-NOTICES.txt`.
The [v0.8.0 release](https://github.com/Cyan4973/xxHash/releases/tag/v0.8.0)
made XXH3 output stable across future versions. Thus the upstream v0.8.3
implementation supplies the unseeded XXH3 behavior used by DuckStation's
vendored v0.8.0 without importing any emulator code.
WebP decoding uses only the static decoder from the official
[libwebp 1.6.0 tag](https://chromium.googlesource.com/webm/libwebp/+/refs/tags/v1.6.0),
commit `4fa21912338357f89e4fd51cf2368325b59e9bd9`, under BSD-3-Clause.
The immutable source archive has SHA-256
`923f3382a47a2af185c3240c954cf004428b237bd7317413a95146d01eb4b94b`.
Its copyright, COPYING, PATENTS, and AUTHORS notices ship in
`runtime/licenses/libwebp-NOTICES.txt`. No encoder, command-line utility, or
libwebp DLL is required. PNG/JPEG use the existing shared stb_image implementation.
## Supported files
Choose a game/package folder containing `replacements/` and `dumps/`, or choose
`replacements/` itself. Replacement PNG/JPEG/static WebP images can live in subdirectories. See the
[player workflow](HD_TEXTURE_PACKS.md) for dumping, editing and reloading.
DuckStation documents its serial-based folders and authoring process in its
[texture replacement wiki](https://github.com/stenzek/duckstation/wiki/Texture-Replacement).
Paletted filename stems follow this grammar:
```text
<kind>-<mode>-<sourceHash>-<paletteHash>-<sourceWords>x<sourceHeight>-<offsetX>-<offsetY>-<width>x<height>-P<min>-<max>
kind = texupload | texpage
mode = P4 | P8 | STP4 | STP8
```
Direct stems omit the palette fields:
```text
<kind>-<mode>-<sourceHash>-<sourceWords>x<sourceHeight>-<offsetX>-<offsetY>-<width>x<height>
mode = C16 | STC16
```
Append `.png`, `.jpg`/`.jpeg`, or `.webp`. Hash fields contain exactly 16 hexadecimal digits; either case
is accepted. Dimensions, offsets and palette endpoints are decimal. Source
width is in native VRAM words. Rectangle width and X offset are expanded
texels: four per word for P4, two for P8, one for C16. Height and Y offset are
rows. A page's source size is 64x256, 128x256 or 256x256 words respectively.
These definitions follow the
[upstream filename documentation](https://github.com/stenzek/duckstation/wiki/Texture-Replacement).
The loader validates nonzero sizes, native VRAM bounds, word-aligned horizontal
rectangles, page bounds and palette endpoints. The replacement image's actual
size controls its independent X/Y scale; integer and equal-axis scale factors
are not required. The renderer applies the declared native rectangle's origin
and extent when mapping UVs.
## Hash compatibility
Both texture and palette identities use unseeded XXH3-64 over packed native
PSX words. Words are serialized low byte first. Rectangles concatenate each
row's words without stride padding. `texupload` hashes the complete tracked
post-write source rectangle; `texpage` hashes its declared subrectangle.
Dimensions and palette data do not enter the texture hash.
A full CLUT hashes 16 P4 or 256 P8 words, including bit 15. A P8 CLUT extending
beyond VRAM's right edge hashes only the remaining words, without wrapping.
For reduced `Pmin-max` ranges, the reference implementation hashes the **first
`max-min+1` palette words**, without advancing by `min`; a range whose declared
maximum crosses the edge does not match. It does not revalidate used indices
against the declared range during lookup. psxrecomp preserves this behavior
for interoperability. Changing a palette entry outside that prefix can leave
a replacement matched. Set `ReducePaletteRange: false` to capture full ranges
when that tradeoff is preferable. These
are observations of the pinned
[texture-cache behavior](https://github.com/stenzek/duckstation/blob/697599c47a646a6cfcc4d246018adf4904d55001/src/core/gpu_hw_texture_cache.cpp).
## Alpha conventions
The decoder preserves raw RGBA bytes. Ordinary P4/P8/C16 replacements treat
alpha below 128 as cutout and alpha at least 128 as occupied, including black.
ST names encode PSX transparency classification: alpha at most 242 marks
STP, while alpha at least 243 marks an opaque texel. Exact RGBA zero is cutout;
ST black with alpha at least 243 also becomes PSX transparent zero. Alpha zero
with nonzero RGB in an ST image remains a semitransparent texel. Actual blending
still uses the primitive's PSX transparency mode. These rules follow the pinned
[replacement merge shader](https://github.com/stenzek/duckstation/blob/697599c47a646a6cfcc4d246018adf4904d55001/src/core/gpu_hw_shadergen.cpp).
When ordinary and ST records cover the same identity and rectangle, psxrecomp
prefers the primitive's convention; if only the other convention exists it can
still supply the image. Duplicate canonical identities remain ambiguous.
Distinct matching parts are clipped and composed in a deterministic order.
The finalized dumper writes rounded 5-bit-to-8-bit native RGB, reduced CLUT
ranges by default, alpha
zero for native color zero, alpha 255 for occupied ordinary texels, and alpha
143 for occupied STP texels in ST dumps, matching the pinned upstream dump's
mask-expression result. `DumpTextureForceAlphaChannel` instead emits ordinary
names and makes every pixel's alpha 255. Native conversion
was checked against the pinned
[GPU helpers](https://github.com/stenzek/duckstation/blob/697599c47a646a6cfcc4d246018adf4904d55001/src/core/gpu_helpers.h).
## Current compatibility limits
The module accepts bounded replacement parts clipped to the draw query.
It supports ordinary unchanged uploads and nonwrapping page subrectangles.
OpenGL composes up to 64 matching parts over a native-pixel fallback, with at
most 256 cached compositions, 64 MiB of composed RGBA, and 8192 pixels per side.
`ReplacementScaleLinearFilter` enables color interpolation when scaling parts
while keeping cutout, STP, and opaque classification discrete. The default is
nearest scaling. Decode or composition limits retain native rendering.
Bounded copy handling, partial-overwrite splitting, and adjacent upload
coalescing can be enabled with their configuration keys; their defaults are
off. Splits retain the immutable original source hash and words. Exceeding a
split limit finalizes observed usage and drops the remaining tracked identity.
Coalescing accepts unused, unsplit uploads adjacent to the right or below,
within the configured incoming-write dimensions. Copies normally invalidate
their destination; `ConvertCopiesToWrites` can refresh an existing destination
source's original extent when its active region contains the copy. It does not
clone the source identity. A same-session pack reload
can copy intact upload identities into the newly indexed pack. Loading a
savestate clears them; page matching remains available, while upload matching
needs subsequent guest uploads. The module's rectangle and query caches must
be invalidated on every native VRAM write.
The following cases use native rendering or report an unsupported-file/option
diagnostic:
- Wrapped UV intervals, horizontally wrapped pages and wrapped uploads.
- `vram-write-` XXH3-128 images and legacy unnamed layouts.
- Animated WebP and image payloads outside PNG/JPEG/static WebP.
- Unrecognized or unimplemented `config.yaml` behavior.
`config.yaml` supports root-level scalar options and a bounded `Aliases:` mapping
with literal or quoted filename keys and relative PNG/JPEG/WebP paths under
`replacements/`. A single-line literal/folded block value
is accepted. Escaped strings, multiline values, anchors and general YAML
features are outside the subset. Absolute paths and `..` traversal are
rejected. A direct canonical replacement takes precedence over its alias.
Unknown top-level options are reported by name and are not applied.
The root-level authoring keys and defaults are:
| Key | Default | Behavior |
| --- | --- | --- |
| `DumpTexturePages` | `false` | Select page capture instead of upload tracking. |
| `DumpFullTexturePages` | `false` | Capture full 256x256 pages in page mode. |
| `DumpTextureForceAlphaChannel` | `false` | Force opaque alpha and ordinary names for texture dumps. |
| `DumpC16Textures` | `false` | Include direct-color texture captures. |
| `ReducePaletteRange` | `true` | Emit the used palette-index range, with the pinned prefix-hash convention. |
| `ConvertCopiesToWrites` | `false` | Refresh bounded existing destination-source tracking after copies. |
| `MaxVRAMWriteSplits` | `0` | Bound partial-overwrite source splitting; zero disables it. |
| `MaxVRAMWriteCoalesceWidth`, `MaxVRAMWriteCoalesceHeight` | `0` | Bound incoming uploads eligible for adjacent coalescing. |
| `DumpTextureWidthThreshold`, `DumpTextureHeightThreshold` | `16` | Minimum finalized texture dimensions in texels. |
| `DumpVRAMWriteForceAlphaChannel` | `true` | Recognized upstream background-write option; that separate dump mode is unsupported. |
| `DumpVRAMWriteWidthThreshold`, `DumpVRAMWriteHeightThreshold` | `128` | Recognized background-write thresholds; do not enable XXH3-128 dumping. |
| `ReplacementScaleLinearFilter` | `false` | Interpolate composition colors within the nearest pixel's PSX alpha class. |
| `MaxHashCacheEntries` | `256` | Rectangle-hash cache entries, capped at 4096. |
| `MaxHashCacheVRAMUsageMB` | `64` | Source-snapshot budget in MiB, capped at 64. |
| `MaxReplacementCacheVRAMUsage` | `64` | Decoded replacement budget in MiB, capped at 64. |
Use these keys at the root, not inside `Options:`. General YAML structures are
unsupported. Existing full-range replacement filenames remain valid even when
new captures use reduced ranges. The Tomba example's `config.yaml.example`
documents the defaults without activating or overwriting a user's config.
The cache keys are compatibility mappings to this implementation's bounded
CPU storage: `MaxHashCacheVRAMUsageMB` controls source snapshots, rather than
reproducing DuckStation's GPU hash-cache allocation.
## Bounds and lifecycle
The loader hard-fails above 262144 candidate files or entries, scans at most
1048576 directory entries and limits recursive nesting to 16 levels. Duplicate
identities are marked ambiguous. Matching indexes upload hashes and page
geometry, with 256 rectangle hashes and 256 positive/negative query results.
Native page/CLUT writes invalidate affected cached queries. A query requiring
over 4096 candidate page geometries or more than 4 MiB of new hashing reports
an error and renders natively.
One low-priority background worker performs PNG/JPEG/WebP decoding and PNG dumping. The
decode queue is bounded to 32, decoded metadata to 512 entries, and decoded
pixels to a default 64 MiB budget. Encoded files are limited to 64 MiB and each
image dimension to 8192 before decoding. Pixel leases survive eviction and
pack destruction. Complete compositions retain their source pixels and can
be reused after source PNGs leave the decoded cache. OpenGL also reuses
GPU-resident full replacements without decoding them again; its image cache
holds at most 256 entries within the existing 128 MiB budget. Decoding failure
leaves native rendering active.
Dumps accumulate the union of word-aligned used rectangles per source and
palette, including semitransparent draw usage. Changed observations are
checkpointed approximately once per second during draws, so long-lived
backgrounds appear while capture remains enabled. Checkpoints retain the
original words, captured palettes, and crop union; later usage can enlarge
the crop. Unchanged captures are deduplicated. Sources also finalize when
they retire or capture is flushed.
Default upload mode emits `texupload`; optional page mode emits `texpage`.
Original source snapshots are bounded to 64 MiB by default, with at most 8192
tracked sources/uploads and 8192 palette records; pressure retires sources and
finalizes their observed usage. The write queue is bounded to eight jobs and
32 MiB of queued RGBA, applying backpressure instead of silently losing captures,
and dump deduplication to a rolling 8192 identities. Exiting the game flushes
remaining sources and waits for PNG writing. A temporary file is published
atomically without replacing an existing final file. Write failures remove
only the created temporary and are returned by capture/flush operations.
All snapshots decode native VRAM rather than replacement
pixels.
Texture identities describe upload/page pixels, palette ranges, source sizes,
and used rectangles. A PSX sprite assembled from several uploads or palettes
can still produce separate parts; palette animation can produce several IDs.
Different histories, supported options, wrapping, or composition requirements
can still differ from DuckStation. This is bounded interoperability, not a
promise that every real game's dumps have identical filenames or reconstructed
sprite images. Alias names can make editing easier without changing identity.
## Independent validation
`runtime/tests/test_duckstation_texture_pack.cpp` contains fixed XXH3 vectors
generated with the separate Python xxhash 3.6.0 native wheel. Input word `i`
is `((i * 7919) ^ 0xa51c) & 0xffff`, serialized little endian. Examples:
| Word count | XXH3-64 |
| --- | --- |
| 0 | `2D06800538D394C2` |
| 4 | `3631D2A04EC85311` |
| 8 | `000CA76CB9B329DC` |
| 16 | `9CBCDEA9305D7528` |
| 256 | `0005D0B83A005F4E` |
| 8192 | `C68A00DCCA652920` |
The test also checks literal filenames, strided native rectangles, crop
coordinates, CLUT edges and reduced-prefix behavior, positive/negative cache
invalidation, reload identity copying, duplicate ambiguity, alias diagnostics,
raw alpha thresholds, Unicode folders, background decoding, native dump pixels,
worker shutdown and preservation of existing edited files. Renderer fixtures
exercise the alpha classification and native-VRAM authority separately.
`texture_image_decode_test` uses synthetic literal-pixel fixtures generated by
Pillow 12.3.0. Windows Clang and Linux GCC checks cover raw PNG/lossless WebP
RGBA (including alpha 0, 1, 128, 254), baseline/progressive JPEG, lossy WebP,
dimensions, encoded/decoded bounds, truncated input, and explicit animation
rejection. Decoders accept bytes supplied by the background loader and perform
no filesystem access themselves.
An official pinned DuckStation binary was also used as an external oracle:
`0.1-12074-g697599c47 (dev)`, development ZIP SHA-256
`4813f22823e6aa262c46a4a516f10b01e1e2ddf6fcea82763e359ef9ac812a9d`.
The owned standalone PS-X probe contains no game artwork. It uploads 10x56
words at VRAM (640,0), repeating `3210 7654 BA98 FEDC` 140 times. Palette A at
(0,256) is zero followed by `(i*1537)&0x7fff` for indices 1..15; palette B at
(0,257) is zero followed by `((i*293)&0x7fff)|0x8000`. It draws a 32x32 ordinary
head and nested 36x32 semitransparent head at UV (0,0) with A, then a 40x24
ordinary body at UV (0,32) with B. Overwriting the complete upload retires it.
The reference produces exactly these two lifetime-union files:
| Reference filename | Decoded RGBA SHA-256 |
| --- | --- |
| `texupload-STP4-18DDF9B731440D20-0DAE5F73C88719B1-10x56-0-0-36x32-P0-15.png` | `5d1bec29956fb3979547f18c9ccc719903924bd9c9d70584913d363b67248ca7` |
| `texupload-P4-18DDF9B731440D20-AF506869B80F5CE7-10x56-0-32-40x24-P0-15.png` | `1a26a7b32dc147cd952364bc59290eaaa8f973fb1c2201c84b9e2756a7bc3479` |
psxrecomp produces the same two filenames and dimensions, with every decoded
RGBA byte identical to those reference PNGs. The module regression preserves
the literal oracle names; the primary audit compared the actual files independently.
This controlled oracle isolates naming, lifetime union, palette hashing, and
native pixel conversion. It does not establish full Tomba gameplay identity
equivalence, and no reference binary or proprietary game pixels are distributed.