photo: native grab; shadows: exact character shadow port; cas: tee/lens fixes

This commit is contained in:
mchughalex 2026-07-15 08:39:41 +01:00
parent 184d3aac9d
commit 0a73e321de
5 changed files with 788 additions and 46 deletions

View file

@ -39,7 +39,8 @@ cbuffer S : register(b1) {
float4 sh_c1; // cascade 1 scale.xy + offset.zw [PS c1]
float4 sh_c2; // cascade 2 scale.xy + offset.zw [PS c2]
float4 sh_color; // shadow color rgb [PS c8] + its luma in w
float4 sh_misc; // x = depth bias [PS c5.x], y = enable
float4 sh_misc; // x = depth bias [PS c5.x], y = enable,
// zw = atlas dimensions (3*tile, tile)
// Exact world-shading frame rows (consumed by the env-family branch):
float4 sh_sun; // xyz = sun direction [PS c6], w = scene exposure [c10.x]
float4 sh_env; // x = material multiplier [PS c11.y], yz = tree lightmap
@ -52,6 +53,11 @@ cbuffer S : register(b1) {
float4 dyn_amb; // xyz = flat ambient (c15.rgb), w = bounce scale (c15.w)
float4 dyn_misc; // x = material multiplier (c14.y),
// y = static world-shadow floor (c8.w)
// Character CSM receive biases (game char PS bank, finest cascade first)
// + enable in w: the exact 9-tap character shadow sampling
// (cacstamp_skin_nisPS port; see SampleCsmShadow). sh_misc.zw carry the
// atlas dimensions for the point-snapped taps.
float4 sh_char;
};
// Character-family lighting (defaultcharacter.fx and friends): canonical
// per-draw rows captured from the guest PIXEL constant bank at palette
@ -133,9 +139,14 @@ VSOut vs_main(float3 p : POSITION, float2 uv : TEXCOORD0, float2 uv2 : TEXCOORD1
// Native CSM atlas sample at a world position: finest covering cascade,
// s = saturate(infront + 1 - coverage). Returns 1 (lit) when uncovered or
// shadows are off. extra_bias suppresses receiver self-shadow acne on
// surfaces that are themselves casters (characters, held board). A NEGATIVE
// extra_bias selects the game's dynamicobject receive bias instead: a
// per-cascade literal 0.007 (finest) / 0.015 (outer) replacing sh_misc.x
// surfaces that are themselves casters (characters, held board), in DEPTH
// UNITS like the game's own bias constants (receiver c5.x, the dynobj
// literals); a world-metric bias was tried for the editor face band and
// REVERTED: the band was the HAT casting (the game's shadow passes skip
// it, see shadow_caster parity), not under-biasing, and the 0.144 m value
// erased the legit head-onto-neck shadow. A NEGATIVE extra_bias selects
// the game's dynamicobject receive bias instead: a per-cascade literal
// 0.007 (finest) / 0.015 (outer) replacing sh_misc.x
// (from the dynamicobject_defaultPS ucode). Props
// are casters themselves, and with only the world bias (c5.x = 0 in every
// capture) their flat tops compared against their own atlas depth; the
@ -156,11 +167,45 @@ float SampleCsmShadow(float3 wp, float extra_bias) {
if (casc <= 0.0) {
return 1.0;
}
float2 suv = float2(luv.x / 6.0 + (casc * 2.0 - 1.0) / 6.0,
luv.y * -0.5 + 0.5);
// Character receivers: the game's EXACT sampling (cacstamp_skin_nisPS
// ucode + live captured constants; offline-validated against the
// emulated frame). Reference = saturate(raw_depth - c9[cascade])
// (the per-cascade biases REPLACE the world receiver bias), NINE POINT
// taps at +-1 game-texel of the 512 tile (u step 1/1536 of the 3-tile
// atlas), binary sge each, averaged x 1/9 (literal c255.w). No coverage
// term: uncovered texels hold the far clear and compare lit.
if (extra_bias > 0.0 && sh_char.w > 0.0) {
float cbias = casc > 2.5 ? sh_char.z : (casc > 1.5 ? sh_char.y : sh_char.x);
float refd = saturate(dot(sh_z.xyz, wp) + sh_z.w - cbias);
// Tap offsets in game-map texels, BILINEAR-filtered like the game's
// fetches; the atlas blur dilates depth outward exactly so filtered
// compares stay valid across coverage edges; point-snapped taps
// quantized the penumbra into a stippled banding ("glisten"). Taps
// clamp half a texel inside the tile so filtering never bleeds the
// neighboring cascade.
float2 atlas_dim = float2(sh_misc.z, sh_misc.w);
float2 game_texel = float2(1.0 / 1536.0, 1.0 / 512.0);
float tile_x0 = (casc * 2.0 - 2.0) / 6.0;
float tile_x1 = casc * 2.0 / 6.0;
float acc = 0.0;
[unroll] for (int k = 0; k < 9; ++k) {
const float2 kOfs[9] = {float2(0.0, 0.0), float2(-1.0, -1.0),
float2(0.0, -1.0), float2(1.0, -1.0),
float2(-1.0, 0.0), float2(1.0, 0.0),
float2(-1.0, 1.0), float2(0.0, 1.0),
float2(1.0, 1.0)};
float2 s = suv + kOfs[k] * game_texel;
s.x = clamp(s.x, tile_x0 + 0.5 / atlas_dim.x, tile_x1 - 0.5 / atlas_dim.x);
s.y = clamp(s.y, 0.5 / atlas_dim.y, 1.0 - 0.5 / atlas_dim.y);
acc += shadow_atlas.Sample(smp_clamp, s).x >= refd ? 1.0 : 0.0;
}
return acc / 9.0;
}
float bias = extra_bias < 0.0 ? (casc > 1.5 ? 0.015 : 0.007)
: sh_misc.x + extra_bias;
float rd = dot(sh_z.xyz, wp) + sh_z.w - bias;
float2 suv = float2(luv.x / 6.0 + (casc * 2.0 - 1.0) / 6.0,
luv.y * -0.5 + 0.5);
float2 sm2 = shadow_atlas.Sample(smp_clamp, suv);
return saturate((sm2.x >= rd ? 1.0 : 0.0) + (1.0 - sm2.y));
}
@ -380,6 +425,12 @@ float4 ps_main(VSOut i) : SV_Target {
// defaultcharacter/cacstamp PSes end `max oC0.w, c13.x / c22.x`:
// the entity's spawn fade (see the livingworld comment above).
out_a = ch_misc.x;
// character.alpha accessory (sunglass lens): coverage from the mesh's
// "alpha" channel at the raw second texcoord, like hair (cac_alphaPS:
// oC0.w = tf5(uv2).r * c22.x), routed to the blended sub-pass.
if (ch_misc.z > 0.5) {
out_a *= saturate(decal_art.Sample(smp, i.uv2).r);
}
}
// Exact tone chain: sqrt(0.5 * (max(x*E/4 + 0.75, 1) - sat(1 - x*E)^2)).
float E = max(ch_key.w, 0.01);

View file

@ -837,6 +837,23 @@ extern "C" REX_FUNC(sub_826147C8) {
__imp__sub_826147C8(ctx, base);
}
// Sk8::BE::ScreenshotBackEnd::GrabScreenshot(bool), the actual grab: the
// game CPU-reads the resolved screenshot target from guest memory and
// JPEG-encodes it. Fires in EVERY grab flow; the photo-mission Select
// confirm does NOT go through FrontEndState_Replay2::TakePhoto (a logged
// full photo-mission run never opened the card compose window), so this is
// the canonical shutter heartbeat. Right after the grab
// the FE composes the framed display card (white border / caption / logo)
// over the JPEG texture in a ONE-SHOT RTT pass; the card compose window
// (UpdatePhotoGrabWindow) keys off this timestamp so that pass executes and
// its resolve lands in CPU guest memory for the native 2D decoder.
extern "C" REX_FUNC(sub_824FD550) {
if (skate3::native_render::Enabled()) {
skate3::native_scene::OnTakePhoto();
}
__imp__sub_824FD550(ctx, base);
}
// rw::movie::MovieDecoder::Decode(int, VideoRenderable**,
// SubtitleRenderable*): fires per decoded FMV frame while any movie plays
// (boot intro logos and all other rw::movie playback). Heartbeat for the

View file

@ -153,6 +153,24 @@ REXCVAR_DEFINE_BOOL(
"were BLACK. Costs one synchronous readback per small resolve, photo-flow "
"frames only.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(
skate3_native_render_scene_photo_grab_native, true, "Skate 3",
"Fulfill the photo grab NATIVELY while a photo flow is active: keep "
"emulated-draw suppression ON (the 40-50 fps editor cost was the "
"unsuppressed emulated pipeline rendering the whole scene + postfx at "
"scaled res every armed frame, proven by the kFast run: zero readback "
"drains, same fps) and instead downsample the native output to the "
"game's 1152x640 screenshot target each armed frame, read it back "
"(double-buffered, no GPU drain) and CPU-tile it big-endian into guest "
"memory at 0x04911000 where ScreenshotBackEnd::GrabScreenshot JPEG-"
"encodes it. In the photo editor the native output IS the ported postfx "
"chain result; in the plain TakePhoto window it is the plain frame, "
"both the correct grab semantics. OFF = the emulated fallback: forced "
"kFull resolve readbacks + suppression lifted for the whole window "
"(skate3_native_render_scene_photo_readback). If native frames stop "
"mid-window (mode toggle, unexpected yield), the window watchdog flips "
"to the emulated fallback by itself.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(
skate3_native_render_scene_photo_native, true, "Skate 3",
"Render the photo-mission photo editor NATIVELY, applying the game's "
@ -306,11 +324,30 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shadows, true, "Skate 3",
"material constants.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32(skate3_native_render_scene_shadow_tile, 1024, "Skate 3",
"Shadow cascade tile resolution. The game renders 512, but the "
"emulated baseline renders the pass resolution-scaled; 1024 "
"matches the edge crispness players currently see.")
"Shadow cascade tile resolution. The game's own atlas is "
"three 512 tiles; the blur offsets scale with this value so "
"the penumbra WIDTH stays console-equal at any resolution "
"(like the emulated GPU: scaled raster, logical-texel blur). "
"1024 = fine silhouettes at the game's softness; 512 = "
"console-exact but visibly blocky edges up close.")
.range(256, 4096)
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shadow_caster_parity, true,
"Skate 3",
"Character pieces cast dynamic shadows only when the game "
"submitted them to its own shadow passes this frame (per-piece "
"caster list; e.g. the CAS trucker hat never casts; natively "
"casting it painted a hard brim band across the editor face). "
"OFF = every published character piece casts (old behavior).")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_char_shadow_exact, true,
"Skate 3",
"Characters sample the CSM atlas with the game's own math: "
"per-cascade receive biases captured from the character PS "
"banks and NINE point taps at +-1 game-texel averaged /9 "
"(cacstamp_skin_nisPS ucode port). OFF = the legacy single "
"bilinear tap + coverage term.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
// Reflective-glass (env families 5/6) isolation controls for the F12
// dialog: live A/B of each stage of the cube-reflection term against the
// emulated look (F5).
@ -941,6 +978,16 @@ std::vector<DrawItem> g_frame_dynitems;
// for the post-draw state fixup (deferred / world-path captures draw after
// their capture; the fixup fills palette or world from the actual draw).
std::unordered_multimap<uint64_t, size_t> g_frame_pending_by_buffers;
// Contexts submitted through an ORTHO (shadow caster-cascade) bank this
// frame, i.e. items the GAME drew into its own shadow passes. The game's
// caster list is per-piece: the CAS trucker hat submits ONLY to the main
// view (verified in capture: every char piece has shadow-pass draws
// except the hat); natively casting it painted a hard low-sun brim band
// across the editor face that the emulated frame never shows. The native
// atlas pass renders char pieces only when their ctx is in this set
// (rescued/gap items inherit last frame's flag). Guarded by
// g_palette_mutex; cleared at the end of BuildFrameScene.
std::unordered_set<uint32_t> g_frame_ortho_ctx;
// (ib_obj << 32 | vb_obj) -> dynitem indices whose character-lighting rows
// should refresh on every later matching draw this frame. The one-shot
// palette fixup usually pops on the shadow-CASTER draw, whose PS bank is
@ -969,6 +1016,11 @@ std::unordered_map<uint64_t, std::array<uint32_t, 12>> g_frame_draw_fetch;
// instance in the frame. Guest-render-thread only (capture hooks +
// BuildFrameScene), no lock needed.
std::unordered_map<uint32_t, std::array<float, 60>> g_char_rows_cache;
// Frame-global character CSM receive biases (see FrameScene::char_shadow_bias)
// + the guest frame they were captured on (served while <120 frames stale;
// they only change with the scene lighting setup). Guest render thread only.
float g_char_shadow_bias[3] = {};
uint64_t g_char_shadow_bias_frame = 0;
std::atomic<uint64_t> g_char_rows_reused{0};
// mesh -> last published bone palette (skinned single-instance meshes).
// Rescue for frames whose palette capture was REFUSED by the acceptance
@ -1528,6 +1580,28 @@ uint32_t BankPaletteBase(uint8_t* base, uint32_t bank) {
}
return f[0] * f[0] + f[1] * f[1] + f[2] * f[2] <= 0.0025f;
};
// The ropa sim-INACTIVE flag row reads (1, ~0, JUNK, ~0); x is written
// bit-exact 1.0 by the game but z holds uninitialized memory (the CAS
// editor banks carry ~-2e23 there). The junk z fails every range check
// above, leaving the bank UNCLASSIFIABLE: the editor tee's main-pass
// capture then never succeeded, and the shadow (pre-pass) bank instead
// matched bone_at(7) MID-PALETTE (real palette at c5) = a one-bone-late
// palette that passes the 6-sample gate but fails the 32-sample publish
// veto every frame, the invisible edit-skater garment (captured:
// published bone k == bank bone k+1, bones 14/15
// junk, spread 112 vs bind 0.7). An out-of-range z under exact
// (1, ~0, *, ~0) is PROOF the row is a flag, never a bone row.
const auto ropa_flag_row_at = [&](uint32_t reg) -> bool {
float f[4];
for (int i = 0; i < 4; ++i) {
f[i] = LoadGuestF32(base, bank + (reg * 4 + uint32_t(i)) * 4);
}
if (!(std::fabs(f[0] - 1.0f) <= 1e-3f && std::fabs(f[1]) <= 1e-3f &&
std::fabs(f[3]) <= 1e-3f)) {
return false;
}
return !(f[2] > -1e7f && f[2] < 1e7f);
};
// DETERMINISTIC main-pass detection first: that layout keeps the CAMERA
// POSITION at c4 (palette at c7, or c8 behind a parameter row). Matching
// c4 against the frame camera pins the layout without any scoring; the
@ -1542,15 +1616,18 @@ uint32_t BankPaletteBase(uint8_t* base, uint32_t bank) {
const float dz = LoadGuestF32(base, bank + 18 * 4) - g_fog_cam[2];
if (dx * dx + dy * dy + dz * dz < 25.0f &&
(g_fog_cam[0] != 0.0f || g_fog_cam[1] != 0.0f || g_fog_cam[2] != 0.0f)) {
if (param_row_at(7) && bone_at(8)) return 8;
if ((param_row_at(7) || ropa_flag_row_at(7)) && bone_at(8)) return 8;
if (bone_at(7)) return 7;
return 0;
}
}
if (bone_at(4)) return 4;
if (param_row_at(4) && bone_at(5)) return 5;
// The ropa junk-z flag check must run BEFORE bone_at(7): on a pre-pass
// ropa bank (flag c4, palette c5) rows c7.. are mid-palette bone rows and
// bone_at(7) matches them, the one-bone-late class.
if ((param_row_at(4) || ropa_flag_row_at(4)) && bone_at(5)) return 5;
if (bone_at(7)) return 7;
if (param_row_at(7) && bone_at(8)) return 8;
if ((param_row_at(7) || ropa_flag_row_at(7)) && bone_at(8)) return 8;
return 0;
}
@ -1740,12 +1817,20 @@ uint32_t RefinePaletteBase(uint8_t* base, uint32_t bank, uint32_t palette_base,
// BankPaletteBase pins the main-pass layout with).
const auto param_like = [&](uint32_t reg) -> bool {
float f[4];
bool in_range = true;
for (int i = 0; i < 4; ++i) {
f[i] = LoadGuestF32(base, bank + (reg * 4 + uint32_t(i)) * 4);
if (!(f[i] > -1e7f && f[i] < 1e7f)) {
return false;
in_range = false;
}
}
if (!in_range) {
// (1, ~0, JUNK, ~0): the ropa sim-inactive flag row with
// uninitialized z (CAS editor banks); the out-of-range component
// PROVES it is not a bone row (see BankPaletteBase's twin check).
return std::fabs(f[0] - 1.0f) <= 1e-3f && std::fabs(f[1]) <= 1e-3f &&
std::fabs(f[3]) <= 1e-3f && !(f[2] > -1e7f && f[2] < 1e7f);
}
if (f[0] * f[0] + f[1] * f[1] + f[2] * f[2] <= 0.0025f) {
return true; // (0,0,0,w): the ropa flag row
}
@ -2371,6 +2456,12 @@ bool BuildItemFromMesh(uint8_t* base, uint32_t mesh, DrawItem& item) {
} else {
item.char_family = 2; // skin/face/cloth/leather/shift/ropa
}
// character.alpha (exact): translucent accessory pieces
// (sunglass lenses), alpha from the "alpha" channel texture
// at uv2, blended after every opaque piece (see
// DrawItem::char_alpha; captured draw order:
// cac_alpha draws after all opaque CAC, before hair).
item.char_alpha = std::memcmp(sub, "alpha", 6) == 0;
}
// environment.decal / environment.decal_tileable: graffiti and
// painted-branding overlay meshes (see DrawItem::decal).
@ -2921,6 +3012,10 @@ struct PfxCapture {
};
PfxCapture g_pfx_cap[kPfxPassCount] = {};
std::atomic<bool> g_photo_flow_frame{false};
// Photo display-card sighting stamp (see the photo-grab helpers for the
// full story): stamped by Publish2dDraws on the guest thread whenever the
// card-signature quad is in the 2D stream; read by YieldForPhotoDisplay.
std::atomic<int64_t> g_photo_card_seen_ns{-1};
// Defined with the yield helpers below (anonymous namespace); used by the
// BuildFrameScene publish gate (the chain must only run while the editor
// itself is up).
@ -3616,6 +3711,12 @@ void CaptureCharLighting(uint8_t* base, DrawItem& item) {
}
}
d[14 * 4 + 0] = std::clamp(row((fam == 1 ? 13u : 22u) + plus, 0), 0.0f, 1.0f);
// character.alpha accessory (sunglass lens): tell the PS fam-1/2 branch
// to take alpha from the coverage texture at t4/uv2 (ch_misc.z); the
// item is routed to the blended sub-pass (see DrawItem::char_alpha).
if (item.char_alpha && item.hair_alpha_tex != 0) {
d[14 * 4 + 2] = 1.0f;
}
} else if (fam == 3) {
const float amb = row(19u, 3);
if (!(amb >= 0.0f && amb < 16.0f)) return;
@ -3673,6 +3774,24 @@ void CaptureCharLighting(uint8_t* base, DrawItem& item) {
d[14 * 4 + 0] = 1.0f;
}
d[14 * 4 + 1] = float(fam);
// Frame-global character CSM receive biases: one row below the light row
// in the fam-2 layouts (gameplay light c9 -> biases c8; editor light c10
// -> biases c9; verified in capture: (0.005,0.014,0.020)
// with the same values in every char bank). Captured once per frame from
// any validating fam-2 bank; consumed by the exact 9-tap char shadow
// sampling (FrameScene::char_shadow_bias).
if (fam == 2) {
const uint32_t bias_r = 8u + plus;
const float bx = row(bias_r, 0);
const float by = row(bias_r, 1);
const float bz = row(bias_r, 2);
if (bx > 0.0f && bx <= by && by <= bz && bz < 0.1f) {
g_char_shadow_bias[0] = bx;
g_char_shadow_bias[1] = by;
g_char_shadow_bias[2] = bz;
g_char_shadow_bias_frame = g_guest_frame;
}
}
std::memcpy(item.char_rows, local, sizeof(item.char_rows));
g_char_valid.fetch_add(1, std::memory_order_relaxed);
// Remember the validated rows per garment for the cross-frame fallback
@ -4229,6 +4348,18 @@ uint32_t CaptureDynamicState(uint8_t* base, uint32_t ctx, bool world_path,
}
}
std::lock_guard<std::mutex> lock(g_palette_mutex);
// Record shadow-pass submissions per ctx (even refused/pending captures):
// the game's per-piece caster list = exactly the ctxs it submits through
// ortho banks; the native atlas pass mirrors it (see g_frame_ortho_ctx).
// Stamp the item copy too; the game draws its shadow passes FIRST, so
// every later copy (main pass, fixups, rescues, LW store ingest) carries
// the flag; a final pre-publish pass ORs the set over merged items.
const bool ortho_submit = !world_path && BankIsOrtho(base, bank);
if (ortho_submit) {
g_frame_ortho_ctx.insert(ctx);
}
item.shadow_caster =
ortho_submit || (ctx != 0 && g_frame_ortho_ctx.count(ctx) != 0);
g_frame_dynitems.push_back(std::move(item));
const size_t index = g_frame_dynitems.size() - 1;
if (g_frame_dynitems[index].pending ||
@ -7083,6 +7214,16 @@ void Publish2dDraws(uint8_t* base) {
}
}
}
// Photo display-card sighting (see g_photo_card_seen_ns): fetch word2
// encodes exactly 504x640, the JPEG-decode card texture's unique dims.
if (g_photo_flow_frame.load(std::memory_order_relaxed) &&
(d.fetch[2] & 0x03FFFFFFu) == 0x004FE1F7u && d.src_stride == 24) {
g_photo_card_seen_ns.store(
std::chrono::duration_cast<std::chrono::nanoseconds>(
PerfClock::now().time_since_epoch())
.count(),
std::memory_order_relaxed);
}
published.push_back(std::move(d));
}
std::lock_guard<std::mutex> lock(g_2d_mutex);
@ -7248,9 +7389,11 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
// leaving them in place across an early return (no perspective view, empty
// frame) would desynchronize the indices stored in the records.
std::vector<DrawItem> dynitems;
std::unordered_set<uint32_t> ortho_ctx;
{
std::lock_guard<std::mutex> lock(g_palette_mutex);
dynitems.swap(g_frame_dynitems);
ortho_ctx.swap(g_frame_ortho_ctx);
g_frame_pending_by_buffers.clear();
g_frame_char_refresh.clear();
}
@ -8615,6 +8758,14 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
std::memcpy(scene.shadow_rows, g_shadow_rows, sizeof(g_shadow_rows));
scene.shadow_valid = true;
}
// Character CSM receive biases (see the header comment): frame-coherent,
// served while fresh enough that a few capture-less frames don't flap the
// char shadow between the exact and legacy paths.
if (g_char_shadow_bias[0] > 0.0f &&
g_guest_frame - g_char_shadow_bias_frame <= 120) {
std::memcpy(scene.char_shadow_bias, g_char_shadow_bias,
sizeof(scene.char_shadow_bias));
}
std::memcpy(scene.family_rows, g_family_rows, sizeof(g_family_rows));
if (g_dynobj_have) {
std::memcpy(scene.dynobj_rows, g_dynobj_rows, sizeof(g_dynobj_rows));
@ -8956,6 +9107,17 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
return;
}
}
// Game shadow-pass parity (see g_frame_ortho_ctx): mark the items whose
// ctx the game submitted through an ortho caster bank this frame. Runs
// after every item source (merge, rescues, gap fill) and is OR-only:
// fresh captures default false, rescued/gap copies keep their stored
// flag, so a frame whose shadow capture was refused doesn't blink the
// entity's shadow off.
for (DrawItem& item : scene.items) {
if (item.ctx != 0 && ortho_ctx.count(item.ctx) != 0) {
item.shadow_caster = true;
}
}
g_last_publish_ns.store(std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count(),
@ -9426,6 +9588,24 @@ struct RendererState {
ID3D12PipelineState* pso_blur = nullptr;
ID3D12PipelineState* pso_blur_blit = nullptr;
ID3D12PipelineState* pso_blur_down = nullptr;
// Native photo grab (photo_grab_native): the blur downsample pass (whose
// 1152x640 blur space happens to BE the game's screenshot-target raster)
// renders the finished frame into blur_tex[0], which is then copied into
// one of these persistently-mapped READBACK buffers; the next armed frame
// CPU-tiles the completed one big-endian into the guest grab target. Two
// buffers so no frame ever waits on the GPU (a full kFull-style drain per
// frame was the old path's stall). Row pitch 1152*4 = 4608 is already
// 256-aligned.
static constexpr uint32_t kGrabRowPitch = kBlurWidth * 4;
ID3D12Resource* grab_readback[2] = {nullptr, nullptr};
uint8_t* grab_readback_ptr[2] = {nullptr, nullptr};
uint64_t grab_submission[2] = {0, 0};
bool grab_pending[2] = {false, false};
uint32_t grab_write_index = 0;
bool grab_failed = false;
// Window stats for the close log: frames written to guest + CPU tile time.
uint32_t grab_writes = 0;
uint64_t grab_cpu_us = 0;
// Native FMV: overlay-2D pipeline variant whose PS combines the movie
// player's three CPU-filled YUV plane textures (ps_yuv2d; substituted for
// the captured movie quad in the 2D replay, see OnMovieFrame).
@ -14335,41 +14515,160 @@ const char* PhotoEditorSignal(uint8_t* base) {
return signal;
}
// The photo-grab readback window (see skate3_native_render_scene_photo_readback):
// while the photo editor is up OR a TakePhoto fired within the last few
// seconds, (a) arm the SDK's forced small-resolve CPU readback so the
// resolved screenshot target actually lands in guest memory for the CPU
// JPEG encode, and (b) lift emulated-draw suppression so the passes that
// render that target execute even when the native output is active (a
// no-op while the photo yield has the native output inactive anyway).
// Runs every frame from RenderScene regardless of the yield decisions.
// ---- Native photo grab (photo_grab_native) ---------------------------------
// The grab content is produced NATIVELY while emulated-draw suppression stays
// ON: RenderScene downsamples the finished native frame into blur_tex[0]
// (1152x640: the blur space IS the game's screenshot raster), copies it to a
// persistently-mapped READBACK buffer (double-buffered, never a GPU drain),
// and the next armed frame CPU-tiles the completed one big-endian into the
// guest screenshot target that ScreenshotBackEnd::GrabScreenshot reads.
// Layout contract (traced through sub_824FD550 -> the sub_82A7DC20 surface
// download): tiled Xenos 2D, 32bpp big-endian ARGB (guest byte order
// A,R,G,B); the game XGUntileTextureLevel's it into a linear buffer and
// hands the raw bytes to JPEG::WriteJPEG with no channel swap anywhere. The
// 160x90 gallery thumb is CPU-resized from this same image INSIDE
// GrabScreenshot (cImageOperateMan::ResizeJPEG); there is no second buffer
// to fill. Ground truth fetch words (from capture): 1152x640
// pitch 1152 k_8_8_8_8 tiled at 0x04911000 (the same surface the SDK's
// Import-Skater special case hardcodes). The write transform is
// byte-for-byte PROVEN offline against that capture's real readback
// content: tiling + [A,R,G,B] guest order reproduced all 0x2D0000 bytes
// with zero mismatches (alpha included: the emulated resolve also wrote
// 255).
constexpr uint32_t kGrabGuestAddr = 0x04911000;
// The CPU (and the app's whole texture-decode path) accesses physical
// memory through the 0xA0000000 cached-physical view; the gsnap ground
// truth lives at 0xA4911000 and plain base+0x04911000 is NOT an alias of
// those pages. All host-side reads/writes of the grab target go through
// this view.
constexpr uint32_t kGrabGuestView = 0xA0000000u | kGrabGuestAddr;
// Photo window armed AND fulfilled natively this window (RenderScene's grab
// block produces/consumes while this is set; the watchdog in
// UpdatePhotoGrabWindow clears it and flips to the emulated fallback if
// native frames stop landing).
std::atomic<bool> g_photo_grab_native_armed{false};
// Last successful guest write (PerfClock ns): the watchdog liveness signal.
std::atomic<int64_t> g_grab_last_write_ns{-1};
// The photo display-card quad was in the 2D stream this instant (PerfClock
// ns, -1 = never). Stamped by Publish2dDraws (guest thread, runs every
// guest frame regardless of yields) whenever the card-signature quad, the
// 504x640 LINEAR JPEG-decode texture, unique to the photo display card,
// is present. Drives YieldForPhotoDisplay: the display screen renders
// EMULATED, because the game composes the framed card (white border /
// caption / logo) in a ONE-SHOT pitch-1280 pass whose timing proved
// unpredictable (observed anywhere from settle+0.8s to settle+4.0s; every
// readback-window attempt missed it and cost seconds of concurrent-pipeline
// lag). Yielded-emulated, the compose and the display chain just work
// GPU-side with no readbacks, and with the native renderer not running
// concurrently the frozen scene renders at the emulated pipeline's own
// healthy rate. (g_photo_card_seen_ns itself is defined next to
// g_photo_flow_frame; Publish2dDraws lives outside this anonymous
// namespace.)
// The display yield is active / when it last ended (watchdog interplay:
// grab writes legitimately stop while yielded, and get fresh grace after).
std::atomic<bool> g_photo_display_yielding{false};
std::atomic<int64_t> g_photo_display_yield_end_ns{-1};
// Validate the game-side target before writing guest memory: GrabScreenshot
// reads its main-target dims from the postfx render manager (mgr =
// *(0x83083C5C), width/height at mgr+380/384). The GPU address has been
// 0x04911000 in every observed session, but refuse to write if the dims
// chain does not confirm the expected 1152x640 raster.
bool GrabTargetValid(uint8_t* base) {
uint32_t mgr = 0, w = 0, h = 0;
if (!GuestTryLoadU32(base, 0x83083C5C, &mgr) || mgr == 0 ||
!GuestTryLoadU32(base, mgr + 380, &w) ||
!GuestTryLoadU32(base, mgr + 384, &h)) {
return false;
}
return w == RendererState::kBlurWidth && h == RendererState::kBlurHeight;
}
// CPU-tile one mapped readback image (R10G10B10A2 rows at kGrabRowPitch)
// into the guest screenshot target: assemble the tiled big-endian ARGB image
// in a host staging buffer, then land it with one SEH-guarded copy. Inverse
// of the decode path's run-copy untiler: at 32bpp a 4-texel-aligned x-run of
// 4 maps to contiguous tiled bytes (same proof as the untiler's run_blocks =
// 16 >> bpb_log2), so one GetTiledOffset2D per 4 texels. 1152x640 = 184k
// offset computations, well under a millisecond.
bool WriteGrabToGuest(uint8_t* base, const uint8_t* src) {
constexpr uint32_t kW = RendererState::kBlurWidth;
constexpr uint32_t kH = RendererState::kBlurHeight;
static thread_local std::vector<uint8_t> staging;
staging.resize(size_t(kW) * kH * 4);
for (uint32_t y = 0; y < kH; ++y) {
const uint32_t* row = reinterpret_cast<const uint32_t*>(
src + size_t(y) * RendererState::kGrabRowPitch);
for (uint32_t x = 0; x < kW; x += 4) {
const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D(
int32_t(x), int32_t(y), kW, 2));
uint32_t* dst = reinterpret_cast<uint32_t*>(staging.data() + off);
for (uint32_t i = 0; i < 4; ++i) {
// R10G10B10A2 (R bits 0..9, G 10..19, B 20..29), top 8 of each ->
// little-endian byte order A,R,G,B = the guest's big-endian ARGB.
const uint32_t v = row[x + i];
dst[i] = 0xFFu | ((v << 6) & 0xFF00u) | ((v << 4) & 0xFF0000u) |
((v << 2) & 0xFF000000u);
}
}
}
return GuestTryCopy(base + kGrabGuestView, staging.data(), staging.size());
}
// The photo-grab window (see skate3_native_render_scene_photo_readback /
// photo_grab_native): while the photo editor is up OR a TakePhoto fired
// within the last few seconds, the game CPU-reads the resolved screenshot
// target from guest memory to JPEG-encode the photo. Two ways to fulfill it:
// - NATIVE (photo_grab_native, default): RenderScene writes the target
// itself from the native output; emulated-draw suppression stays ON, so
// the emulated pipeline never renders the scene+postfx concurrently (that
// concurrent 3x3-scale render was the editor's 40-50 fps, proven by the
// kFast run: zero readback drains, same fps).
// - EMULATED fallback: (a) arm the SDK's forced small-resolve CPU readback
// so the game's own resolve lands in guest memory, and (b) lift
// emulated-draw suppression so the passes that render the target execute.
// Runs every frame from RenderScene regardless of the yield decisions. A
// watchdog flips native -> emulated mid-window if no native grab write has
// landed recently (mode toggle, unexpected yield, readback failure).
void UpdatePhotoGrabWindow(uint8_t* base) {
// The grab target is 1152x640x4 = 0x2D0000 bytes (the same surface the
// SDK's Import-Skater special case reads); the thumb path is smaller.
// Framebuffer-sized resolves (0x384000 at 1280x720) stay excluded.
constexpr int32_t kForceReadbackMaxLength = 0x2D0000;
static bool s_armed = false;
static bool s_emulated_armed = false;
static int64_t s_arm_ns = 0;
static int64_t s_last_run_ns = 0;
static int32_t s_readback_saved = 0;
static bool s_suppress_saved = false;
static bool s_halfpx_saved = false;
const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
PerfClock::now().time_since_epoch())
.count();
// This function only runs while the native renderer is producing frames
// (RenderScene). A gap means the native path was toggled off or yielding
// - grab writes legitimately paused, so give the watchdog a fresh grace
// period on resume instead of instantly tripping the fallback
// (an emulated/native comparison toggle on the display
// screen fired the watchdog 2 ms after switching back).
if (s_armed && s_last_run_ns != 0 && now_ns - s_last_run_ns > 300'000'000) {
s_arm_ns = now_ns;
}
s_last_run_ns = now_ns;
bool want = false;
if (REXCVAR_GET(skate3_native_render_scene_photo_readback)) {
want = PhotoEditorSignal(base) != nullptr;
if (!want) {
const int64_t last_ns = g_take_photo_last_ns.load(std::memory_order_relaxed);
if (last_ns >= 0) {
const int64_t now_ns =
std::chrono::duration_cast<std::chrono::nanoseconds>(
PerfClock::now().time_since_epoch())
.count();
want = now_ns - last_ns < 3'000'000'000;
}
}
}
// Same window arms the photo postfx constant capture (CapturePfxConstants
// runs on the guest thread at the SetPending hook).
// runs on the guest thread at the draw-done hook).
g_photo_flow_frame.store(want, std::memory_order_relaxed);
if (want && !s_armed) {
const auto arm_emulated = [&] {
s_readback_saved = REXCVAR_GET(native_render_force_resolve_readback_max_length);
if (s_readback_saved <= 0) {
REXCVAR_SET(native_render_force_resolve_readback_max_length,
@ -14386,28 +14685,145 @@ void UpdatePhotoGrabWindow(uint8_t* base) {
if (!s_halfpx_saved) {
REXCVAR_SET(readback_resolve_half_pixel_offset, true);
}
REXLOG_INFO(
"native-scene: photo flow - forcing small-resolve CPU readback "
"(the photo grab reads the resolved screenshot target from guest "
"memory){}",
s_suppress_saved ? " + emulated draw suppression OFF" : "");
s_emulated_armed = true;
};
if (want && !s_armed) {
s_armed = true;
s_arm_ns = now_ns;
g_grab_last_write_ns.store(-1, std::memory_order_relaxed);
g_r.grab_writes = 0;
g_r.grab_cpu_us = 0;
// Drop any readback still pending from a previous window; its content
// is stale (pre-window) and must never land ahead of fresh frames.
g_r.grab_pending[0] = false;
g_r.grab_pending[1] = false;
if (REXCVAR_GET(skate3_native_render_scene_photo_grab_native) &&
!g_r.grab_failed) {
g_photo_grab_native_armed.store(true, std::memory_order_relaxed);
REXLOG_INFO(
"native-scene: photo flow - NATIVE grab armed (suppression stays "
"on; the native output is downsampled + CPU-tiled into the guest "
"screenshot target each frame; the display-card screen yields to "
"the emulated output)");
} else {
arm_emulated();
REXLOG_INFO(
"native-scene: photo flow - forcing small-resolve CPU readback "
"(the photo grab reads the resolved screenshot target from guest "
"memory){}",
s_suppress_saved ? " + emulated draw suppression OFF" : "");
}
} else if (!want && s_armed) {
if (s_readback_saved <= 0) {
REXCVAR_SET(native_render_force_resolve_readback_max_length,
s_readback_saved);
s_armed = false;
if (g_photo_grab_native_armed.exchange(false, std::memory_order_relaxed)) {
REXLOG_INFO(
"native-scene: photo flow ended - native grab wrote {} frames "
"(avg tile+write {} us)",
g_r.grab_writes,
g_r.grab_writes ? g_r.grab_cpu_us / g_r.grab_writes : 0);
}
if (s_emulated_armed) {
s_emulated_armed = false;
if (s_readback_saved <= 0) {
REXCVAR_SET(native_render_force_resolve_readback_max_length,
s_readback_saved);
}
if (s_suppress_saved) {
REXCVAR_SET(native_render_suppress_emulated_draws, true);
}
if (!s_halfpx_saved) {
REXCVAR_SET(readback_resolve_half_pixel_offset, false);
}
REXLOG_INFO(
"native-scene: photo flow ended - resolve readback{} restored",
s_suppress_saved ? " + suppression" : "");
}
}
// Watchdog: native mode armed but no grab write has landed within 700 ms
// of the window opening / the last write / the display yield ending;
// native frames stopped while the native output should be live
// (persistent yield, readback failure). Flip to the emulated fallback so
// the photo still works; one-way for the rest of this window. Skipped
// while the display-card yield is active: grab writes legitimately stop
// there (the photo was already taken and saved).
if (s_armed && !s_emulated_armed &&
!g_photo_display_yielding.load(std::memory_order_relaxed) &&
g_photo_grab_native_armed.load(std::memory_order_relaxed)) {
const int64_t last_write = g_grab_last_write_ns.load(std::memory_order_relaxed);
const int64_t yield_end =
g_photo_display_yield_end_ns.load(std::memory_order_relaxed);
const int64_t basis = std::max(std::max(last_write, s_arm_ns), yield_end);
if (now_ns - basis > 700'000'000) {
g_photo_grab_native_armed.store(false, std::memory_order_relaxed);
arm_emulated();
REXLOG_WARN(
"native-scene: photo flow - native grab produced no frames for "
"700 ms; falling back to the forced-readback window{}",
s_suppress_saved ? " + suppression lifted" : "");
}
}
}
// Photo display-card screen: yield to the emulated output while the framed
// card is up (card-signature quad in the 2D stream, stamped by
// Publish2dDraws). The game composes the framed card in a ONE-SHOT
// pitch-1280 pass at an unpredictable moment after display-open
// (observed settle+0.8s to settle+4.0s); chasing it with
// suppression-lift + forced-readback windows never worked and cost
// seconds of concurrent-pipeline lag. Yielded-emulated, the compose and the
// display chain run GPU-side with no readbacks, exactly as on console, and
// with the native renderer idle the frozen scene renders at the emulated
// pipeline's own rate. A short PRE-ROLL lifts suppression while the native
// output still presents, so the emulated framebuffer has repainted (the
// card fly-in, live) before the swap, no stale-frame flash (the FMV-yield
// lesson).
bool YieldForPhotoDisplay() {
constexpr int64_t kCardGoneNs = 250'000'000;
constexpr int64_t kPreRollNs = 250'000'000;
static bool s_preroll = false;
static bool s_suppress_saved = false;
static int64_t s_preroll_ns = 0;
const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
PerfClock::now().time_since_epoch())
.count();
const int64_t seen = g_photo_card_seen_ns.load(std::memory_order_relaxed);
const bool card_up = g_photo_flow_frame.load(std::memory_order_relaxed) &&
seen >= 0 && now_ns - seen < kCardGoneNs;
if (!card_up) {
if (s_preroll) {
s_preroll = false;
if (s_suppress_saved) {
REXCVAR_SET(native_render_suppress_emulated_draws, true);
}
if (g_photo_display_yielding.exchange(false, std::memory_order_relaxed)) {
g_photo_display_yield_end_ns.store(now_ns, std::memory_order_relaxed);
REXLOG_INFO(
"native-scene: photo display card gone - native output resumes "
"(suppression restored)");
}
}
return false;
}
if (!s_preroll) {
s_preroll = true;
s_preroll_ns = now_ns;
s_suppress_saved = REXCVAR_GET(native_render_suppress_emulated_draws);
if (s_suppress_saved) {
REXCVAR_SET(native_render_suppress_emulated_draws, true);
}
if (!s_halfpx_saved) {
REXCVAR_SET(readback_resolve_half_pixel_offset, false);
REXCVAR_SET(native_render_suppress_emulated_draws, false);
}
REXLOG_INFO(
"native-scene: photo flow ended - resolve readback{} restored",
s_suppress_saved ? " + suppression" : "");
s_armed = false;
"native-scene: photo display card up - emulated pre-roll "
"(suppression lifted; yielding to the emulated output in {} ms so "
"the game's own card compose + display chain render there)",
kPreRollNs / 1'000'000);
}
if (now_ns - s_preroll_ns < kPreRollNs) {
return false;
}
if (!g_photo_display_yielding.exchange(true, std::memory_order_relaxed)) {
REXLOG_INFO("native-scene: photo display - yielded to the emulated output");
}
return true;
}
bool YieldForPhotoEditor(uint8_t* base) {
@ -14617,6 +15033,16 @@ bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context,
REXCVAR_GET(skate3_native_render_scene_entity_fade)) {
continue;
}
// Per-piece caster parity: the game's own shadow passes skip some
// character pieces (the CAS trucker hat casts NOTHING; natively
// casting it painted a hard low-sun brim band across the editor face
// that the emulated frame never shows). Cast only what the game
// submitted through an ortho bank this frame (see
// DrawItem::shadow_caster / g_frame_ortho_ctx).
if (item.char_family != 0 && !item.shadow_caster &&
REXCVAR_GET(skate3_native_render_scene_shadow_caster_parity)) {
continue;
}
// NO inline decode here (this block used to re-decode every cloth
// garment every frame, ~2.9 ms, the 160 fps cap during real play).
// The dyn decode jobs / worker miss queue keep the cache fresh, one
@ -14750,7 +15176,16 @@ bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context,
list.RSSetViewport(vp);
D3D12_RECT rc{LONG(tile) * ci, 0, LONG(tile) * (ci + 1), LONG(tile)};
list.RSSetScissorRect(rc);
const float bc[12] = {horizontal ? 1.0f : 0.0f, horizontal ? 0.0f : 1.0f,
// Blur tap offsets in GAME-map texels (the game's atlas tile is 512;
// its blur shader steps whole logical texels). At higher native tile
// resolutions the step scales up so the blur's WORLD reach, the
// penumbra width every receiver sees, stays console-equal: 512 tiles
// rendered the exact width but visibly blocky silhouettes;
// 1024 with unscaled 1-texel steps
// halved the game's softness. Matches the emulated GPU, which
// resolution-scales the raster while the blur offsets stay logical.
const float step = std::max(1.0f, float(tile) / 512.0f);
const float bc[12] = {horizontal ? step : 0.0f, horizontal ? 0.0f : step,
ntaps[ci], src_raw ? 1.0f : 0.0f,
kernels[ci][0], kernels[ci][1], kernels[ci][2], 0.0f,
float(tile) * ci, float(tile) * (ci + 1) - 1.0f,
@ -15016,6 +15451,9 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
if (base == nullptr) {
return false;
}
if (YieldForPhotoDisplay()) {
return false;
}
if (YieldForPhotoEditor(base)) {
return false;
}
@ -15332,6 +15770,19 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
cb[23] = 0.299f * sh[32] + 0.587f * sh[33] + 0.114f * sh[34];
cb[24] = sh[20]; // depth bias (PS c5.x)
cb[25] = 1.0f; // enable
// sh_misc.zw = atlas dimensions (3*tile x tile) for the char path's
// point-snapped taps.
cb[26] = float(g_r.shadow_tile * 3);
cb[27] = float(g_r.shadow_tile);
// sh_char = the game's per-cascade character receive biases + enable
// (see FrameScene::char_shadow_bias; exact 9-tap char sampling).
if (scene.char_shadow_bias[0] > 0.0f &&
REXCVAR_GET(skate3_native_render_scene_char_shadow_exact)) {
cb[56] = scene.char_shadow_bias[0];
cb[57] = scene.char_shadow_bias[1];
cb[58] = scene.char_shadow_bias[2];
cb[59] = 1.0f;
}
}
// Exact world-shading frame rows (valid whenever the env-family PS bank
// was captured this frame, independent of the shadow ATLAS being
@ -16166,11 +16617,13 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
decal_tex = ad;
}
}
if (item.char_family >= 4 && item.char_family <= 5 &&
if (((item.char_family >= 4 && item.char_family <= 5) || item.char_alpha) &&
item.hair_alpha_tex != 0) {
// Hair strand coverage rides the (otherwise unused) decal slot; the
// PS hair branch samples it at the raw second texcoord. The white
// fallback keeps failed decodes opaque rather than invisible.
// character.alpha lenses use the same plumbing (ch_misc.z signals the
// fam-1/2 branch to take alpha from this texture).
decal_tex = resolve_texture(item.hair_alpha_tex, 5);
}
// F7 ring: stamp the SERVED content fingerprints (see SceneRingItem);
@ -16660,6 +17113,13 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
const bool hair_blend = item.char_family >= 4 && item.char_family <= 5 &&
char_capture_ok;
const bool glass_blend = item.char_family == 7 && char_capture_ok;
// character.alpha accessory pieces (sunglass lenses): translucent, alpha
// from the coverage texture at uv2 (ch_misc.z set by the capture); the
// game draws them after every opaque character piece (see
// DrawItem::char_alpha). Gated on the SAME capture validation as hair:
// without validated rows ch_misc.z is 0 and the item stays opaque.
const bool cac_alpha_blend = item.char_alpha && char_capture_ok &&
item.char_rows[14 * 4 + 2] > 0.0f;
// Per-entity spawn/distance fade (CharFadeAlpha): the game submits
// LivingWorld entity draws at alpha 0 through the whole spawn settle
// (NPCs drop ~1 m to the ground before fading in) and ramps alpha with
@ -16732,7 +17192,7 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
}
};
if ((item.transparent || item.water || hair_blend || glass_blend ||
refl_trans_blend || char_fade_blend) &&
cac_alpha_blend || refl_trans_blend || char_fade_blend) &&
debug_mode == 0) {
if (REXCVAR_GET(skate3_native_render_scene_transparents)) {
transparent_items.push_back(&item);
@ -17501,6 +17961,197 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
}
}
// ---- Native photo grab (photo_grab_native) ----
// Consume: CPU-tile the newest fence-completed readback into the guest
// screenshot target. Produce: downsample this frame's finished output
// (photo editor = the ported postfx chain result just drawn above; plain
// TakePhoto window = the plain frame, both the correct grab semantics)
// into blur_tex[0] via the blur chain's prefiltered downsample (its
// 1152x640 blur space IS the game's screenshot raster) and enqueue the
// copy into the free readback buffer. Runs BEFORE the 2D overlay so the
// photo never contains editor chrome / HUD, and before the blur block so
// this scratch use of blur_tex[0] is enqueued ahead of any blur rewrite.
if (g_photo_grab_native_armed.load(std::memory_order_relaxed) &&
g_r.pso_blur_down != nullptr && g_r.blur_tex[0] != nullptr &&
g_r.output_srv_allocated && !g_r.grab_failed) {
auto* grab_cp = context.d3d12.command_processor;
// Lazy readback-pair creation (persistently mapped; the copy is never
// waited on same-frame; the old path's per-resolve GPU drain was the
// editor's multi-second stall class).
if (g_r.grab_readback[0] == nullptr) {
D3D12_HEAP_PROPERTIES heap_props{};
heap_props.Type = D3D12_HEAP_TYPE_READBACK;
D3D12_RESOURCE_DESC desc{};
desc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;
desc.Width =
UINT64(RendererState::kGrabRowPitch) * RendererState::kBlurHeight;
desc.Height = 1;
desc.DepthOrArraySize = 1;
desc.MipLevels = 1;
desc.SampleDesc.Count = 1;
desc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;
for (int i = 0; i < 2 && !g_r.grab_failed; ++i) {
if (FAILED(g_r.device->CreateCommittedResource(
&heap_props, D3D12_HEAP_FLAG_NONE, &desc,
D3D12_RESOURCE_STATE_COPY_DEST, nullptr,
IID_PPV_ARGS(&g_r.grab_readback[i]))) ||
FAILED(g_r.grab_readback[i]->Map(
0, nullptr,
reinterpret_cast<void**>(&g_r.grab_readback_ptr[i])))) {
g_r.grab_failed = true;
}
}
if (g_r.grab_failed) {
REXLOG_ERROR(
"native-scene: photo grab readback creation failed - native "
"grab disabled (watchdog will fall back)");
}
}
if (!g_r.grab_failed) {
// Consume the newest completed buffer; retire older completed ones
// unwritten so stale content never lands after fresh content.
const uint64_t grab_completed = grab_cp->GetCompletedSubmission();
int newest = -1;
for (int i = 0; i < 2; ++i) {
if (g_r.grab_pending[i] && g_r.grab_submission[i] < grab_completed &&
(newest < 0 ||
g_r.grab_submission[i] > g_r.grab_submission[newest])) {
newest = i;
}
}
if (newest >= 0) {
static bool s_grab_write_warned = false;
const auto tile_t0 = PerfClock::now();
const bool wrote = GrabTargetValid(base) &&
WriteGrabToGuest(base, g_r.grab_readback_ptr[newest]);
if (wrote) {
g_r.grab_cpu_us +=
uint64_t(std::chrono::duration_cast<std::chrono::microseconds>(
PerfClock::now() - tile_t0)
.count());
++g_r.grab_writes;
g_grab_last_write_ns.store(
std::chrono::duration_cast<std::chrono::nanoseconds>(
PerfClock::now().time_since_epoch())
.count(),
std::memory_order_relaxed);
if (g_r.grab_writes == 1) {
REXLOG_INFO(
"native-scene: photo grab - first native frame written to "
"guest 0x{:08X} via the 0xA0000000 view (tiled BE ARGB "
"1152x640)",
kGrabGuestAddr);
}
} else if (!s_grab_write_warned) {
s_grab_write_warned = true;
REXLOG_WARN(
"native-scene: photo grab - guest target validation/write "
"FAILED (dims chain mismatch or unmapped page); the window "
"watchdog will fall back to forced readbacks");
}
for (int i = 0; i < 2; ++i) {
if (g_r.grab_pending[i] && g_r.grab_submission[i] < grab_completed) {
g_r.grab_pending[i] = false;
}
}
}
// Produce into the free buffer (skip the frame if both are still in
// flight, never wait).
const uint32_t w = g_r.grab_write_index;
if (!g_r.grab_pending[w]) {
// Re-point the shared output SRV slot at the current output
// resource (it can change between frames, same idiom as the blur
// block below; if blur also runs this frame it recreates the same
// descriptor).
{
D3D12_SHADER_RESOURCE_VIEW_DESC srv{};
srv.Format = context.d3d12.guest_output_format;
srv.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
srv.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srv.Texture2D.MipLevels = 1;
D3D12_CPU_DESCRIPTOR_HANDLE slot =
g_r.srv_heap->GetCPUDescriptorHandleForHeapStart();
slot.ptr += size_t(g_r.output_srv_slot) * g_r.srv_size;
g_r.device->CreateShaderResourceView(
context.d3d12.guest_output_resource, &srv, slot);
}
context.d3d12.push_transition_barrier(
context.d3d12.command_processor_user_data,
context.d3d12.guest_output_resource,
D3D12_RESOURCE_STATE_RENDER_TARGET,
D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE);
context.d3d12.submit_barriers(context.d3d12.command_processor_user_data);
D3D12_CPU_DESCRIPTOR_HANDLE grab_rtv =
g_r.rtv_heap->GetCPUDescriptorHandleForHeapStart();
grab_rtv.ptr += size_t(5) * g_r.rtv_size;
list.D3DOMSetRenderTargets(1, &grab_rtv, FALSE, nullptr);
const D3D12_VIEWPORT grab_vp{0.0f,
0.0f,
float(RendererState::kBlurWidth),
float(RendererState::kBlurHeight),
0.0f,
1.0f};
const D3D12_RECT grab_sc{0, 0, LONG(RendererState::kBlurWidth),
LONG(RendererState::kBlurHeight)};
list.RSSetViewport(grab_vp);
list.RSSetScissorRect(grab_sc);
list.D3DIASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
list.D3DSetPipelineState(g_r.pso_blur_down);
const float grab_consts[4] = {1.0f / float(context.guest_output_width),
1.0f / float(context.guest_output_height),
0.0f, 0.0f};
list.D3DSetGraphicsRoot32BitConstants(0, 4, grab_consts, 0);
{
D3D12_GPU_DESCRIPTOR_HANDLE h =
g_r.srv_heap->GetGPUDescriptorHandleForHeapStart();
h.ptr += size_t(g_r.output_srv_slot) * g_r.srv_size;
context.d3d12.set_graphics_root_descriptor_table(
context.d3d12.command_processor_user_data, 1, h);
}
list.D3DDrawInstanced(3, 1, 0, 0);
// blur_tex[0] -> the readback buffer; restore steady states.
context.d3d12.push_transition_barrier(
context.d3d12.command_processor_user_data, g_r.blur_tex[0],
D3D12_RESOURCE_STATE_RENDER_TARGET,
D3D12_RESOURCE_STATE_COPY_SOURCE);
context.d3d12.push_transition_barrier(
context.d3d12.command_processor_user_data,
context.d3d12.guest_output_resource,
D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE,
D3D12_RESOURCE_STATE_RENDER_TARGET);
context.d3d12.submit_barriers(context.d3d12.command_processor_user_data);
D3D12_TEXTURE_COPY_LOCATION grab_src{};
grab_src.pResource = g_r.blur_tex[0];
grab_src.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX;
grab_src.SubresourceIndex = 0;
D3D12_TEXTURE_COPY_LOCATION grab_dst{};
grab_dst.pResource = g_r.grab_readback[w];
grab_dst.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT;
grab_dst.PlacedFootprint.Offset = 0;
grab_dst.PlacedFootprint.Footprint.Format =
context.d3d12.guest_output_format;
grab_dst.PlacedFootprint.Footprint.Width = RendererState::kBlurWidth;
grab_dst.PlacedFootprint.Footprint.Height = RendererState::kBlurHeight;
grab_dst.PlacedFootprint.Footprint.Depth = 1;
grab_dst.PlacedFootprint.Footprint.RowPitch =
RendererState::kGrabRowPitch;
list.D3DCopyTextureRegion(&grab_dst, 0, 0, 0, &grab_src, nullptr);
context.d3d12.push_transition_barrier(
context.d3d12.command_processor_user_data, g_r.blur_tex[0],
D3D12_RESOURCE_STATE_COPY_SOURCE,
D3D12_RESOURCE_STATE_RENDER_TARGET);
context.d3d12.submit_barriers(context.d3d12.command_processor_user_data);
// Restore the frame's output binding for the passes that follow.
list.D3DOMSetRenderTargets(1, &output_rtv, FALSE, nullptr);
list.RSSetViewport(viewport);
list.RSSetScissorRect(scissor);
g_r.grab_submission[w] = grab_cp->GetCurrentSubmission();
g_r.grab_pending[w] = true;
g_r.grab_write_index = 1 - w;
}
}
}
// Popup background blur: exact port of the game's blur_hBlur/vBlur +
// postfx_basictex chain (see kBlurShaderSource): H blur of the finished
// frame into a 1152x640 intermediate, V blur, then a fullscreen bilinear

View file

@ -148,6 +148,21 @@ struct DrawItem {
// Detected via the AttribulatorMaterialName channel; consumed by
// CaptureSkinnedState.
bool ropa;
// character.alpha: translucent CAC accessory pieces (sunglass lenses).
// cac_alphaPS outputs alpha = the mesh's "alpha" channel texture sampled
// at the raw second texcoord x the entity alpha constant (ucode: oC0.w =
// tf5(uv2).r * c22.x) and the game draws them AFTER every opaque
// character piece: rendered opaque the lens is a solid black patch.
// Routed to the sorted alpha sub-pass like hair; the coverage texture
// rides the same hair_alpha_tex/decal-slot plumbing (ch_misc.z > 0
// signals the PS fam-2 branch to take alpha from t4@uv2).
bool char_alpha = false;
// The game submitted this ctx through an ortho (shadow caster-cascade)
// bank this frame; the game's caster list is PER-PIECE (the CAS trucker
// hat never enters the shadow passes) and the native atlas pass mirrors
// it for character items. Rescued/gap-filled copies inherit the stored
// flag so a refused-capture frame doesn't blink the entity's shadow.
bool shadow_caster = false;
// ROPA shape blending (set by InterpolateDynamicItems): decoded shape
// generations (DynDecodeJob seq) + weights reproducing the SAME temporal
// kernel the body bones / garment world were evaluated with this frame:
@ -279,6 +294,14 @@ struct FrameScene {
// Consumed by the exact dynamicobject scene PS branch.
bool dynobj_valid = false;
float dynobj_rows[10] = {};
// Character-receiver per-cascade CSM receive biases, captured from a
// validated fam-2 character PIXEL bank (gameplay c8 / editor c9: one row
// below the light row; frame-global, e.g. (0.005, 0.014, 0.020)). The
// game's character shaders build their shadow reference as
// saturate(rd - bias[cascade]) and take NINE point taps of the atlas at
// +-1 game-texel, averaged (cacstamp_skin_nisPS ucode; literal 1/9 =
// c255.w). 0 = not captured -> legacy single-tap char path.
float char_shadow_bias[3] = {};
// Sky-dome viewpos: sky.* meshes are camera-relative (sky.fx VS adds
// g_vViewPos), but the game pins the sky viewpos Y at a fixed level
// elevation (165.0 in every capture) so the skyline doesn't bob with the

@ -1 +1 @@
Subproject commit b8258670da206dd2875c08ab437458a274558df0
Subproject commit 065cade4d314f0ab34201e633ec2904857af4101