[iterate-3AD] Fix 2nd splash logo rendering black: re-upload evolving atlas

The publisher (SQUARE ENIX) and the 2nd developer/studio splash logo share
one K8888 atlas at physical base 0x4dbee000, sampled at different UVs. The
publisher's white text occupies the top V-bands; the developer logo's
(bluish/gold) artwork is CPU-written into the SAME surface AFTER the publisher
frame, so the atlas evolves across frames.

The UI host texture cache (`texture_cache_host::upload`) only re-uploads a
`TextureKey` when `version_when_uploaded` increases. But the per-draw bind in
`render.rs` hardcoded `version_when_uploaded = 1` for every draw, so once the
atlas was first uploaded (during the publisher frame, with only the top bands
filled) the cache pinned that partial upload. The 2nd logo, sampling a V-band
that was still zero at first-upload time, read transparent-black -> rendered
nothing (the "white-triangle / black stub" the user saw after SQUARE ENIX).

Verdict: (G) a legitimate 2nd LOGO item whose real artwork lives in the same
evolving atlas — NOT a spurious 3rd item, and NOT a geometry/shader/blend gap.
Measured via readback: the 2nd-logo geometry rasterizes correctly (3 on-screen
quads), interp1 (UV) and interp0 (color) reach the PS with real values, the
texture content at the sampled bands exists — only the bound wgpu texture was
the stale partial upload.

Fix (UI-only, deterministic core untouched):
- `gpu_system`: thread the real content `version` (from `span_max_version`)
  into `last_draw_textures` (now `(key, version, bytes)`).
- `draw_capture::DrawCapture.textures`: same 3-tuple.
- `render.rs`: use the real `version` (not a hardcoded 1) so the host cache
  re-uploads when the guest fills more of the atlas.
- `exports.rs` `vd_swap`: the legacy single-texture `publish_texture` bridge
  drops the version (`(key, _v, bytes) -> (key, bytes)`).

Readback (env-gated probe, removed before commit): after the fix the 2nd logo
renders real varied artwork (blue + gold texels in a centered strip) instead
of black. Determinism: `check -n50m --gpu-inline --stable-digest` byte-
identical to the c0c6088 baseline (captured both via git-stash). 686 tests
green. No faking — real decoded texels through the real guest draw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-19 13:38:35 +02:00
parent c0c6088e4d
commit 3f8d3b6f1c
4 changed files with 42 additions and 10 deletions

View File

@@ -72,7 +72,16 @@ pub struct DrawCapture {
/// per-draw so the textured logo samples the real artwork instead of the /// per-draw so the textured logo samples the real artwork instead of the
/// magenta stub. Empty for flat (no-tfetch) draws. Populated by /// magenta stub. Empty for flat (no-tfetch) draws. Populated by
/// `gpu_system` after decode (left empty by `build`). /// `gpu_system` after decode (left empty by `build`).
pub textures: Vec<(crate::texture_cache::TextureKey, Vec<u8>)>, ///
/// Each entry is `(key, content_version, bytes)`. iterate-3AD: the
/// `content_version` (from `span_max_version` over the texel span) lets the
/// UI host texture cache RE-UPLOAD when the guest fills more of an evolving
/// atlas. The publisher and the 2nd splash logo share one K8888 surface
/// (base `0x4dbee000`); the 2nd logo's texels are CPU-written *after* the
/// publisher's first upload. Without the real version the host cache (which
/// previously pinned `version_when_uploaded = 1`) kept the first partial
/// upload, so the 2nd logo sampled its still-zero atlas region as black.
pub textures: Vec<(crate::texture_cache::TextureKey, u64, Vec<u8>)>,
/// iterate-3Y: per-draw color/blend render state captured from the /// iterate-3Y: per-draw color/blend render state captured from the
/// register file so the host pipeline composites the way the guest /// register file so the host pipeline composites the way the guest
/// intends (instead of one fixed alpha-blend state). Mirrors the fields /// intends (instead of one fixed alpha-blend state). Mirrors the fields

View File

@@ -429,7 +429,7 @@ pub struct GpuSystem {
/// hardcoded slot). `vd_swap` publishes the first of these to the UI so /// hardcoded slot). `vd_swap` publishes the first of these to the UI so
/// the replay binds the texture the draw actually samples. Cleared and /// the replay binds the texture the draw actually samples. Cleared and
/// repopulated each draw; empty when the active PS issues no `tfetch`. /// repopulated each draw; empty when the active PS issues no `tfetch`.
pub last_draw_textures: Vec<(crate::texture_cache::TextureKey, Vec<u8>)>, pub last_draw_textures: Vec<(crate::texture_cache::TextureKey, u64, Vec<u8>)>,
/// 10 MiB shadow of the Xenos EDRAM. Written by clear-resolves and /// 10 MiB shadow of the Xenos EDRAM. Written by clear-resolves and
/// (future) host-render-target readback; read by the resolve byte-copy /// (future) host-render-target readback; read by the resolve byte-copy
/// path that writes tiled pixels into guest memory. Allocated once at /// path that writes tiled pixels into guest memory. Allocated once at
@@ -1411,7 +1411,17 @@ impl GpuSystem {
let version = span_max_version(mem, key.base_address, span_bytes.max(4)); let version = span_max_version(mem, key.base_address, span_bytes.max(4));
match self.texture_cache.ensure_cached(key, version, mem) { match self.texture_cache.ensure_cached(key, version, mem) {
Ok(entry) => { Ok(entry) => {
self.last_draw_textures.push((entry.key, entry.bytes.clone())); // iterate-3AD: carry the real content `version`
// (from `span_max_version`) so the UI host
// texture cache re-uploads when the guest fills
// more of an evolving atlas (e.g. the 2nd splash
// logo's texels land after the publisher's, in
// the SAME K8888 surface). Previously the UI
// pinned `version_when_uploaded = 1`, so the
// first (partial) upload stuck and later draws
// sampled the not-yet-filled region as black.
self.last_draw_textures
.push((entry.key, version, entry.bytes.clone()));
metrics::counter!( metrics::counter!(
"gpu.texture.decode", "gpu.texture.decode",
"fmt" => format!("{:?}", key.format), "fmt" => format!("{:?}", key.format),

View File

@@ -3195,7 +3195,14 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// the first (the UI binds a single texture today). When the last draw // the first (the UI binds a single texture today). When the last draw
// used a flat (no-tfetch) shader the list is empty, so we fall back to // used a flat (no-tfetch) shader the list is empty, so we fall back to
// the legacy slot-0 probe to preserve behavior on flat-only frames. // the legacy slot-0 probe to preserve behavior on flat-only frames.
let published = gpu_inline.last_draw_textures.first().cloned().or_else(|| { // The legacy single-texture `publish_texture` bridge wants
// `(TextureKey, bytes)`; `last_draw_textures` now also carries the
// content version (for the per-draw host-cache re-upload). Drop it here.
let published = gpu_inline
.last_draw_textures
.first()
.map(|(k, _v, b)| (*k, b.clone()))
.or_else(|| {
// Fallback: probe fetch constant slot 0 directly. Texture fetch // Fallback: probe fetch constant slot 0 directly. Texture fetch
// constants live at `CONST_BASE_FETCH + slot*6` in the register // constants live at `CONST_BASE_FETCH + slot*6` in the register
// file; read 6 dwords, decode the key, hit the CPU cache with // file; read 6 dwords, decode the key, hit the CPU cache with

View File

@@ -775,14 +775,20 @@ impl RenderState {
.. ..
} = self; } = self;
match cap.textures.first() { match cap.textures.first() {
Some((key, bytes)) => { Some((key, version, bytes)) => {
// Stable version: identical (key,bytes) across draws // iterate-3AD: use the decoder's real content `version`
// reuse the uploaded wgpu texture (the splash artwork is // (from `span_max_version`) so the host cache re-uploads
// static). A genuine content change arrives as a new key // when the guest fills MORE of an evolving atlas. The
// (base_address/dims) from the decoder. // publisher and the 2nd splash logo share one K8888
// surface (base 0x4dbee000); the 2nd logo's texels land
// AFTER the first upload. With the old hardcoded
// `version_when_uploaded = 1`, the same `TextureKey`
// never re-uploaded, so the 2nd logo sampled its (then
// still-zero) atlas region as black. The real version
// increases as the guest writes, triggering re-upload.
let cached = xenia_gpu::texture_cache::CachedTexture { let cached = xenia_gpu::texture_cache::CachedTexture {
key: *key, key: *key,
version_when_uploaded: 1, version_when_uploaded: *version,
bytes: bytes.clone(), bytes: bytes.clone(),
}; };
host_texture_cache.upload(device, queue, &cached); host_texture_cache.upload(device, queue, &cached);