From 0a73e321de9eb6495c197809efb73f2439cb5a86 Mon Sep 17 00:00:00 2001 From: mchughalex <25671999+mchughalex@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:39:41 +0100 Subject: [PATCH] photo: native grab; shadows: exact character shadow port; cas: tee/lens fixes --- src/native/shaders/scene.hlsl | 63 ++- src/skate3_native_render.cpp | 17 + src/skate3_native_scene.cpp | 729 ++++++++++++++++++++++++++++++++-- src/skate3_native_scene.h | 23 ++ third_party/rexglue-sdk | 2 +- 5 files changed, 788 insertions(+), 46 deletions(-) diff --git a/src/native/shaders/scene.hlsl b/src/native/shaders/scene.hlsl index 0aff8e4..3070e01 100644 --- a/src/native/shaders/scene.hlsl +++ b/src/native/shaders/scene.hlsl @@ -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); diff --git a/src/skate3_native_render.cpp b/src/skate3_native_render.cpp index ef57ee1..927841d 100644 --- a/src/skate3_native_render.cpp +++ b/src/skate3_native_render.cpp @@ -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 diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index b897a7e..fb8fda2 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -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 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 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 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> g_frame_draw_fetch; // instance in the frame. Guest-render-thread only (capture hooks + // BuildFrameScene), no lock needed. std::unordered_map> 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 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 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 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 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( + PerfClock::now().time_since_epoch()) + .count(), + std::memory_order_relaxed); + } published.push_back(std::move(d)); } std::lock_guard 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 dynitems; + std::unordered_set ortho_ctx; { std::lock_guard 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::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 g_photo_grab_native_armed{false}; +// Last successful guest write (PerfClock ns): the watchdog liveness signal. +std::atomic 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 g_photo_display_yielding{false}; +std::atomic 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 staging; + staging.resize(size_t(kW) * kH * 4); + for (uint32_t y = 0; y < kH; ++y) { + const uint32_t* row = reinterpret_cast( + 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(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( + 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( - 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( + 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(&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( + PerfClock::now() - tile_t0) + .count()); + ++g_r.grab_writes; + g_grab_last_write_ns.store( + std::chrono::duration_cast( + 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 diff --git a/src/skate3_native_scene.h b/src/skate3_native_scene.h index 5415111..d75e3e2 100644 --- a/src/skate3_native_scene.h +++ b/src/skate3_native_scene.h @@ -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 diff --git a/third_party/rexglue-sdk b/third_party/rexglue-sdk index b825867..065cade 160000 --- a/third_party/rexglue-sdk +++ b/third_party/rexglue-sdk @@ -1 +1 @@ -Subproject commit b8258670da206dd2875c08ab437458a274558df0 +Subproject commit 065cade4d314f0ab34201e633ec2904857af4101