scene: 2D decode overhaul - WC-readback fix, run-copy untile, inline-first, hot in-place updates

This commit is contained in:
mchughalex 2026-07-15 04:34:36 +01:00
parent 22bd170bac
commit 01de213ad2

View file

@ -263,6 +263,25 @@ REXCVAR_DEFINE_BOOL(
"gameplay frame. Menu-only cost: the midsize postfx-chain passes also " "gameplay frame. Menu-only cost: the midsize postfx-chain passes also "
"execute there (mode 0 was the long-lived default before mode 2).") "execute there (mode 0 was the long-lived default before mode 2).")
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32(
skate3_native_render_scene_menu_rtt_scope, 1, "Skate 3",
"When menu_rtt_passes is on, WHEN to hold suppress_mode at 0 during "
"menus. 0 = the whole menu context (pre-round behavior): the game's "
"postfx chain then executes emulated at 1152x640 x resolution scale "
"EVERY menu frame: under the pause menu that is the full world postfx, "
"on FMV/title screens the movie postfx, pacing the pipeline and "
"fighting the native renderer for the GPU (the menu sluggishness at a "
"high fps counter). 1 = only inside a PORTRAIT WINDOW: the FE "
"push-state stack contains a screen not known steady-safe (known: 0 = "
"FE root, 56 = pause root, 24 = FMV, 17 = pause challenge map), OR an "
"unsafe screen was entered/left within the last 3 s (one-shot portrait "
"RTT renders fire at those transitions), OR the CAS heartbeat is "
"fresh, OR the stack is unreadable; everything unknown fails OPEN to "
"mode 0. Outside the window menus run the "
"gameplay-proven mode-2 suppression (lightmap pages + <= 512 "
"composites still execute).")
.range(0, 1)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL( REXCVAR_DEFINE_BOOL(
skate3_native_render_scene_menu_unsuppress, false, "Skate 3", skate3_native_render_scene_menu_unsuppress, false, "Skate 3",
"ESCAPE HATCH, normally unnecessary: while a menu/pause/loading context " "ESCAPE HATCH, normally unnecessary: while a menu/pause/loading context "
@ -407,19 +426,18 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ropa_blend, true, "Skate 3",
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32( REXCVAR_DEFINE_INT32(
skate3_native_render_scene_2d_async_px, 1, "Skate 3", skate3_native_render_scene_2d_async_px, 1, "Skate 3",
"Pixel-count threshold above which 2D/HUD texture decodes route to the " "Pixel-count threshold above which 2D/HUD texture decodes are ELIGIBLE "
"words-miss decode workers instead of running inline on the render " "for the words-miss decode workers. Eligible decodes (first sightings "
"thread. Inline 2D decodes stall the frame AND the guest (swap blocks; " "and content changes alike) still run INLINE while the per-frame inline "
"guest_dt_max 100-256 ms in menu windows), and the cost " "budget (4 ms; fullscreen-class art gets double) lasts; the run-copy "
"is NOT proportional to texel count: 8888 APT tiles pay a per-PIXEL " "untiler made even 1280x720 sub-millisecond, and the old always-async "
"tiled-offset computation plus two CreateCommittedResource calls, so " "policy made every APT re-raster (NEW address = new key = first "
"even 64x64 tiles measured 3-19 ms. Default 1 = " "sighting) skip its quad for the 1-3 worker frames: menu elements and "
"everything async: a first sighting skips the quad for the 1-3 frames " "fullscreen backdrops visibly BLINKED through animations. The workers are "
"the worker needs (imperceptible pop-in at render rate), a content " "the burst-overflow valve: past the budget, first sightings skip 1-3 "
"change keeps serving the stale decode until the commit swaps it. " "frames and content changes serve the stale decode until the commit. "
"0 = all inline (old " "0 = everything inline unconditionally; larger values exempt small art "
"behavior); larger values gate by pixel count (the original big-art " "from async eligibility entirely.")
"threshold was 131072).")
.range(0, 1 << 24) .range(0, 1 << 24)
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_2d_sharp, 0.0, "Skate 3", REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_2d_sharp, 0.0, "Skate 3",
@ -1386,6 +1404,11 @@ std::atomic<uint64_t> g_rej_bbox{0};
// processor thread (render segments); read by the render thread's stats log. // processor thread (render segments); read by the render thread's stats log.
// Atomics with relaxed ordering: telemetry, not synchronization. // Atomics with relaxed ordering: telemetry, not synchronization.
using PerfClock = std::chrono::steady_clock; using PerfClock = std::chrono::steady_clock;
inline uint64_t PerfNsSince(PerfClock::time_point t0) {
return uint64_t(
std::chrono::duration_cast<std::chrono::nanoseconds>(PerfClock::now() - t0)
.count());
}
struct PerfWindow { struct PerfWindow {
std::atomic<uint64_t> ns{0}; std::atomic<uint64_t> ns{0};
std::atomic<uint64_t> max_ns{0}; std::atomic<uint64_t> max_ns{0};
@ -3263,6 +3286,95 @@ bool CasEditorActive(uint8_t* base) {
return false; return false;
} }
// Should the emulated sub-framebuffer RTT passes execute right now (the
// menus' suppress_mode -> 0 forcing, see menu_rtt_scope)? The one-shot
// skater-portrait RTT renders are the only reason menus need mode 0, and
// they fire at SCREEN TRANSITIONS (screen open / accepted edit), but
// holding mode 0 for the whole menu context kept the game's postfx chain
// executing emulated at scale every menu frame (full world postfx under
// the pause menu; movie postfx on FMV screens), pacing the pipeline
// against the native renderer. Window = TRUE (mode 0) when:
// - the FE push-state stack is unreadable (fail open = old behavior), or
// - it contains any screen id NOT known steady-safe (0 = FE root, 56 =
// pause root, 24 = FMV, surveyed across captured sessions), or
// - it CHANGED within the last 3 s (covers unknown screens' entry
// one-shots and the open-race before the id lands), or
// - the CAS editor heartbeat is fresh.
// Render thread only (statics). Every stack change logs its ids (capped)
// so unknown portrait screens name themselves in the session log.
bool PortraitRttWindowActive() {
if (CasEditorHeartbeatFresh()) {
return true;
}
uint8_t* base = g_guest_base.load(std::memory_order_relaxed);
if (base == nullptr) {
return true;
}
constexpr uint32_t kFrontEndManagerPtr = 0x830CFE14;
uint32_t mgr = 0, beg = 0, end = 0;
if (!GuestTryLoadU32(base, kFrontEndManagerPtr, &mgr) || mgr == 0 ||
!GuestTryLoadU32(base, mgr + 0x210, &beg) ||
!GuestTryLoadU32(base, mgr + 0x214, &end) || beg > end ||
end - beg > 20 * 16) {
return true;
}
const uint32_t n = (end - beg) / 20;
uint32_t ids[16] = {};
bool unknown_screen = false;
uint64_t sig = 1469598103934665603ull;
for (uint32_t i = 0; i < n; ++i) {
uint32_t f0 = 0;
if (!GuestTryLoadU32(base, beg + i * 20, &f0)) {
return true;
}
ids[i] = f0;
sig = (sig ^ f0) * 1099511628211ull;
// Steady-safe screens (no one-shot portrait RTT renders): 0 = FE root,
// 56 = pause root, 24 = FMV, 17 = pause challenge map (the game FLAPS
// [0,56]<->[0,17] ~1/s while the map is open, with 17
// unclassified every flap re-armed the grace window and held mode 0,
// pinning the map screen at 62-94 fps).
if (f0 != 0 && f0 != 56 && f0 != 24 && f0 != 17) {
unknown_screen = true;
}
}
static uint64_t s_last_sig = 0;
static int64_t s_change_ns = -1;
static bool s_prev_unknown = false;
const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
if (sig != s_last_sig) {
s_last_sig = sig;
// Grace only when an UNSAFE screen is involved; entering one is
// already covered by presence (fail-open), so the window's real job is
// the tail after LEAVING one (the accepted-edit portrait re-render
// fires as the editor pops). Safe<->safe transitions (pause <->
// challenge map flapping) must not arm it.
if (unknown_screen || s_prev_unknown) {
s_change_ns = now_ns;
}
s_prev_unknown = unknown_screen;
static uint32_t s_logs = 0;
if (s_logs < 64) {
++s_logs;
char buf[128] = ""; // n == 0 leaves the loop unrun; print "[]"
int off = 0;
for (uint32_t i = 0; i < n && off < int(sizeof(buf)) - 8; ++i) {
off += std::snprintf(buf + off, sizeof(buf) - off, "%s%u", i ? "," : "", ids[i]);
}
REXLOG_INFO(
"native-scene: FE stack changed: [{}] (portrait window {}; "
"steady-safe = 0/56/24/17)",
buf, unknown_screen ? "OPEN - unclassified screen" : "steady");
}
}
if (s_change_ns >= 0 && now_ns - s_change_ns < 3'000'000'000ll) {
return true;
}
return unknown_screen;
}
void OnRenderStateUpload(uint64_t mask, uint32_t bank, uint32_t ptr) { void OnRenderStateUpload(uint64_t mask, uint32_t bank, uint32_t ptr) {
(void)mask; (void)mask;
if (ptr == 0) { if (ptr == 0) {
@ -8863,6 +8975,12 @@ struct MeshBuffers {
struct GuestTexture { struct GuestTexture {
ID3D12Resource* texture = nullptr; ID3D12Resource* texture = nullptr;
ID3D12Resource* upload = nullptr; // kept alive; copy recorded in deferred list ID3D12Resource* upload = nullptr; // kept alive; copy recorded in deferred list
// In-place re-decode ping-pong partner (UpdateGuestTexture2DInPlace): the
// GPU may still be copying from the OTHER upload buffer for the previous
// frame's content change, so hot content alternates buffers instead of
// paying two CreateCommittedResource calls per change.
ID3D12Resource* upload_b = nullptr;
bool upload_flip = false;
uint32_t fetch_words[6] = {}; // big-endian words as read for revalidation uint32_t fetch_words[6] = {}; // big-endian words as read for revalidation
uint32_t srv_slot = 0; uint32_t srv_slot = 0;
// For failed decodes: frame number for periodic retry (payload may stream // For failed decodes: frame number for periodic retry (payload may stream
@ -9488,6 +9606,7 @@ bool AllocGuestSrvSlot(uint32_t& slot) {
void RetireGuestTexture(const GuestTexture& t, uint64_t submission) { void RetireGuestTexture(const GuestTexture& t, uint64_t submission) {
if (t.texture) g_r.retired.emplace_back(t.texture, submission); if (t.texture) g_r.retired.emplace_back(t.texture, submission);
if (t.upload) g_r.retired.emplace_back(t.upload, submission); if (t.upload) g_r.retired.emplace_back(t.upload, submission);
if (t.upload_b) g_r.retired.emplace_back(t.upload_b, submission);
if (t.valid) g_r.retired_srv_slots.emplace_back(t.srv_slot, submission); if (t.valid) g_r.retired_srv_slots.emplace_back(t.srv_slot, submission);
} }
@ -10263,25 +10382,54 @@ bool UploadGeneratedMips(const NativeGuestOutputRenderContext& context, uint8_t*
size = min_size; size = min_size;
} }
// Untile into linear block rows, endian-swap per row. // Untile into linear block rows, endian-swap per row. Run-copy untiling:
// same contiguity rule as the plain path (see EnsureGuestTextureFromWords):
// aligned x-runs of clamp(16 >> bpb_log2, 1, 8) blocks are contiguous.
std::vector<uint8_t> linear(size_t(cols) * rows * bytes_per_block); std::vector<uint8_t> linear(size_t(cols) * rows * bytes_per_block);
const uint32_t run_blocks = std::clamp(16u >> bytes_per_block_log2, 1u, 8u);
for (uint32_t by = 0; by < rows; ++by) { for (uint32_t by = 0; by < rows; ++by) {
uint8_t* out_row = linear.data() + size_t(by) * cols * bytes_per_block; uint8_t* out_row = linear.data() + size_t(by) * cols * bytes_per_block;
for (uint32_t bx = 0; bx < cols; ++bx) { if (!info.is_tiled) {
uint32_t source_offset; const uint32_t row_off = ((by + oy) * pitch_blocks + ox) * bytes_per_block;
if (info.is_tiled) { const uint32_t row_bytes = cols * bytes_per_block;
source_offset = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( if (row_off + row_bytes <= size) {
int32_t(bx + ox), int32_t(by + oy), pitch_blocks, bytes_per_block_log2)); std::memcpy(out_row, gen_scratch.data() + row_off, row_bytes);
} else { } else {
source_offset = ((by + oy) * pitch_blocks + bx + ox) * bytes_per_block; for (uint32_t bx = 0; bx < cols; ++bx) {
} const uint32_t off = row_off + bx * bytes_per_block;
if (source_offset + bytes_per_block > size) { if (off + bytes_per_block > size) {
std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block);
continue; continue;
} }
std::memcpy(out_row + size_t(bx) * bytes_per_block, gen_scratch.data() + source_offset, std::memcpy(out_row + size_t(bx) * bytes_per_block, gen_scratch.data() + off,
bytes_per_block); bytes_per_block);
} }
}
} else {
uint32_t bx = 0;
while (bx < cols) {
const uint32_t x = bx + ox;
const uint32_t run = std::min(cols - bx, run_blocks - (x & (run_blocks - 1)));
const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D(
int32_t(x), int32_t(by + oy), pitch_blocks, bytes_per_block_log2));
const uint32_t bytes = run * bytes_per_block;
if (off + bytes <= size) {
std::memcpy(out_row + size_t(bx) * bytes_per_block, gen_scratch.data() + off,
bytes);
} else {
for (uint32_t i = 0; i < run; ++i) {
const uint32_t boff = off + i * bytes_per_block;
if (boff + bytes_per_block > size) {
std::memset(out_row + size_t(bx + i) * bytes_per_block, 0, bytes_per_block);
} else {
std::memcpy(out_row + size_t(bx + i) * bytes_per_block,
gen_scratch.data() + boff, bytes_per_block);
}
}
}
bx += run;
}
}
SwapGuestEndian(out_row, cols * bytes_per_block, info.endianness); SwapGuestEndian(out_row, cols * bytes_per_block, info.endianness);
} }
@ -10466,9 +10614,20 @@ bool UploadGeneratedMips(const NativeGuestOutputRenderContext& context, uint8_t*
return true; return true;
} }
// Phase attribution for the SLOW-inline-decode log: time inside D3D12
// resource creation / the generated-mips path / the guest scratch copies,
// reset at each EnsureGuestTextureFromWords entry (per thread: workers and
// the render thread decode concurrently).
thread_local uint64_t g_tex_dec_create_ns = 0;
thread_local uint64_t g_tex_dec_gen_ns = 0;
thread_local uint64_t g_tex_dec_copy_ns = 0;
bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context, bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context,
uint8_t* base, const uint32_t words[6], uint8_t* base, const uint32_t words[6],
GuestTexture& out) { GuestTexture& out) {
g_tex_dec_create_ns = 0;
g_tex_dec_gen_ns = 0;
g_tex_dec_copy_ns = 0;
std::memcpy(out.fetch_words, words, 6 * sizeof(uint32_t)); std::memcpy(out.fetch_words, words, 6 * sizeof(uint32_t));
xenos::xe_gpu_texture_fetch_t fetch = {}; xenos::xe_gpu_texture_fetch_t fetch = {};
@ -10547,7 +10706,10 @@ bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context,
if (base_fmt == xenos::TextureFormat::k_DXT1 || if (base_fmt == xenos::TextureFormat::k_DXT1 ||
base_fmt == xenos::TextureFormat::k_8_8_8_8) { base_fmt == xenos::TextureFormat::k_8_8_8_8) {
GuestTexture gen = out; // keeps fetch_words already copied GuestTexture gen = out; // keeps fetch_words already copied
if (UploadGeneratedMips(context, base, info, fetch.swizzle, gen)) { const auto gen_t0 = PerfClock::now();
const bool gen_ok = UploadGeneratedMips(context, base, info, fetch.swizzle, gen);
g_tex_dec_gen_ns += PerfNsSince(gen_t0);
if (gen_ok) {
out = gen; out = gen;
return true; return true;
} }
@ -10608,6 +10770,7 @@ bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context,
s.scratch_off = scratch_total; s.scratch_off = scratch_total;
scratch_total += s.size; scratch_total += s.size;
} }
const auto copy_t0 = PerfClock::now();
tex_scratch.resize(scratch_total); tex_scratch.resize(scratch_total);
uint32_t mips_copied = 0; uint32_t mips_copied = 0;
bool copy_truncated = false; bool copy_truncated = false;
@ -10629,6 +10792,7 @@ bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context,
} }
++mips_copied; ++mips_copied;
} }
g_tex_dec_copy_ns += PerfNsSince(copy_t0);
if (mips_copied == 0) { if (mips_copied == 0) {
return false; return false;
} }
@ -10675,12 +10839,14 @@ bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context,
desc.MipLevels = UINT16(mip_count); desc.MipLevels = UINT16(mip_count);
desc.Format = host.resource_format; desc.Format = host.resource_format;
desc.SampleDesc.Count = 1; desc.SampleDesc.Count = 1;
const auto create_t0 = PerfClock::now();
if (FAILED(device->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &desc, if (FAILED(device->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &desc,
D3D12_RESOURCE_STATE_COPY_DEST, nullptr, D3D12_RESOURCE_STATE_COPY_DEST, nullptr,
IID_PPV_ARGS(&out.texture)))) { IID_PPV_ARGS(&out.texture)))) {
return false; return false;
} }
out.upload = CreateUploadBuffer(device, upload_size); out.upload = CreateUploadBuffer(device, upload_size);
g_tex_dec_create_ns += PerfNsSince(create_t0);
if (!out.upload) { if (!out.upload) {
out.texture->Release(); out.texture->Release();
out.texture = nullptr; out.texture = nullptr;
@ -10699,24 +10865,75 @@ bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context,
const uint8_t* guest = tex_scratch.data() + srcs[m].scratch_off; const uint8_t* guest = tex_scratch.data() + srcs[m].scratch_off;
const uint32_t row_bytes = p.cols * bytes_per_block; const uint32_t row_bytes = p.cols * bytes_per_block;
uint32_t guard_zeroed = 0; // blocks zeroed by the range guard (diag) uint32_t guard_zeroed = 0; // blocks zeroed by the range guard (diag)
// Run-copy untiling. The per-BLOCK GetTiledOffset2D loop made a single
// 1280x720 8888 decode cost 3-13 ms (921k address computations + 4-byte
// memcpys); animating fullscreen menu art and FMV planes pay that on
// EVERY content change, which is where the menu frame spikes lived.
// From the tiling formula (util.cpp GetTiledOffset2D): within a row, an
// x-run aligned to run_blocks = clamp(16 >> bpb_log2, 1, 8) maps to
// CONTIGUOUS byte offsets; the micro term stays inside its 16-byte
// nibble group ((y & 0xE) contributes only multiples of 16 at every
// bpb), and runs <= 8 aligned blocks never cross the (x>>3)/(x>>5) mix
// boundaries. Linear mips copy whole rows.
//
// ROWS STAGE THROUGH A CACHED BUFFER, never the upload mapping directly:
// upload heaps are WRITE-COMBINED memory, and SwapGuestEndian/565-swap
// READ the row back; uncached WC reads cost ~33 ns/byte, which was the
// long-unattributed 35-135 ms decode class (512x512 = 1 MB = 34 ms,
// 1024x1024+chain = 135 ms; linear fit across a slow-decode log).
// The mapping gets exactly one streaming memcpy per row.
const uint32_t run_blocks =
std::clamp(16u >> bytes_per_block_log2, 1u, 8u);
static thread_local std::vector<uint8_t> row_buf;
row_buf.resize(row_bytes);
for (uint32_t by = 0; by < p.rows; ++by) { for (uint32_t by = 0; by < p.rows; ++by) {
uint8_t* out_row = mapping + p.offset + size_t(by) * p.pitch; uint8_t* out_row = row_buf.data();
for (uint32_t bx = 0; bx < p.cols; ++bx) { if (!info.is_tiled) {
uint32_t source_offset; const uint32_t row_off =
if (info.is_tiled) { ((by + oy) * src_pitch_blocks + ox) * bytes_per_block;
source_offset = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( if (row_off + row_bytes <= src_size) {
int32_t(bx + ox), int32_t(by + oy), src_pitch_blocks, bytes_per_block_log2)); std::memcpy(out_row, guest + row_off, row_bytes);
} else { } else {
source_offset = ((by + oy) * src_pitch_blocks + bx + ox) * bytes_per_block; for (uint32_t bx = 0; bx < p.cols; ++bx) {
} const uint32_t off = row_off + bx * bytes_per_block;
if (source_offset + bytes_per_block > src_size) { if (off + bytes_per_block > src_size) {
std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block);
++guard_zeroed; ++guard_zeroed;
continue; continue;
} }
std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + source_offset, std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off,
bytes_per_block); bytes_per_block);
} }
}
} else {
uint32_t bx = 0;
while (bx < p.cols) {
const uint32_t x = bx + ox;
const uint32_t run =
std::min(p.cols - bx, run_blocks - (x & (run_blocks - 1)));
const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D(
int32_t(x), int32_t(by + oy), src_pitch_blocks, bytes_per_block_log2));
const uint32_t bytes = run * bytes_per_block;
if (off + bytes <= src_size) {
std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, bytes);
} else {
// Padded macro rows past the copied size: per-block guard-zero
// (offsets within the run are contiguous, same proof as above).
for (uint32_t i = 0; i < run; ++i) {
const uint32_t boff = off + i * bytes_per_block;
if (boff + bytes_per_block > src_size) {
std::memset(out_row + size_t(bx + i) * bytes_per_block, 0,
bytes_per_block);
++guard_zeroed;
} else {
std::memcpy(out_row + size_t(bx + i) * bytes_per_block, guest + boff,
bytes_per_block);
}
}
}
bx += run;
}
}
SwapGuestEndian(out_row, row_bytes, info.endianness); SwapGuestEndian(out_row, row_bytes, info.endianness);
if (swap_rb_565) { if (swap_rb_565) {
for (uint32_t i = 0; i + 2 <= row_bytes; i += 2) { for (uint32_t i = 0; i + 2 <= row_bytes; i += 2) {
@ -10727,6 +10944,7 @@ bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context,
std::memcpy(out_row + i, &value, sizeof(value)); std::memcpy(out_row + i, &value, sizeof(value));
} }
} }
std::memcpy(mapping + p.offset + size_t(by) * p.pitch, out_row, row_bytes);
} }
// Half-black-mip diagnostic (PCU Library banners): discriminate "the // Half-black-mip diagnostic (PCU Library banners): discriminate "the
// guest pool genuinely holds zeros for this mip" from "our addressing // guest pool genuinely holds zeros for this mip" from "our addressing
@ -10852,6 +11070,227 @@ bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context,
// from an explicit stable words snapshot, ReadStableTexWords + // from an explicit stable words snapshot, ReadStableTexWords +
// EnsureGuestTextureFromWords, and lands in the words-keyed store.) // EnsureGuestTextureFromWords, and lands in the words-keyed store.)
// In-place re-decode for HOT single-mip 2D content (video planes, animating
// fullscreen menu/loading art): identical fetch words = identical layout, so
// the committed texture, upload footprint and SRV slot are all reusable;
// the retire+recreate path paid two CreateCommittedResource calls per
// content change (~90/s during FMV playback, plus every animation step of
// fullscreen frontend art). Non-pow2 single-mip entries only: pow2 entries
// may own a real or generated mip chain (the full path handles those).
// The TEAR-GUARD also lives here: the CPU writer (VP6 video decode, APT
// re-raster) can be mid-write when the liveness probe fires; decoding a
// half-written fullscreen payload shows a frame of mixed old/new content:
// a subtle whole-screen brightness flicker on fades and video. The guest
// copy retries until the probe fingerprint reads stable across it.
bool UpdateGuestTexture2DInPlace(const NativeGuestOutputRenderContext& context,
uint8_t* base, GuestTexture& t) {
if (!t.valid || t.texture == nullptr || t.srv_mips != 1 || t.incomplete) {
return false;
}
xenos::xe_gpu_texture_fetch_t fetch = {};
fetch.dword_0 = t.fetch_words[0];
fetch.dword_1 = t.fetch_words[1];
fetch.dword_2 = t.fetch_words[2];
fetch.dword_3 = t.fetch_words[3];
fetch.dword_4 = t.fetch_words[4];
fetch.dword_5 = t.fetch_words[5];
if (fetch.type != xenos::FetchConstantType::kTexture || fetch.base_address == 0) {
return false;
}
const rex::graphics::FormatInfo* pre_fi =
rex::graphics::FormatInfo::Get(uint32_t(fetch.format));
if (pre_fi == nullptr || pre_fi->block_width == 0 || pre_fi->block_height == 0 ||
pre_fi->bytes_per_block() == 0) {
return false;
}
rex::graphics::TextureInfo info;
if (!rex::graphics::TextureInfo::Prepare(fetch, &info)) {
return false;
}
HostTextureFormat host;
if (!GetHostTextureFormat(info.format, host)) {
return false;
}
if (info.dimension != xenos::DataDimension::k2DOrStacked || info.is_stacked ||
info.width >= 8192 || info.height >= 8192 || info.memory.base_address == 0 ||
info.memory.base_size == 0 || info.memory.base_size > 64u * 1024u * 1024u) {
return false;
}
const rex::graphics::FormatInfo* format_info = info.format_info();
const uint32_t bytes_per_block = format_info->bytes_per_block();
if (bytes_per_block == 0 || (bytes_per_block & (bytes_per_block - 1)) != 0) {
return false;
}
const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block));
const uint32_t width = info.width + 1u;
const uint32_t height = info.height + 1u;
if ((width & (width - 1)) == 0 && (height & (height - 1)) == 0) {
return false; // pow2: may own a mip chain, full path
}
if (host.srv_format != t.srv_format) {
return false; // the existing SRV recipe must stay exact
}
const uint32_t block_w = format_info->block_width;
const uint32_t block_h = format_info->block_height;
// Mip-0 source extent, same rules as the full decode.
uint32_t ox = 0, oy = 0;
const uint32_t addr = info.GetMipLocation(0, &ox, &oy, true);
if (addr == 0) {
return false;
}
const uint32_t pitch_blocks = info.extent.block_pitch_h;
const uint32_t cols = (width + block_w - 1) / block_w;
const uint32_t rows = (height + block_h - 1) / block_h;
const uint32_t min_size = info.memory.base_size;
uint32_t size = min_size;
if (info.is_tiled) {
size = std::max(size, rex::graphics::texture_util::GetTiledAddressUpperBound2D(
cols + ox, rows + oy, pitch_blocks, bytes_per_block_log2));
}
static thread_local std::vector<uint8_t> inplace_scratch;
inplace_scratch.resize(size);
// Tear-guarded guest copy: retry while the writer races us.
uint64_t fp = 0;
for (int attempt = 0; attempt < 3; ++attempt) {
const uint64_t fp_before = SampleProbeFingerprint(base, t);
if (!GuestTryCopy(inplace_scratch.data(), base + (0xA0000000u | addr), size)) {
if (min_size >= size ||
!GuestTryCopy(inplace_scratch.data(), base + (0xA0000000u | addr), min_size)) {
return false; // payload unreadable: let the full path sort it out
}
size = min_size;
}
fp = SampleProbeFingerprint(base, t);
if (fp != 0 && fp == fp_before) {
break;
}
}
// Upload footprint (single mip at offset 0).
const uint32_t pitch =
(cols * bytes_per_block + (D3D12_TEXTURE_DATA_PITCH_ALIGNMENT - 1u)) &
~(D3D12_TEXTURE_DATA_PITCH_ALIGNMENT - 1u);
const uint32_t upload_size = pitch * rows;
ID3D12Device* device = context.d3d12.device;
auto* command_processor = context.d3d12.command_processor;
ID3D12Resource*& up = t.upload_flip ? t.upload_b : t.upload;
if (up != nullptr && up->GetDesc().Width < upload_size) {
// Never expected (same words = same footprint): recreate defensively.
g_r.retired.emplace_back(up, command_processor->GetCurrentSubmission());
up = nullptr;
}
if (up == nullptr) {
up = CreateUploadBuffer(device, upload_size);
if (up == nullptr) {
return false;
}
}
uint8_t* mapping = nullptr;
if (FAILED(up->Map(0, nullptr, reinterpret_cast<void**>(&mapping)))) {
return false;
}
const bool swap_rb_565 =
rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_5_6_5;
const uint8_t* guest = inplace_scratch.data();
const uint32_t row_bytes = cols * bytes_per_block;
const uint32_t run_blocks = std::clamp(16u >> bytes_per_block_log2, 1u, 8u);
// Rows stage through a CACHED buffer; the endian/565 swaps read the row
// back, and reading the write-combined upload mapping costs ~33 ns/byte
// (the 35-135 ms decode class; see the plain path).
static thread_local std::vector<uint8_t> row_buf;
row_buf.resize(row_bytes);
for (uint32_t by = 0; by < rows; ++by) {
uint8_t* out_row = row_buf.data();
if (!info.is_tiled) {
const uint32_t row_off = ((by + oy) * pitch_blocks + ox) * bytes_per_block;
if (row_off + row_bytes <= size) {
std::memcpy(out_row, guest + row_off, row_bytes);
} else {
for (uint32_t bx = 0; bx < cols; ++bx) {
const uint32_t off = row_off + bx * bytes_per_block;
if (off + bytes_per_block > size) {
std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block);
} else {
std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off,
bytes_per_block);
}
}
}
} else {
uint32_t bx = 0;
while (bx < cols) {
const uint32_t x = bx + ox;
const uint32_t run = std::min(cols - bx, run_blocks - (x & (run_blocks - 1)));
const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D(
int32_t(x), int32_t(by + oy), pitch_blocks, bytes_per_block_log2));
const uint32_t bytes = run * bytes_per_block;
if (off + bytes <= size) {
std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, bytes);
} else {
for (uint32_t i = 0; i < run; ++i) {
const uint32_t boff = off + i * bytes_per_block;
if (boff + bytes_per_block > size) {
std::memset(out_row + size_t(bx + i) * bytes_per_block, 0, bytes_per_block);
} else {
std::memcpy(out_row + size_t(bx + i) * bytes_per_block, guest + boff,
bytes_per_block);
}
}
}
bx += run;
}
}
SwapGuestEndian(out_row, row_bytes, info.endianness);
if (swap_rb_565) {
for (uint32_t i = 0; i + 2 <= row_bytes; i += 2) {
uint16_t value;
std::memcpy(&value, out_row + i, sizeof(value));
value = uint16_t((value & 0x07E0u) | ((value >> 11) & 0x1Fu) |
((value & 0x1Fu) << 11));
std::memcpy(out_row + i, &value, sizeof(value));
}
}
std::memcpy(mapping + size_t(by) * pitch, out_row, row_bytes);
}
up->Unmap(0, nullptr);
// Barriers must land in the deferred list BEFORE the copy: flush both
// sides explicitly (earlier draws this frame sample the OLD content,
// later ones the new: same semantics as a retire+recreate mid-frame).
auto& list = command_processor->GetDeferredCommandList();
context.d3d12.push_transition_barrier(context.d3d12.command_processor_user_data,
t.texture,
D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE,
D3D12_RESOURCE_STATE_COPY_DEST);
context.d3d12.submit_barriers(context.d3d12.command_processor_user_data);
D3D12_TEXTURE_COPY_LOCATION dst{};
dst.pResource = t.texture;
dst.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX;
dst.SubresourceIndex = 0;
D3D12_TEXTURE_COPY_LOCATION src{};
src.pResource = up;
src.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT;
src.PlacedFootprint.Offset = 0;
src.PlacedFootprint.Footprint.Format = host.resource_format;
src.PlacedFootprint.Footprint.Width = cols * block_w;
src.PlacedFootprint.Footprint.Height = rows * block_h;
src.PlacedFootprint.Footprint.Depth = 1;
src.PlacedFootprint.Footprint.RowPitch = pitch;
list.D3DCopyTextureRegion(&dst, 0, 0, 0, &src, nullptr);
context.d3d12.push_transition_barrier(context.d3d12.command_processor_user_data,
t.texture, D3D12_RESOURCE_STATE_COPY_DEST,
D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE);
context.d3d12.submit_barriers(context.d3d12.command_processor_user_data);
t.upload_flip = !t.upload_flip;
t.payload_fp = fp;
t.near_black = SampleProbeNearBlack(base, t);
t.recheck_frame = 0;
return true;
}
// Environment CUBE map for the water / reflective-glass reflection term // Environment CUBE map for the water / reflective-glass reflection term
// (t6). Six faces untiled independently per level (Xenos cubes are 2D-tiled // (t6). Six faces untiled independently per level (Xenos cubes are 2D-tiled
// per face slice), WITH the guest mip chain; gradient-derived LOD on the // per face slice), WITH the guest mip chain; gradient-derived LOD on the
@ -12114,7 +12553,7 @@ bool EnsurePhotoFxPipeline(const NativeGuestOutputRenderContext& context) {
const char* ps; const char* ps;
DXGI_FORMAT rtv; DXGI_FORMAT rtv;
}; };
const Entry entries[8] = { const Entry entries[9] = {
{"vs_raw", "ps_depthpack", DXGI_FORMAT_R8G8B8A8_UNORM}, {"vs_raw", "ps_depthpack", DXGI_FORMAT_R8G8B8A8_UNORM},
{"vs_offset", "ps_visualfx", DXGI_FORMAT_R8G8B8A8_UNORM}, {"vs_offset", "ps_visualfx", DXGI_FORMAT_R8G8B8A8_UNORM},
{"vs_offset", "ps_dof_down", DXGI_FORMAT_R8G8B8A8_UNORM}, {"vs_offset", "ps_dof_down", DXGI_FORMAT_R8G8B8A8_UNORM},
@ -13618,22 +14057,30 @@ bool YieldForMenus(const NativeGuestOutputRenderContext& context) {
{ {
static bool s_mode_forced = false; static bool s_mode_forced = false;
static int32_t s_mode_saved = 0; static int32_t s_mode_saved = 0;
// menu_rtt_scope 1: only hold mode 0 inside the portrait window
// (screen-transition grace / not-known-steady screens / CAS); outside
// it, menus keep the gameplay-proven mode-2 suppression instead of
// executing the game's postfx chain emulated at scale every frame
// (the pause-menu/title-screen GPU contention).
const bool want = const bool want =
in_menus && REXCVAR_GET(skate3_native_render_scene_menu_rtt_passes); in_menus && REXCVAR_GET(skate3_native_render_scene_menu_rtt_passes) &&
(REXCVAR_GET(skate3_native_render_scene_menu_rtt_scope) == 0 ||
PortraitRttWindowActive());
if (want && !s_mode_forced) { if (want && !s_mode_forced) {
s_mode_saved = REXCVAR_GET(native_render_suppress_mode); s_mode_saved = REXCVAR_GET(native_render_suppress_mode);
if (s_mode_saved != 0) { if (s_mode_saved != 0) {
REXCVAR_SET(native_render_suppress_mode, 0); REXCVAR_SET(native_render_suppress_mode, 0);
REXLOG_INFO( REXLOG_INFO(
"native-scene: menu context - suppress mode {} -> 0 (portrait " "native-scene: portrait window - suppress mode {} -> 0 (RTT "
"RTT passes execute; restored on gameplay)", "passes execute; restored when the window closes)",
s_mode_saved); s_mode_saved);
} }
s_mode_forced = true; s_mode_forced = true;
} else if (!want && s_mode_forced) { } else if (!want && s_mode_forced) {
if (s_mode_saved != 0) { if (s_mode_saved != 0) {
REXCVAR_SET(native_render_suppress_mode, s_mode_saved); REXCVAR_SET(native_render_suppress_mode, s_mode_saved);
REXLOG_INFO("native-scene: leaving menu context - suppress mode {} restored", REXLOG_INFO(
"native-scene: portrait window closed - suppress mode {} restored",
s_mode_saved); s_mode_saved);
} }
s_mode_forced = false; s_mode_forced = false;
@ -16353,17 +16800,16 @@ 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);
// Routing (see the 2d_async_px cvar): inline 2D decodes stall the frame // Routing (see the 2d_async_px cvar): INLINE-FIRST under the per-frame
// AND the guest, and the cost is NOT proportional to texel count (8888 // budget; the run-copy untiler + in-place hot updates made decodes
// APT tiles pay a per-PIXEL tiled-offset loop + 2 CreateCommittedResource // cheap (1280x720 sub-ms; no CreateCommittedResource churn on content
// calls; 64x64 tiles measured 3-19 ms with the regen attempt on top) // changes), so first sightings AND content changes decode inline for
// - so FIRST SIGHTINGS and COLD content changes decode on // 1-frame content latency. The async workers are the burst-overflow
// the words-miss workers. HOT content (changed within the last ~60 // valve only: they exist because inline decodes stall the frame AND the
// frames: video planes, actively-animating menu/HUD elements) re-decodes // guest (swap blocks), which mattered when a screen change brought
// INLINE under a small per-frame budget instead; the async round trip // 100+ ms of per-pixel decode work, but routinely
// (probe cadence + worker + commit ~ 4-5 frames) capped animating UI at // skipping first-sight quads made every APT re-raster (new address =
// a ~30 Hz content rate, which read as "menus feel like 15-30 fps while // new key) blink its element for 1-3 frames (the menu flicker).
// the counter says 140".
const int32_t async_px = REXCVAR_GET(skate3_native_render_scene_2d_async_px); const int32_t async_px = REXCVAR_GET(skate3_native_render_scene_2d_async_px);
const uint32_t px_w = (fetch[2] & 0x1FFFu) + 1; const uint32_t px_w = (fetch[2] & 0x1FFFu) + 1;
const uint32_t px_h = ((fetch[2] >> 13) & 0x1FFFu) + 1; const uint32_t px_h = ((fetch[2] >> 13) & 0x1FFFu) + 1;
@ -16389,18 +16835,32 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
// them on their first decoded content. // them on their first decoded content.
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) {
const bool hot = // Content changes decode INLINE while the per-frame budget lasts
frame_number - it->second.last_change_frame <= 60; // (1-frame latency for animating UI); fullscreen-class art gets an
// extended budget; serving it stale/blank is a whole-screen
// artifact, and the run-copy untiler made even 1280x720 cheap.
const bool fullscreen_class = uint64_t(px_w) * px_h >= 460'000;
inline_redecode = inline_redecode =
video || !async_ui || (hot && hot_inline_budget_ns > 0); video || !async_ui || hot_inline_budget_ns > 0 ||
(fullscreen_class && hot_inline_budget_ns > -4'000'000);
if (inline_redecode) { if (inline_redecode) {
// Same layout = update the committed texture in place (no
// CreateCommittedResource churn, SRV slot kept, tear-guarded).
const auto up_t0 = PerfClock::now();
if (UpdateGuestTexture2DInPlace(context, base, it->second)) {
const uint64_t up_ns = perf_ns_since(up_t0);
hot_inline_budget_ns -= int64_t(up_ns);
g_pw_tex_decode.Add(up_ns);
it->second.last_change_frame = frame_number;
return &it->second;
}
RetireGuestTexture(it->second, RetireGuestTexture(it->second,
command_processor->GetCurrentSubmission()); command_processor->GetCurrentSubmission());
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
} else { } else {
// Cold change (poster rotation class): heal on the workers, // Budget exhausted (burst overflow): heal on the workers, serve
// serve the stale decode meanwhile. // the stale decode meanwhile.
EnqueueWordsMiss(key, fetch, /*ui=*/true); EnqueueWordsMiss(key, fetch, /*ui=*/true);
it->second.recheck_frame = frame_number + 2; it->second.recheck_frame = frame_number + 2;
g_2d_async_stale.fetch_add(1, std::memory_order_relaxed); g_2d_async_stale.fetch_add(1, std::memory_order_relaxed);
@ -16416,10 +16876,19 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
} }
} }
if (it == g_r.tex_store.end()) { if (it == g_r.tex_store.end()) {
if (async_ui && !video && !inline_redecode) { // First sightings also decode INLINE while the budget lasts. The old
// First sighting: decode on the workers and skip the quad for the // always-async policy skipped the quad for the 1-3 worker-round-trip
// 1-3 frames that takes (imperceptible pop-in at the render rate, // frames, but animating APT art re-rasterizes at NEW addresses, so
// vs a whole-frame render+guest stall inline). // every animation step was a "first sighting" and menu elements
// (including fullscreen backdrops) BLINKED through every animation
// (askip +150/window during navigation, visible
// menu flicker/brightness pulsing). Async remains the burst-overflow
// valve only.
const bool fullscreen_class = uint64_t(px_w) * px_h >= 460'000;
const bool inline_ok =
hot_inline_budget_ns > 0 ||
(fullscreen_class && hot_inline_budget_ns > -4'000'000);
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);
return nullptr; return nullptr;
@ -16440,10 +16909,12 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
const uint32_t n = s_slow.fetch_add(1, std::memory_order_relaxed); const uint32_t n = s_slow.fetch_add(1, std::memory_order_relaxed);
if (n < 24 || (n & 255u) == 0) { if (n < 24 || (n & 255u) == 0) {
REXLOG_INFO( REXLOG_INFO(
"native-scene: SLOW inline 2D decode {:.1f}ms {}x{} " "native-scene: SLOW inline 2D decode {:.1f}ms (copy {:.1f} "
"fetch=[{:08X} {:08X} {:08X}] forced={} (n={})", "create {:.1f} gen {:.1f}) {}x{} fetch=[{:08X} {:08X} {:08X}] "
double(decode_ns) / 1e6, px_w, px_h, fetch[0], fetch[1], "forced={} (n={})",
fetch[2], force_inline ? 1 : 0, n); double(decode_ns) / 1e6, double(g_tex_dec_copy_ns) / 1e6,
double(g_tex_dec_create_ns) / 1e6, double(g_tex_dec_gen_ns) / 1e6,
px_w, px_h, fetch[0], fetch[1], fetch[2], force_inline ? 1 : 0, n);
} }
} }
if (!gt.valid) { if (!gt.valid) {
@ -16736,19 +17207,24 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
std::memcpy(rows + 240 * 4, scene.photo_fx.vs[pass], std::memcpy(rows + 240 * 4, scene.photo_fx.vs[pass],
sizeof(scene.photo_fx.vs[pass])); sizeof(scene.photo_fx.vs[pass]));
std::memcpy(rows + 250 * 4, kPfxLiterals[pass], sizeof(kPfxLiterals[pass])); std::memcpy(rows + 250 * 4, kPfxLiterals[pass], sizeof(kPfxLiterals[pass]));
// Half-dest-pixel interpolator offsets: the game's quad VSs carry // The game's quad VSs add half a DEST texel to the sampling uv
// half a pixel of THEIR render target (visualfx dest 1152x640 in // (visualfx c3 = 0.5/1152,0.5/640; dof passes c3 = 0.5/576,
// VS c3, fisheye dest 1280x720 framebuffer in VS c2) for // 0.5/320; fisheye c2 = 0.5/1280,0.5/720), D3D9/Xenos raster
// D3D9-style texel alignment. The native full-res targets are // compensation: their pixel centers interpolate uv = i/W, so
// guest_output-sized; substitute half a NATIVE pixel. (The // +half lands on the intended (i+0.5)/W. Our D3D12 fullscreen
// half-res DOF passes keep the game's exact 576x320 targets, so // triangle interpolates (i+0.5)/W ALREADY; keeping the captured
// their captured rows stay.) Guarded to the expected sub-pixel // offsets shifts each pass's sampling by half a dest texel
// magnitudes so a mis-captured row can't become a visible shift. // (content drifts up-left ~1.5 half-res texels across the three
if (pass == kPfxVisualFx || pass == kPfxFisheye) { // half-res DOF passes = the observed "blur starts above the
float* half_px = rows + (pass == kPfxVisualFx ? 243 : 242) * 4; // pole" mask misregistration). ZERO them
// natively. Guarded to the expected sub-pixel magnitudes so a
// mis-captured row is left alone.
{
float* half_px =
rows + (pass == kPfxFisheye ? 242 : 243) * 4;
if (std::fabs(half_px[0]) < 0.002f && std::fabs(half_px[1]) < 0.004f) { if (std::fabs(half_px[0]) < 0.002f && std::fabs(half_px[1]) < 0.004f) {
half_px[0] = 0.5f / float(context.guest_output_width); half_px[0] = 0.0f;
half_px[1] = 0.5f / float(context.guest_output_height); half_px[1] = 0.0f;
} }
} }
} }