render: fingerprint DXT alpha blocks by their color half (stale menu art)

This commit is contained in:
mchughalex 2026-07-19 18:51:25 +01:00
parent cbc492a407
commit a5f54b4827
3 changed files with 105 additions and 6 deletions

View file

@ -874,6 +874,13 @@ REXCVAR_DEFINE_STRING(skate3_native_render_scene_trace_mesh, "", "Skate 3",
"every worker commit touching the mesh's objects. " "every worker commit touching the mesh's objects. "
"Empty = off.") "Empty = off.")
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_STRING(skate3_native_render_scene_trace_2d, "", "Skate 3",
"Hex fetch word 1 (base|flags) of a 2D overlay texture "
"to trace through the 2D resolver ('2d-trace:' log "
"lines): per-resolve entry state, liveness probe "
"results and the serve/heal route taken, plus the "
"worker commits for its words key. Empty = off.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32(skate3_native_render_scene_detail_hold, 240, "Skate 3", REXCVAR_DEFINE_INT32(skate3_native_render_scene_detail_hold, 240, "Skate 3",
"Frames an item slot keeps serving its previous HIGHER-" "Frames an item slot keeps serving its previous HIGHER-"
"resolution texture after the game's material-detail " "resolution texture after the game's material-detail "

View file

@ -150,6 +150,7 @@ REXCVAR_DECLARE(int32_t, skate3_native_render_scene_ssr_steps);
REXCVAR_DECLARE(int32_t, skate3_native_render_scene_warmup_budget_ms); REXCVAR_DECLARE(int32_t, skate3_native_render_scene_warmup_budget_ms);
REXCVAR_DECLARE(int32_t, skate3_native_render_scene_warmup_min_items); REXCVAR_DECLARE(int32_t, skate3_native_render_scene_warmup_min_items);
REXCVAR_DECLARE(std::string, skate3_native_render_scene_trace_mesh); REXCVAR_DECLARE(std::string, skate3_native_render_scene_trace_mesh);
REXCVAR_DECLARE(std::string, skate3_native_render_scene_trace_2d);
REXCVAR_DECLARE(std::string, skate3_native_render_snapshot_dir); REXCVAR_DECLARE(std::string, skate3_native_render_snapshot_dir);
#if (defined(REX_HAS_D3D12) && REX_HAS_D3D12) || (defined(REX_HAS_VULKAN) && REX_HAS_VULKAN) #if (defined(REX_HAS_D3D12) && REX_HAS_D3D12) || (defined(REX_HAS_VULKAN) && REX_HAS_VULKAN)
@ -6688,6 +6689,14 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
REXLOG_INFO("tex-trace: mesh={:08X} {}", parsed, REXLOG_INFO("tex-trace: mesh={:08X} {}", parsed,
parsed ? "TRACING" : "off"); parsed ? "TRACING" : "off");
} }
const std::string t2(REXCVAR_GET(skate3_native_render_scene_trace_2d));
const uint32_t parsed2 =
t2.empty() ? 0u : uint32_t(std::strtoul(t2.c_str(), nullptr, 16));
if (parsed2 != g_trace_2d_w1) {
g_trace_2d_w1 = parsed2;
REXLOG_INFO("2d-trace: w1={:08X} {}", parsed2,
parsed2 ? "TRACING" : "off");
}
} }
// ---- Loading -> gameplay takeover (seamless boot / map-change loads) ---- // ---- Loading -> gameplay takeover (seamless boot / map-change loads) ----
@ -8830,6 +8839,13 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
return &g_r.white; return &g_r.white;
} }
const uint64_t key = FetchWordsKey(fetch); const uint64_t key = FetchWordsKey(fetch);
// 2D-resolver trace (trace_2d cvar, matched on fetch word 1): logs the
// per-resolve entry state + route below, and registers the words key so
// the worker-commit trace covers its heals too.
const bool tr2d = g_trace_2d_w1 != 0 && fetch[1] == g_trace_2d_w1;
if (tr2d) {
g_trace_keys.insert(key);
}
// Routing (see the 2d_async_px cvar): INLINE-FIRST under the per-frame // Routing (see the 2d_async_px cvar): INLINE-FIRST under the per-frame
// budget; the run-copy untiler + in-place hot updates made decodes // budget; the run-copy untiler + in-place hot updates made decodes
// cheap (1280x720 sub-ms; no CreateCommittedResource churn on content // cheap (1280x720 sub-ms; no CreateCommittedResource churn on content
@ -8856,15 +8872,24 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
if (it != g_r.tex_store.end()) { if (it != g_r.tex_store.end()) {
it->second.last_used_frame = frame_number; it->second.last_used_frame = frame_number;
} }
if (it != g_r.tex_store.end() && it->second.valid && !it->second.incomplete && if (it != g_r.tex_store.end() && it->second.valid &&
frame_number >= it->second.recheck_frame && frame_number >= it->second.recheck_frame &&
REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) {
// Content liveness for CPU-rewritten UI art: video frames and APT // Content liveness for CPU-rewritten UI art: video frames and APT
// re-rasterized tiles rewrite the same payload with the fetch words // re-rasterized tiles rewrite the same payload with the fetch words
// unchanged; without the probe the words-keyed cache would freeze // unchanged; without the probe the words-keyed cache would freeze
// them on their first decoded content. // them on their first decoded content. Incomplete decodes (truncated
// tiled-mip copy) re-decode here too, like the 3D route revalidator;
// the 2D-only art (menu thumbnails) has no other heal path.
const uint64_t fp = SampleProbeFingerprint(base, it->second); const uint64_t fp = SampleProbeFingerprint(base, it->second);
if (fp != 0 && fp != it->second.payload_fp) { if ((fp != 0 && fp != it->second.payload_fp) || it->second.incomplete) {
// Rolling-capped heal telemetry for menu-class art (the map-switch
// thumbnail wrong-image reports): names the key, the route taken
// and the fingerprint transition.
static std::atomic<uint32_t> s_2d_heal_logs{0};
const bool log_heal =
uint64_t(px_w) * px_h >= 32768 &&
s_2d_heal_logs.fetch_add(1, std::memory_order_relaxed) < 64;
// Content changes decode INLINE while the per-frame budget lasts // Content changes decode INLINE while the per-frame budget lasts
// (1-frame latency for animating UI); fullscreen-class art gets an // (1-frame latency for animating UI); fullscreen-class art gets an
// extended budget; serving it stale/blank is a whole-screen // extended budget; serving it stale/blank is a whole-screen
@ -8873,6 +8898,14 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
inline_redecode = inline_redecode =
video || !async_ui || hot_inline_budget_ns > 0 || video || !async_ui || hot_inline_budget_ns > 0 ||
(fullscreen_class && hot_inline_budget_ns > -4'000'000); (fullscreen_class && hot_inline_budget_ns > -4'000'000);
if (log_heal) {
REXLOG_INFO(
"native-scene: 2D content heal {}x{} key={:016X} fp {:016X}->"
"{:016X} inc={} route={}",
px_w, px_h, key, it->second.payload_fp, fp,
it->second.incomplete ? 1 : 0,
inline_redecode ? "inline" : "async");
}
if (inline_redecode) { if (inline_redecode) {
// Same layout = update the committed texture in place (no // Same layout = update the committed texture in place (no
// CreateCommittedResource churn, SRV slot kept, tear-guarded). // CreateCommittedResource churn, SRV slot kept, tear-guarded).
@ -8884,6 +8917,11 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
it->second.last_change_frame = frame_number; it->second.last_change_frame = frame_number;
return &it->second; return &it->second;
} }
if (tr2d) {
REXLOG_INFO("2d-trace: f{} key={:016X} inplace update REFUSED - "
"retire + fresh decode",
frame_number, key);
}
RetireGuestTexture(it->second, context.device->CurrentSubmission()); RetireGuestTexture(it->second, context.device->CurrentSubmission());
g_r.tex_store.erase(it); g_r.tex_store.erase(it);
it = g_r.tex_store.end(); // falls into the inline decode below it = g_r.tex_store.end(); // falls into the inline decode below
@ -8920,6 +8958,10 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
if (async_ui && !video && !inline_redecode && !inline_ok) { if (async_ui && !video && !inline_redecode && !inline_ok) {
EnqueueWordsMiss(key, fetch, /*ui=*/true); EnqueueWordsMiss(key, fetch, /*ui=*/true);
g_2d_async_skip.fetch_add(1, std::memory_order_relaxed); g_2d_async_skip.fetch_add(1, std::memory_order_relaxed);
if (tr2d) {
REXLOG_INFO("2d-trace: f{} key={:016X} SKIP (async first sight)",
frame_number, key);
}
return nullptr; return nullptr;
} }
// Small HUD/spline art decodes inline (sub-ms; async would pop // Small HUD/spline art decodes inline (sub-ms; async would pop
@ -8958,8 +9000,40 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
cmd->FlushBarriers(); cmd->FlushBarriers();
gt.last_used_frame = frame_number; gt.last_used_frame = frame_number;
gt.last_change_frame = frame_number; gt.last_change_frame = frame_number;
if (tr2d) {
REXLOG_INFO("2d-trace: f{} key={:016X} INLINE decode valid={} inc={} "
"fp={:016X}",
frame_number, key, gt.valid ? 1 : 0, gt.incomplete ? 1 : 0,
gt.payload_fp);
}
it = g_r.tex_store.emplace(key, gt).first; it = g_r.tex_store.emplace(key, gt).first;
} }
if (it != g_r.tex_store.end() &&
(tr2d || (g_in_menus_frame.load(std::memory_order_relaxed) &&
uint64_t(px_w) * px_h >= 32768))) {
// Menu thumbnail-class serve log: per buffer (fetch word 1), log when
// EITHER the served cache content (stored fp) OR the live guest
// content (probe fp) changes. A served-fp that lags the live-fp is
// exactly the stale-thumbnail bug; the buffer holds the new area's
// art while we keep serving the previous decode.
static std::mutex s_2d_log_mutex;
static std::unordered_map<uint32_t, uint64_t> s_2d_seen; // w1 -> stored^live
const uint64_t live_fp = SampleProbeFingerprint(base, it->second);
const uint64_t sig = it->second.payload_fp ^ (live_fp * 3);
std::lock_guard<std::mutex> lk(s_2d_log_mutex);
auto sit = s_2d_seen.find(fetch[1]);
if (sit == s_2d_seen.end() || sit->second != sig) {
s_2d_seen[fetch[1]] = sig;
REXLOG_INFO(
"2d-thumb: f{} w1={:08X} {}x{} key={:016X} served_fp={:016X} "
"live_fp={:016X}{} valid={} inc={} recheck=f{}",
frame_number, fetch[1], px_w, px_h, key, it->second.payload_fp,
live_fp,
(live_fp != 0 && live_fp != it->second.payload_fp) ? " STALE" : "",
it->second.valid ? 1 : 0, it->second.incomplete ? 1 : 0,
it->second.recheck_frame);
}
}
return it->second.valid ? &it->second : &g_r.white; return it->second.valid ? &it->second : &g_r.white;
}; };
const uint32_t ui_region = const uint32_t ui_region =

View file

@ -250,6 +250,9 @@ inline std::atomic<uint64_t> g_skip_new{0};
// mesh's current store keys (so worker-commit events can be matched), and // mesh's current store keys (so worker-commit events can be matched), and
// the last logged per-ctx state signature (summaries log on change). // the last logged per-ctx state signature (summaries log on change).
inline uint32_t g_trace_mesh_addr = 0; inline uint32_t g_trace_mesh_addr = 0;
// 2D-resolver trace: fetch word 1 (base|flags) selected by the trace_2d
// cvar; 0 = off. Render thread only.
inline uint32_t g_trace_2d_w1 = 0;
inline std::unordered_set<uint64_t> g_trace_keys; inline std::unordered_set<uint64_t> g_trace_keys;
inline std::unordered_map<uint32_t, uint64_t> g_trace_sig; inline std::unordered_map<uint32_t, uint64_t> g_trace_sig;
// Words-keyed (event-ad / streamed-artwork) serving: stale = site served // Words-keyed (event-ad / streamed-artwork) serving: stale = site served
@ -258,8 +261,8 @@ inline std::unordered_map<uint32_t, uint64_t> g_trace_sig;
inline std::atomic<uint64_t> g_ad_stale_served{0}; inline std::atomic<uint64_t> g_ad_stale_served{0};
inline std::atomic<uint64_t> g_ad_placeholder{0}; inline std::atomic<uint64_t> g_ad_placeholder{0};
// Fill GuestTexture::probe_addr with up to 16 of the texture's own mip-0 // Fill GuestTexture::probe_addr with up to 64 of the texture's own mip-0
// blocks: a 4x4 spread over the block grid, each resolved through the same // blocks: an 8x8 spread over the block grid, each resolved through the same
// tiled/linear addressing the decode uses, clamped to the guarded copy's // tiled/linear addressing the decode uses, clamped to the guarded copy's
// readable range. Probes crossing into the padded macro-row tail are skipped // readable range. Probes crossing into the padded macro-row tail are skipped
// rather than clamped; the tail belongs to pool neighbors. // rather than clamped; the tail belongs to pool neighbors.
@ -277,6 +280,20 @@ inline void BuildPayloadProbes(const rex::graphics::TextureInfo& info, uint32_t
const uint32_t height = info.height + 1u; const uint32_t height = info.height + 1u;
const uint32_t cols = (width + fi->block_width - 1) / fi->block_width; const uint32_t cols = (width + fi->block_width - 1) / fi->block_width;
const uint32_t rows = (height + fi->block_height - 1) / fi->block_height; const uint32_t rows = (height + fi->block_height - 1) / fi->block_height;
// DXT2/3/4/5 blocks LEAD with their 8-byte alpha half, and on fully
// opaque art that half is the same constant in every block; the leading
// qword hashed identically across entirely DIFFERENT images (every
// pause-menu location photo fingerprinted alike), so in-place content
// swaps were invisible to the liveness probes. Probe the color half of
// those blocks instead. (DXT1's 8-byte block IS the color half; DXN's
// leading half is a real content channel; both keep offset 0.)
using TF = rex::graphics::xenos::TextureFormat;
const uint32_t probe_off =
(info.format == TF::k_DXT2_3 || info.format == TF::k_DXT4_5 ||
info.format == TF::k_DXT2_3_AS_16_16_16_16 ||
info.format == TF::k_DXT4_5_AS_16_16_16_16)
? 8u
: 0u;
for (uint32_t gy = 0; gy < 8; ++gy) { for (uint32_t gy = 0; gy < 8; ++gy) {
for (uint32_t gx = 0; gx < 8; ++gx) { for (uint32_t gx = 0; gx < 8; ++gx) {
const uint32_t bx = cols > 1 ? gx * (cols - 1) / 7 : 0; const uint32_t bx = cols > 1 ? gx * (cols - 1) / 7 : 0;
@ -291,7 +308,8 @@ inline void BuildPayloadProbes(const rex::graphics::TextureInfo& info, uint32_t
if (off + bpb > copy_size) { if (off + bpb > copy_size) {
continue; continue;
} }
out.probe_addr[out.probe_count++] = (0xA0000000u | mip0_addr) + off; out.probe_addr[out.probe_count++] =
(0xA0000000u | mip0_addr) + off + probe_off;
} }
} }
} }