diff --git a/src/skate3_native_render.cpp b/src/skate3_native_render.cpp index 90a99e3..bca4a64 100644 --- a/src/skate3_native_render.cpp +++ b/src/skate3_native_render.cpp @@ -585,6 +585,20 @@ extern "C" REX_FUNC(sub_82963510) { } } +// Sk8::Challenge::PhotoReplayController::Update(float): runs once per guest +// frame while a photo-mission's photo editor is up (pick-a-photo + +// depth-of-field / saturation / brightness / contrast controls). Heartbeat +// for the native scene's photo-editor yield: the editor's effects are the +// game's own postfx chain, which only the emulated path executes. Reached +// via the recomp function table (virtual dispatch), so the override fires +// like any direct call. +extern "C" REX_FUNC(sub_825623F0) { + if (skate3::native_render::Enabled()) { + skate3::native_scene::OnPhotoReplayUpdate(); + } + __imp__sub_825623F0(ctx, base); +} + // Sk8::WorldPresentation::AddRenderInstance(pegasus::tInstance*): the world // registry add, fired per placed instance while a map loads (r4 = tInstance). // The prewarm's primary mesh source: the instance's tRModelData mesh table diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index 29b96ab..27fe37b 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -123,6 +123,16 @@ REXCVAR_DEFINE_INT32(skate3_native_render_scene_prewarm_budget_ms, 32, "Skate 3" "0 disables registration prewarm.") .range(0, 1000) .lifecycle(rex::cvar::Lifecycle::kHotReload); +REXCVAR_DEFINE_BOOL(skate3_native_render_scene_photo_yield, true, "Skate 3", + "Yield to the emulated output while a photo-mission's photo editor " + "(the FE PhotoSelect screen) is up. The editor's depth of field / " + "saturation / brightness / contrast / lens vignette are the game's " + "own postfx chain, which native rendering suppresses; natively " + "the photo showed the raw scene and the effect controls did " + "nothing. The scene is frozen there, so emulated-path performance " + "is fine. Detected by polling the FrontEndManager NIS push-state " + "stack (plus the PhotoReplayController heartbeat as a backup).") + .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_snapshot_all_draws, false, "Skate 3", "Record the draw stream on every recorded frame instead of 2 of every " "60 (large .draws.bin; for targeted investigations)") @@ -454,6 +464,11 @@ bool g_fog_frame_done = false; float g_sky_height = 165.0f; // every Port Carverton capture to date bool g_sky_have = false; bool g_sky_frame_done = false; +// Sky sun rows from the same draw's PIXEL bank (see FrameScene::sky_sun): +// light dir (c0.xyz), sun angular scale (c4.x), pre-tone multiplier (c4.y), +// exposure (c3.x). Persist like g_sky_height once captured. +float g_sky_sun[6] = {}; +bool g_sky_sun_have = false; // UI background blur (see FrameScene::ui_blur and kBlurShaderSource): while // a frontend popup is up the game appends blur_hBlur/vBlur + basictex passes // after the postfx uber. g_ui_blur holds the captured kernel scale (PS c0.x, @@ -2135,6 +2150,19 @@ void OnVsConstantUpload(uint8_t* base, uint64_t mask, uint32_t bank, uint32_t pt g_vs_bank.store(ptr, std::memory_order_relaxed); } +// Last PhotoReplayController::Update heartbeat, nanoseconds on PerfClock +// (steady). -1 until the first heartbeat. Written from the guest thread, +// read by RenderScene on the render thread. +static std::atomic g_photo_replay_last_ns{-1}; + +void OnPhotoReplayUpdate() { + g_photo_replay_last_ns.store( + std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(), + std::memory_order_relaxed); +} + void On2dPhase(uint32_t bit, bool enter) { if (bit >= 6) { return; @@ -2958,6 +2986,33 @@ void OnDrawDone(uint8_t* base, uint32_t func, uint32_t r4, uint32_t r5, uint32_t g_sky_height = LoadGuestF32(base, bank + 17 * 4); g_sky_have = true; g_sky_frame_done = true; + // Same draw, PIXEL bank (sky_defaultPS layout, verified in + // capture): c0.xyz = g_vLightDir (unit vector + // toward the sun), c4.x = sun angular scale (m_params[0].x, 0.75), + // c4.y = sky pre-tone multiplier (m_params[0].y, 0.35), c3.x = scene + // exposure (g_envattributes[2].x, 2.5). Sanity-gated: a stale bank + // here would put the sun glow in a wrong spot or blow out the tone. + const uint32_t sky_ps = g_ps_bank.load(std::memory_order_relaxed); + if (sky_ps != 0) { + float dir[3]; + for (int k = 0; k < 3; ++k) { + dir[k] = LoadGuestF32(base, sky_ps + uint32_t(k) * 4); + } + const float n2 = dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]; + const float scale = LoadGuestF32(base, sky_ps + (4 * 4 + 0) * 4); + const float mult = LoadGuestF32(base, sky_ps + (4 * 4 + 1) * 4); + const float expo = LoadGuestF32(base, sky_ps + (3 * 4 + 0) * 4); + if (n2 > 0.8f && n2 < 1.2f && scale > 1e-3f && scale < 100.0f && + mult > 1e-3f && mult < 100.0f && expo > 0.01f && expo < 100.0f) { + g_sky_sun[0] = dir[0]; + g_sky_sun[1] = dir[1]; + g_sky_sun[2] = dir[2]; + g_sky_sun[3] = scale; + g_sky_sun[4] = mult; + g_sky_sun[5] = expo; + g_sky_sun_have = true; + } + } } if (dx * dx + dy * dy + dz * dz < 25.0f) { if (!g_fog_frame_done) { @@ -4200,7 +4255,7 @@ bool SmoothCamera(const float view[16], const float proj[16], const float raw_vp // order, so the k-th copy pairs with last frame's k-th copy. Componentwise // lerp is exact enough at adjacent-sim-tick deltas (~5 ms of motion). // Guest render thread only. -void InterpolateDynamicItems(FrameScene& scene, double now) { +void InterpolateDynamicItems(uint8_t* base, FrameScene& scene, double now) { // Per-entity pose RING, like the camera's: the playback clock sits ~2 sim // periods behind `now`, so a two-pose history never brackets it (the // interpolation alpha pinned at 0 and entities rendered STALE STEPPED @@ -4220,6 +4275,17 @@ void InterpolateDynamicItems(FrameScene& scene, double now) { int count = 0; int newest = 0; uint64_t seen = 0; + // Per-bone skin-weighted vertex centroids in BIND space (w, x, y, z), + // lazily computed from the mesh's own vertex buffer the first time a + // bone of this entity trips the spin-collapse guard. The centroid IS + // the wheel's geometric center (verified in capture: + // wheel-mesh bone centroids match the motion-solved spin pivot to a + // millimeter), so the collapse guard can pin it to the boxcar path + // exactly, no runtime estimation (every estimator variant tested was + // noisier than the artifact it fixed). + uint32_t cen_vb = 0; + uint32_t cen_bytes = 0; + std::vector> cen; }; static std::unordered_map s_hist; static uint64_t s_frame = 0; @@ -4449,24 +4515,173 @@ void InterpolateDynamicItems(FrameScene& scene, double now) { } if (ncollapsed > 0) { // Collapsed bone (rotating too fast inside the window to - // average, spinning wheels, and briefly fast-swinging limbs): - // plain adjacent-sample lerp at the playback point. R and t come - // from the same pose pair (no orbit), samples are ~7.5 ms apart - // (only a few % midpoint shrink), and limbs that trip the - // detector during tricks render essentially correctly. KNOWN - // COSMETIC LIMIT: wheels ride the raw 60 Hz-lumpy path while the - // deck rides the boxcar, a subtle lag/catch-up between wheels - // and board at speed. Smarter per-frame constructions all failed - // WORSE; do not iterate live again; solve offline against the - // bone_signal captures. + // average, spinning wheels, and briefly fast-swinging limbs). + // Base fallback: plain adjacent-sample lerp at the playback point + // (R and t from the SAME pose pair, no orbit; ~7.5 ms spacing + // keeps shrink to a few %). Bones whose mesh vertices yield a + // skin-weighted BIND-SPACE CENTROID get the exact PIVOT-BOXCAR + // form instead: render the orthonormalized lerp rotation + // translated so the centroid rides the same boxcar path as every + // other filtered bone: t_out = tbar + Rbar*c - Ro*c, where + // Rbar/tbar is the boxcar affine already in acc[] (its action on + // ANY fixed bind-space point IS that point's smoothed path). The + // centroid is the wheel's geometric center (a wheel is symmetric + // about its axle; verified in capture: mesh + // centroids match the motion-solved spin pivot to a millimeter), + // so the wheel is exact at its center and sub-mm across its + // ~4 cm extent; a limb pins its own centroid to its smoothed + // path (full-norm rotation, no lerp shrink). Offline-validated: + // wheel-vs-deck + // high-frequency wobble 1.3 -> 0.02 cm rms at constant speed / + // 1.0 -> 0.29 cm at 25 m/s with speed changes, better than the + // game's own 60 Hz output (0.05-0.56 cm). DO NOT replace the + // centroid with a motion-ESTIMATED pivot: every estimator shape + // tried (shared / per-window velocity elimination, quadratic + // motion models, theta gates, consistency resets, axis + // projection) was noisier than the artifact it fixed. + // Junk palette rows + // (unreferenced by vertices) get no centroid weight and keep the + // lerp. + if ((h.cen_vb != item.vb_addr || h.cen_bytes != item.vb_bytes) && + item.stride != 0 && item.bw_offset != 0 && item.bi_offset != 0) { + // One-time bind-space centroid pass over this entity's vertex + // buffer (guest thread; raw guest reads are safe here, same + // as RefinePaletteBase). u8x4 attributes are big-endian per + // 32-bit word: component k is byte (24 - 8k) of the host-order + // load. + h.cen_vb = item.vb_addr; + h.cen_bytes = item.vb_bytes; + h.cen.assign(nbones, std::array{0.0f, 0.0f, 0.0f, 0.0f}); + const uint32_t vcount = + std::min(item.vb_bytes / item.stride, 200000); + for (uint32_t vtx = 0; vtx < vcount; ++vtx) { + const uint32_t v = item.vb_addr + vtx * item.stride; + float p[3]; + const uint32_t pa = v + item.pos_offset; + bool ok_fmt = true; + switch (item.pos_fmt) { + case 57: + for (int a = 0; a < 3; ++a) { + const uint32_t u = REX_LOAD_U32(pa + a * 4); + std::memcpy(&p[a], &u, 4); + } + break; + case 32: + for (int a = 0; a < 3; ++a) { + p[a] = GuestHalfToFloat(uint16_t(REX_LOAD_U16(pa + a * 2))); + } + break; + case 26: { + constexpr float kScale = 2.0f / 32767.0f; + for (int a = 0; a < 3; ++a) { + p[a] = int16_t(REX_LOAD_U16(pa + a * 2)) * kScale + + (a == 1 ? 0.8f : 0.0f); + } + break; + } + default: + ok_fmt = false; + break; + } + if (!ok_fmt) { + break; + } + const uint32_t bw = REX_LOAD_U32(v + item.bw_offset); + const uint32_t bidx = REX_LOAD_U32(v + item.bi_offset); + for (int k = 0; k < 4; ++k) { + const uint32_t wgt = (bw >> (24 - 8 * k)) & 0xFF; + if (wgt == 0) { + continue; + } + const uint32_t bone = (bidx >> (24 - 8 * k)) & 0xFF; + if (bone >= nbones) { + continue; + } + auto& cb = h.cen[bone]; + const float wf = float(wgt) * (1.0f / 255.0f); + cb[0] += wf; + cb[1] += wf * p[0]; + cb[2] += wf * p[1]; + cb[3] += wf * p[2]; + } + } + for (auto& cb : h.cen) { + if (cb[0] > 0.5f) { + cb[1] /= cb[0]; + cb[2] /= cb[0]; + cb[3] /= cb[0]; + } + } + } + uint64_t pivot_upgraded = 0; for (size_t b = 0; b < nbones; ++b) { if (!s_collapsed[b]) { continue; } const size_t bi = b * 12; + float lp[12]; for (int i = 0; i < 12; ++i) { - acc[bi + i] = q0.b[bi + i] + (q1.b[bi + i] - q0.b[bi + i]) * ba; + lp[i] = q0.b[bi + i] + (q1.b[bi + i] - q0.b[bi + i]) * ba; } + const bool upgraded = [&]() { + if (filter_w <= 0.0005 || b >= h.cen.size() || + h.cen[b][0] <= 0.5f) { + return false; // no centroid (junk row / undecodable VB) + } + const float c[3] = {h.cen[b][1], h.cen[b][2], h.cen[b][3]}; + const float* bar = acc.data() + bi; + // Orthonormalize the lerped rotation (rows): recovers the + // full-norm midpoint rotation from the shrunk chord. + double r0[3] = {lp[0], lp[1], lp[2]}; + double r1[3] = {lp[4], lp[5], lp[6]}; + double n0 = std::sqrt(r0[0] * r0[0] + r0[1] * r0[1] + r0[2] * r0[2]); + if (n0 < 1e-4) { + return false; + } + for (double& v : r0) v /= n0; + const double d01 = r0[0] * r1[0] + r0[1] * r1[1] + r0[2] * r1[2]; + for (int i = 0; i < 3; ++i) r1[i] -= d01 * r0[i]; + const double n1 = + std::sqrt(r1[0] * r1[0] + r1[1] * r1[1] + r1[2] * r1[2]); + if (n1 < 1e-4) { + return false; + } + for (double& v : r1) v /= n1; + const double r2[3] = {r0[1] * r1[2] - r0[2] * r1[1], + r0[2] * r1[0] - r0[0] * r1[2], + r0[0] * r1[1] - r0[1] * r1[0]}; + const double* rows[3] = {r0, r1, r2}; + // t_out = tbar + Rbar*c - Ro*c: the pivot lands exactly on + // the boxcar path (in phase with the deck and every other + // filtered bone), spin phase comes from the lerp pair. + for (int r = 0; r < 3; ++r) { + double rb = 0.0, ro = 0.0; + for (int c2 = 0; c2 < 3; ++c2) { + rb += double(bar[r * 4 + c2]) * c[c2]; + ro += rows[r][c2] * c[c2]; + } + acc[bi + r * 4 + 0] = float(rows[r][0]); + acc[bi + r * 4 + 1] = float(rows[r][1]); + acc[bi + r * 4 + 2] = float(rows[r][2]); + acc[bi + r * 4 + 3] = float(double(bar[r * 4 + 3]) + rb - ro); + } + return true; + }(); + if (upgraded) { + ++pivot_upgraded; + } else { + for (int i = 0; i < 12; ++i) { + acc[bi + i] = lp[i]; + } + } + } + // Sparse telemetry: how many bones ride the pivot upgrade. + static uint64_t s_pivot_frames = 0, s_pivot_bones = 0; + s_pivot_bones += pivot_upgraded; + if (++s_pivot_frames % 3000 == 0 && s_pivot_bones > 0) { + REXLOG_INFO("native-scene pivot: {} bone-upgrades / 3000 frames", + s_pivot_bones); + s_pivot_bones = 0; } } } @@ -4675,8 +4890,10 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { ++g_scene_2d_generation; } // Publish this frame's in-world spline draws: evaluate the guest B-spline - // VS on the CPU (see SplineDraw for the decoded algorithm) into final - // clip-space strip vertices so the render side is a passthrough draw. + // VS on the CPU (see SplineDraw for the decoded algorithm) into WORLD-space + // strip vertices; the render side projects them with the scene's (smoothed) + // view_proj like every other world item, keeping them in phase with the + // re-timed camera. { std::vector frame_spline; { @@ -4731,10 +4948,17 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { wp[a] = wp[a] * (1.0f / 6.0f) + (wr[0] * off[0] + wr[1] * off[1] + wr[2] * off[2] + wr[3] * off[3]); } - float clip[4]; - for (int a = 0; a < 4; ++a) { - const float* pr = row(a); - clip[a] = pr[0] * wp[0] + pr[1] * wp[1] + pr[2] * wp[2] + pr[3]; + // Fade still evaluates against the draw's own clip z (the ramps + // span hundreds of meters; the ~30 ms offset from the smoothed + // camera is invisible), but the PUBLISHED position is WORLD-space: + // the render side projects with the scene's (smoothed) view_proj so + // the neon signs ride the exact same camera timeline as the world. + // Baking the guest VP here was the "waypoint sign judders / lags + // and catches up" bug once smooth_camera re-timed everything else. + float clip_z; + { + const float* pr = row(2); + clip_z = pr[0] * wp[0] + pr[1] * wp[1] + pr[2] * wp[2] + pr[3]; } // Near/far fade against i_clipvalues (clip-space z, pre-divide). A // zero range degenerates to a step, like the shader's rcp(0) = inf. @@ -4747,11 +4971,11 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { return z - start > 0.0f ? 1.0f : 0.0f; }; const float fade = - std::min(ramp(clip[2], cv[0], cv[1]), 1.0f - ramp(clip[2], cv[2], cv[3])); - dst[0] = clip[0]; - dst[1] = clip[1]; - dst[2] = clip[2]; - dst[3] = clip[3]; + std::min(ramp(clip_z, cv[0], cv[1]), 1.0f - ramp(clip_z, cv[2], cv[3])); + dst[0] = wp[0]; + dst[1] = wp[1]; + dst[2] = wp[2]; + dst[3] = 1.0f; dst[4] = p[1]; dst[5] = p[2]; dst[6] = fade; @@ -5091,7 +5315,7 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { std::memcpy(scene.cam_pos, smooth_cam, sizeof(smooth_cam)); // Keep the skater/NPCs/props in phase with the smoothed camera: // interpolate their palettes/worlds at the same playback time. - InterpolateDynamicItems(scene, now_s); + InterpolateDynamicItems(base, scene, now_s); } } @@ -5325,6 +5549,10 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { if (g_sky_have) { scene.sky_height = g_sky_height; } + if (g_sky_sun_have) { + std::memcpy(scene.sky_sun, g_sky_sun, sizeof(g_sky_sun)); + scene.sky_sun_valid = true; + } const bool blur_active = g_ui_blur_seen || g_ui_blur_hold > 0; scene.ui_blur = blur_active ? g_ui_blur : 0.0f; if (g_ui_blur_seen) { @@ -6158,6 +6386,33 @@ float4 ps_main(VSOut i) : SV_Target { float aref = cam_pos.w < -0.5 ? 0.1176 : 0.35; clip(misc.x > 0.0 ? albedo.a * albedo.a - 0.0627 : albedo.a - aref); } + // Exact sky dome (cam_pos.w = -40; sky_defaultPS transcribed from the + // Skate 3 ucode in a live capture). The emulated frame's big sun + // glow is computed IN the dome shader: a 1D radial gradient (the sky + // material's `specular` channel, bound at t4 here) indexed by the sine of + // the angle between the dome direction and the sun, its rgb^2 amplified + // by 1/sat(a + 0.01), the alpha falloff makes the core HDR-bright, then + // added to the squared panorama and run through the standard exposure/ + // tonemap/sqrt chain (no fog on the sky) and the postfx uber 1.41. + // mat_tint.xyz = sun dir, mat_tint.w = the level sky elevation; + // overlay.x = sun angular scale, overlay.y = pre-tone multiplier, + // misc.y = scene exposure. + if (cam_pos.w < -39.5 && cam_pos.w > -40.5) { + // The dome mesh is camera-relative: world = mesh + (cam.x, sky_h, + // cam.z), so the shader's dome-local direction (sky.fx In.vPos) is + // rpos + (0, cam.y - sky_h, 0). + float3 dome = normalize(i.rpos + float3(0.0, cam_pos.y - mat_tint.w, 0.0)); + float3 lin = albedo.rgb * albedo.rgb; + float dotPL = saturate(dot(dome, mat_tint.xyz)); + float sinA = sqrt(saturate(1.0 - dotPL * dotPL)); + float4 sun = decal_art.Sample(smp_clamp, float2(sinA / overlay.x, 0.5 / 16.0)); + lin += sun.rgb * sun.rgb / saturate(sun.a + 0.01); + float3 xe = lin * overlay.y * misc.y; + float3 t1 = saturate(1.0 - xe); + float3 tm = max(xe * 0.25 + 0.75, 1.0) - t1 * t1; + float3 cc = saturate(sqrt(max(tm * 0.5, 0.0)) * 1.41); + return float4(cc, 1.0); + } // dynamicobject.fx props (cam_pos.w = -(20 + variant): -21 default, // -22 alphatest). Rigid movable objects (dispensers, dumpsters, benches, // cans). Lit with the game's own dynamicobject model (verified exact): @@ -6842,14 +7097,19 @@ float4 ps_main(VSOut i) : SV_Target { )"; // In-world neon spline shader (waypoint arrows / marker beams). The guest -// B-spline VS is evaluated on the CPU at publish time, so the VS here is a -// clip-space passthrough; the two PS variants transcribe the game's own -// spline.fx (Skate-Shaders repo): "default" = additive glow with the -// squared-gamma trick, "darken" = straight-alpha backdrop dimming. The -// gradient texture uses the wrap/aniso sampler like the original i_diffuse -// (U runs 0..N along the band). +// B-spline VS is evaluated on the CPU at publish time into WORLD-space +// vertices; the VS here projects them with the scene's (smoothed) view_proj +// rows so the signs stay glued to the world under camera re-timing. The two +// PS variants transcribe the game's own spline.fx (Skate-Shaders repo): +// "default" = additive glow with the squared-gamma trick, "darken" = +// straight-alpha backdrop dimming. The gradient texture uses the wrap/aniso +// sampler like the original i_diffuse (U runs 0..N along the band). const char kShaderSplineSource[] = R"( cbuffer C : register(b0) { + float4 vp0; // scene view_proj rows (row-vector: clip = x*vp0+y*vp1+z*vp2+w*vp3) + float4 vp1; + float4 vp2; + float4 vp3; float4 intensity; // i_intensity as staged (x = default gain, y = darken gain) }; Texture2D tex : register(t0); @@ -6861,7 +7121,7 @@ struct VSOut { }; VSOut vs_main(float4 p : POSITION, float2 uv : TEXCOORD0, float fade : TEXCOORD1) { VSOut o; - o.pos = p; + o.pos = p.x * vp0 + p.y * vp1 + p.z * vp2 + p.w * vp3; o.uv = uv; o.fade = fade; return o; @@ -9238,7 +9498,8 @@ void WarmItemResources(const NativeGuestOutputRenderContext& context, uint8_t* b if (item.char_family >= 4 && item.char_family <= 5) { warm_texture(item.hair_alpha_tex); } - if (item.env_family != 0 && !item.decal && item.env_family != 10) { + if ((item.env_family != 0 && !item.decal && item.env_family != 10) || + item.unlit) { // unlit = sky: spec_tex is the 1D sun gradient warm_texture(item.spec_tex); } // Environment cube (negative-cached like the draw path). @@ -9463,7 +9724,8 @@ void ProcessPrewarmEntry(uint8_t* base, const PrewarmEntry& e) { if (item.char_family >= 4 && item.char_family <= 5) { stage_texture(item.hair_alpha_tex); } - if (item.env_family != 0 && !item.decal && item.env_family != 10) { + if ((item.env_family != 0 && !item.decal && item.env_family != 10) || + item.unlit) { // unlit = sky: spec_tex is the 1D sun gradient stage_texture(item.spec_tex); } @@ -9806,6 +10068,72 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat if (base == nullptr) { return false; } + // Photo-mission photo editor (the "Pick a photo" screen with the depth of + // field / saturation / brightness / contrast controls; FE screen class + // PhotoSelect, challenge/photoselect.swf): the editor's effects ARE the + // game's postfx chain, which native rendering suppresses, so natively + // the photo showed the raw scene and the controls did nothing. Yield to + // the emulated output while the editor is up: the emulated frame there is + // complete and exact, and the scene is frozen so emulated-path + // performance is fine. Unlike the menus branch above this touches no + // caches and no takeover gates; native rendering resumes on the next + // frame after the editor closes. + // + // Detection is a per-frame poll of the game's FE state (stateless, so it + // can't get stuck): FrontEndManager singleton ptr global 0x830CFE14 + // (TU3; from SingletonHolder::Instance = 824AD2F0), + // whose NIS FE push-state stack (eastl::vector of 20-byte records at + // +0x210) holds a {1, 11} record exactly while the photographer NIS has + // the editor pushed. Surveyed across 50 gsnaps spanning gameplay, menus + // and other missions: every non-editor record reads {x, -1}; only the + // photo-editor capture shows a non-(-1) second field. The + // PhotoReplayController heartbeat (sub_825623F0 hook) is kept as a + // secondary signal for photo flows that bypass the photographer NIS. + if (REXCVAR_GET(skate3_native_render_scene_photo_yield)) { + static bool s_in_photo_editor = false; + const char* signal = nullptr; + { + constexpr uint32_t kFrontEndManagerPtr = 0x830CFE14; + uint32_t mgr = 0, beg = 0, end = 0; + if (GuestTryLoadU32(base, kFrontEndManagerPtr, &mgr) && mgr != 0 && + GuestTryLoadU32(base, mgr + 0x210, &beg) && + GuestTryLoadU32(base, mgr + 0x214, &end) && beg < end && + end - beg <= 20 * 16) { + const uint32_t n = (end - beg) / 20; + for (uint32_t i = 0; i < n && signal == nullptr; ++i) { + uint32_t f0 = 0, f1 = 0; + if (GuestTryLoadU32(base, beg + i * 20, &f0) && + GuestTryLoadU32(base, beg + i * 20 + 4, &f1) && f0 == 1 && f1 == 11) { + signal = "FE PhotoSelect push-state"; + } + } + } + } + if (signal == nullptr) { + const int64_t last_ns = g_photo_replay_last_ns.load(std::memory_order_relaxed); + const int64_t now_ns = std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(); + if (last_ns >= 0 && now_ns - last_ns < 250'000'000) { + signal = "PhotoReplayController heartbeat"; + } + } + const bool photo_active = signal != nullptr; + if (photo_active != s_in_photo_editor) { + s_in_photo_editor = photo_active; + if (photo_active) { + REXLOG_INFO( + "native-scene: photo editor - yielding to emulated output ({}; the " + "game's postfx applies the photo effects)", + signal); + } else { + REXLOG_INFO("native-scene: photo editor closed - native output resumes"); + } + } + if (photo_active) { + return false; + } + } const auto render_t0 = PerfClock::now(); const auto perf_ns_since = [](PerfClock::time_point t0) { @@ -10695,6 +11023,18 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat spec_bound = true; } } + // Sky dome: the material's `specular` channel is the 1D radial sun + // gradient (512x16), bound in the free decal slot for the exact sky + // branch. Until it decodes the dome falls back to the plain fullbright + // panorama (sunless for the 1-3 frames in flight). + bool sky_sun_bound = false; + if (item.unlit && item.spec_tex != 0) { + const GuestTexture* sun = resolve_texture(item.spec_tex); + if (sun != &g_r.white) { + decal_tex = sun; + sky_sun_bound = true; + } + } const bool is_decal = item.char_family < 4 && item.decal && decal_tex != &g_r.white; constants[44] = item.macro_scale; @@ -10736,6 +11076,23 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat constants[51] = scene.fog_ramp[2]; std::memcpy(constants + 40, scene.fog_color, 4 * sizeof(float)); } + // cam_pos.w = -40 selects the exact sky branch: the game computes the + // sun glow inside the dome shader (see the PS sky branch). Needs the + // frame's captured sky sun rows AND the 1D gradient bound above; falls + // back to the legacy fullbright dome otherwise. The sky item never uses + // mat_tint/overlay/misc, so those root constants carry the sun rows. + if (debug_mode == 0 && item.unlit && sky_sun_bound && scene.sky_sun_valid) { + constants[39] = -40.0f; + constants[40] = scene.sky_sun[0]; // mat_tint.xyz = sun direction + constants[41] = scene.sky_sun[1]; + constants[42] = scene.sky_sun[2]; + constants[43] = scene.sky_height; // mat_tint.w = dome viewpos Y + constants[44] = scene.sky_sun[3]; // overlay.x = sun angular scale + constants[45] = scene.sky_sun[4]; // overlay.y = pre-tone multiplier + constants[46] = 0.0f; + constants[47] = 0.0f; + constants[49] = scene.sky_sun[5]; // misc.y = scene exposure + } list.D3DSetGraphicsRoot32BitConstants(0, 52, constants, 0); const D3D12_GPU_DESCRIPTOR_HANDLE heap_start = @@ -10923,8 +11280,12 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat const uint32_t srv_slot = resolve_2d_texture(s.fetch)->srv_slot; list.D3DSetPipelineState(s.pass == 1 ? g_r.pso_spline_darken : g_r.pso_spline_default); - // Root constants: i_intensity as staged (c149). - list.D3DSetGraphicsRoot32BitConstants(0, 4, s.consts + 149 * 4, 0); + // Root constants: the scene's (smoothed) view_proj rows; the verts + // are world-space, then i_intensity as staged (c149). + float spline_consts[20]; + std::memcpy(spline_consts, scene.view_proj, sizeof(float) * 16); + std::memcpy(spline_consts + 16, s.consts + 149 * 4, sizeof(float) * 4); + list.D3DSetGraphicsRoot32BitConstants(0, 20, spline_consts, 0); D3D12_GPU_DESCRIPTOR_HANDLE srv_gpu = g_r.srv_heap->GetGPUDescriptorHandleForHeapStart(); srv_gpu.ptr += size_t(srv_slot) * g_r.srv_size; diff --git a/src/skate3_native_scene.h b/src/skate3_native_scene.h index ec41fd9..b3c7674 100644 --- a/src/skate3_native_scene.h +++ b/src/skate3_native_scene.h @@ -228,6 +228,16 @@ struct FrameScene { // skater. Captured per frame from the sky draw's VS bank; the default // covers frames before the first capture. float sky_height = 165.0f; + // Sky sun rows, captured together with sky_height from the sky draw's + // PIXEL bank (sky_defaultPS layout): [0..2] = g_vLightDir (unit vector + // toward the sun), [3] = sun angular scale (m_params[0].x), + // [4] = sky pre-tone multiplier (m_params[0].y), [5] = scene exposure + // (g_envattributes[2].x). Consumed by the exact sky scene-PS branch; + // the emulated frame's big sun glow is computed IN the sky dome shader + // from a 1D radial gradient (the sky material's `specular` channel), + // not by a separate draw. + bool sky_sun_valid = false; + float sky_sun[6] = {}; // Fullscreen UI background blur: 0 = off; otherwise the kernel scale of // the game's blur_hBlur/vBlur + basictex pass chain (PS c0.x, 8 in every // capture), detected per frame from the blur_hBlurPS draw. The native @@ -279,6 +289,13 @@ void OnAddRenderInstance(uint8_t* base, uint32_t instance); void OnVsConstantUpload(uint8_t* base, uint64_t mask, uint32_t bank, uint32_t ptr, uint32_t device); +// Called (per guest frame) from the Sk8::Challenge::PhotoReplayController:: +// Update hook while a photo-mission's photo editor is up. Heartbeat for the +// photo-editor yield: the editor's depth-of-field / saturation / brightness +// / contrast effects are the game's own postfx chain, which only the +// emulated path executes. +void OnPhotoReplayUpdate(); + // 2D/APT phase bracket (the HUD and every other 2D element is a Flash SWF // converted to EA APT, rendered through FE::AptRenderingIntegration). Guest // draws issued while inside a bracket are 2D draws. bit: 0 =