// Native scene renderer, game-thread half: captures the hooked MeshContext // stream and guest render state, and builds/publishes the per-frame // FrameScene. The render-thread RHI half lives in // skate3_native_scene_gpu.cpp; state shared between the two is in // skate3_native_scene_state.h. #include "skate3_native_scene.h" #include "generated/skate3_init.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #if defined(_WIN32) #include #endif #include #include #include #include #include "native/skate3_native_diag.h" #include "native/skate3_native_entity.h" #include "native/skate3_native_guest_read.h" #include "native/skate3_native_lw.h" #include "native/skate3_native_palette.h" // Offline-compiled SPIR-V for the native shaders (compiled from the HLSL // sources with DXC): the Vulkan RHI backend consumes these blobs; the D3D12 // backend runtime-compiles the embedded HLSL as before. #include "native/shaders/spirv/skate3_native_shaders_spirv.h" #include "skate3_native_scene_state.h" #if (defined(REX_HAS_D3D12) && REX_HAS_D3D12) || (defined(REX_HAS_VULKAN) && REX_HAS_VULKAN) #include #include #include #include #if defined(_WIN32) #include #endif #endif REXCVAR_DECLARE(std::string, skate3_native_render_snapshot_dir); REXCVAR_DEFINE_BOOL(skate3_native_render_scene, true, "Skate 3", "Render the game scene natively from the hooked MeshContext stream, " "replacing the emulated GPU output (requires skate3_native_render). " "Hot-toggles live between the native and emulated renderers (F5).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_lightmaps, true, "Skate 3", "Sample guest lightmap textures in the native scene renderer. The " "old 'lightpages decode black' finding is stale: in gameplay the " "composed atlas payloads are CPU-readable (offline-validated: " "lightmap x2 at the |uv2| unwrap reproduces the emulated baked " "sun/shadow/AO structure), and the texture payload revalidation " "re-decodes pages that were captured before composition.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_menu_blur_sigma, 9.0, "Skate 3", "Settings-menu backdrop gaussian sigma, in 1080p pixels (matches " "the menu design's blur radius; scales with output " "resolution). Only used while the host settings overlay is open.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_debug, 0, "Skate 3", "Native scene debug: 0=normal, 1=clear only, 2=solid color per item, " "3=limit to 20 items, 4=depth test disabled") .range(0, 4) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_msaa, 4, "Skate 3", "MSAA sample count for the native scene (1 = off, 2/4/8). Distant " "thin geometry (railings, wires) shimmers without it; mipmaps only " "fix texture aliasing. Applies live: the scene pipeline family and " "MSAA targets rebuild on change.") .range(1, 8) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_world_v2_tan_sign, 1.0, "Skate 3", "World-shading v2: polarity relating the stored frame's " "cross(binormal, normal) x handedness to the game's tangent " "(+1/-1). Calibrated against the emulated reference; flips the " "kd sun-direction response if wrong.") .range(-1.0, 1.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_world_v2, true, "Skate 3", "World-shading v2: per-pixel normal/detail/spec maps on the exact " "environment families (the game's own GetTangentLight kd + phong " "terms; v1 folded them to the flat-map constants). Off = the v1 " "flat response, for A/B comparison.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_dynobj_v2, true, "Skate 3", "Dynamicobject shading v2: per-pixel normal/detail/spec maps on the " "exact dynamicobject families (mapped world normal, the game's " "GetTangentLight kd and both phong spec variants; v1 folded them " "to the flat-map constants). Off = the v1 flat response, for A/B " "comparison.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ssao, true, "Skate 3", "Screen-space ambient occlusion (GTAO) in the native renderer: soft " "contact shading where surfaces meet (under ledges, rails, vehicles, " "the skater). A post pass over the resolved scene depth, an " "enhancement beyond the original game's shading.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_ssao_radius, 0.8, "Skate 3", "SSAO sample radius in world units. Larger radii darken bigger " "cavities but read as wide soft pools around characters; small " "radii keep it to contact shading.") .range(0.1, 8.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_ssao_luma_protect, 1.2, "Skate 3", "How strongly bright (sun-lit) surfaces resist SSAO darkening, " "a post-tonemap approximation of ambient-only occlusion (direct " "sunlight is not occluded by nearby geometry). 0 = AO applies " "uniformly regardless of lighting.") .range(0.0, 4.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_ssao_intensity, 1.4, "Skate 3", "SSAO strength: exponent on the linear visibility term (0 = " "invisible, 1 = physical, >1 = accentuated).") .range(0.0, 4.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ssao_full_res, false, "Skate 3", "Evaluate SSAO at full output resolution instead of half. Roughly " "4x the GPU cost for a subtle sharpening of the smallest contact " "shadows; the depth-aware blur hides most of the difference.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ssao_debug, false, "Skate 3", "Replace the frame with the SSAO visibility term (tuning aid).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ssr, false, "Skate 3", "Screen-space reflections on reflective glass and water: " "on-screen scenery reflected in real time where the original " "game shows only a static environment cube. Rays that miss " "(off-screen content, screen edges) fade back to the cube " "term. Requires the HDR scene intermediate. EXPERIMENTAL: " "off by default pending image-quality calibration (residual " "smearing/noise on some surfaces).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_ssr_steps, 48, "Skate 3", "SSR screen-space march taps per ray (half-res pass). More " "taps resolve thinner reflected geometry at proportional " "GPU cost.") .range(8, 64) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_ssr_thickness, 1.0, "Skate 3", "SSR depth thickness (view units): how deep behind a " "visible surface a marching ray still counts as a hit. " "Too small and rays tunnel through thin ledges/rails; too " "large and reflections smear under overhangs.") .range(0.05, 8.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_ssr_intensity, 1.0, "Skate 3", "SSR composite strength over the material's cube " "reflection term (0 = effectively off).") .range(0.0, 2.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_ssr_debug, 0, "Skate 3", "SSR debug view: 1 = raw ray color + confidence, 2 = " "reflection G-buffer normals, 3 = reflectivity (red) x " "depth visibility (green), 4 = ray autopsy (green = " "visibility reject, blue = degenerate, cyan = off-screen, " "magenta = near plane, yellow = out of steps, red = " "too-thick crossings, white = hit x confidence).") .range(0, 4) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_hdr, true, "Skate 3", "Render the 3D scene into a float (HDR) intermediate and apply the " "game's shared tone chain once in a host post pass, the basis for " "real bloom (and later HDR effects). With bloom off the output is " "equivalent to the classic in-material tonemap. Off = the classic " "path, for A/B parity checks.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_bloom, true, "Skate 3", "Real bloom from the HDR scene: a downsample/upsample pyramid driven " "by pre-tonemap brightness (night lamps, neon, the sun and sky " "glow). Requires the HDR intermediate.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_bloom_threshold, 0.9, "Skate 3", "Bloom brightness threshold in pre-tonemap units (1.0 = the tone " "curve's saturation point; 0.85 corresponds to ~97% display " "white). Sunlit surfaces sit around 0.5-0.65 pre-tonemap; " "thresholds that reach them veil the whole daytime frame in " "glow; near-clipped lamp cores, neon, sky glare and the sun " "stay above 0.85.") .range(0.0, 8.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_bloom_knee, 0.08, "Skate 3", "Bloom soft-knee width around the threshold (0 = hard cutoff).") .range(0.0, 2.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_bloom_intensity, 0.025, "Skate 3", "Bloom strength: the pyramid's contribution added to the " "pre-tonemap scene (0 = off; the pyramid accumulates ~5 levels, " "so small values are already visible around bright lights).") .range(0.0, 2.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_hdr_packed, true, "Skate 3", "Use the packed R11G11B10 float format for the HDR scene targets " "instead of RGBA16F: halves the scene-pass color bandwidth (most " "of the HDR cost at high resolution + MSAA) at slightly lower " "precision in very dark gradients.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_hdr_debug, 0, "Skate 3", "HDR post debug: 0 = off, 1 = show the bloom term only, 2 = show " "the raw pre-tonemap scene (clamped), 3 = show the fused SSAO " "multiplier plane, 4 = show the sun-shaft plane only, 5 = show " "the directional haze term only.") .range(0, 5) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shafts, true, "Skate 3", "Volumetric sun shafts: the air along each view ray is marched " "against the dynamic CSM atlas and the static world-shadow map, " "and the SHADOWED portion proportionally dims the light seen " "through it: dark crepuscular shafts cut by real shadow " "volumes (buildings, trees, underpasses) into the game's own " "sun glow. Fully lit air is untouched, so open scenes stay " "exactly as authored. Requires the HDR intermediate and this " "frame's shadow pass.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shafts_intensity, 0.6, "Skate 3", "Sun-shaft strength: how strongly fully shadowed air dims " "the scene behind it, scaled by the forward-scatter phase " "(at the default, fully shaded air dims ~30-50% at typical " "sun-facing angles and saturates looking straight at the " "sun; 0 = off).") .range(0.0, 1.5) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shafts_reach, 40.0, "Skate 3", "Sun-shaft march reach (world units): how far in front of " "the camera the air is sampled for sun visibility. Longer " "reaches pick up more distant shadow volumes at coarser " "sampling for the same step count.") .range(5.0, 300.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_shafts_steps, 64, "Skate 3", "Sun-shaft march step ceiling per pixel; rays shorter than " "the full reach take proportionally fewer steps at the same " "per-meter density. More steps resolve thinner shadow " "volumes at proportional GPU cost.") .range(8, 64) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_shafts_res, 4, "Skate 3", "Sun-shaft march resolution divisor (2 = half res, 4 = " "quarter res). The march output is low frequency (jittered " "average under a tent blur) and upsamples depth-aware, so " "higher divisors trade little quality for a proportional " "GPU saving.") .range(2, 8) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_sun_override, false, "Skate 3", "Lighting lab: replace the captured sun direction with the " "azimuth/elevation cvars. Every per-frame light transform is " "rebuilt around the new direction, so the dynamic CSM shadows, " "the static world-shadow map, material shadow receivers and " "the volumetric shafts all follow the moved sun. Baked " "lightmap shade and the sky dome's painted sun cannot move " "(game content), a tuning tool, not a time-of-day system.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_sun_azimuth, 220.0, "Skate 3", "Overridden sun azimuth in degrees (compass heading the sun " "sits toward; 0 = +Z north, 90 = +X east).") .range(0.0, 360.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_sun_elevation, 35.0, "Skate 3", "Overridden sun elevation in degrees above the horizon (low " "values = long shadows and the most visible shafts).") .range(2.0, 88.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_haze, true, "Skate 3", "Directional atmospheric haze: sun-scattered light added with " "view distance, strongest looking toward the sun, tinted by the " "frame's own fog color (so it tracks time of day and fades at " "night). Kept subtle; the game's authored sky and fog carry " "the base atmosphere, and additive terms read as extra fog " "quickly. Requires the HDR intermediate.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_haze_intensity, 0.08, "Skate 3", "Directional haze strength (pre-tonemap scale of the " "toward-the-sun scattering excess; 0 = off).") .range(0.0, 2.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_haze_density, 0.005, "Skate 3", "Directional haze density per view unit: how quickly the " "scattering saturates with distance (0.005 reaches ~63% at " "200 units).") .range(0.0, 0.05) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_showcase, false, "Skate 3", "Run the graphics build-up showcase: the screen snaps to " "black, flat clay geometry wipes in, the scene is rebuilt " "layer by layer (each layer revealed by a vertical split " "wiping across the screen with both sides rendered live), " "and the run closes by wiping back to black. The black " "bookends double as cut markers for screen recordings. " "Layer order and grouping come from " "skate3_native_render_scene_showcase_order (edited in the F12 " "showcase setup window). Clears itself when the sequence " "finishes; set to false mid-run to cancel. Also on " "Ctrl+Shift+B by default (bind_skate3_showcase). Layers whose feature " "is disabled (or unavailable without the HDR intermediate) " "are skipped.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_STRING( skate3_native_render_scene_showcase_order, skate3::native_scene::kShowcaseOrderDefault, "Skate 3", "Showcase layer order: comma-separated reveal steps, '+' joins layers " "into one wipe, a '-' prefix disables a layer while keeping its " "position. Tokens: albedo, lighting, materials, decals, dyn, shadows, " "ao, ssr, vol, bloom (the run always starts from clay geometry, and a " "final full-render step is appended when the list leaves layers " "unrevealed). decals (graffiti/paint art + macro weathering; renders on " "the full material look, so place it after materials) and dyn (dynamic " "entities: characters, props, cloth) HIDE their content until revealed; " "left out or disabled, that content renders from clay onward as usual. " "The CSM atlas is shared by both sides of the split, so a dyn step " "AFTER shadows shows entity shadows before their owners; order dyn " "first. Example: \"albedo,lighting,materials,decals,dyn,shadows+ao\".") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_showcase_hold, 2.5, "Skate 3", "Showcase: seconds each build-up stage holds fullscreen " "between wipes.") .range(0.0, 30.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_showcase_wipe, 3.0, "Skate 3", "Showcase: seconds each split wipe takes to cross the " "screen.") .range(0.2, 30.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_freecam, false, "Skate 3", "Detach the render camera from the game (drone / free-fly " "cam): WASD fly, E/Space up, Q/C down, arrow keys or " "right-mouse drag look, Z/X zoom, Shift fast, Ctrl slow. " "The game keeps simulating, but its main render camera is " "taken over (ViewCamera::SetViewMatrix override), so the " "game culls and submits the world around the flown pose " "itself. Also on the End key (bind_skate3_freecam).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_freecam_speed, 8.0, "Skate 3", "Freecam base fly speed in world units (meters) per " "second. Shift = 4x, Ctrl = 0.2x.") .range(0.5, 100.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_freecam_look_speed, 90.0, "Skate 3", "Freecam arrow-key look rate in degrees per second.") .range(10.0, 360.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_freecam_capture_input, true, "Skate 3", "While the freecam is engaged, keep keyboard/controller " "input away from the game so flying doesn't also steer " "the skater. Turn off to fly and play at the same time.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_2d, true, "Skate 3", "Replay the game's 2D/APT (Flash HUD) draws as a native overlay pass " "on top of the 3D scene") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_splines, true, "Skate 3", "Replay the game's in-world neon spline draws (waypoint arrows, " "marker beams; spline_darken/spline_default shaders) inside the " "native scene pass") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_selection_outline, true, "Skate 3", "Replay the park-editor / object-mover selected-object outline: the " "game stencil-marks the selected object after the sky and a postfx " "edge-detect adds the blue contour (postfx_edgedetectstencil port)") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_quadlists, false, "Skate 3", "Render captured non-indexed quad-list draws. Off by default: every " "quad-list capture seen so far is a PARTICLE system (disjoint 2-4cm " "sprites), which renders as floating white squares without the game's " "sprite textures and blending.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_mesh_decode_budget, 0, "Skate 3", "Max INLINE mesh decodes per rendered frame (0 = unlimited). Only " "dynamic payloads (skinned/cloth/ropa, whose buffers change every " "frame) decode inline on the render thread; static world meshes " "and all textures decode asynchronously on the prewarm workers.") .range(0, 100000) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_warmup_budget_ms, 8, "Skate 3", "Per-frame milliseconds of the post-takeover settle decode pass: " "after a loading screen the native output takes over on the first " "substantial post-load scene (the registration prewarm decoded the " "world behind the load), and for a short window this budget mops " "up whatever prewarm missed while the draw path's miss budgets " "are clamped; leftovers render white/skip for a frame instead of " "freezing. 0 disables the takeover gates + settle pass entirely " "(legacy immediate behavior, stale-scene flash and all).") .range(0, 1000) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_settle_max_frames, 900, "Skate 3", "Hard cap on how far past takeover the settle pass may keep " "extending itself (frames). Content that keeps failing " "revalidation (streamed payloads, old-map items over reused " "arenas after a map change) otherwise re-defers work every frame " "and holds the settle pass - and its full per-frame budget - open " "for the rest of the session (the sticky ~80fps state). Past the " "cap the draw path's own miss budgets take over. 0 = uncapped") .range(0, 100000) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_warmup_min_items, 32, "Skate 3", "Scene item count below which warmup keeps yielding to the " "emulated output: right after the gameplay flip the capture holds " "only a handful of items while the game is still fading in, and " "taking over then shows a black/empty world. Every real gameplay " "scene measures 100+ items.") .range(0, 10000) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_prewarm_budget_ms, 32, "Skate 3", "Per-frame milliseconds spent decoding freshly REGISTERED world " "meshes (AddRenderInstance hook) and their textures while the " "loading screen is up; the heavy lifting happens behind the load, " "so gameplay starts with hot caches and takeover is immediate. " "The loading screen renders emulated at hundreds of fps, so even " "32 ms/frame keeps it ~30 fps; map-change loads register most of " "the world in their FINAL seconds, so the drain rate in that " "window decides how much pop-in survives into gameplay. " "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_scene_photo_readback, true, "Skate 3", "While a photo flow is active (the photo-mission editor, or a few " "seconds after any TakePhoto), arm the SDK's forced small-resolve CPU " "readback (native_render_force_resolve_readback_max_length) and lift " "emulated-draw suppression. The game takes photos by CPU-reading a " "resolved 1152x640 PostFX screenshot target from guest memory " "(ScreenshotBackEnd::GrabScreenshot -> JPEG); photo missions keep the " "gameplay presence context so no readback path ever ran and the grabbed " "memory stayed all-zero; the final photo display and the saved photo " "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_display_yield, false, "Skate 3", "Yield the photo DISPLAY-CARD screen to the emulated output while the " "framed card is up. OFF (default) since the shutter burst: the game " "CPU-composes the framed card into the card texture at the grab (the " "burst makes its inputs real), and the native 2D replay samples that " "same texture; the display screen renders fully native. Turn ON as a " "safety hatch if the display screen misrenders natively.") .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 " "own postfx chain (depth of field / saturation / brightness / contrast " "/ lens vignette) as exact ucode ports (photo_fx.hlsl: visualfx -> DOF " "downsample -> tap9dofMotionBlur -> tap9dof -> uber -> fisheye) driven " "by the LIVE constants the game stages for its own (suppressed) postfx " "draws each frame. Takes precedence over " "skate3_native_render_scene_photo_yield; if the pass captures are not " "yet fresh (first frames of the editor), the scene renders without the " "effects until they land. Known deltas: the motion-accumulation feed " "(see photo_native_accum), the bloom pyramid contribution (disabled in " "every editor capture), grade LUT served as identity, deck AO.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32( skate3_native_render_scene_photo_native_accum, 0, "Skate 3", "Source for the visualfx pass's quarter-res motion-accumulation input " "(the game feeds a jitter-accumulated buffer natively unmodeled): 0 = " "black, 1 = downsampled scene (pre-grade), 2 = downsampled final frame. " "Compare against the emulated editor (F11 pair) and keep the match.") .range(0, 2) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32( skate3_native_render_scene_photo_native_debug, 0, "Skate 3", "Photo-editor native postfx DEBUG VIEW drawn instead of the chain's " "final output: 1 = the visualfx CoC map (DoF blur amount; black = in " "focus, grey/white = blurred), 2 = packed-depth reconstruction (red = " "near band saturate((1-d)*100), green/blue = d fraction ramps; a flat " "screen here means the native depth is not reaching the chain). 0 = " "off.") .range(0, 2) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_pause_native, true, "Skate 3", "Keep the NATIVE renderer active through the in-game pause menu " "instead of yielding to the emulated output. Pause is told apart " "from loading screens / the boot frontend by the world still " "submitting perspective scenes while the presence context reads 0 " "(loads and the frontend stop publishing within ~300 ms, which " "falls back to the yield path and its cache clears). Native pause " "also skips the pause-entry cache clears, so unpausing costs " "nothing.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_loading_native, true, "Skate 3", "Render post-startup loading screens natively, black backdrop " "plus the game's own captured 2D loading UI, instead of " "yielding to the emulated output. The loading-screen " "housekeeping (cache clears, registration prewarm, takeover " "arming) is unchanged; only the presented pixels switch source. " "The boot flow before the first gameplay stays emulated.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_boot_native, true, "Skate 3", "Extend native rendering to the game startup flow (intro videos, " "boot frontend, the first load); i.e. drop the first-gameplay " "prerequisite from the native menu/loading modes, and render the " "pre-takeover boot frames as native 2D-over-black instead of " "yielding. With this and the pause/loading modes on, the emulated " "GPU output is never presented.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_fmv_native, true, "Skate 3", "Render FMVs natively: the movie player CPU-decodes VP6 into " "three YUV plane textures (VideoRenderer_RwTexture members, " "published per frame by the Render hook), and the captured " "movie quad (the AptMovieIntegration stride-24 draw; through " "the plain 2D shader it rendered as an opaque black cover, its " "c8 is black) is substituted with the ps_yuv2d combine inside " "the 2D replay, order-faithful and at the quad's own geometry " "(windowed movies place exactly). When off (or the planes fail " "to publish), FMVs fall back to the emulated yield " "(skate3_native_render_scene_fmv_yield).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_fmv_yield, true, "Skate 3", "Yield to the emulated output while an FMV is playing (intro " "logos, any rw::movie playback). The video frame is CPU-decoded " "into a texture and reaches the screen through the game's postfx " "chain + swap without any capturable 2D draw (F11-proven on the " "boot intro: 15 draws, all postfx passes + fade fills, none " "sampling the video), so the native path has nothing to replay; " "the emulated frame is complete and correct there, same class as " "the photo editor. Detected via the MovieDecoder::Decode " "heartbeat; native rendering resumes within 0.5 s of the last " "decoded frame.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_cas_yield, false, "Skate 3", "Yield to the emulated output while the create-a-skater editor " "(the CAS 'Edit Skater' screen) is up. OFF by default since the " "editor renders natively: the char-lighting capture understands " "the editor CAC compiles' shifted constant layout (see " "CaptureCharLighting's editor fam-2 retry), the texture-space " "composite passes (cac*_unwrapPS, pitch <= 512) execute under " "the pitch-selective suppression, and the ropa/palette VS layout " "is unchanged. Known native deltas: the deck's shift-recolor " "masks and the editor's own DOF chain are not modeled. Turn ON " "to get the exact emulated editor instead (photo-editor class; " "detected via FE push-state id 15 + the _nis shader heartbeat).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL( skate3_native_render_scene_menu_rtt_passes, true, "Skate 3", "While a menu/pause/loading context (presence context 0) renders " "natively, drop native_render_suppress_mode to 0 (suppress " "framebuffer-sized passes ONLY) so the game's sub-framebuffer " "render-to-texture passes execute; the team-menu/Import-Skater skater " "portrait boxes are one-shot RTT passes at a surface pitch inside the " "mode-2 suppressed band (> 512, != 1024): under mode 2 they never ran " "and the boxes stayed empty. The visible frame stays fully native " "(framebuffer passes remain suppressed); this is the same " "execute-the-composition-passes-emulated class as lightmap pages and " "CAS outfit composition. Restored to the configured mode on the first " "gameplay frame. Menu-only cost: the midsize postfx-chain passes also " "execute there (mode 0 was the long-lived default before mode 2).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32( skate3_native_render_scene_menu_rtt_scope, 1, "Skate 3", "When menu_rtt_passes is on, WHEN to hold suppress_mode at 0 during " "menus. 0 = the whole menu context (pre-round behavior): the game's " "postfx chain then executes emulated at 1152x640 x resolution scale " "EVERY menu frame: under the pause menu that is the full world postfx, " "on FMV/title screens the movie postfx, pacing the pipeline and " "fighting the native renderer for the GPU (the menu sluggishness at a " "high fps counter). 1 = only inside a PORTRAIT WINDOW: the FE " "push-state stack contains a screen not known steady-safe (known: 0 = " "FE root, 56 = pause root, 24 = FMV, 17 = pause challenge map), OR an " "unsafe screen was entered/left within the last 3 s (one-shot portrait " "RTT renders fire at those transitions), OR the CAS heartbeat is " "fresh, OR the stack is unreadable; everything unknown fails OPEN to " "mode 0. Outside the window menus run the " "gameplay-proven mode-2 suppression (lightmap pages + <= 512 " "composites still execute).") .range(0, 1) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL( skate3_native_render_scene_menu_unsuppress, false, "Skate 3", "ESCAPE HATCH, normally unnecessary: while a menu/pause/loading context " "(presence context 0) renders natively, temporarily clear " "native_render_suppress_emulated_draws so ALL emulated passes execute. " "The original motivation, the team-menu skater portrait boxes are " "one-shot render-to-texture passes that suppression left forever empty " ", is covered without this by the SDK's pitch-selective suppression " "(native_render_suppress_mode 2: surfaces <= 512 px wide, incl. the " "portrait cards, always execute). Turn on only if small offscreen " "composites are missing in menus despite that; costs the full emulated " "pipeline's GPU time during menus.") .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)") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shadows, true, "Skate 3", "Render the game's dynamic CSM shadows natively (skater, NPCs, " "movable props onto the world). " "Cascade matrices are captured per frame from the game's own " "material constants.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_caster_refresh_all, true, "Skate 3", "Perspective-bank palette refresh covers ALL caster-sourced " "skinned captures: skinned-published ROPA garments join the " "refresh pool (their exclusion left the player's tee/hair riding " "the ortho caster banks, whose fine animation runs ticks stale in " "bursts, the high-fps hair-off-the-head wedge / vanishing " "garment), and the refresh target is picked by palette identity " "(<1.5 m) across every caster-sourced clone instead of " "oldest-only (mesh-sharing NPC clones probed the wrong item, the " "guard refused, and the right clone stayed on its stale caster " "bank, the NPC blink). false = legacy oldest-only, non-ropa " "refresh.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_char_rows_inst, true, "Skate 3", "Per-(mesh,instance) fallback for the character-lighting capture: " "items whose capture failed to validate this frame reuse their own " "last validated rows. Covers instanced pieces and the player, which " "the single-instance mesh fallback and the LW entity cache both " "miss; without it hair flips between the blended sub-pass and the " "legacy opaque path on capture-failed frames (a frame-to-frame " "flicker whose rate scales with fps).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_shadow_tile, 0, "Skate 3", "Shadow cascade tile resolution. The game's own atlas is " "three 512 tiles; the blur steps raster texels (the game's " "tfetch offsets, which the emulated GPU applies in scaled " "texels), so higher values sharpen both the silhouette and " "the penumbra like the emulated baseline. 512 = the softer " "original-console look, blocky up close. 0 = auto: 512 x " "the render resolution scale (the Resolution Scale " "setting), matching the emulated renderer's shadow " "crispness at any render resolution. Applies live: the " "atlas chain recreates on change.") .range(0, 4096) .lifecycle(rex::cvar::Lifecycle::kHotReload); 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_shadow_static_casters, true, "Skate 3", "Native static sun-shadow map: render the STATIC world " "(buildings, trees, rails, placed props) into a separate " "camera-centered ortho depth map along the material sun, " "sampled by every lit surface in addition to the game's " "dynamic cascades. Live static shade on characters and " "props (which baked lightmaps cannot shade) and shadows " "that follow a moved sun; receivers keep the darker of " "baked and live shade rather than doubling.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_static_strength, 1.0, "Skate 3", "How dark static-geometry shadows get (1 = the full " "dynamic-shadow clamp, 0 = invisible). Applied at " "receive from the static sun map, so characters and " "props keep full-strength shadows. Moderate values " "keep live static shade believable where it disagrees " "with the baked lighting's own sun.") .range(0.0, 1.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_static_radius, 180.0, "Skate 3", "Half-extent in meters of the static sun-shadow map's " "far cascade (camera-centered; the mid and inner " "cascades cover 1/2 and 1/6 of this at 2x and 6x " "the texel density). Larger reaches farther at lower " "texel density; the term fades out at the outer edge.") .range(40.0, 600.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_shadow_static_size, 4096, "Skate 3", "Static sun-shadow map resolution per cascade tile " "(the map is three tiles). At the default radius the " "inner cascade gets ~1.5 cm texels, mid ~4 cm, far " "~9 cm. Applies live: the map recreates on change.") .range(1024, 8192) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shadow_pcss, true, "Skate 3", "Contact-hardening soft shadows (PCSS): a blocker search " "estimates the caster distance per pixel and the filter " "width follows the sun's angular size: crisp at the " "contact point, progressively softer with caster height. " "OFF = the game's fixed-width blurred atlas sampling.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_pcss_sun_deg, 2.5, "Skate 3", "Sun angular diameter in degrees for the PCSS penumbra " "(the real sun is ~0.53; larger reads softer).") .range(0.1, 8.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_pcss_max_m, 0.8, "Skate 3", "Maximum PCSS penumbra half-width in meters (caps how " "soft very tall casters can get).") .range(0.05, 5.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_pcss_blocker_m, 2.5, "Skate 3", "PCSS blocker-search radius in meters. Must be at " "least the max penumbra for distant casters to soften " "fully; larger values let unrelated nearby casters " "soften edges they should not.") .range(0.1, 8.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_pcss_min_texel, 1.0, "Skate 3", "Minimum PCSS filter radius in physical atlas texels " "(keeps fully hardened edges filtered).") .range(0.0, 4.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_static_bias, 0.08, "Skate 3", "Static sun-shadow receiver bias in METERS; static " "geometry is its own caster, so its surfaces compare " "against their own map depth. Slope-scaled: grazing " "sun angles get up to several times this. Raise on " "acne (stipple on sunlit ground/walls), lower if " "static shadows visibly detach from their casters.") .range(0.0, 0.5) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_static_bias_vk, 0.0, "Skate 3", "Additional static sun-shadow receiver bias in METERS " "applied on the Vulkan backend only (a tuning margin " "for backend shader-toolchain rounding differences; " "raise only if acne appears on Vulkan specifically).") .range(0.0, 0.5) .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). REXCVAR_DEFINE_INT32(skate3_native_render_scene_refl_mode, 0, "Skate 3", "Reflective glass debug: 0 normal, 1 cube term off, 2 cube at " "the absolute LOD in refl_lod, 3 flat normal (no normal-map " "perturb), 4 visualize the cube sample only, 5 body only (no " "spec, no cube), 6 normal-map LOD bias from the slider.") .range(0, 6) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_refl_lod, 0.0, "Skate 3", "Reflective glass debug: mode 2 = absolute cube mip level; " "other modes = EXTRA LOD bias on top of the automatic " "640p-parity bias.") .range(-4.0, 12.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); // Constant tangent-space normal tilt on the reflective glass, live-tunable // (F12). The defaults are DERIVED, not tuned: the material's 16x16 detail // texture is a constant BC1 block whose endpoints expand on HARDWARE by bit // replication: 5-bit red 16 -> (16<<3)|(16>>2) = 132/255, 6-bit green 32 // -> (32<<2)|(32>>4) = 130/255; so the shader's 2*d - 1 fold is exactly // (0.035294, 0.019608). An earlier fold used our CPU decoder's // integer-division expansion (131/129 -> 0.028/0.012), leaving a ~0.8 deg // constant normal tilt = the residual reflection-position offset that was // dialed out by hand to (0.036, 0.019), matching the hardware value to // the slider step and confirming the derivation. REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_refl_bias_x, 0.035294, "Skate 3", "Reflective glass: constant tangent-X (horizontal) normal " "tilt added to the normal-map sample (= the detail " "constant's hardware-BC1 fold).") .range(-0.2, 0.2) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_refl_bias_y, 0.019608, "Skate 3", "Reflective glass: constant binormal-Y (vertical) normal " "tilt added to the normal-map sample (= the detail " "constant's hardware-BC1 fold).") .range(-0.2, 0.2) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_refl_bias_auto, true, "Skate 3", "Derive the reflective-glass normal tilt from each material's " "own detail texture (hardware-exact BC1 decode of its constant " "color) instead of the refl_bias_x/y values. The sliders " "remain the fallback/override with auto off.") .lifecycle(rex::cvar::Lifecycle::kHotReload); // Fine-grained feature gates for the F12 native-render debug dialog: each // isolates one subsystem so regressions (flicker, wrong shading) can be // bisected live without rebuilds. All hot-reload, default on. REXCVAR_DEFINE_BOOL(skate3_native_render_scene_macro, true, "Skate 3", "Apply the macrooverlay grime/crack multiply on world materials") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_decals, true, "Skate 3", "Composite environment.decal art (graffiti/paint) over base diffuse") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_transparents, true, "Skate 3", "Draw environment.transparent items (mist/glass/fences) in the " "alpha-blended sub-pass") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_world_items, true, "Skate 3", "Publish world sort-list items (static geometry)") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_entity_fade, true, "Skate 3", "Honor the game's per-entity spawn/distance fade: LivingWorld " "pres entities (NPCs, traffic vehicles) publish an opacity that " "every character-family PS writes as output alpha (peds c21.x, " "defaultcharacter c13.x, cacstamp c22.x, vehicle body c20.x). " "The game submits their draws at alpha 0 through the whole " "spawn settle (the physics drop) and ramps alpha up afterwards " "/ by distance. Off = the old behavior: entities render fully " "opaque from their first draw (mid-air spawn pop, early " "distant pop-in).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_dynamic_items, true, "Skate 3", "Publish dynamic entities (characters, props, cloth)") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL( skate3_native_render_scene_lw_fade, true, "Skate 3", "Serve LivingWorld NPC/vehicle fade alpha from the entity itself " "(entity+528 via the LW entity store, mapped per MeshContext) instead " "of the per-draw captured constant row. The captured row is a per-draw " "inference: capture races serve alpha=1 (opaque mid-air spawns, no " "fade-in) or a clone's foreign row (one-frame invisibility blinks). " "Off = the pre-store captured-row behavior.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL( skate3_native_render_scene_lw_gap_fill, true, "Skate 3", "Republish a LivingWorld NPC for up to 2 frames when its MeshContext " "drops out of the submit records while the entity is still alive (the " "1-3 frame publish GAPs: an in-view NPC vanishing for a frame reads as " "a blink/teleport). Off = gaps render as-is.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL( skate3_native_render_scene_lw_identity, true, "Skate 3", "Key LivingWorld entities' pose-smoothing rings by their MeshContext " "(the game's own per-instance identity) instead of (mesh, occurrence) " "pairing. Same-mesh clone reshuffles in the sort lists can then never " "mispair a ring (the NPC/prop teleport-slide class). Off = the " "positional re-pair heuristics alone.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ropa_inline, false, "Skate 3", "Decode CPU-cloth (ROPA) garment VBs inline on the render thread " "instead of the worker jobs. Measured 4.3ms avg per garment " "decode (committed-resource allocation dominates) AND it does not " "address the jelly; the mismatch is the cloth shape having no " "place on the interpolation play clock, not decode latency. Kept " "for experiments only.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ropa_blend, true, "Skate 3", "Lerp CPU-cloth shape generations onto the motion-smoothing play " "clock at draw time (pose<->shape pairing via the interp ring); " "the stepped shape against the interpolated body was the tee " "jelly/clip-through, worse at LOWER fps. OFF = newest decode " "(old behavior).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32( skate3_native_render_scene_2d_async_px, 1, "Skate 3", "Pixel-count threshold above which 2D/HUD texture decodes are ELIGIBLE " "for the words-miss decode workers. Eligible decodes (first sightings " "and content changes alike) still run INLINE while the per-frame inline " "budget (4 ms; fullscreen-class art gets double) lasts; the run-copy " "untiler made even 1280x720 sub-millisecond, and the old always-async " "policy made every APT re-raster (NEW address = new key = first " "sighting) skip its quad for the 1-3 worker frames: menu elements and " "fullscreen backdrops visibly BLINKED through animations. The workers are " "the burst-overflow valve: past the budget, first sightings skip 1-3 " "frames and content changes serve the stale decode until the commit. " "0 = everything inline unconditionally; larger values exempt small art " "from async eligibility entirely.") .range(0, 1 << 24) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_2d_sharp, 0.0, "Skate 3", "Sharp-magnification amount for the 2D/HUD overlay (0 = plain " "bilinear, up to 2). Much of the APT (Flash) HUD samples " "cached-bitmap tiles whose texel count equals their 720p display " "size (score digits, gauge ring, compass; measured density 1.0 " "in the 2D draw stream) while text batches sample 512x512 glyph " "atlases at 8-10 texels/pixel; at 2-3x output scales the tiles " "blur under bilinear while atlas text stays crisp; the same on " "console/emulated, the content is simply 720p. Catmull-Rom + " "clamped unsharp mask where the fetch is magnified (>1.25x), " "ramped to full by 2x; minified/1:1 fetches untouched. DEFAULT " "0 (off): sharpened ramps read worse than the soft " "bilinear; the honest fix is higher-res source pixels (APT " "cache-tile rasterization scale), not edge-contrast synthesis.") .range(0.0, 2.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ropa_boxcar, true, "Skate 3", "Blend CPU-cloth shape generations through the SAME 8-tap boxcar " "kernel the body bones/garment world are filtered with (see " "smooth_camera_filter_ms) instead of a plain 2-generation lerp. " "The plain lerp reconstructs the 60Hz limb signal SHARPLY while " "the boxcar rounds the body ~a window; the cloth led the body " "through every direction change by an excursion that scales with " "the guest period (the residual tee jelly after the blend fix). " "OFF = plain bracketing lerp (for A/B).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_ropa_bias, 0, "Skate 3", "Shift the ROPA pose<->shape pairing by N ring poses (+ = fresher " "shape, - = older), a live trim for any residual constant drape " "lag/lead while skating. 0 = the paired generation.") .range(-2, 2) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_ropa_delay, 0, "Skate 3", "Delay CPU-cloth VB snapshots by N guest frames before the worker " "decode, phase-aligning the cloth SHAPE with the motion-smoothing " "play clock the body renders on (~2 periods behind now). Without " "it the drape is ~2 frames AHEAD of the rendered body; it hangs " "where the body WILL be and leads it through direction changes " "(the tee jelly / clip-through-torso). DEFAULT 0: in practice " "the garment LAGGED, and delay made it worse; the " "phase model was backwards. Kept for experiments.") .range(0, 4) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_STRING(skate3_native_render_scene_trace_mesh, "", "Skate 3", "Hex guest mesh address to trace end-to-end through the " "texture pipeline ('tex-trace:' log lines): per-frame " "served objects + content fingerprints (on change), " "every slot resolve decision (direct/sticky/hold/near-" "black/white), every payload/words poll verdict, and " "every worker commit touching the mesh's objects. " "Empty = off.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_STRING(skate3_native_render_scene_trace_2d, "", "Skate 3", "Hex fetch word 1 (base|flags) of a 2D overlay texture " "to trace through the 2D resolver ('2d-trace:' log " "lines): per-resolve entry state, liveness probe " "results and the serve/heal route taken, plus the " "worker commits for its words key. Empty = off.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_detail_hold, 240, "Skate 3", "Frames an item slot keeps serving its previous HIGHER-" "resolution texture after the game's material-detail " "system rebinds a strictly smaller one (streaming " "pressure flaps a nearby mesh's DT material to its UN " "variant for ~0.5 s and back, the visible 'different " "texture set' flash; the detailed decode is still " "cached host-side, so the flap can be invisible). A " "downgrade that persists past the hold is adopted (a " "real demote as you leave the area). 0 = serve the " "guest binding verbatim (the console's own detail pop).") .range(0, 2000) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_tex_store_mb, 1280, "Skate 3", "Texture content-store GPU byte budget in MB. The store " "is count-capped, but per-entry sizes differ per map and " "the idle guards keep a superseded map's working set " "resident for minutes after a switch; over this budget " "the LRU drains oldest-first with a shortened idle " "guard so VRAM does not accumulate across map changes.") .range(256, 16384) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_mesh_store_mb, 1024, "Skate 3", "Mesh cache GPU byte budget in MB (vertex + index " "buffers of cached decodes). Same byte-pressure LRU " "behavior as the texture-store budget.") .range(256, 16384) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_retain_offscreen, true, "Skate 3", "Keep recently seen static items in the scene while the game " "view-culls them: the re-timed (smoothed) render camera trails " "the guest pose by up to the filter window, so items leaving " "the guest frustum were visibly torn down right at the screen " "edges during pans/traversal. Items the guest frustum can see " "but stopped submitting (LOD switch, despawn) drop immediately.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_tex_revalidate, true, "Skate 3", "Re-fingerprint cached texture payloads every 16 frames and " "re-decode on change (heals late-composed lightmap pages)") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_stretch_guard, true, "Skate 3", "Draw-time stretch veto: skin cached sample verts of each " "skinned mesh's GPU-resident decode with the final palette " "every frame; wider than bind size = the 1-frame mangled-" "ribbon flash; skip the item's draws (blink)") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_stretch_guard_dump, false, "Skate 3", "On a stretch veto, dump the full bone palette and probe " "verts to logs/stretch_*.txt (first 6 trips) for offline " "diagnosis of which rows are junk") .debug_only() .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_lm_dump, false, "Skate 3", "Diagnostic: dump the decoded mip 0 of every generated-mip " "(no-chain composed page) texture to native_texture_dumps/ " "as raw RGBA for offline diffing against the gsnap decode") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_backface_cull, true, "Skate 3", "Backface-cull world env materials like the game does: the " "material XMLs set CULLMODE=FRONT on every environment " "family (banners calibrated game-kept faces = our D3D12 " "BACK faces -> CULL_FRONT). CULL_NONE stacked hidden faces " "into the frame: double glass panes + interior wall faces " "behind the translucent canopy glass = the too-bright " "slope / too-dark awning deltas. Trees/alphatest (fams " "7/9/10) and mirrored instances stay uncull(ed).") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_mesh_revalidate, true, "Skate 3", "Re-decode cached meshes when their payload fingerprint changes " "(streaming arena reuse; also picks up CPU-animated buffers)") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL( skate3_native_render_scene_char_track, false, "Skate 3", "Per-frame diagnostic log line tracking the main character's body and " "ropa garment through the motion-smoothing pipeline: pre/post-interp " "reference positions, ring state, ingest/reset/claim events, resolved " "mode, shape-kernel state, and the garment<->body offset. Heavy " "(one line per rendered frame), for artifact diagnosis runs.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_smooth_camera, true, "Skate 3", "Re-time the camera on the host clock: the guest publishes new " "camera poses on its own sim tick (~170-240 Hz, irregular: " "measured streaks of 10 rendered frames on one pose at 400 fps), " "which reads as the world juddering/skipping while panning. " "Interpolates between the last two distinct guest poses, one " "sim-interval behind (a few ms of added camera latency, no " "overshoot). Teleports/cuts snap.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE( skate3_native_render_scene_smooth_camera_filter_ms, 50.0, "Skate 3", "Boxcar filter window (ms) applied to the smoothed camera pose, centered " "on the playback point. The game's camera pose VALUES advance in 60 " "Hz-quantized lumps at high render rates (measured: +-2.2 deg off a " "constant-rate stick pan, velocity CV 0.84); 50 ms = three 60 Hz " "periods nulls the quantization at any render rate (measured 185 -> 7 " "deg/s rms frame-to-frame velocity jitter) for ~25 ms extra camera " "latency. 0 = off (raw sample interpolation).") .lifecycle(rex::cvar::Lifecycle::kHotReload); // Synthetic camera pan: a judder-isolation probe, not a feature. Replaces // the camera with a host-generated constant-rate horizontal pan so each // pipeline stage can be judged against a mathematically smooth ground truth // (the guest's own camera path is irregular; smooth output can never be // eyeballed against it). Modes pick the injection stage: // 1 = time-based: pose is a pure function of the host clock at scene-build // time, bypassing guest pose sampling AND the smoother entirely. Still // judders => the fault is downstream (frame pacing / present / display). // 2 = fixed-step: pose advances a constant angle per PUBLISHED FRAME // (ignores time). Smooth only if displayed frames appear at even // intervals, the complement of mode 1 (irregular pacing that mode 1's // time base compensates for shows up here, and vice versa). // 3 = through-smoother: the ~1 kHz sampler thread synthesizes guest-like // pose samples (~200 Hz, deliberately irregular) and the normal // SmoothCamera reconstruction runs on them; reconstruction error vs // the known ideal is measured numerically (err_deg in the log line). // Judders here but not in mode 1 => the smoother is at fault. REXCVAR_DEFINE_INT32(skate3_native_render_scene_synthetic_pan, 0, "Skate 3", "Synthetic constant-rate camera pan (judder isolation): 0 = off, " "1 = time-based at scene build, 2 = fixed angle step per frame, " "3 = synthetic samples through the camera smoother.") .debug_only() .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_synthetic_pan_rate, 90.0, "Skate 3", "Synthetic pan rate in degrees/second") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_synthetic_pan_amp, 0.0, "Skate 3", "Synthetic pan amplitude in degrees: 0 = continuous full 360 " "rotation (sweep the REAL camera around once with the stick " "after engaging; the probe accumulates a union of every static " "item the game submits, filling in the full surround); > 0 = " "triangle-wave +-amp around the engage heading.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_bonesig_auto, 0.0, "Skate 3", "Auto-arm bone-signal recordings of this many seconds " "(entity-pose diagnosis, same output as the F12 button): " "first window ~30 s after the native scene comes up, " "re-armed every 90 s, 3 windows max. 0 = off.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_sort_opaque, true, "Skate 3", "Draw opaque scene items front-to-back (bbox-center camera " "distance). Early-z rejects occluded pixels before the heavy " "material shading runs; the game's own sort order is by " "render state, not depth.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_tex_mips, true, "Skate 3", "Upload guest texture MIP CHAINS (off = mip 0 only; distant " "surfaces alias but mip-related artifacts disappear). Flush the " "texture cache after toggling.") .lifecycle(rex::cvar::Lifecycle::kHotReload); // Hook layer master switch, defined in skate3_native_render.cpp. The runtime // toggle refuses to flip the scene on without it: the hooks that feed the // scene are only installed when it was set at boot. REXCVAR_DECLARE(bool, skate3_native_render); // Defined in native/skate3_native_entity.cpp (the identity store module). REXCVAR_DECLARE(bool, skate3_native_render_scene_entity_ropa_world_primary); // Defined in native/skate3_native_lw.cpp (the LW entity store module). REXCVAR_DECLARE(bool, skate3_native_render_scene_lw_palette); // SDK-level emulated-draw suppression (rexglue native_guest_renderer.cpp): // command processors skip emulated draw/resolve execution while the native // output is active. Temporarily overridden to false during menu contexts by // YieldForMenus (see skate3_native_render_scene_menu_unsuppress). REXCVAR_DECLARE(bool, native_render_suppress_emulated_draws); // SDK-level suppression PASS FILTER (d3d12 command_processor.cpp): 0 = // suppress framebuffer-sized passes only (pitch >= 1280), 2 = suppress all // except lightmap pages (1024) and small composites (<= 512). YieldForMenus // drops it to 0 during menu contexts so the skater-portrait RTT passes // (pitch > 512, in the mode-2 suppressed band) execute; see // skate3_native_render_scene_menu_rtt_passes. REXCVAR_DECLARE(int32_t, native_render_suppress_mode); // SDK-level forced resolve readback window (rexglue command_processor.cpp / // both backends' IssueCopy): when > 0, resolves up to that byte length are // synchronously read back to CPU-visible guest memory regardless of // readback_resolve and gameplay state. Armed by UpdatePhotoGrabWindow while // a photo flow is active; the game CPU-reads the resolved screenshot // target to build the photo JPEG. REXCVAR_DECLARE(int32_t, native_render_force_resolve_readback_max_length); // SDK-level readback-downscale sample point (resolve_downscale shader): when // set, the scaled->1x extraction samples the CENTER host pixel of each // scale_x*scale_y block instead of the top-left one, compensating the // D3D9-style half-pixel offset that becomes a (scale/2)-pixel content shift // at scaled resolutions. Armed with the photo window so grabbed photos // sample host pixel centers. REXCVAR_DECLARE(bool, readback_resolve_half_pixel_offset); // SDK-level async pipeline compilation (rexglue command_processor.cpp): the // d3d12 backend SKIPS draws whose pipeline is still compiling. Forced // synchronous during menu contexts by YieldForMenus so one-shot portrait // renders can't lose still-compiling pieces (first-run armless skaters). REXCVAR_DECLARE(bool, async_shader_compilation); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_perf_log, false, "Skate 3", "Log periodic native-renderer performance breakdown lines " "(see skate3_native_render_scene_perf_interval)") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_INT32(skate3_native_render_scene_perf_interval, 600, "Skate 3", "Frames between native-scene perf/stats log lines. Lower " "values give finer windows for chasing transient frame-" "rate dips at the cost of log volume.") .range(60, 6000) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_occlusion_cull, true, "Skate 3", "Skip drawing static world items whose bounds are " "provably hidden behind already-rendered geometry (depth-" "grid test, conservative: anything unprovable still " "draws). Shadow passes are exempt, so hidden geometry " "keeps casting shadows. Dense areas with extended world " "streaming spend roughly half their per-item CPU on such " "items.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_occlusion_cull_build, true, "Skate 3", "Also skip the per-frame scene-item rebuild for statics " "the occlusion cull proved hidden, on 3 of every 4 " "frames (staggered per instance). The every-4th-frame " "rebuild re-enters the item in the rendered scene so " "disocclusion is re-tested and the persistent shadow " "caster cache stays refreshed. Requires " "skate3_native_render_scene_occlusion_cull.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_occlusion_cull_guest, true, "Skate 3", "Also skip the GUEST engine's draw-list dispatch for " "items the occlusion cull proved hidden (the dispatch " "builds command packets the native renderer suppresses " "anyway, and it is the frame-time-critical cost). " "Capture still sees every item, so shadows and scene " "state are unaffected. Requires " "skate3_native_render_scene_occlusion_cull.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_perf_items, false, "Skate 3", "Deep per-item CPU profiling: adds a perf-items log line " "per perf window attributing per-item cost to pipeline " "stages (mesh/texture/constants/submit; cache validate/" "fingerprint/walk/fetch) and splitting draw time between " "items inside vs outside the rendered view frustum. Adds " "measurable overhead at high item counts; leave off " "outside investigations.") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ring, false, "Skate 3", "Record a rolling per-frame scene-composition ring (~900 " "frames); F7 dumps it to logs/scene_ring_.csv for " "diffing 1-2 frame artifacts no capture can catch") .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_native_render_scene_dyn_gap_fill, true, "Skate 3", "Re-publish character/cloth/skinned items for up to two " "missed publish frames (high-frame-rate body flicker)") .lifecycle(rex::cvar::Lifecycle::kHotReload); namespace skate3::native_scene { namespace { // Verified guest structure offsets. constexpr uint32_t kCtxEffectList = 0x04; constexpr uint32_t kEffectListPassCount = 0x44; constexpr uint32_t kCtxDrawCountU16 = 0x38; constexpr uint32_t kCtxDrawList = 0x48; constexpr uint32_t kMeshMaterial = 0x24; constexpr uint32_t kMeshVertexDescriptor = 0x28; constexpr uint32_t kMeshIndexBuffer = 0x30; constexpr uint32_t kMeshVertexBuffer = 0x34; constexpr uint32_t kBufferPhysAddr = 0x18; constexpr uint32_t kVbBytes = 0x20; constexpr uint32_t kIbCount = 0x20; constexpr uint32_t kViewCameraFromView = 0x08; constexpr uint32_t kViewCamViewProj = 0xA0; // Cross-thread renderer state (published scene, prewarm queues, capture // rows, telemetry, perf windows) lives in skate3_native_scene_state.h. float LoadGuestF32(uint8_t* base, uint32_t addr) { const uint32_t bits = REX_LOAD_U32(addr); return std::bit_cast(bits); } uint32_t BSwap32(uint32_t v) { return (v >> 24) | ((v >> 8) & 0xFF00u) | ((v << 8) & 0xFF0000u) | (v << 24); } // Draws completed since startup (any guest draw function). The RenderMesh // hook samples it around the original call: an unchanged sequence means the // mesh's draws were deferred by the dispatcher and the constant bank still // belongs to some earlier mesh; its transform must come from the post-draw // fixup, never from the bank at submit-exit (a leftover identity matrix at // c4 validates as a plausible world and renders the prop at the origin). std::atomic g_draw_seq{0}; // Provenance of the last completed guest draw: (ib_obj << 32 | vb_obj) for // indexed 3D draws, 0 for everything else. The submit-exit capture may only // consume the constant bank when the mesh's OWN draw was the last one to // flush it; `drew_inside` alone proved only that SOME draw ran during the // submit call. When this mesh's draws were deferred but another entity's // inline draws ran inside the call, the bank holds that entity's palette, // and the sample-projection acceptance gate cannot reliably refuse it: a // vehicle right next to the skater, skinned by the skater's foreign // palette, still projects on-screen at a plausible spread, and rendered // glued to his walking bones (the player-becomes-the-vehicle bug). std::atomic g_last_draw_ibvb{0}; // Where does this bank keep its bone palette? Pre-pass layout: c4 (bone 0's // affine rows right after viewproj). Main-pass layout: camera position at // c4, two parameter rows, palette at c7. A camera-position row is easily // told from a bone rotation row by its norm (hundreds vs ~1). The ropa-cloth // skinned VS variant (character.cloth_ropa, player tees) keeps one extra // parameter row (0,0,0,1) in front of the palette; its zero xyz norm fails // the bone check outright (unlike the NPC cloth/morph variant's (1,0,0,0), // which passes at c4 and is corrected by RefinePaletteBase), so the palette // really starts at c5 (pre-pass) / c8 (main-pass); reading c7 there lands // mid-palette and scrambles every bone by two rows (the mangled player-shirt // bug). Returns the base register, or 0 when no location holds plausible // bone rows. uint32_t BankPaletteBase(uint8_t* base, uint32_t bank) { const auto bone_at = [&](uint32_t reg) -> bool { for (int r = 0; r < 3; ++r) { float f[4]; for (int i = 0; i < 4; ++i) { f[i] = LoadGuestF32(base, bank + ((reg + r) * 4 + i) * 4); if (!(f[i] > -1e7f && f[i] < 1e7f)) return false; } const float n = f[0] * f[0] + f[1] * f[1] + f[2] * f[2]; if (!(n > 0.0025f && n < 400.0f)) return false; if (!(f[3] > -20000.f && f[3] < 20000.f)) return false; } return true; }; // A parameter/sentinel row like (0,0,0,1): xyz norm ~0, never a bone row. const auto param_row_at = [&](uint32_t reg) -> bool { float f[3]; for (int i = 0; i < 3; ++i) { f[i] = LoadGuestF32(base, bank + (reg * 4 + i) * 4); if (!(f[i] > -1e7f && f[i] < 1e7f)) return false; } 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); }; // The sim-ACTIVE variant of the ropa flag row reads (~0, JUNK, ~0, JUNK): // x is written 0.0 (sim owns the garment) with uninitialized memory in the // unwritten lanes (observed ~7e20 on the trick-guide demo skater's tee, // c7/c4). The junk lanes fail every range check // above, leaving the bank UNCLASSIFIABLE in the true main-pass state // (return 0; the capture never resolved and the garment stayed pending), // while the post-shadow state matched bone_at(7) mid-palette instead and // handed the rigid-matrix read the shadow block's (0,0,0,1) tail row at // c191. An out-of-range lane under x ~ 0 and z ~ 0 is the same structural // proof as the junk-z inactive variant: a real bone row is never junk. const auto ropa_active_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]) <= 1e-3f && std::fabs(f[2]) <= 1e-3f)) { return false; } return !(f[1] > -1e7f && f[1] < 1e7f) || !(f[3] > -1e7f && f[3] < 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 // norm checks alone accepted banks whose c4 was the camera followed by // leftover bone rows (the trucker cap's single main-pass capture), and // score-based c7-vs-c8 arbitration flipped to the row-shifted palette // whenever camera rotation clipped a sample of the real one (the cap // teleporting to axis-permuted coordinates while rotating). { const float dx = LoadGuestF32(base, bank + 16 * 4) - g_fog_cam[0]; const float dy = LoadGuestF32(base, bank + 17 * 4) - g_fog_cam[1]; 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) || ropa_flag_row_at(7) || ropa_active_flag_row_at(7)) && bone_at(8)) { return 8; } if (bone_at(7)) return 7; return 0; } } if (bone_at(4)) return 4; // The ropa junk-lane flag checks 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) || ropa_active_flag_row_at(4)) && bone_at(5)) { return 5; } if (bone_at(7)) return 7; if ((param_row_at(7) || ropa_flag_row_at(7) || ropa_active_flag_row_at(7)) && bone_at(8)) { return 8; } return 0; } // The cloth/morph skinned VS variant (skating-NPC torsos) keeps ONE extra // parameter row between the viewproj and the bone palette: observed live, // c4 = (1,0,0,0) with the palette at c5 (pre-pass) / c8 (main-pass). A // palette read one register early hands every bone [neighbor row, row0, // row1]; each row is still a perfectly plausible bone row, so the norm // checks in BankPaletteBase cannot catch it, and the mesh skins to // component-rotated coordinates ~300 m off in the sky. That was the // invisible-NPC-torso bug: the torso was captured, non-pending, palette and // texture resolved, and rendered far outside the view. // // Discriminate by skinning a few sample vertices with EVERY candidate base // (pre-pass c4/c5, main-pass c7/c8, plus the caller's guess) and projecting // them with the pass's own viewproj (bank c0..c3, column-vector rows): the // game drew this mesh with these constants, so the correct base puts the // samples inside the clip volume (validated offline across every skinned // draw of an F10 capture: correct base 1.00, wrong base <= ~0.3), AND keeps // the skinned samples' spatial spread near the mesh's bind-pose size. The // spread test is what rejects a FOREIGN palette that lands coincidentally // in view: the player's trucker cap draws exactly ONCE per frame (main // pass, palette at c7) while leftover rows at c4 pass the norm checks, // junk-skinning it into a ~3 m smear near the world origin (bind size // ~0.4 m) that was sometimes on screen, i.e. the sometimes-visible // disappearing-hat bug. Returns the winning base, or 0 when no candidate // both projects in-clip and keeps a sane spread; the caller then refuses // the capture and the post-draw fixup retries on a later draw. // structural_guess: the caller PROVED the guess base's layout structurally // (the ropa flag row read exactly (1,...) from the mesh's own bank; every // capture site guarantees own-draw provenance now, so the foreign-bank // hypothesis the strict gate defends against is off the table). When the // strict half-in-clip score still refuses every home, accept the guess if // all samples skin IN FRONT of the projection inside a loose 6x guard band // at a sane spread: up close the garment fills/overflows the screen and // most sample verts clip the tight 1.5x band; the strict gate refused the // CORRECT palette every frame and the torso stayed invisible until the NPC // walked far enough away. A row-shifted palette still fails this (skins // hundreds of meters off-view or behind the camera). uint32_t RefinePaletteBase(uint8_t* base, uint32_t bank, uint32_t palette_base, const DrawItem& item, bool structural_guess = false) { if (item.bw_offset == 0 || item.bi_offset == 0 || item.stride == 0) { return palette_base; } const uint32_t count = item.vb_bytes / item.stride; if (count < 2) { return palette_base; } float vp[16]; for (int i = 0; i < 16; ++i) { vp[i] = LoadGuestF32(base, bank + i * 4); if (!(vp[i] > -1e9f && vp[i] < 1e9f)) return palette_base; } constexpr uint32_t kSamples = 6; // Sample verts decoded ONCE (native/skate3_native_guest_read.h); only the // candidate palette base varies between score() calls. SkinSampleVert sverts[kSamples]; if (!ReadSkinSamplesGuest(base, item, kSamples, sverts)) { // Unsupported position format: every candidate is unscorable (-1); keep // the caller's guess, as the old per-candidate decode did. return palette_base; } // score: fraction (0..16) of samples that skin in front of and inside the // bank's own clip volume; *out_spread = the skinned samples' bbox diagonal // (world units) for the bind-size sanity test below. *out_front_all (when // asked): every sample is in FRONT of the projection within a loose 6x // guard band, the relaxed near-camera criterion (see structural_guess). const auto score = [&](uint32_t pb, float* out_spread, bool* out_front_all = nullptr) -> int { int ok = 0; int loose = 0; int n = 0; bool rows_sane = true; float qmin[3] = {1e9f, 1e9f, 1e9f}; float qmax[3] = {-1e9f, -1e9f, -1e9f}; for (uint32_t s = 0; s < kSamples; ++s) { float q[3]; if (SkinPointBankRows(base, bank, pb, sverts[s], q, &rows_sane) == 0) { continue; } float clip[4]; for (int r = 0; r < 4; ++r) { clip[r] = vp[r * 4] * q[0] + vp[r * 4 + 1] * q[1] + vp[r * 4 + 2] * q[2] + vp[r * 4 + 3]; } const float aw = std::abs(clip[3]) < 1.0f ? 1.0f : std::abs(clip[3]); ++n; for (int a = 0; a < 3; ++a) { qmin[a] = std::min(qmin[a], q[a]); qmax[a] = std::max(qmax[a], q[a]); } // In FRONT of the projection (w > 0) and inside a generous guard band. // Without the w check a foreign palette that skins the mesh BEHIND the // camera can still land |x|,|y| within the band and score well. if (clip[3] > 0.0f && std::abs(clip[0]) <= 1.5f * aw && std::abs(clip[1]) <= 1.5f * aw) { ++ok; } if (clip[3] > 0.0f && std::abs(clip[0]) <= 6.0f * aw && std::abs(clip[1]) <= 6.0f * aw) { ++loose; } } if (n == 0) { return -1; } if (!rows_sane) { return -2; // provably not a palette (vs -1 = nothing to judge) } if (out_front_all) { *out_front_all = n >= 2 && loose == n; } const float dx = qmax[0] - qmin[0]; const float dy = qmax[1] - qmin[1]; const float dz = qmax[2] - qmin[2]; *out_spread = std::sqrt(dx * dx + dy * dy + dz * dz); return (ok * 16) / n; }; // Bind-pose size of the SAMPLED span (approximates the mesh diagonal): // legit skinning keeps the world spread near it; a foreign palette // composes inconsistent transforms and smears the samples several times // wider. Generous bound: articulation can stretch a garment's sampled // span, junk palettes overshoot by ~10x. const float bind_diag = BindDiag(item); const float max_spread = std::max(3.0f * bind_diag, bind_diag + 1.0f); // Bounded from BELOW too: a palette of non-pose rows can skin every // sample to nearly one point that happens to project on-screen (the // vehicle-flick garbage). Samples span the whole VB, so legit skinning // keeps a large fraction of the bind size; small items (hats: samples // can cluster on one bone) skip the floor. const float min_spread = bind_diag > 1.0f ? 0.2f * bind_diag : 0.0f; // Acceptance gate: skins into the bank's own view AND at a sane size. // IMPORTANT selection constraint (offline-validated on the cap capture): // a palette shifted by whole rows is a RIGID transform of the mesh, same // spread, often still on screen, so "best spread/score wins" mis-picks // permuted bases. Selection therefore stays conservative: keep the // caller's guess (old base-vs-base+1 arbitration) whenever it passes the // gate, and only on gate FAILURE fall through to the other layout homes // (pre-pass c4/c5, main-pass c7/c8) in canonical order. The trucker cap // is the motivating case: it draws ONCE per frame (main-pass layout, // palette at c7) while leftovers at c4 pass the row-plausibility checks; // the old code never looked past c4/c5 and skinned it into a ~3 m smear // near the origin (the disappearing-hat bug). const auto gate = [&](uint32_t pb, int* ok_out) -> bool { float spread = 0.0f; const int ok = score(pb, &spread); if (ok_out) { *ok_out = ok; } return ok >= 8 && spread <= max_spread && spread >= min_spread; }; int s_std = -1; int s_plus = -1; const bool std_pass = gate(palette_base, &s_std); if (s_std == -1) { // Unsupported position format / no weighted samples: nothing to judge; // keep the caller's guess rather than refusing every capture. (-2 = // provably-insane rows falls through: try the other homes, else refuse.) return palette_base; } // The guess wins whenever it passes; +1 (the cloth/morph parameter-row // variant) is consulted only on FAILURE. A row-shifted palette is a rigid // axis-permutation of the mesh, often still on screen with a sane spread // - so "switch when +1 scores strictly better" flip-flopped whenever // camera rotation clipped one sample of the real base (cap teleporting // while rotating). The genuine +1 layouts skin the guess-base hundreds of // meters off-view, which the gate rejects decisively. if (std_pass) { return palette_base; } // Fallback homes need their layout's STRUCTURAL signature, not just a // passing projection score. The projection gate alone is fooled by // ONE-BONE-LATE palettes: a vehicle filling the screen at close range // clips half its samples (the correct base FAILS the gate), while a // base+3-register home shifts the vehicle BODY; most of its verts hang // off the LAST real bone, onto the next leftover rows in the bank, // i.e. whatever skinned entity staged before. Standing next to a truck // that is the PLAYER: the shifted palette projects beautifully and was // accepted, gluing the truck body to the walking skater (the // player-becomes-the-vehicle bug; proven in capture: // real palette c4..c18, published capture bone k = real bone k+1, body // bone 4 = the player's stale c19 rows). Signatures, from the verified // layouts: // +1 homes (cloth/morph/ropa): a PARAMETER row directly in front of // the palette: (0,0,0,w) (ropa flag) or (1,0,0,0) (NPC morph). // main-pass homes 7/8: the CAMERA POSITION at c4 (the same key // 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)) { 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 } // (1,0,0,0): the NPC cloth/morph variant's parameter row. A bone row0 // can also be (1,0,0,tx) for an unrotated bone; its w is the world // translation x, so require |w| small too. return std::fabs(f[0] - 1.0f) <= 1e-3f && std::fabs(f[1]) <= 1e-3f && std::fabs(f[2]) <= 1e-3f && std::fabs(f[3]) <= 1.5f; }; const auto cam_at_c4 = [&]() -> bool { if (g_fog_cam[0] == 0.0f && g_fog_cam[1] == 0.0f && g_fog_cam[2] == 0.0f) { return false; } const float dx = LoadGuestF32(base, bank + 16 * 4) - g_fog_cam[0]; const float dy = LoadGuestF32(base, bank + 17 * 4) - g_fog_cam[1]; const float dz = LoadGuestF32(base, bank + 18 * 4) - g_fog_cam[2]; return dx * dx + dy * dy + dz * dz < 25.0f; }; const auto home_ok = [&](uint32_t pb) -> bool { switch (pb) { case 4: // Never fall back to the pre-pass home on a structurally MAIN-pass // bank (camera at c4): palette@4 is then the ONE-BONE-SHIFTED // palette: bone 0 = the camera + parameter rows, every other bone // = its neighbor's affine. A close vehicle whose real palette@7 // fails the strict projection gate (screen-filling: samples clip // out of the band) fell through here, and the shifted palette // skins adjacent bones plausibly enough to PASS, the // camera-position-as-bone-0 vehicle mangle (proven in capture: // main bank c4 = frame camera, c6.w = -0.5 = // the published bone0_t, real palette at c7). return !cam_at_c4(); case 5: return param_like(4); case 7: return cam_at_c4(); case 8: return cam_at_c4() && param_like(7); default: return false; } }; if (home_ok(palette_base + 1) && gate(palette_base + 1, &s_plus)) { g_palette_base_plus1.fetch_add(1, std::memory_order_relaxed); return palette_base + 1; } for (const uint32_t pb : {4u, 7u, 5u, 8u}) { if (pb == palette_base || pb == palette_base + 1) { continue; } if (home_ok(pb) && gate(pb, nullptr)) { return pb; } } // Camera-pinned MAIN-pass home: a close vehicle fills the screen and // clips most samples out of the strict band, so gate(7/8) fails even on // the REAL palette; with the pre-pass fallback (correctly) blocked // above, the capture then refused every frame and the vehicle // flickered/vanished during close passes. The camera key at c4 proves // the layout structurally; accept on the loose front-of-projection // criterion (same relaxation as the ropa structural path below). if (cam_at_c4()) { const uint32_t pb_main = param_like(7) ? 8u : 7u; float spread = 0.0f; bool front_all = false; const int sc = score(pb_main, &spread, &front_all); if (sc >= 0 && front_all && spread <= max_spread && spread >= min_spread) { static std::atomic s_main_relaxed{0}; const uint64_t n = s_main_relaxed.fetch_add(1, std::memory_order_relaxed); if (n < 8 || (n & 1023u) == 0) { REXLOG_INFO( "native-scene: main-pass palette relaxed-accept mesh={:08X} " "base={} score={} spread={:.2f} bind={:.2f} (n={})", item.mesh, pb_main, sc, spread, bind_diag, n); } return pb_main; } } // Structurally-proven guess (ropa flag row, own-draw bank): near-camera // relaxed acceptance; see the function comment. Tried LAST so a strictly // passing home always wins first. if (structural_guess) { float spread = 0.0f; bool front_all = false; if (score(palette_base, &spread, &front_all) >= 0 && front_all && spread <= max_spread) { g_ropa_relaxed.fetch_add(1, std::memory_order_relaxed); return palette_base; } } // No candidate skins this mesh into the bank's own view at a sane size: // the bank belongs to another mesh. The caller refuses the capture (item // stays pending) and the post-draw fixup re-captures from a real draw. return 0; } // Rigid transform validation: the game uses (at least) two VS constant // layouts, verified from recorded draw streams: the pre-pass layout has a // row-vector 4x4 world at c4..c7 (rotation rows end in 0, translation in // c7); the main-pass layout has the camera position at c4 and the world // 4x4 at c8..c11. Older meshes use a column-vector affine 4x3 at c4..c6. // Sanity-check rows so a camera-position or parameter block is never // mistaken for a matrix. bool TryRow4x4(uint8_t* base, uint32_t bank, uint32_t reg, float* out) { float f[16]; for (int i = 0; i < 16; ++i) { f[i] = LoadGuestF32(base, bank + (reg * 4 + i) * 4); if (!(f[i] > -1e7f && f[i] < 1e7f)) return false; } if (f[3] != 0.0f || f[7] != 0.0f || f[11] != 0.0f || f[15] != 1.0f) return false; for (int r = 0; r < 3; ++r) { const float n = f[r * 4] * f[r * 4] + f[r * 4 + 1] * f[r * 4 + 1] + f[r * 4 + 2] * f[r * 4 + 2]; if (!(n > 0.0025f && n < 400.0f)) return false; } if (!(f[12] > -20000.f && f[12] < 20000.f && f[13] > -20000.f && f[13] < 20000.f && f[14] > -20000.f && f[14] < 20000.f)) { return false; } std::memcpy(out, f, 16 * sizeof(float)); return true; } bool TryColAffine(uint8_t* base, uint32_t bank, uint32_t reg, float* out) { float f[12]; for (int i = 0; i < 12; ++i) { f[i] = LoadGuestF32(base, bank + (reg * 4 + i) * 4); if (!(f[i] > -1e7f && f[i] < 1e7f)) return false; } for (int r = 0; r < 3; ++r) { const float n = f[r * 4] * f[r * 4] + f[r * 4 + 1] * f[r * 4 + 1] + f[r * 4 + 2] * f[r * 4 + 2]; if (!(n > 0.0025f && n < 400.0f)) return false; if (!(f[r * 4 + 3] > -20000.f && f[r * 4 + 3] < 20000.f)) return false; } for (int i = 0; i < 3; ++i) { for (int j = 0; j < 3; ++j) { out[i * 4 + j] = f[j * 4 + i]; } out[i * 4 + 3] = 0.0f; } out[12] = f[3]; out[13] = f[7]; out[14] = f[11]; out[15] = 1.0f; return true; } // Fill a rigid item's world matrix from a constant bank, whichever layout // the staging draw used. bool BankRigidWorld(uint8_t* base, uint32_t bank, float* out) { return TryRow4x4(base, bank, 4, out) || TryRow4x4(base, bank, 8, out) || TryColAffine(base, bank, 4, out); } // Orthographic viewproj at c0..c3 (bottom row exactly (0,0,0,1)): the CSM // caster-cascade banks, verified in capture (a // truck's three caster draws vs its perspective main-pass draw). See // DrawItem::caster_bank. bool BankIsOrtho(uint8_t* base, uint32_t bank) { const float x = LoadGuestF32(base, bank + (3 * 4 + 0) * 4); const float y = LoadGuestF32(base, bank + (3 * 4 + 1) * 4); const float z = LoadGuestF32(base, bank + (3 * 4 + 2) * 4); const float w = LoadGuestF32(base, bank + (3 * 4 + 3) * 4); return std::fabs(x) < 1e-6f && std::fabs(y) < 1e-6f && std::fabs(z) < 1e-6f && std::fabs(w - 1.0f) < 1e-3f; } // AUX perspective viewproj (bank c0..c3, column-vector rows): a perspective // pass whose projection is NOT screen-shaped; the skater-portrait // render-to-texture passes (team menu boxes / Import Skater card) render a // tall narrow card (aspect ~0.4-0.5) while every screen view (main, editor, // water reflection) is >= 4:3 (16:9 default, wider ultrawide). For a // combined viewproj the projection scales survive as row norms: the view's // rotation rows are unit, so ||c0.xyz|| = m00 and ||c1.xyz|| = m11, and // aspect(w/h) = m11/m00 (verified in capture: // 1.5/0.843 = 1.78 exactly). Captures from aux passes must never enter the // palette/world stores: the portrait stage sits ~85 m from the player and a // portrait-pass capture merged onto the player's meshes produced the mixed // palettes the stretch veto hid (bones 0-1 at the // player, rows 2+ at the stage). Ortho banks (CSM caster cascades, square // aspect) are handled by BankIsOrtho and are NOT aux. bool BankIsAuxPerspective(uint8_t* base, uint32_t bank) { if (BankIsOrtho(base, bank)) { return false; } float n0 = 0.0f; float n1 = 0.0f; for (int i = 0; i < 3; ++i) { const float a = LoadGuestF32(base, bank + (0 * 4 + i) * 4); const float b = LoadGuestF32(base, bank + (1 * 4 + i) * 4); if (!(a > -1e6f && a < 1e6f && b > -1e6f && b < 1e6f)) { return false; // implausible bank: let the existing gates decide } n0 += a * a; n1 += b * b; } if (!(n0 > 1e-12f && n1 > 1e-12f)) { return false; } // aspect < 1.2 (n1/n0 < 1.44): narrower than any screen view. return n1 < n0 * 1.44f; } bool GuestReadableApprox(uint8_t* base, uint32_t addr) { // The hook layer only walks pointers the game is actively rendering from; // they are mapped. Reject null/small. (void)base; return addr >= 0x10000; } // Guarded bulk copy: skate3::native_scene::GuestTryCopy, moved to // native/skate3_native_guest_read.cpp so every guest reader shares the one // correctly-built SEH guard. The compiler-trap documentation // (volatile fn-ptr + noinline: plain __try{memcpy}__except compiles to an // UNPROTECTED `jmp memcpy`) moved with it. } // namespace // SEH-guarded single-value guest loads for registry-time walks (the // loading-screen prewarm): unlike the capture-path walks, whose pointers // the game is actively rendering from, registry probes dereference // candidate words that may not be pointers at all, and load-time payloads // that may not be committed yet. GuestReadableApprox is only a null/small // filter; these actually survive the fault. bool GuestTryLoadU32(uint8_t* base, uint32_t addr, uint32_t* out) { if (addr < 0x10000) { return false; } uint32_t raw; if (!GuestTryCopy(&raw, REX_RAW_ADDR(addr), 4)) { return false; } *out = __builtin_bswap32(raw); return true; } bool GuestTryLoadU64(uint8_t* base, uint32_t addr, uint64_t* out) { if (addr < 0x10000) { return false; } uint64_t raw; if (!GuestTryCopy(&raw, REX_RAW_ADDR(addr), 8)) { return false; } *out = __builtin_bswap64(raw); return true; } namespace { // Guarded bounded C-string read (`out` gets up to cap-1 chars + NUL, tail // zeroed like the old byte-at-a-time loops). Fast path is one bulk guarded // copy; a short string right before an unmapped page falls back to // byte-wise guarded reads so it still resolves. void GuestTryReadString(uint8_t* base, uint32_t addr, char* out, uint32_t cap) { std::memset(out, 0, cap); if (addr < 0x10000) { return; } if (GuestTryCopy(out, REX_RAW_ADDR(addr), cap - 1)) { const size_t n = strnlen(out, cap - 1); std::memset(out + n, 0, cap - n); return; } for (uint32_t k = 0; k + 1 < cap; ++k) { uint8_t c; if (!GuestTryCopy(&c, REX_RAW_ADDR(addr + k), 1) || c == 0) { break; } out[k] = char(c); } } // Committed-page check for bulk reads (transient ring memory can be // partially uncommitted, and resources may be released between the game // thread capturing an address and the render thread reading it; a blind // memcpy would fault). bool GuestRangeReadable(uint8_t* base, uint32_t addr, uint32_t size) { #if defined(_WIN32) uint8_t* p = base + addr; uint8_t* end = p + size; while (p < end) { MEMORY_BASIC_INFORMATION info{}; if (VirtualQuery(p, &info, sizeof(info)) == 0 || info.State != MEM_COMMIT || (info.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0) { return false; } p = static_cast(info.BaseAddress) + info.RegionSize; } return true; #else (void)base; (void)addr; (void)size; return false; #endif } // Payload fingerprint (FNV-1a over bytes sampled across the whole VB/IB) so // the renderer re-decodes when streaming replaces or fills in the data at // this address, including middle-of-buffer fills. Guarded reads: capture- // path payloads are always resident (the game is drawing from them), but // the registration prewarm fingerprints meshes whose payload pages may not // be committed yet; the failure defers the mesh instead of faulting the // thread. Returns false when the payload is unreadable. bool ComputeItemFingerprint(uint8_t* base, DrawItem& item) { uint64_t h = 1469598103934665603ull; const auto mix = [&h](uint64_t v) { h = (h ^ v) * 1099511628211ull; }; mix(item.vb_addr); mix(item.ib_addr); mix(item.vb_bytes); mix(item.ib_count); if (item.vb_bytes >= 8 && item.ib_count >= 4) { for (uint32_t k = 0; k < 16; ++k) { uint64_t vq = 0, iq = 0; const uint32_t vb_off = uint32_t(uint64_t(item.vb_bytes - 8) * k / 15u) & ~7u; const uint32_t ib_off = uint32_t(uint64_t(item.ib_count * 2 - 8) * k / 15u) & ~7u; if (!GuestTryLoadU64(base, item.vb_addr + vb_off, &vq) || !GuestTryLoadU64(base, item.ib_addr + ib_off, &iq)) { return false; } mix(vq); mix(iq); } } item.fingerprint = h; return true; } // Walk one MeshContext into a DrawItem (geometry, material and draw list; // world left as identity). Returns false if any pointer in the chain is // implausible. For dynamic entities this MUST run at RenderMesh hook time; // the whole chain lives in transient per-frame arenas. } // namespace // Parse a guest mesh struct (vertex descriptor, buffers, bbox, material // channels, payload fingerprint) into the item. The draw list and world // transform are the caller's responsibility (world starts as identity). bool BuildItemFromMesh(uint8_t* base, uint32_t mesh, DrawItem& item) { const uint32_t vdesc = REX_LOAD_U32(mesh + kMeshVertexDescriptor); const uint32_t ib = REX_LOAD_U32(mesh + kMeshIndexBuffer); const uint32_t vb = REX_LOAD_U32(mesh + kMeshVertexBuffer); if (!GuestReadableApprox(base, vdesc) || !GuestReadableApprox(base, ib) || !GuestReadableApprox(base, vb)) { g_rej_chain.fetch_add(1, std::memory_order_relaxed); return false; } // Vertex descriptor: find the stream-0 position element and the first // stream-0 texcoord (D3DDECLUSAGE 5) for the diffuse map. Read via ONE // guarded bulk copy into scratch: descriptors are runtime renderengine // objects, and the registration prewarm can reach a mesh before its // descriptor is initialized (a raw read there faults the thread; the // capture path also gets marginally faster than the per-field volatile // loads). uint32_t desc_head[3]; if (!GuestTryCopy(desc_head, REX_RAW_ADDR(vdesc), sizeof(desc_head))) { g_rej_chain.fetch_add(1, std::memory_order_relaxed); return false; } const uint32_t num_elements = BSwap32(desc_head[2]) >> 16; // u16 at +8 if (num_elements == 0 || num_elements > 32) return false; // Element table at +0x10 (16 bytes per element) followed by the stride // byte at +(num_elements + 1) * 16. uint8_t desc_tab[32 * 16 + 1]; const uint32_t desc_tab_bytes = num_elements * 16 + 1; if (!GuestTryCopy(desc_tab, REX_RAW_ADDR(vdesc + 0x10), desc_tab_bytes)) { g_rej_chain.fetch_add(1, std::memory_order_relaxed); return false; } const auto elem_u16 = [&](uint32_t i, uint32_t off) -> uint32_t { return (uint32_t(desc_tab[i * 16 + off]) << 8) | desc_tab[i * 16 + off + 1]; }; const auto elem_u32 = [&](uint32_t i, uint32_t off) -> uint32_t { uint32_t v; std::memcpy(&v, desc_tab + i * 16 + off, 4); return BSwap32(v); }; bool have_pos = false; bool have_bw = false; bool have_bi = false; item.uv_offset = 0; item.uv_fmt = 0; item.uv2_offset = 0; item.uv2_fmt = 0; item.bw_offset = 0; item.bi_offset = 0; item.normal_offset = 0; item.normal_fmt = 0; item.tangent_offset = 0; item.binormal_offset = 0; item.tb_fmt = 0; item.tan_fmt = 0; bool have_tan = false; bool have_bin = false; item.skinned = false; for (uint32_t i = 0; i < num_elements; ++i) { const uint32_t stream = elem_u16(i, 0); const uint32_t usage = desc_tab[i * 16 + 9]; if (stream != 0) continue; if (usage == 0 && !have_pos) { item.pos_offset = uint16_t(elem_u16(i, 2)); item.pos_fmt = uint8_t(elem_u32(i, 4) & 0x3F); have_pos = true; } else if (usage == 3 && item.normal_fmt == 0) { const uint8_t fmt = uint8_t(elem_u32(i, 4) & 0x3F); if (fmt == 16) { // k_10_11_11 packed normal item.normal_offset = uint16_t(elem_u16(i, 2)); item.normal_fmt = fmt; } } else if (usage == 5 && item.uv_fmt == 0) { item.uv_offset = uint16_t(elem_u16(i, 2)); item.uv_fmt = uint8_t(elem_u32(i, 4) & 0x3F); } else if (usage == 5 && item.uv2_fmt == 0) { item.uv2_offset = uint16_t(elem_u16(i, 2)); item.uv2_fmt = uint8_t(elem_u32(i, 4) & 0x3F); } else if (usage == 1 && !have_bw) { // blend weights u8x4 item.bw_offset = uint16_t(elem_u16(i, 2)); have_bw = (elem_u32(i, 4) & 0x3F) == 6; } else if (usage == 2 && !have_bi) { // blend indices u8x4 item.bi_offset = uint16_t(elem_u16(i, 2)); have_bi = (elem_u32(i, 4) & 0x3F) == 6; } else if (usage == 6 && !have_tan && (elem_u32(i, 4) & 0x3F) == 16) { // tangent k_10_11_11 item.tangent_offset = uint16_t(elem_u16(i, 2)); have_tan = true; } else if (usage == 7 && !have_bin && (elem_u32(i, 4) & 0x3F) == 16) { // binormal k_10_11_11 item.binormal_offset = uint16_t(elem_u16(i, 2)); have_bin = true; } } if (have_tan && have_bin) { item.tb_fmt = 16; } if (have_tan) { item.tan_fmt = 16; // tangent-only layouts (water meshes) use this } if (!have_pos) { g_rej_geom.fetch_add(1, std::memory_order_relaxed); return false; } item.skinned = have_bw && have_bi; item.stride = desc_tab[num_elements * 16]; // byte at vdesc+(num+1)*16 if (item.stride == 0) { g_rej_geom.fetch_add(1, std::memory_order_relaxed); return false; } // Mesh BBox = two Vector4s at +0x00/+0x10. Reject NaN/absurd bounds. for (int axis = 0; axis < 3; ++axis) { const float lo = LoadGuestF32(base, mesh + axis * 4); const float hi = LoadGuestF32(base, mesh + 0x10 + axis * 4); if (!(hi - lo < 50000.0f)) { g_rej_bbox.fetch_add(1, std::memory_order_relaxed); return false; } item.bbox_min[axis] = lo; item.bbox_max[axis] = hi; } item.mesh = mesh; item.pending = false; item.cloth_quads = false; item.vb_obj = vb; item.ib_obj = ib; // Guarded: the registration prewarm can reach buffer objects before they // finish initializing (the capture path only ever sees live ones). uint32_t vb_words[3] = {}, ib_words[3] = {}; if (!GuestTryCopy(vb_words, REX_RAW_ADDR(vb + kBufferPhysAddr), sizeof(vb_words)) || !GuestTryCopy(ib_words, REX_RAW_ADDR(ib + kBufferPhysAddr), sizeof(ib_words))) { g_rej_chain.fetch_add(1, std::memory_order_relaxed); return false; } item.vb_addr = BSwap32(vb_words[0]) & 0xFFFFFFFC; item.vb_bytes = BSwap32(vb_words[2]); item.ib_addr = BSwap32(ib_words[0]) & 0xFFFFFFFC; item.ib_count = BSwap32(ib_words[2]); if (item.vb_addr == 0 || item.ib_addr == 0 || item.vb_bytes == 0 || item.ib_count == 0 || item.vb_bytes % item.stride != 0) { g_rej_geom.fetch_add(1, std::memory_order_relaxed); return false; } // Material channels: resolve the "diffuse" and "lightmap" texture guids to // registered texture objects. item.diffuse_tex = 0; item.lightmap_tex = 0; item.macro_tex = 0; item.macro_scale = 1.0f; item.macro_opacity = 1.0f; item.scroll_u = 0.0f; item.scroll_v = 0.0f; item.decal_art = 0; item.hair = false; item.char_family = 0; item.hair_alpha_tex = 0; item.ropa = false; item.decal = false; item.decal_tileable = false; item.transparent = false; item.water = false; item.water_flowing = false; item.water_ocean = 0; item.water_normal = 0; item.water_normal2 = 0; item.water_env = 0; item.unlit = false; item.env_family = 0; item.dynobj = 0; item.spec_tex = 0; item.tint[0] = item.tint[1] = item.tint[2] = item.tint[3] = 0.0f; // Material header + channel array read via guarded bulk copies: the // registration prewarm walks materials while their arena is still // loading, where raw reads fault the thread. (The capture path gets the // same copies; one memcpy per material beats ~8 volatile loads per // channel anyway.) const uint32_t material = REX_LOAD_U32(mesh + kMeshMaterial); uint32_t mat_head[3] = {}; if (GuestReadableApprox(base, material) && GuestTryCopy(mat_head, REX_RAW_ADDR(material), sizeof(mat_head))) { const uint32_t num_channels = BSwap32(mat_head[0]); const uint32_t channels = BSwap32(mat_head[2]); uint8_t chan_buf[32 * 0x20]; if (num_channels != 0 && num_channels <= 32 && GuestReadableApprox(base, channels) && GuestTryCopy(chan_buf, REX_RAW_ADDR(channels), num_channels * 0x20)) { const auto chan_u32 = [&](uint32_t idx, uint32_t off) -> uint32_t { uint32_t v; std::memcpy(&v, chan_buf + idx * 0x20 + off, 4); return BSwap32(v); }; for (uint32_t i = 0; i < num_channels; ++i) { const uint32_t name = chan_u32(i, 0); char text[20]; GuestTryReadString(base, name, text, sizeof(text)); if (text[0] == '\0') continue; uint32_t* slot = nullptr; if (std::memcmp(text, "diffuse", 8) == 0) { slot = &item.diffuse_tex; } else if (std::memcmp(text, "lightmap", 9) == 0) { slot = &item.lightmap_tex; } else if (std::memcmp(text, "macrooverlay", 13) == 0) { slot = &item.macro_tex; } else if (std::memcmp(text, "decal", 6) == 0) { slot = &item.decal_art; } else if (std::memcmp(text, "normal", 7) == 0) { // Exact match only. Consumed by the water branches (the flowing // ripple map / the ocean's first PCA component); other families // ignore it. slot = &item.water_normal; } else if (std::memcmp(text, "normal2", 8) == 0) { // The ocean's second PCA normal component map. slot = &item.water_normal2; } else if (std::memcmp(text, "alpha", 6) == 0) { // Hair strand coverage (cac_hair/defaulthair tf5, sampled at the // second texcoord), the hair alpha-blend term. slot = &item.hair_alpha_tex; } else if (std::memcmp(text, "environment", 12) == 0) { // Environment CUBE map, the water and environment.reflective* // reflection term. slot = &item.water_env; } else if (std::memcmp(text, "detail", 7) == 0) { // Exact match ("detailNormalUVScale" is a different channel). // Constant detail texture folded into the fam 5/6 normal // composition (see DrawItem::detail_tex). slot = &item.detail_tex; } else if (std::memcmp(text, "specular", 9) == 0 || std::memcmp(text, "noise", 6) == 0) { // Spec/eccentricity/reflection-mask map (environment families) / // the animated.tree "noise" tint map, bound at t4 on families // that carry no decal art (see DrawItem::spec_tex). slot = &item.spec_tex; } else if (std::memcmp(text, "detailNormalUVScale", 20) == 0) { // Shader-constant channel (the game's VS c8.x, typically 8): the // detail normal map's UV multiplier, consumed by the v2 kd term. const float f = std::bit_cast(chan_u32(i, 0x10)); if (f > 0.0f && f < 1e6f) { item.detail_scale = f; } continue; } else if (std::memcmp(text, "uAnimationSpeed", 16) == 0 || std::memcmp(text, "vAnimationSpeed", 16) == 0) { // Shader-constant channels (the game's VS c7.x / c8.x): UV scroll // speed in texcoords per second, consumed by the fam-14 // scrollincandescent branch. Signed; the direction is authored. const float f = std::bit_cast(chan_u32(i, 0x10)); if (std::fabs(f) < 1e6f) { // false for NaN too (text[0] == 'u' ? item.scroll_u : item.scroll_v) = f; } continue; } else if (std::memcmp(text, "macroOverlayUVScale", 20) == 0 || std::memcmp(text, "macroOverlayOpacity", 20) == 0) { // Shader-constant channel: the float lives in the first guid word. const float f = std::bit_cast(chan_u32(i, 0x10)); if (f > 0.0f && f < 1e6f) { (text[12] == 'U' ? item.macro_scale : item.macro_opacity) = f; } continue; } else if (std::memcmp(text, "Attribul", 8) == 0) { // AttribulatorMaterialName: chan+0x18 is the material name string. // "character.hair" marks the grayscale hair that needs the // per-character tint from the pixel constant bank; "sky.*" draws // fullbright; "character.cloth_ropa" is the Ropa cloth-sim VS // variant (flag-row-switched skinned/rigid, see CaptureSkinnedState). const uint32_t s = chan_u32(i, 0x18); if (GuestReadableApprox(base, s)) { // 40 bytes: "character.livingworld_vehicles_glass" (36 chars) is // the longest name that must be distinguishable; the previous // 28-byte buffer truncated both vehicle names into the plain // "livingworld" pedestrian prefix. char mat_name[40]; GuestTryReadString(base, s, mat_name, sizeof(mat_name)); // character.*_ropa = the Ropa cloth-sim VS variant (flag-row // switched skinned/rigid, see CaptureSkinnedState). The suffix // composes with the base family name: cloth_ropa (player tees), // default_cloth_ropa (NPC jackets), hair_ropa and // default_hair_ropa all exist in the attrib table; so detect it // generically and STRIP it, letting the family idioms below see // the base name. Matching only the player's character.cloth_ropa // left NPC ropa garments on the generic skinned path, where // every palette home is refused: the layout's guess register is // this VS's FLAG row (scores 0), and the true +1 home fails the // (0,0,0,w)/(1,0,0,0) parameter-row signature because this // variant's row is (1, junk, junk, junk), the persistent // invisible-torso NPC (observed: a // character.default_cloth_ropa mesh refused all three captures every // frame while the draw-time banks pass the gate at c5/c8). item.ropa = false; { const size_t len = strnlen(mat_name, sizeof(mat_name)); if (len >= 5 && len < sizeof(mat_name) && std::memcmp(mat_name, "character.", 10) == 0 && std::memcmp(mat_name + len - 5, "_ropa", 5) == 0) { item.ropa = true; mat_name[len - 5] = '\0'; } } item.hair = std::memcmp(mat_name, "character.hair", 15) == 0; item.unlit = std::memcmp(mat_name, "sky.", 4) == 0; // Character shading family (see DrawItem::char_family). Order // matters: "default_hair" before the "default" prefix, exact // "hair" after (memcmp includes the NUL for exact names). if (std::memcmp(mat_name, "character.", 10) == 0) { const char* sub = mat_name + 10; if (std::memcmp(sub, "default_hair", 13) == 0) { item.char_family = 5; } else if (std::memcmp(sub, "default", 7) == 0) { item.char_family = 1; } else if (std::memcmp(sub, "hair", 5) == 0) { item.char_family = 4; } else if (std::memcmp(sub, "livingworld_vehicles_glass", 27) == 0) { item.char_family = 7; // reflection-only blended windows } else if (std::memcmp(sub, "livingworld_vehicles", 21) == 0) { item.char_family = 6; // vehicle.fx paint/spec/cube body } else if (std::memcmp(sub, "livingworld", 11) == 0) { item.char_family = 3; } 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). // Tileable art WRAPS (rock/cliff faces tile the art across the // whole surface, uv spans many periods; clamping stretches the // border texels into giant streaks); single-placement decal art // CLAMPS (wrap tiled the graffiti across the plaza). item.decal = std::memcmp(mat_name, "environment.decal", 17) == 0; item.decal_tileable = std::memcmp(mat_name, "environment.decal_tileable", 26) == 0; // environment.transparent: alpha-blended world geometry (mist // sheets, glass, fences); see DrawItem::transparent. item.transparent = std::memcmp(mat_name, "environment.transparent", 23) == 0; // water.* (canal) and ocean.* (the sea): transparent sub-pass // with the dedicated water shading branch (see DrawItem::water). // ocean.default has NO diffuse channel at all (ocean.fx computes // color purely from the environment cube x lightmap x fresnel); // without this branch it rendered as an 8 km white plane (white // fallback diffuse x near-white ocean lightmap x2). item.water = std::memcmp(mat_name, "water.", 6) == 0 || std::memcmp(mat_name, "ocean.", 6) == 0; // water.flowing* = the flowingwateralpha shader family (canal / // waterfall). Takes the exact water branch when the frame's // m_params rows are captured (see FrameScene::water_rows). item.water_flowing = std::memcmp(mat_name, "water.flowing", 13) == 0; // ocean.default / ocean.reflection: the sea surface and its // baked horizon reflection sheet (see DrawItem::water_ocean). if (std::memcmp(mat_name, "ocean.default", 14) == 0) { item.water_ocean = 1; } else if (std::memcmp(mat_name, "ocean.reflection", 17) == 0) { item.water_ocean = 2; } // Exact world-shading family (see DrawItem::env_family). The // attrib <-> pixel-shader-family mapping is 1:1; the shading // models were verified per-pixel against the game's own shaders. const auto is = [&](const char* s, size_t n) { return std::memcmp(mat_name, s, n) == 0; }; if (is("environment.default", 20)) { item.env_family = 1; } else if (is("environmentsimple.default", 26)) { item.env_family = 2; } else if (is("environment.decal_tileable", 26)) { item.env_family = 4; // includes decal_tileable_simple } else if (is("environment.decal", 18)) { item.env_family = 3; } else if (is("environment.reflective_simple", 30)) { item.env_family = 6; } else if (is("environment.reflective_trans", 29)) { // transparentenvironmentreflective: the sloped glass canopy / // awning panels. Blended in the sorted alpha sub-pass with the // fam-5 shading minus kd/macro, premultiplied body, out a^2 // (model verified 0.0-error against the ucode). item.env_family = 13; } else if (is("environment.reflective", 23)) { item.env_family = 5; } else if (is("environmentsimple.alphatest", 28)) { item.env_family = 7; } else if (is("environmentsimple.diffuse", 26)) { item.env_family = 8; } else if (is("tree.default", 13)) { item.env_family = 9; } else if (is("animated.tree", 14)) { item.env_family = 10; } else if (is("proxyworld.", 11)) { item.env_family = 11; } else if (is("incandescent.default", 21)) { item.env_family = 12; } else if (is("incandescent.backlituvscroll", 29)) { // scrollincandescent.fx: emissive diffuse scrolled by // g_fAnimationTime x the u/vAnimationSpeed channels (the // stadium LED chyron band). item.env_family = 14; } // dynamicobject.fx props (dispensers, dumpsters, benches, cans): // rigid movable objects with their own dual-shadow lit PS // (model verified exact against the game's own pixel shader). // Separate from the world env families; the lighting rows come // from the draw's PS bank, not the frame-global world rows. if (is("dynamicobject.alphatest", 24)) { item.dynobj = 2; } else if (is("dynamicobject", 13)) { item.dynobj = 1; } } continue; } if (slot != nullptr && *slot == 0) { // Prefer the channel's live stream record (chan+0x1C -> word 0 = // the renderengine::Texture actually bound): runtime-composed // customization textures (CAS face/skin, shoes, deck, wheels) // are never registered under an asset GUID. Validate via the // fetch-constant type bits before trusting the pointer. // Guarded: the prewarm walks materials whose stream records / // texture objects may not be loaded yet. const uint32_t stream = chan_u32(i, 0x1C); uint32_t tex = 0, tex_w0 = 0; if (GuestTryLoadU32(base, stream, &tex) && GuestTryLoadU32(base, tex + 7 * 4, &tex_w0) && (tex_w0 & 3u) == 2u) { *slot = tex; } if (*slot == 0) { const uint64_t guid = (uint64_t(chan_u32(i, 0x10)) << 32) | chan_u32(i, 0x14); std::lock_guard lock(g_texture_map_mutex); auto it = g_texture_map.find(guid & kGuidMask); if (it != g_texture_map.end()) { *slot = it->second; } } } // No early-out: channel order varies per material and the float // channels ride AFTER their texture (macroOverlayUVScale follows // macrooverlay on the plaza asphalt, breaking once the textures // resolved left the macro tiling at 1.0 instead of 0.3). } } } // Payload fingerprint: see ComputeItemFingerprint (the item cache // refreshes it on its own cadence). if (!ComputeItemFingerprint(base, item)) { g_rej_geom.fetch_add(1, std::memory_order_relaxed); return false; } // Transform (identity for world geometry, instance matrix for props; // characters get their bone array's first matrix, which roughly places // them until skinning is implemented). std::memset(item.world, 0, sizeof(item.world)); item.world[0] = item.world[5] = item.world[10] = item.world[15] = 1.0f; return true; } namespace { // Streamed-artwork override (event ads): the Massive ad system rebinds // texture fetch slots at draw time to show the current event-ad art in // place of the asset's default poster. Two observed shapes: a 16x16 // min-mip stub channel whose real art exists ONLY at draw time (the // ace-of-spades "Big Event" grid), and a full-size default poster (the // letter-writing frames) whose diffuse is wholesale REPLACED with the // current ad (the MONDO "THE NEW VIDEO" portrait). Adopt the fetch // slot-4 words recorded at this mesh's LAST indexed draw this frame, // but only if that draw's slot 3 is this item's own lightmap // (mirror-masked base compare), which proves the recorded fetch state // belongs to the mesh's MAIN-pass draw (the z-prepass leaves another // material's bindings). Slot targets are family-specific (Skate 2 // shader source): environmentsimple.* sample the diffuse at s4; // environment.decal samples its decal overlay art at s4 (diffuse rides // s6); environment.default has DETAIL at s4, so its diffuse adoption // stays stub-gated; never adopt a detail texture over real poster art. // Per-frame state (g_frame_draw_fetch), so this runs on every item build; // it is NOT part of the cacheable item core. void AdoptDrawFetchOverrides(uint8_t* base, DrawItem& item) { if (item.env_family != 0 && item.diffuse_tex != 0 && item.lightmap_tex != 0) { // Physical base of a fetch word-1, collapsing the 0xA0000000-style // cached/uncached mirrors (the channel object records the mirror // address, the draw-time fetch constant the plain one). const auto phys_base = [](uint32_t w1) { return w1 & 0x1FFFF000u; }; std::lock_guard lock(g_palette_mutex); const auto fit = g_frame_draw_fetch.find((uint64_t(item.ib_obj) << 32) | item.vb_obj); if (fit != g_frame_draw_fetch.end()) { // Texture-object reads only on a fetch-map hit, and guarded: the map // is empty on prewarm/loading frames, and the GUID registry can // resolve to a freed previous-map object there (raw reads faulted). uint32_t lm_w1 = 0; const uint32_t* slot3 = fit->second.data(); const uint32_t* slot4 = fit->second.data() + 6; const uint32_t art_w = (slot4[2] & 0x1FFFu) + 1; const uint32_t art_h = ((slot4[2] >> 13) & 0x1FFFu) + 1; const bool main_pass = (slot3[0] & 3u) == 2u && (slot4[0] & 3u) == 2u && GuestTryLoadU32(base, item.lightmap_tex + 8 * 4, &lm_w1) && phys_base(slot3[1]) == phys_base(lm_w1); // Dimension floor: never adopt a placeholder-sized s4 (an idle ad // rotation slot) over whatever the channel resolves to. // // Own-channel guard: on families whose s4 carries a surface map // rather than art (environment.default binds its detail normal map // there), the stub gate alone is not enough. A material demoted by // the streamer has a stub-sized diffuse too, and adopting s4 then // serves the material's OWN detail/spec/normal map as the diffuse // (distant demoted tree trunks rendered as their flat blue normal // map). Real event-ad art is never one of the material's own channel // textures, so any s4 that matches one is a normal binding, not a // rebind worth adopting. const auto s4_is_own_channel = [&](uint32_t chan_tex) { uint32_t own_w1 = 0; return chan_tex != 0 && GuestTryLoadU32(base, chan_tex + 8 * 4, &own_w1) && phys_base(slot4[1]) == phys_base(own_w1); }; const bool s4_is_own = s4_is_own_channel(item.detail_tex) || s4_is_own_channel(item.spec_tex) || s4_is_own_channel(item.water_normal); if (main_pass && !s4_is_own && (art_w >= 32 || art_h >= 32)) { if (item.env_family == 3) { uint32_t da_w1 = 0; if (item.decal_art != 0 && GuestTryLoadU32(base, item.decal_art + 8 * 4, &da_w1) && phys_base(slot4[1]) != phys_base(da_w1)) { std::memcpy(item.decal_fetch, slot4, 6 * sizeof(uint32_t)); } } else { uint32_t diff_w2 = 0, diff_w1 = 0; if (!GuestTryLoadU32(base, item.diffuse_tex + 9 * 4, &diff_w2) || !GuestTryLoadU32(base, item.diffuse_tex + 8 * 4, &diff_w1)) { diff_w2 = 0; diff_w1 = slot4[1]; // unreadable diffuse object: adopt nothing } const uint32_t diff_w = (diff_w2 & 0x1FFFu) + 1; const uint32_t diff_h = ((diff_w2 >> 13) & 0x1FFFu) + 1; const bool diff_stub = diff_w <= 32 && diff_h <= 32; const bool s4_is_diffuse = item.env_family == 2 || item.env_family == 7 || item.env_family == 8; if ((s4_is_diffuse || diff_stub) && phys_base(slot4[1]) != phys_base(diff_w1)) { std::memcpy(item.diffuse_fetch, slot4, 6 * sizeof(uint32_t)); } } } } } } // ---- World-item cache ------------------------------------------------------ // BuildItemFromMesh walks the descriptor, the material channels (string // reads) and the payload fingerprint, and used to run for EVERY visible // item EVERY frame on the guest render thread. The walk's result is stable // while a mesh stays loaded, so it is cached per mesh: // - every frame: 4-pointer structural validation + buffer address check // (streaming reuses arena addresses; re-inits rebuild) // - every 4th frame (or every frame for skinned/ropa payloads, whose // buffers the CPU sim rewrites): fingerprint refresh // - every 32nd frame: full rebuild (material channels re-resolve: late // CAS composites, streamed textures binding into channel records) // Invalidated when the mesh re-registers (tRModelData::Fixup / // AddRenderInstance -> OnMeshRegistered), on the menus/loading flip and by // the mesh-cache debug flush. Guarded by its own mutex: builders run on the // guest render thread, invalidation on loader threads. struct CachedItemCore { DrawItem item; // draws empty, world identity, fetch overrides zero uint64_t fp_frame = 0; // next fingerprint refresh (guest frames) uint64_t rebuild_frame = 0; // next full rebuild }; std::mutex g_item_cache_mutex; std::unordered_map g_item_cache; void InvalidateCachedItem(uint32_t mesh) { std::lock_guard lock(g_item_cache_mutex); g_item_cache.erase(mesh); } } // namespace void ClearItemCache() { std::lock_guard lock(g_item_cache_mutex); g_item_cache.clear(); } namespace { bool BuildItemFromMeshCached(uint8_t* base, uint32_t mesh, DrawItem& item) { const uint64_t frame = g_guest_frame; const bool prof = REXCVAR_GET(skate3_native_render_scene_perf_items); { std::lock_guard lock(g_item_cache_mutex); auto it = g_item_cache.find(mesh); if (it != g_item_cache.end() && frame < it->second.rebuild_frame) { CachedItemCore& core = it->second; // Structural validation: the mesh's pointer fields must still match // the cached walk. const bool intact = REX_LOAD_U32(mesh + kMeshVertexBuffer) == core.item.vb_obj && REX_LOAD_U32(mesh + kMeshIndexBuffer) == core.item.ib_obj; // Buffer payload address/size can move under the same objects // (streaming re-init), cheap guarded re-read every frame. uint32_t vb_words[3] = {}, ib_words[3] = {}; if (intact && GuestTryCopy(vb_words, REX_RAW_ADDR(core.item.vb_obj + kBufferPhysAddr), sizeof(vb_words)) && GuestTryCopy(ib_words, REX_RAW_ADDR(core.item.ib_obj + kBufferPhysAddr), sizeof(ib_words)) && (BSwap32(vb_words[0]) & 0xFFFFFFFC) == core.item.vb_addr && BSwap32(vb_words[2]) == core.item.vb_bytes && (BSwap32(ib_words[0]) & 0xFFFFFFFC) == core.item.ib_addr && BSwap32(ib_words[2]) == core.item.ib_count) { // Fingerprint cadence: per frame for CPU-rewritten payloads // (skinned/ropa), every 4th frame otherwise (mesh_revalidate heals // streamed fills within that window; address reuse is caught by the // registration invalidation immediately). const bool dynamic_payload = core.item.skinned || core.item.ropa; if (dynamic_payload || frame >= core.fp_frame) { const auto fp_t0 = prof ? PerfClock::now() : PerfClock::time_point{}; const bool fp_ok = ComputeItemFingerprint(base, core.item); if (prof) { g_pw_bi_fp.Add(PerfNsSince(fp_t0)); } if (!fp_ok) { g_rej_geom.fetch_add(1, std::memory_order_relaxed); g_item_cache.erase(it); return false; } core.fp_frame = frame + 4; } item = core.item; g_item_cache_hits.fetch_add(1, std::memory_order_relaxed); return true; } g_item_cache.erase(it); } } const auto walk_t0 = prof ? PerfClock::now() : PerfClock::time_point{}; const bool walked = BuildItemFromMesh(base, mesh, item); if (prof) { g_pw_bi_walk.Add(PerfNsSince(walk_t0)); } if (!walked) { return false; } g_item_cache_builds.fetch_add(1, std::memory_order_relaxed); std::lock_guard lock(g_item_cache_mutex); if (g_item_cache.size() >= 16384) { // Runaway growth backstop (streaming churn over a long session): drop // everything and let the next frames rebuild, one frame of full walks. g_item_cache.clear(); } CachedItemCore core; core.item = item; core.fp_frame = frame + 4; // Materials with an unresolved diffuse retry the full walk quickly: CAS // composites and streamed channel textures bind shortly after first sight // (ocean.default legitimately has no diffuse and just rebuilds often, // a handful of items). core.rebuild_frame = frame + (item.diffuse_tex != 0 ? 32 : 2); g_item_cache[mesh] = std::move(core); return true; } bool BuildItemGeometry(uint8_t* base, uint32_t ctx, DrawItem& item) { const uint32_t record = REX_LOAD_U32(ctx); if (!GuestReadableApprox(base, record)) { g_rej_chain.fetch_add(1, std::memory_order_relaxed); return false; } const uint32_t mesh = REX_LOAD_U32(record); if (!GuestReadableApprox(base, mesh)) { g_rej_chain.fetch_add(1, std::memory_order_relaxed); return false; } const bool prof = REXCVAR_GET(skate3_native_render_scene_perf_items); const auto core_t0 = prof ? PerfClock::now() : PerfClock::time_point{}; const bool built = BuildItemFromMeshCached(base, mesh, item); if (prof) { g_pw_bi_core.Add(PerfNsSince(core_t0)); } if (!built) { return false; } // Draw-time fetch overrides are per-frame state, applied after the cached // core, on every build. const auto fetch_t0 = prof ? PerfClock::now() : PerfClock::time_point{}; AdoptDrawFetchOverrides(base, item); if (prof) { g_pw_bi_fetch.Add(PerfNsSince(fetch_t0)); } // Identity for per-instance consumers (the occlusion cull's guest-side // dispatch filter keys on it; the dynamic capture path overwrites it with // its own value). item.ctx = ctx; // Culled island draw list from the context. const uint32_t draw_count = REX_LOAD_U16(ctx + kCtxDrawCountU16); const uint32_t draw_list = REX_LOAD_U32(ctx + kCtxDrawList); if (draw_count == 0 || draw_count > 512 || !GuestReadableApprox(base, draw_list)) { g_rej_draws.fetch_add(1, std::memory_order_relaxed); return false; } item.draws.reserve(draw_count); for (uint32_t i = 0; i < draw_count; ++i) { const uint32_t d = draw_list + i * 16; DrawEntry entry{REX_LOAD_U32(d), REX_LOAD_U32(d + 4), REX_LOAD_U32(d + 8), REX_LOAD_U32(d + 12)}; if (entry.index_count == 0 || entry.index_count > item.ib_count) continue; item.draws.push_back(entry); } if (item.draws.empty()) { g_rej_draws.fetch_add(1, std::memory_order_relaxed); return false; } return true; } } // namespace bool Enabled() { return SceneEnabled(); } void SetSettingsMenuBlur(bool enabled) { g_settings_menu_blur.store(enabled, std::memory_order_relaxed); REXLOG_INFO("native-scene: settings-menu blur {} (post-processor {})", enabled ? "ON" : "off", enabled ? "armed" : "disarms after ease-out"); if (enabled) { g_post_blur_log_count.store(0, std::memory_order_relaxed); // Arms the emulated-output post-processor (boot frames / manual emulated // mode). It disarms itself once the blur has fully eased out. rex::graphics::RequestNativeGuestOutputPostProcess(true); } } void FlushTextureCache() { g_flush_textures.store(true, std::memory_order_relaxed); } void RequestSceneRingDump() { g_scene_ring_dump.store(true, std::memory_order_release); } void FlushMeshCache() { g_flush_meshes.store(true, std::memory_order_relaxed); } // RecordBoneSignal / RecordCameraSignal: native/skate3_native_diagnostics.cpp. bool ToggleSceneEnabled() { if (!REXCVAR_GET(skate3_native_render)) { REXLOG_WARN( "native-scene: renderer toggle ignored; the skate3_native_render hook layer " "is off (set it and restart; it installs the capture hooks the scene needs)"); return false; } const bool enabled = !SceneEnabled(); if (enabled) { // Re-enabling doubles as the retry gesture after a hard pipeline // failure (which yields to the emulated output while the cvars stay // on): clear the latch so EnsurePipeline runs again. ResetSceneFailure(); // Capture idles while the emulated renderer is active, so anything still // published is from before the switch away (stale camera). Drop it: // RenderScene yields to the emulated frame until the capture hooks // publish a fresh scene (the next frame). { std::lock_guard lock(g_scene_mutex); g_scene.reset(); } { std::lock_guard lock(g_2d_mutex); g_scene_2d.clear(); g_scene_spline.clear(); } // Warm up before taking over: after a long emulated stretch the decode // caches can be cold/stale, and the takeover frame would pay the whole // decode burst at once. A warm cache completes warmup in one frame. g_warmup_armed.store(true, std::memory_order_relaxed); // The off-screen retention map is equally stale after the gap. g_retained_clear.store(true, std::memory_order_relaxed); } REXCVAR_SET(skate3_native_render_scene, enabled); REXLOG_INFO("native-scene: switched to the {} renderer (runtime toggle)", enabled ? "NATIVE" : "EMULATED"); return enabled; } void OnRegisterTexture(uint64_t guid, uint32_t texture) { if (texture == 0) { return; } std::lock_guard lock(g_texture_map_mutex); g_texture_map[guid & kGuidMask] = texture; } void OnMeshRegistered(uint8_t* base, uint32_t mesh) { if (mesh == 0) { return; } // A (re-)registration is the "content changed at this address" signal; // drop the cached item core so the next frame re-walks it. InvalidateCachedItem(mesh); // Publish the guest base here too: at boot the loading-screen prewarm // workers run before the first gameplay frame would otherwise publish it. g_guest_base.store(base, std::memory_order_relaxed); std::lock_guard lock(g_prewarm_mutex); const uint64_t now = g_frames_rendered.load(std::memory_order_relaxed); const auto [it, fresh] = g_prewarm_seen.try_emplace(mesh, now); // 300-frame (~2 s) dedup window: activation bursts re-register the same // model once per clone instance: queue it once; a re-registration past // the window is a re-stream (content changed at this address) and must // re-decode BEFORE its first draw or it pops in as a visible miss. if (!fresh) { if (now - it->second < 300) { return; } it->second = now; } if (g_prewarm_queue.size() < 65536) { g_prewarm_queue.push_back({mesh, 60}); g_prewarm_cv.notify_one(); } } // Validate a candidate tRModelData against the layout the game's OWN // AddRenderInstance (sub_82791290) walks: mesh table pointer at model+0x24, // mesh COUNT as a u16 at model+0x32 (`lhz r10,50(r11)`). Table entries are // 8 bytes: word0 = mesh, word1 = 0 for the optimesh world form (its // material lives at mesh+0x24, which is also why a {mesh, material} pair // scan finds nothing) / non-zero for the tRMeshData character/prop form. // Every dereference is SEH-guarded: the offset probe feeds this arbitrary // instance words (bbox floats, guids) as candidates. static bool PlausibleRModel(uint8_t* base, uint32_t model) { uint32_t count_w = 0, table = 0; if (!GuestTryLoadU32(base, model + 0x30, &count_w) || !GuestTryLoadU32(base, model + 0x24, &table)) { return false; } const uint32_t num_meshes = count_w & 0xFFFF; // u16 at +0x32 if (num_meshes == 0 || num_meshes > 512) { return false; } // Entry 0: the mesh must carry a plausible material (channel count sane). uint32_t mesh0 = 0, mesh0_mat = 0, mat_channels = 0; return GuestTryLoadU32(base, table, &mesh0) && GuestTryLoadU32(base, mesh0 + kMeshMaterial, &mesh0_mat) && mesh0_mat >= 0x10000 && GuestTryLoadU32(base, mesh0_mat, &mat_channels) && mat_channels != 0 && mat_channels <= 64; } // Queue every optimesh-form mesh of a validated tRModelData for the prewarm // decode workers. static void QueueModelMeshes(uint8_t* base, uint32_t model) { uint32_t count_w = 0, table = 0; if (!GuestTryLoadU32(base, model + 0x30, &count_w) || !GuestTryLoadU32(base, model + 0x24, &table)) { return; } const uint32_t num_meshes = count_w & 0xFFFF; for (uint32_t i = 0; i < num_meshes && i < 512; ++i) { uint32_t mesh = 0, entry_mat = 0; if (!GuestTryLoadU32(base, table + i * 8, &mesh) || !GuestTryLoadU32(base, table + i * 8 + 4, &entry_mat)) { continue; } if (entry_mat != 0) { continue; // tRMeshData form (characters/props), wrong offsets } OnMeshRegistered(base, mesh); } } void OnModelFixup(uint8_t* base, uint32_t model) { // Fires per model during the load's DISK-STREAMING phase (arena fixup), // the early prewarm source. The validation gate keeps non-render models // (and any layout drift) out of the queue. if (model != 0 && PlausibleRModel(base, model)) { QueueModelMeshes(base, model); } } void OnAddRenderInstance(uint8_t* base, uint32_t instance) { if (instance == 0) { return; } // Resolve tInstance::m_pRModel. The game's own AddRenderInstance reads it // at +0x80 (`lwz r11,128(r4)`); the offset is still confirmed by the // validation probe before first use so an image-version drift degrades to // "prewarm off" instead of queueing garbage. uint32_t off = g_instance_rmodel_offset.load(std::memory_order_relaxed); if (off == 0) { for (uint32_t cand = 0x80; cand < 0xC0; cand += 4) { uint32_t model = 0; if (GuestTryLoadU32(base, instance + cand, &model) && PlausibleRModel(base, model)) { g_instance_rmodel_offset.store(cand, std::memory_order_relaxed); REXLOG_INFO("native-scene: tInstance::m_pRModel offset confirmed at +0x{:X}", cand); off = cand; break; } } if (off == 0) { return; // CModel-only / embedded instance, or the model is not ready } } uint32_t model = 0; if (!GuestTryLoadU32(base, instance + off, &model) || !PlausibleRModel(base, model)) { return; // instances without a renderable model are normal } QueueModelMeshes(base, model); } struct PfxCapture { float ps[32][4]; float vs[8][4]; uint32_t fetch[8][6]; int64_t ps_ns; // PerfClock stamp of the last PS-row capture (0 = never) bool vs_seen; }; PfxCapture g_pfx_cap[kPfxPassCount] = {}; void OnPhotoGrabRequest() { g_photo_grab_request_ns.store( std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(), std::memory_order_relaxed); } void OnPhotoGrabDone() { g_photo_grab_done_ns.store( std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(), std::memory_order_relaxed); } // True while the photo display-card quad is in the live 2D stream during an // armed photo flow; the compose auto-trace trigger // (skate3_native_render.cpp) keys on it. bool PhotoCardVisible() { if (!g_photo_flow_frame.load(std::memory_order_relaxed)) { return false; } const int64_t seen = g_photo_card_seen_ns.load(std::memory_order_relaxed); if (seen < 0) { return false; } const int64_t now_ns = std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(); return now_ns - seen < 250'000'000; } // Debug-path classification, cached per shader object (guest render thread // only, like the blur classifier). -1 = not a photo postfx shader. int ClassifyPfxShader(uint8_t* base, uint32_t obj) { if (obj == 0 || !GuestReadableApprox(base, obj)) { return -1; } static std::unordered_map cache; auto it = cache.find(obj); if (it != cache.end()) { return it->second; } char text[112] = {}; for (int k = 0; k < 111; ++k) { text[k] = char(REX_LOAD_U8(obj + 0x54 + k)); if (text[k] == '\0') break; } int pass = -1; if (std::strstr(text, "postfx_visualfxPS") != nullptr) { pass = kPfxVisualFx; } else if (std::strstr(text, "bloom_dof_motionblur_dof_dowsample") != nullptr) { // sic: the game's own shader name carries the 'dowsample' typo. pass = kPfxDofDown; } else if (std::strstr(text, "bloom_dof_tap9dofMotionBlur") != nullptr) { pass = kPfxDofMB; } else if (std::strstr(text, "bloom_dof_tap9dofPS") != nullptr) { pass = kPfxDof; } else if (std::strstr(text, "postfx_uberPS") != nullptr) { pass = kPfxUber; } else if (std::strstr(text, "postfx_basictex_fisheye") != nullptr) { pass = kPfxFisheye; } if (cache.size() < 4096) { cache.emplace(obj, pass); } return pass; } void CapturePfxConstants(uint8_t* base, uint32_t bank_ptr, uint32_t device, bool pixel) { // Shader labels can be swapped in the OnSetShader hook; classify both. int pass = ClassifyPfxShader(base, g_cur_ps_obj.load(std::memory_order_relaxed)); if (pass < 0) { pass = ClassifyPfxShader(base, g_cur_vs_obj.load(std::memory_order_relaxed)); } if (pass < 0) { return; } PfxCapture& cap = g_pfx_cap[pass]; // First-hit diagnostics: which passes ever capture, and on which side. static uint8_t s_seen[kPfxPassCount][2] = {}; if (!s_seen[pass][pixel ? 0 : 1]) { s_seen[pass][pixel ? 0 : 1] = 1; REXLOG_INFO("native-scene: pfx capture first hit pass={} {} bank={:08X}", pass, pixel ? "PS" : "VS", bank_ptr); } if (pixel) { for (int r = 0; r < 32; ++r) { for (int i = 0; i < 4; ++i) { cap.ps[r][i] = LoadGuestF32(base, bank_ptr + uint32_t(r * 4 + i) * 4); } } if (device != 0 && GuestReadableApprox(base, device + 0x480)) { for (int s = 0; s < 8; ++s) { for (int w = 0; w < 6; ++w) { cap.fetch[s][w] = REX_LOAD_U32(device + 0x480 + uint32_t(s * 6 + w) * 4); } } } cap.ps_ns = std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(); } else { for (int r = 0; r < 8; ++r) { for (int i = 0; i < 4; ++i) { cap.vs[r][i] = LoadGuestF32(base, bank_ptr + uint32_t(r * 4 + i) * 4); } } cap.vs_seen = true; } } void OnVsConstantUpload(uint8_t* base, uint64_t mask, uint32_t bank, uint32_t ptr, uint32_t device) { (void)base; (void)mask; if (device != 0) { g_device.store(device, std::memory_order_relaxed); } if (!SceneEnabled() || ptr == 0) { return; } if (bank == 0x4400) { g_ps_bank.store(ptr, std::memory_order_relaxed); return; } if (bank != 0x4000) { return; } g_vs_uploads.fetch_add(1, std::memory_order_relaxed); g_vs_bank.store(ptr, std::memory_order_relaxed); // NOTE: the photo postfx constant capture used to also run here (upload // time), but upload-time snapshots can attribute rows staged for the NEXT // pass to the previous pass's shader (the game stages constants before // rebinding), overwriting the correct draw-time capture taken by the // OnDrawDone hook; the F11 per-draw records read the same banks at the // draw hook and are correct for every postfx pass, so the draw hook is // the sole capture site now. } void OnPhotoReplayUpdate() { g_photo_replay_last_ns.store( std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(), std::memory_order_relaxed); } void OnTakePhoto() { g_take_photo_last_ns.store( std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(), std::memory_order_relaxed); } void OnMovieDecode() { g_movie_decode_last_ns.store( std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(), std::memory_order_relaxed); } void OnMovieFrame(uint8_t* base, uint32_t renderer) { // Plane texture members of VideoRenderer_RwTexture, from the recompiled // Render body (Lock/FillTextureData/Unlock trios): [this+12] = Y, // [this+124] = U, [this+68] = V; the fill sources are the renderable's // y/u/v buffers at +8/+12/+16 (TransferYUVBuffer order). static constexpr uint32_t kPlaneOfs[3] = {12, 124, 68}; MoviePlanes mf; for (int p = 0; p < 3; ++p) { uint32_t obj = 0; if (!GuestTryLoadU32(base, renderer + kPlaneOfs[p], &obj) || obj < 0x10000) { return; } // Stable fetch-words read at tex+0x1C (the D3D12 section's // ReadStableTexWords, inlined to keep this compiled guest-side). uint32_t raw[6], raw2[6]; if (!GuestTryCopy(raw, base + obj + 0x1C, sizeof(raw)) || !GuestTryCopy(raw2, base + obj + 0x1C, sizeof(raw2)) || std::memcmp(raw, raw2, sizeof(raw)) != 0) { return; } for (int i = 0; i < 6; ++i) { mf.words[p][i] = BSwap32(raw[i]); } if ((mf.words[p][0] & 3u) != 2 || mf.words[p][1] == 0) { return; // not a live texture fetch constant } } mf.y_addr = mf.words[0][1]; mf.ns = std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(); bool new_key = false; { std::lock_guard lock(g_movie_mutex); int slot = 0; for (int m = 0; m < kMaxMovies; ++m) { if (g_movies[m].y_addr == mf.y_addr) { slot = m; break; } if (g_movies[m].ns < g_movies[slot].ns) { slot = m; // evict the stalest entry for a new video } } new_key = g_movies[slot].y_addr != mf.y_addr; g_movies[slot] = mf; } OnMovieDecode(); // shared FMV heartbeat (the emulated-yield fallback) static std::atomic s_logged{0}; if (new_key && s_logged.fetch_add(1, std::memory_order_relaxed) < 8) { REXLOG_INFO( "native-scene: FMV planes published (y=[{:08X} {:08X}] u=[{:08X} " "{:08X}] v=[{:08X} {:08X}])", mf.words[0][0], mf.words[0][1], mf.words[1][0], mf.words[1][1], mf.words[2][0], mf.words[2][1]); } } void On2dPhase(uint32_t bit, bool enter) { if (bit >= 6) { return; } if (enter) { g_phase2d_depth[bit].fetch_add(1, std::memory_order_relaxed); } else { g_phase2d_depth[bit].fetch_sub(1, std::memory_order_relaxed); } } uint32_t Phase2dFlags() { uint32_t flags = 0; for (uint32_t bit = 0; bit < 6; ++bit) { if (g_phase2d_depth[bit].load(std::memory_order_relaxed) != 0) { flags |= 1u << bit; } } return flags; } // Guest render thread only: per-object cached debug-path classifier // (same pattern as ClassifySplineShader). bool IsCasEditorPs(uint8_t* base, uint32_t obj) { if (obj < 0x10000) { return false; } static std::unordered_map cache; auto it = cache.find(obj); if (it != cache.end()) { return it->second; } char text[97] = {}; if (!GuestTryCopy(text, base + obj + 0x54, 96)) { return false; // not cached: unreadable now may be readable later } text[96] = '\0'; const char* leaf = std::strrchr(text, '\\'); leaf = leaf ? leaf + 1 : text; const bool cas = std::strstr(leaf, "_nis") != nullptr && (std::strncmp(leaf, "cacstamp_", 9) == 0 || std::strncmp(leaf, "cac_", 4) == 0 || std::strncmp(leaf, "defaultcharacter_", 17) == 0); if (cache.size() > 4096) { cache.clear(); } cache.emplace(obj, cas); return cas; } void OnSetShader(bool pixel, uint32_t obj) { (pixel ? g_cur_ps_obj : g_cur_vs_obj).store(obj, std::memory_order_relaxed); // Classify BOTH labels: the hook's pixel/vertex flags are SWAPPED // relative to the real shader types (see ClassifySplineShader, which // checks both trackers for the same reason); gating on `pixel` alone // left the heartbeat dead (the *_nisPS debug paths arrive on the other // label), which blackholed the startup-flow editor. uint8_t* base = g_guest_base.load(std::memory_order_relaxed); if (base != nullptr && IsCasEditorPs(base, obj)) { g_cas_ps_last_ns.store(std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(), std::memory_order_relaxed); } } // The CAS editor is ON SCREEN now: its own "_nis" pixel shaders were set // within the last 0.5 s (see IsCasEditorPs). Consumed by the portrait-pass // publish gate and the takeover gates; the STARTUP-flow editor's scene is // small (below warmup_min_items) and can look like a portrait pass, so the // gates need to know the editor is up. bool CasEditorHeartbeatFresh() { const int64_t last_ns = g_cas_ps_last_ns.load(std::memory_order_relaxed); if (last_ns < 0) { return false; } const int64_t now_ns = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); return now_ns - last_ns < 500'000'000; } // Full CAS-editor detection: FE push-state screen id 15 (present in BOTH // flows, pause: [0,56,63,15], startup new-game: [0,67,15], verified in // capture) OR the _nis shader heartbeat as backup. Cheap // (a handful of guarded u32 reads); stateless per frame. bool CasEditorActive(uint8_t* base) { if (CasEditorHeartbeatFresh()) { return true; } constexpr uint32_t kFrontEndManagerPtr = 0x830CFE14; uint32_t mgr = 0, beg = 0, end = 0; if (GuestTryLoadU32(base, kFrontEndManagerPtr, &mgr) && mgr != 0 && GuestTryLoadU32(base, mgr + 0x210, &beg) && GuestTryLoadU32(base, mgr + 0x214, &end) && beg < end && end - beg <= 20 * 16) { const uint32_t n = (end - beg) / 20; for (uint32_t i = 0; i < n; ++i) { uint32_t f0 = 0; if (GuestTryLoadU32(base, beg + i * 20, &f0) && f0 == 15) { return true; } } } return false; } // Should the emulated sub-framebuffer RTT passes execute right now (the // menus' suppress_mode -> 0 forcing, see menu_rtt_scope)? The one-shot // skater-portrait RTT renders are the only reason menus need mode 0, and // they fire at SCREEN TRANSITIONS (screen open / accepted edit), but // holding mode 0 for the whole menu context kept the game's postfx chain // executing emulated at scale every menu frame (full world postfx under // the pause menu; movie postfx on FMV screens), pacing the pipeline // against the native renderer. Window = TRUE (mode 0) when: // - the FE push-state stack is unreadable (fail open = old behavior), or // - it contains any screen id NOT known steady-safe (0 = FE root, 56 = // pause root, 24 = FMV, surveyed across captured sessions), or // - it CHANGED within the last 3 s (covers unknown screens' entry // one-shots and the open-race before the id lands), or // - the CAS editor heartbeat is fresh. // Render thread only (statics). Every stack change logs its ids (capped) // so unknown portrait screens name themselves in the session log. bool PortraitRttWindowActive() { if (CasEditorHeartbeatFresh()) { return true; } uint8_t* base = g_guest_base.load(std::memory_order_relaxed); if (base == nullptr) { return true; } constexpr uint32_t kFrontEndManagerPtr = 0x830CFE14; uint32_t mgr = 0, beg = 0, end = 0; if (!GuestTryLoadU32(base, kFrontEndManagerPtr, &mgr) || mgr == 0 || !GuestTryLoadU32(base, mgr + 0x210, &beg) || !GuestTryLoadU32(base, mgr + 0x214, &end) || beg > end || end - beg > 20 * 16) { return true; } const uint32_t n = (end - beg) / 20; uint32_t ids[16] = {}; bool unknown_screen = false; uint64_t sig = 1469598103934665603ull; for (uint32_t i = 0; i < n; ++i) { uint32_t f0 = 0; if (!GuestTryLoadU32(base, beg + i * 20, &f0)) { return true; } ids[i] = f0; sig = (sig ^ f0) * 1099511628211ull; // Steady-safe screens (no one-shot portrait RTT renders): 0 = FE root, // 56 = pause root, 24 = FMV, 17 = pause challenge map (the game FLAPS // [0,56]<->[0,17] ~1/s while the map is open, with 17 // unclassified every flap re-armed the grace window and held mode 0, // pinning the map screen at 62-94 fps), 59 = skate-reel browser // (measured A/B: the edit-skater flow pushes // [0,56,63]->[0,56,63,15] and legitimately needs the window; the reel // screen pushes [0,56,59] and its panels are VIDEOS served by the // native FMV substitution, not portrait RTTs; the fail-open window // there bought only its costs, 73 fps + 167 ms sync-compile stalls). if (f0 != 0 && f0 != 56 && f0 != 24 && f0 != 17 && f0 != 59) { unknown_screen = true; } } static uint64_t s_last_sig = 0; static int64_t s_change_ns = -1; static bool s_prev_unknown = false; const int64_t now_ns = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); if (sig != s_last_sig) { s_last_sig = sig; // Grace only when an UNSAFE screen is involved; entering one is // already covered by presence (fail-open), so the window's real job is // the tail after LEAVING one (the accepted-edit portrait re-render // fires as the editor pops). Safe<->safe transitions (pause <-> // challenge map flapping) must not arm it. if (unknown_screen || s_prev_unknown) { s_change_ns = now_ns; } s_prev_unknown = unknown_screen; static uint32_t s_logs = 0; if (s_logs < 64) { ++s_logs; char buf[128] = ""; // n == 0 leaves the loop unrun; print "[]" int off = 0; for (uint32_t i = 0; i < n && off < int(sizeof(buf)) - 8; ++i) { off += std::snprintf(buf + off, sizeof(buf) - off, "%s%u", i ? "," : "", ids[i]); } REXLOG_INFO( "native-scene: FE stack changed: [{}] (portrait window {}; " "steady-safe = 0/56/24/17/59)", buf, unknown_screen ? "OPEN - unclassified screen" : "steady"); } } if (s_change_ns >= 0 && now_ns - s_change_ns < 3'000'000'000ll) { return true; } return unknown_screen; } void OnRenderStateUpload(uint64_t mask, uint32_t bank, uint32_t ptr) { (void)mask; if (ptr == 0) { return; } // Log each distinct bank id once, the AluConstants analog of discovering // 0x4000/0x4400. Tiny lock-free seen-set (at most a handful of banks). static std::atomic seen[8]; for (auto& slot : seen) { uint32_t cur = slot.load(std::memory_order_relaxed); if (cur == bank) { break; } if (cur == 0) { uint32_t expected = 0; if (slot.compare_exchange_strong(expected, bank, std::memory_order_relaxed)) { REXLOG_INFO("native-scene: render-state bank id={:#x} ptr={:08X}", bank, ptr); break; } if (expected == bank) { break; } } } g_rs_bank.store(ptr, std::memory_order_relaxed); } void OnSetViewport(uint8_t* base, uint32_t viewport_ptr) { if (viewport_ptr == 0) { return; } for (int i = 0; i < 6; ++i) { g_cur_viewport[i].store(REX_LOAD_U32(viewport_ptr + i * 4), std::memory_order_relaxed); } } void OnSetScissor(uint8_t* base, uint32_t rect_ptr) { if (rect_ptr == 0) { return; } for (int i = 0; i < 4; ++i) { g_cur_scissor[i].store(REX_LOAD_U32(rect_ptr + i * 4), std::memory_order_relaxed); } } // The character.hair pixel shader keeps the per-character hair color at PS // c17 (fixed layout for that shader; verified from recorded PS banks; // c16/c17 hold the dark ambient/diffuse hair color pair while other // registers carry lighting globals). Used as captured. void CaptureHairTint(uint8_t* base, DrawItem& item) { const uint32_t ps = g_ps_bank.load(std::memory_order_relaxed); if (ps == 0) { return; } float rgb[3]; for (int i = 0; i < 3; ++i) { rgb[i] = LoadGuestF32(base, ps + (17 * 4 + i) * 4); if (!(rgb[i] >= 0.0f && rgb[i] <= 4.0f)) { return; // implausible bank contents; keep the previous/no tint } } for (int i = 0; i < 3; ++i) { item.tint[i] = rgb[i]; } item.tint[3] = 1.0f; } // Character-family lighting capture: reads the family-specific rows of the // PIXEL constant bank into the canonical block the scene PS character branch // consumes (cbuffer CH at b2). Row maps come from the disassembled Skate 3 // pixel shaders (offline-validated by running the actual ucode per pixel): // defaultcharacter (fam 1): light c0, key c6, ambMult c10.w, SH c14..c22 // scaled by c12.y, exposure c4.z, alpha c13.x. // cacstamp/cac_* (fam 2): light c9, key c15, ambMult c19.w, SH c24..c32 // scaled by c21.y, exposure c13.z, alpha c22.x, diffuse tint c23. // livingworld_stamp (fam 3): light c9, key c15, FLAT ambient = c19.w * // (0.1, 0.175, 0.3) (shader literal), exposure c13.z, alpha c21.x, stamp // recolor tints c22 (red mask) / c23 (blue mask). // cac_hair (fam 4): light c4, key c16, ambient c14.w * 0.25, fresnel tint // c17 with power c11.w, exposure c8.z, strand-alpha scale c15.x. // defaulthair (fam 5): light c4, key c15, ambient c14.w * 0.25, fresnel // tint c10 with power c9.z, exposure c8.z, strand-alpha scale c16.x. // vehicle (fam 6, character.livingworld_vehicles): light c9, key c15 // (fresnel power in c15.w), FLAT ambient = c19.w * (0.1, 0.175, 0.3) // (same literal as livingworld), exposure c13.z, alpha c20.x (the // per-entity spawn/distance fade), phong spec color c16 // with power c16.w (stored in the unused SH row 0), paint recolor // colorize_red c21 / colorize_blue c22 (vehicle.fx: where diffuse green // is below the mask threshold, rgb = r * red_tint + b * blue_tint; // that is the taxi yellow; validated by executing the captured // vehicle_defaultPS offline). // vehicle glass (fam 7): same rows; glass tint c18.rgb (zero = the color // is reflection-only) in the tintA slot, alpha out = c20.x * c18.w. // The SH irradiance evaluation is sat(c_base + s*(N.x*r1 + N.y*r2 + N.z*r3) // + s^2*(NxNz*r4 + NzNy*r5 + NyNx*r6) + (3 s^2 Nz^2 - 1)*r7 + s^2*(Nx^2 - // Ny^2)*r8); the scale and the -1 are folded into the stored rows so the // shader evaluates a plain 9-row basis. // // Canonical block (18 float4 rows): [0] = light dir + hair fresnel power, // [1] = key color + exposure, [2] = flat ambient rgb + SH-ambient // multiplier (hair ambient scalar in w), [3..11] = SH rows, [12] = tintA // (w = apply), [13] = tintB + strand-alpha scale (fams 1/2: w = the // material multiplier m_params[0].y instead), [14].x = alpha out, // [14].y = family (0 = capture failed validation -> legacy shading), // [14].z = lens-alpha flag, [14].w = rim fresnel power, [15] = key spec // color + power, [16] = rim spec color + power, [17] = rim light color + // key-spec fresnel power. The spec/rim rows (defaultcharacter: spec c7, // rim spec c8, rim c11, fresnel powers c6.w/c5.z; cacstamp: c16/c17/c20, // c15.w/c14.z [+ the editor row shift]) exist on fams 1/2 only; [15].w // stays 0 when they fail their range gates and the PS terms vanish. void CaptureCharLighting(uint8_t* base, DrawItem& item) { if (item.char_family == 0) { return; } const uint32_t ps = g_ps_bank.load(std::memory_order_relaxed); if (ps == 0) { return; } g_char_attempts.fetch_add(1, std::memory_order_relaxed); const auto row = [&](uint32_t r, uint32_t c) { return LoadGuestF32(base, ps + (r * 4 + c) * 4); }; // Build locally and commit only on success: the capture can run again on a // later draw with the same buffers (the caster-pass bank is stale; its // shadowPS touches no PS constants), and a failed refresh must not wipe // rows a previous successful capture staged. float local[72]; float* d = local; std::memset(local, 0, sizeof(local)); uint8_t fam = item.char_family; // `plus` = register-row shift for the create-a-skater EDITOR compiles of // the fam-2 shaders (cacstamp_/cac_*_nisPS + the ropa variants): the whole // cacstamp layout sits ONE ROW HIGHER there: light c10, key c16, exposure // c14.z, ambMult c20.w, SH c25..c33 scaled by c22.y, alpha c23.x, tint c24 // (ucode-proven from cac_cloth_nisPS, offline-validated on every fam-2 // draw in capture: skin tint (0.80,0.68,0.64), // key (1.0,0.95,0.9), expo 1.5, unit light at c10). The editor's VS/ropa // palette layout is UNCHANGED (flag c7, palette c8, rigid c191 - draw-41 // VS disasm), and the editor hair compile keeps the gameplay fam-4 rows, // so lighting rows are the only editor delta. const auto rows_valid = [&](uint8_t f, uint32_t plus, float* light, float* expo, float* key) { uint32_t light_r = 9, key_r = 15, expo_r = 13, expo_c = 2; switch (f) { case 1: light_r = 0; key_r = 6; expo_r = 4; expo_c = 2; break; case 2: case 3: case 6: case 7: break; // defaults above case 4: light_r = 4; key_r = 16; expo_r = 8; expo_c = 2; break; case 5: light_r = 4; key_r = 15; expo_r = 8; expo_c = 2; break; } light_r += plus; key_r += plus; expo_r += plus; float norm2 = 0.0f; for (int i = 0; i < 3; ++i) { light[i] = row(light_r, uint32_t(i)); if (!(light[i] > -4.0f && light[i] < 4.0f)) return false; norm2 += light[i] * light[i]; } // A real light-direction row carries w = 0; the world materials' // shadow-transform rows are ALSO unit in xyz but keep the light-space // translation (hundreds of meters) in w; reject those banks. const float light_w = row(light_r, 3); if (!(light_w > -1.0f && light_w < 1.0f)) return false; *expo = row(expo_r, expo_c); for (int i = 0; i < 3; ++i) { key[i] = row(key_r, uint32_t(i)); if (!(key[i] >= 0.0f && key[i] < 64.0f)) return false; } // The bank can hold another pass's constants at our capture moment // (characters render a shadow-caster pass first); the light-dir norm // and exposure gates reject those; the item then keeps its previous / // legacy shading. return norm2 > 0.25f && norm2 < 2.25f && *expo > 0.25f && *expo < 16.0f; }; float light[3]; float key[3]; float expo = 0.0f; uint32_t plus = 0; if (!rows_valid(fam, 0, light, &expo, key)) { // NPC skin (character_skin_defaultPS) shares the "character.skin" // attribulator name with the CAC player skin (cacstamp_skin) but uses // the DEFAULTCHARACTER register layout; the two banks are mutually // exclusive on the light-row position (the other layout's slot holds // non-unit data), so a failed fam-2 read retries as fam 1, then as the // EDITOR fam-2 layout (+1 row, see `plus` above); the create-a-skater // screen's _nis compiles, where both standard maps read junk. if (fam == 2 && rows_valid(1, 0, light, &expo, key)) { fam = 1; } else if (fam == 2 && rows_valid(2, 1, light, &expo, key)) { plus = 1; } else { static std::atomic rej_log{0}; const uint32_t n = rej_log.fetch_add(1, std::memory_order_relaxed); if (n < 16 || (n & 2047u) == 0) { REXLOG_DEBUG( "native-scene: char capture REJECTED fam={} light=({:.3f},{:.3f},{:.3f}) " "expo={:.3f} key=({:.3f},{:.3f},{:.3f})", fam, light[0], light[1], light[2], expo, key[0], key[1], key[2]); } return; } } d[0] = light[0]; d[1] = light[1]; d[2] = light[2]; d[4] = key[0]; d[5] = key[1]; d[6] = key[2]; d[7] = expo; if (fam == 1 || fam == 2) { const uint32_t sh_base = (fam == 1 ? 14u : 24u) + plus; const float s = row((fam == 1 ? 12u : 21u) + plus, 1); const float s2 = s * s; const float amb_mult = row((fam == 1 ? 10u : 19u) + plus, 3); if (!(s > -8.0f && s < 8.0f) || !(amb_mult >= 0.0f && amb_mult < 16.0f)) { return; } d[2 * 4 + 3] = amb_mult; for (int r = 0; r < 9; ++r) { const float scale = r == 0 ? 1.0f : (r <= 3 ? s : (r == 7 ? 3.0f * s2 : s2)); for (int c = 0; c < 3; ++c) { float v = row(sh_base + uint32_t(r), uint32_t(c)) * scale; if (!(v > -64.0f && v < 64.0f)) v = 0.0f; d[(3 + r) * 4 + c] = v; } } for (int c = 0; c < 3; ++c) { // Fold the (3 s^2 Nz^2 - 1) term's -1 into the base row. d[3 * 4 + c] -= row(sh_base + 7u, uint32_t(c)); } if (fam == 2) { // CAC diffuse/skin tint (c23; editor c24), multiplies the squared // diffuse. bool tint_ok = true; float tint[3]; for (int c = 0; c < 3; ++c) { tint[c] = row(23u + plus, uint32_t(c)); tint_ok = tint_ok && tint[c] >= 0.0f && tint[c] < 16.0f; } if (tint_ok) { d[12 * 4 + 0] = tint[0]; d[12 * 4 + 1] = tint[1]; d[12 * 4 + 2] = tint[2]; d[12 * 4 + 3] = 1.0f; } } // Material multiplier m_params[0].y (defaultcharacter c5.y / cacstamp // c14.y): the PS multiplies the lit color by it before the tone chain // (1.2 on the gameplay banks). Rides the fam-1/2-unused tintB.w row; // out-of-range keeps 0 and the PS applies 1. { const float m0y = row((fam == 1 ? 5u : 14u) + plus, 1); if (m0y > 0.25f && m0y < 4.0f) { d[13 * 4 + 3] = m0y; } } // Rim light + key/rim phong spec rows (see the header comment; observed // gameplay banks: key spec white pow 5, rim spec gray pow 67/255, rim // 0.2, fresnel powers 0.2-2.0). All-or-nothing behind range gates; // a bank that fails them keeps [15].w at 0 and the PS skips the terms. { const uint32_t ks_r = (fam == 1 ? 7u : 16u) + plus; const uint32_t rs_r = (fam == 1 ? 8u : 17u) + plus; const uint32_t rim_r = (fam == 1 ? 11u : 20u) + plus; const float kfres = row((fam == 1 ? 6u : 15u) + plus, 3); const float rfres = row((fam == 1 ? 5u : 14u) + plus, 2); float sr[12]; bool spec_ok = kfres >= 0.0f && kfres < 512.0f && rfres >= 0.0f && rfres < 512.0f; for (int r = 0; r < 3 && spec_ok; ++r) { const uint32_t src = r == 0 ? ks_r : (r == 1 ? rs_r : rim_r); for (int c = 0; c < 3; ++c) { sr[r * 4 + c] = row(src, uint32_t(c)); spec_ok = spec_ok && sr[r * 4 + c] >= 0.0f && sr[r * 4 + c] < 64.0f; } sr[r * 4 + 3] = row(src, 3); } spec_ok = spec_ok && sr[3] >= 0.5f && sr[3] < 512.0f && sr[7] >= 0.5f && sr[7] < 512.0f; if (spec_ok) { for (int c = 0; c < 4; ++c) { d[15 * 4 + c] = sr[c]; d[16 * 4 + c] = sr[4 + c]; } d[17 * 4 + 0] = sr[8]; d[17 * 4 + 1] = sr[9]; d[17 * 4 + 2] = sr[10]; d[17 * 4 + 3] = kfres; d[14 * 4 + 3] = rfres; } } 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; d[2 * 4 + 0] = amb * 0.1f; d[2 * 4 + 1] = amb * 0.175f; d[2 * 4 + 2] = amb * 0.3f; for (int c = 0; c < 3; ++c) { d[12 * 4 + c] = std::clamp(row(22u, uint32_t(c)), 0.0f, 4.0f); d[13 * 4 + c] = std::clamp(row(23u, uint32_t(c)), 0.0f, 4.0f); } d[12 * 4 + 3] = 1.0f; d[14 * 4 + 0] = std::clamp(row(21u, 0), 0.0f, 1.0f); } else if (fam == 6 || fam == 7) { // vehicle.fx body / vehicle_glass.fx windows (row map above). The // otherwise-unused SH row 0 carries the phong spec color + power. const float amb = row(19u, 3); if (!(amb >= 0.0f && amb < 16.0f)) return; d[2 * 4 + 0] = amb * 0.1f; d[2 * 4 + 1] = amb * 0.175f; d[2 * 4 + 2] = amb * 0.3f; d[3] = std::clamp(row(15u, 3), 1.0f, 64.0f); // fresnel power c15.w for (int c = 0; c < 4; ++c) { d[3 * 4 + c] = std::clamp(row(16u, uint32_t(c)), 0.0f, 64.0f); } if (fam == 6) { for (int c = 0; c < 3; ++c) { d[12 * 4 + c] = std::clamp(row(21u, uint32_t(c)), 0.0f, 4.0f); d[13 * 4 + c] = std::clamp(row(22u, uint32_t(c)), 0.0f, 4.0f); } d[12 * 4 + 3] = 1.0f; // Entity opacity: vehicle_defaultPS ends `max oC0.w, c20.x, c20.x`, // the same per-entity constant the glass variant multiplies by its // material alpha. The LivingWorld spawn/distance fade rides here; // 1.0 once the vehicle is fully faded in. d[14 * 4 + 0] = std::clamp(row(20u, 0), 0.0f, 1.0f); } else { // Glass tint c18.rgb multiplies the ambient/key terms (zero in every // capture = reflection-only glass); alpha out = c20.x * c18.w. for (int c = 0; c < 3; ++c) { d[12 * 4 + c] = std::clamp(row(18u, uint32_t(c)), 0.0f, 4.0f); } d[12 * 4 + 3] = 1.0f; d[14 * 4 + 0] = std::clamp(row(20u, 0) * row(18u, 3), 0.0f, 1.0f); } } else { // Hair: flat ambient scalar + fresnel rim tint, strand-alpha scale. const float amb = row(14u, 3) * 0.25f; d[2 * 4 + 3] = std::clamp(amb, 0.0f, 4.0f); d[3] = std::clamp(row(fam == 4 ? 11u : 9u, fam == 4 ? 3u : 2u), 1.0f, 64.0f); const uint32_t fres_r = fam == 4 ? 17u : 10u; for (int c = 0; c < 3; ++c) { d[13 * 4 + c] = std::clamp(row(fres_r, uint32_t(c)), 0.0f, 8.0f); } d[13 * 4 + 3] = std::clamp(row(fam == 4 ? 15u : 16u, 0), 0.0f, 4.0f); 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 // (see g_char_rows_cache). Runaway-growth backstop only; mesh keys are // bounded by loaded character content in practice. if (g_char_rows_cache.size() > 4096) { g_char_rows_cache.clear(); } std::memcpy(g_char_rows_cache[item.mesh].data(), local, sizeof(local)); } // The game's per-entity spawn/streaming fade. LivingWorld presentation // entities (NPCs, traffic vehicles, spawned props) publish an opacity: // 0 through the whole spawn settle (the physics drop after // CensusMan::SpawnPedestrian/SpawnVehicle), ramping up afterwards and by // distance (cLivingWorldPresEntity::EvaluateOpacityDistance), which every // character-family PS writes as its output alpha (the "alpha out" row of // CaptureCharLighting: peds c21.x, defaultcharacter c13.x, cacstamp c22.x, // vehicle body c20.x, glass c20.x * c18.w, hair strand * scale). Returns // that alpha for items whose validated capture carries it, 1.0 otherwise // (legacy-shaded items keep the old always-opaque behavior). float CharFadeAlpha(const DrawItem& item) { // LW-mapped items: the entity's own // opacity is authoritative; it is the exact value the game serves this // ctx's shader as output alpha, independent of whether the per-draw row // capture validated (or captured a clone's foreign row). Families whose // shader COMPOSES the entity fade with another factor (hair strand-scale, // vehicle-glass tint alpha) keep their captured value bounded by it. if (item.lw_alpha >= 0.0f) { const float a = std::clamp(item.lw_alpha, 0.0f, 1.0f); switch (item.char_family) { case 1: case 2: case 3: case 6: return a; case 4: case 5: return item.char_rows[14 * 4 + 1] > 0.0f ? std::min(a, std::clamp(item.char_rows[13 * 4 + 3], 0.0f, 1.0f)) : a; case 7: return item.char_rows[14 * 4 + 1] > 0.0f ? std::min(a, std::clamp(item.char_rows[14 * 4 + 0], 0.0f, 1.0f)) : a; default: break; } } if (item.char_rows[14 * 4 + 1] <= 0.0f) { return 1.0f; } switch (item.char_family) { case 1: case 2: case 3: case 6: case 7: return std::clamp(item.char_rows[14 * 4 + 0], 0.0f, 1.0f); case 4: case 5: // Hair alpha = strand coverage * scale; the scale constant carries the // entity fade, so a near-zero scale means the whole garment is // invisible this frame. return std::clamp(item.char_rows[13 * 4 + 3], 0.0f, 1.0f); default: return 1.0f; } } // Copy the staged bone palette (and, for hair, the tint) out of the shadow // banks into the item. The banks are reused draw to draw, hence the copy. // The base from BankPaletteBase is refined by +1 for the cloth/morph VS // layout (extra parameter row before the palette); the mesh's own sample // vertices projected with the bank's viewproj decide (RefinePaletteBase). // // character.cloth_ropa items (Ropa cloth-simulated garments, the player's // tee) use a VS that BRANCHES on the row in front of the palette (c4 // pre-pass / c7 main-pass; disassembled from a live // capture): flag.x > 0 skins with the palette one // register late (c5/c8); flag.x <= 0 means the CPU cloth sim already wrote // deformed root-local positions into the (dynamic, per-frame) VB and the VS // ignores palette and blend attributes entirely, applying ONE affine at // c188 (pre-pass) / c191 (main-pass): 3 column-vector [R | t] rows, same // packing as palette rows. Skinning the simulated vertices instead renders // the garment as a mangled ribbon off the body (the distorted-player-shirt // bug). Offline validation: the rigid rows put 31/31 sampled shirt verts in // clip at the player's position; the skinned interpretation scores 0/31. // Transform up to 6 sample vertices of the item by the affine at register m // (3 column-vector [R | t] rows) and project them with the bank's own // viewproj (c0..c3). Returns the count of samples inside the clip volume, // -1 when unscorable. A stale bank (the submit-exit capture can run after // ANOTHER mesh's draw when this mesh's own draws are deferred) holds some // other object's matrix at c188/c191; geometry projected with it lands // far off-clip, while the true matrix scores full (validated offline: // 31/31 vs 0/31 on an F10 capture). // *out_front_all (when asked): every sample lands IN FRONT of the // projection within a loose 6x guard band, the relaxed near-camera // criterion (a sim-active garment right at the camera clips most of its // samples out of the tight 1.5x band, and the strict >=8 gate refused the // CORRECT rigid matrix; same failure mode as the skinned branch). int ScoreRigidAffine(uint8_t* base, uint32_t bank, uint32_t m, const DrawItem& item, bool* out_front_all = nullptr) { if (item.stride == 0) return -1; const uint32_t count = item.vb_bytes / item.stride; if (count < 2) return -1; float vp[16]; float rows[12]; for (int i = 0; i < 16; ++i) { vp[i] = LoadGuestF32(base, bank + i * 4); if (!(vp[i] > -1e9f && vp[i] < 1e9f)) return -1; } for (int i = 0; i < 12; ++i) { rows[i] = LoadGuestF32(base, bank + (m * 4 + i) * 4); } constexpr uint32_t kSamples = 6; SkinSampleVert sverts[kSamples]; if (!ReadSkinSamplesGuest(base, item, kSamples, sverts)) { return -1; } int ok = 0; int loose = 0; int n = 0; for (uint32_t s = 0; s < kSamples; ++s) { const float* p = sverts[s].p; float q[3]; for (int a = 0; a < 3; ++a) { q[a] = rows[a * 4] * p[0] + rows[a * 4 + 1] * p[1] + rows[a * 4 + 2] * p[2] + rows[a * 4 + 3]; } float clip[4]; for (int r = 0; r < 4; ++r) { clip[r] = vp[r * 4] * q[0] + vp[r * 4 + 1] * q[1] + vp[r * 4 + 2] * q[2] + vp[r * 4 + 3]; } const float aw = std::abs(clip[3]) < 1.0f ? 1.0f : std::abs(clip[3]); ++n; if (std::abs(clip[0]) <= 1.5f * aw && std::abs(clip[1]) <= 1.5f * aw) { ++ok; } if (clip[3] > 0.0f && std::abs(clip[0]) <= 6.0f * aw && std::abs(clip[1]) <= 6.0f * aw) { ++loose; } } if (out_front_all) { *out_front_all = n >= 2 && loose == n; } return n == 0 ? -1 : (ok * 16) / n; } // Dense publish-time coherence gate: skin ~32 samples of the LIVE guest VB // with the item's PUBLISHED palette and require bind-pose spread. The // capture acceptance gates sample only 6 verts, so a palette whose junk // rows sit on UNSAMPLED bones (e.g. a staging bank partially overwritten // by the next entity's rows between this mesh's draw and our capture) // passes them and stretches the unsampled islands into the map-length- // ribbon flash. 32 evenly-spaced samples cover the islands the 6-vert // gates miss. Returns the measured spread via out_spread for the // diagnosis log. bool PublishedPaletteSane(uint8_t* base, const DrawItem& item, float* out_spread) { *out_spread = 0.0f; if (item.stride == 0 || item.bones.size() < 12 || item.bw_offset == 0 || item.bi_offset == 0 || item.vb_addr == 0) { return true; } const float bind_diag = BindDiag(item); // 6x, NOT the capture gates' 3x: this judges already-ACCEPTED palettes, // where the observed junk measures 183-405 while legit articulation on // small meshes (a 0.5 m accessory mid-stride) reached 3.1-3.3x and // tripped a 3x bound repeatedly (log 1285 mesh=436E7210, spread 1.6 vs // 1.56 - real world-space palette, needlessly healed/frozen). const float max_spread = std::max(6.0f * bind_diag, bind_diag + 2.0f); constexpr uint32_t kSamples = 32; SkinSampleVert sverts[kSamples]; if (!ReadSkinSamplesGuest(base, item, kSamples, sverts)) { return true; // unsupported position format: nothing to judge } float spread = 0.0f; if (SkinnedSpreadHostRows(sverts, kSamples, item.bones.data(), item.bones.size(), /*min_n=*/4, /*garbage_fails=*/false, &spread) != 1) { return true; // nothing to judge } *out_spread = spread; return spread <= max_spread; } // Returns false when the bank could not be consumed for this item (ropa // rigid matrix implausible or off-clip = stale bank); the caller must // leave/mark the item pending so a later matching draw re-captures it. bool CaptureSkinnedState(uint8_t* base, uint32_t bank, uint32_t palette_base, DrawItem& item) { // Refuse captures staged by AUX perspective passes (skater-portrait RTTs): // the portrait pass draws the SAME meshes as the on-screen player at the // off-map portrait stage, and accepting its palette poisons the mesh/ctx // stores (see BankIsAuxPerspective). The item stays pending; a later // main-view draw of these buffers resolves it, and portrait-only frames // publish nothing (BuildFrameScene's aux-view gate). if (BankIsAuxPerspective(base, bank)) { static std::atomic s_aux_refused{0}; const uint64_t n = s_aux_refused.fetch_add(1, std::memory_order_relaxed); if (n < 4 || (n & 4095u) == 0) { REXLOG_INFO( "native-scene: skinned capture refused - aux perspective pass " "(portrait RTT) mesh={:08X} (n={})", item.mesh, n); } return false; } if (item.ropa && palette_base != 0) { const bool main_pass = palette_base >= 7; const uint32_t flag_reg = main_pass ? 7u : 4u; const float flag_x = LoadGuestF32(base, bank + (flag_reg * 4) * 4); // Diagnosis logging (rate-limited): the flag/matrix registers were // verified against an emulated-mode F10 capture; this confirms what the // live native-mode banks actually hold at OUR capture moments. static std::atomic ropa_log_count{0}; const uint32_t ln = ropa_log_count.fetch_add(1, std::memory_order_relaxed); if (ln < 8 || (ln & 1023u) == 0) { const uint32_t m = main_pass ? 191u : 188u; REXLOG_INFO( "native-scene: ropa mesh={:08X} vb={:08X} base={} score={} " "flag=({:.3f},{:.3f},{:.3f},{:.3f}) " "m[c{}]=({:.3f},{:.3f},{:.3f},{:.2f})({:.3f},{:.3f},{:.3f},{:.2f})({:.3f},{:.3f},{:.3f},{:.2f})", item.mesh, item.vb_obj, palette_base, ScoreRigidAffine(base, bank, m, item), LoadGuestF32(base, bank + (flag_reg * 4 + 0) * 4), LoadGuestF32(base, bank + (flag_reg * 4 + 1) * 4), LoadGuestF32(base, bank + (flag_reg * 4 + 2) * 4), LoadGuestF32(base, bank + (flag_reg * 4 + 3) * 4), m, LoadGuestF32(base, bank + ((m + 0) * 4 + 0) * 4), LoadGuestF32(base, bank + ((m + 0) * 4 + 1) * 4), LoadGuestF32(base, bank + ((m + 0) * 4 + 2) * 4), LoadGuestF32(base, bank + ((m + 0) * 4 + 3) * 4), LoadGuestF32(base, bank + ((m + 1) * 4 + 0) * 4), LoadGuestF32(base, bank + ((m + 1) * 4 + 1) * 4), LoadGuestF32(base, bank + ((m + 1) * 4 + 2) * 4), LoadGuestF32(base, bank + ((m + 1) * 4 + 3) * 4), LoadGuestF32(base, bank + ((m + 2) * 4 + 0) * 4), LoadGuestF32(base, bank + ((m + 2) * 4 + 1) * 4), LoadGuestF32(base, bank + ((m + 2) * 4 + 2) * 4), LoadGuestF32(base, bank + ((m + 2) * 4 + 3) * 4)); } if (flag_x > 0.0f) { // Sim inactive: the palette sits one register late (c5/c8). The LAYOUT // is exact, but the BANK can still be foreign, the same stale-bank // hazard the rigid branch below scores against (the flag itself was // read from the foreign bank, so a positive x proves nothing). Blind // acceptance here staged whatever the foreign bank held as an 84-bone // palette; when the garment's sim was actually ACTIVE (skating NPCs // toggle with distance/activity) that skinned the sim-deformed // vertices, the mangled map-length ribbon (ScoreRigidAffine's 0/31 // interpretation), shadows matching because the caster pass shares the // item. Gate through the same sample-projection acceptance as every // other palette; on refusal the item stays pending (post-draw fixup / // ropa state rescue). The game writes exactly 1.0 into the flag row's // x for sim-inactive (observed on every live capture, incl. the // (1,junk,junk,junk) NPC variant); a bit-exact 1.0 is the structural // proof that unlocks the relaxed near-camera acceptance; any other // positive x (a bone row of some other layout) keeps the strict gate. palette_base = RefinePaletteBase(base, bank, main_pass ? 8u : 5u, item, /*structural_guess=*/flag_x == 1.0f); if (palette_base == 0) { g_ropa_stale.fetch_add(1, std::memory_order_relaxed); return false; } } else { // Dropped-garment gate: a garment the game removed (numCloth back to // 0, VB objects freed) must never publish rigid; whatever this bank // holds is foreign (the game draws the ctx skinned now), and a rigid // publish would pair a live world with our retained stale drape. if (skate3::native_entity::RopaGarmentDropped(base, item.ctx, item.vb_obj)) { g_ropa_stale.fetch_add(1, std::memory_order_relaxed); return false; } // The garment's rigid world is the accepted draw-time bank matrix // (c188/c191): the game stages it tick-exact with the deformed VB // content and with the body palettes packed at EndJobs. (A guest-side // entity L2W read at StartJobs predates the tick's locomotion update; // serving that as the draw world rendered the whole shirt one guest // tick behind the body, a constant velocity-proportional drape lag.) uint32_t m = main_pass ? 191u : 188u; float rows[12]; const auto read_rows = [&](uint32_t reg) -> bool { bool ok = true; for (int r = 0; r < 3 && ok; ++r) { float n = 0.0f; for (int i = 0; i < 4; ++i) { const float f = LoadGuestF32(base, bank + ((reg + r) * 4 + i) * 4); if (!(f > -1e7f && f < 1e7f)) { ok = false; break; } rows[r * 4 + i] = f; if (i < 3) n += f * f; } ok = ok && n > 0.0025f && n < 400.0f && rows[r * 4 + 3] > -20000.f && rows[r * 4 + 3] < 20000.f; } return ok; }; const auto tail_row_at = [&](uint32_t reg) -> bool { const float x = LoadGuestF32(base, bank + (reg * 4 + 0) * 4); const float y = LoadGuestF32(base, bank + (reg * 4 + 1) * 4); const float z = LoadGuestF32(base, bank + (reg * 4 + 2) * 4); const float w = LoadGuestF32(base, bank + (reg * 4 + 3) * 4); return std::fabs(x) <= 1e-4f && std::fabs(y) <= 1e-4f && std::fabs(z) <= 1e-4f && std::fabs(w - 1.0f) <= 1e-4f; }; // The passes stage the matrix as a 4-row block each: main at // c191..c194, shadow at c188..c191 (3 affine rows + a (0,0,0,1) // homogeneous tail). The tail at m+3 is REQUIRED: it is the // structural proof a matrix block lives at m. Without it the read is // mid-palette; an 84-bone body bank's palette (c5/c8 onward) covers // c188..c194 with bone matrices, and a bone matrix (world * // inverse-bind) is a plausible, high-scoring affine that renders the // garment rotated by the bind rotation and offset meters from the // body while tracking the skater. bool plausible = read_rows(m) && tail_row_at(m + 3); // The shadow block's tail lands exactly on c191 = the main block's // first row. A main-pass read that finds that tail at c191 is seeing // the post-shadow bank state, where c188..c190 still hold the same // tick's matrix: consume that copy instead of refusing the capture. if (!plausible && main_pass && tail_row_at(191u) && read_rows(188u)) { m = 188u; plausible = true; } bool front_all = false; const int rigid_score = plausible ? ScoreRigidAffine(base, bank, m, item, &front_all) : -1; // Relaxed near-camera acceptance mirrors the skinned branch: a // sim-active garment right at the camera clips most samples out of // the strict band, but a stale/foreign matrix throws them behind the // projection or far outside even the loose band. if (plausible && (rigid_score >= 8 || front_all)) { if (rigid_score < 8) { g_ropa_relaxed.fetch_add(1, std::memory_order_relaxed); } // Primary entity-world serve: the accepted bank block is a // dereference of the owner entity's m_MatLtoWTrans, so the entity // field is value-identical here (bit-compare tripwire counts any // drift as wprim divergence) and stays correct through bank states // the acceptance can't see. Bank rows remain the fallback. const float* src_rows = rows; float ent_rows[12]; if (REXCVAR_GET(skate3_native_render_scene_entity_ropa_world_primary) && skate3::native_entity::ServeRopaWorld(base, item.ctx, item.vb_obj, ent_rows)) { skate3::native_entity::NoteAcceptedWorldCompare(rows, ent_rows); src_rows = ent_rows; } // Column-vector [R | t] rows -> item.world (row-vector, t in row 3). for (int i = 0; i < 3; ++i) { for (int j = 0; j < 3; ++j) { item.world[i * 4 + j] = src_rows[j * 4 + i]; } item.world[i * 4 + 3] = 0.0f; item.world[12 + i] = src_rows[i * 4 + 3]; } item.world[15] = 1.0f; item.skinned = false; item.bones.clear(); g_ropa_rigid.fetch_add(1, std::memory_order_relaxed); // Identity-store observer: the accepted bank block should be a // dereference of the owner entity's bound world pointer (+416 // transposed for skater-class binds, +352 raw for CAC); the // ident[] w416/w352 telemetry proves or refutes that live. skate3::native_entity::ObserveRopaWorld(base, item.ctx, rows); if (item.hair) { CaptureHairTint(base, item); } CaptureCharLighting(base, item); return true; } // Implausible or off-clip matrix: the bank belongs to another mesh // (the submit-exit capture runs after whatever draw happened to be // inline). Before refusing, try the identity store: the accepted // bank block is a bit-exact dereference of the owner entity's // m_MatLtoWTrans (+416), so // the entity field serves the same matrix through every bank state // the pass sequence can leave behind (post-shadow tail clobber, // foreign bank, torn state). Geometry only; the PS lighting rows // stay on their caches, since the bank is not proven ours here. float ent_rows[12]; if (skate3::native_entity::ServeRopaWorld(base, item.ctx, item.vb_obj, ent_rows)) { for (int i = 0; i < 3; ++i) { for (int j = 0; j < 3; ++j) { item.world[i * 4 + j] = ent_rows[j * 4 + i]; } item.world[i * 4 + 3] = 0.0f; item.world[12 + i] = ent_rows[i * 4 + 3]; } item.world[15] = 1.0f; item.skinned = false; item.bones.clear(); return true; } g_ropa_stale.fetch_add(1, std::memory_order_relaxed); return false; } } else { palette_base = RefinePaletteBase(base, bank, palette_base, item); if (palette_base == 0) { // The bank's palette provably does not skin this mesh into the bank's // own view (foreign/stale bank): refuse; the caller keeps the item // pending and the post-draw (ib,vb) fixup re-captures on a real draw. return false; } } constexpr uint32_t kPaletteFloats = 84 * 12; // c4..c255 = up to 84 bones item.bones.resize(kPaletteFloats); for (uint32_t i = 0; i < kPaletteFloats; ++i) { const float f = LoadGuestF32(base, bank + (palette_base * 4 + i) * 4); item.bones[i] = (f > -1e7f && f < 1e7f) ? f : 0.0f; } if (item.hair) { CaptureHairTint(base, item); } CaptureCharLighting(base, item); return true; } uint64_t DrawSequence() { return g_draw_seq.load(std::memory_order_relaxed); } uint32_t CaptureDynamicState(uint8_t* base, uint32_t ctx, bool world_path, bool drew_inside) { if (!SceneEnabled()) { return 0; } const uint32_t bank = g_vs_bank.load(std::memory_order_relaxed); if (bank == 0) { return 0; } // Items drawn inside an AUX perspective pass (skater-portrait RTTs) never // enter the frame at all: they share (ib,vb) buffers with the on-screen // player, so letting them sit PENDING lets them steal the player's own // post-draw fixups (FIFO oldest-pending) and publish a ghost at the // player's pose. drew_inside guarantees the bank is the pass's own, so // its c0..c3 viewproj identifies the pass (see BankIsAuxPerspective). if (drew_inside && BankIsAuxPerspective(base, bank)) { return 0; } // Guest-thread capture cost telemetry (folded per frame in BuildFrameScene). const auto perf_t0 = PerfClock::now(); struct PerfFold { PerfClock::time_point t0; ~PerfFold() { g_capture_frame_ns += uint64_t( std::chrono::duration_cast(PerfClock::now() - t0) .count()); } } perf_fold{perf_t0}; if (world_path) { // Cheap pre-check so the world path (~800 calls/frame) only pays the // full capture for the meshes that need per-draw transforms: skinned // (LOD pedestrians, jointed props) and rigid MODEL-SPACE props (position // fmt 32/26). Movable props (vending machines) can reach the frame ONLY // through the sort lists, rendered as absolute world geometry they // collapse at the origin. Absolute meshes (fmt 57) stay on the identity // world path. const uint32_t record = REX_LOAD_U32(ctx); if (!GuestReadableApprox(base, record)) return 0; const uint32_t mesh = REX_LOAD_U32(record); if (!GuestReadableApprox(base, mesh)) return 0; const uint32_t vdesc = REX_LOAD_U32(mesh + kMeshVertexDescriptor); if (!GuestReadableApprox(base, vdesc)) return 0; const uint32_t num_elements = REX_LOAD_U16(vdesc + 8); if (num_elements == 0 || num_elements > 32) return 0; bool have_bw = false; bool have_bi = false; uint32_t pos_fmt = 0; bool have_pos = false; for (uint32_t i = 0; i < num_elements; ++i) { const uint32_t e = vdesc + 0x10 + i * 16; if (REX_LOAD_U16(e) != 0) continue; const uint32_t usage = REX_LOAD_U8(e + 9); if (usage == 0 && !have_pos) { pos_fmt = REX_LOAD_U32(e + 4) & 0x3F; have_pos = true; } if (usage == 1 && (REX_LOAD_U32(e + 4) & 0x3F) == 6) have_bw = true; if (usage == 2 && (REX_LOAD_U32(e + 4) & 0x3F) == 6) have_bi = true; } const bool skinned = have_bw && have_bi; const bool model_space = pos_fmt == 32 || pos_fmt == 26; if (!skinned && !model_space) { return 0; } } DrawItem item; if (!BuildItemGeometry(base, ctx, item)) { return 0; } item.ctx = ctx; // identity key for the palette serve / entity store // The bank only provably holds THIS mesh's constants when the last draw // that flushed it bound this mesh's buffers (see g_last_draw_ibvb); // `drew_inside` alone also accepted banks left by another entity's inline // draws while this mesh's own draws were deferred (the walking-vehicle / // origin-vending-machine captures). Deferring those to the post-draw // (ib,vb) fixup pairs the state with the mesh's own real draw. const bool own_draw_last = drew_inside && g_last_draw_ibvb.load(std::memory_order_relaxed) == ((uint64_t(item.ib_obj) << 32) | item.vb_obj); if (drew_inside && !own_draw_last) { g_capture_foreign_bank.fetch_add(1, std::memory_order_relaxed); } // Rigid transform: deferred (multi-pass) rigid props draw later; the // bank belongs to some earlier mesh, and a leftover identity matrix at c4 // VALIDATES as a plausible world (verified from recorded draw streams: // 4 of 6 vending-machine clones captured exact identity and rendered // invisibly at the origin). Defer those to the post-draw fixup, like // skinned palettes. if (!item.skinned) { // Aux perspective passes (portrait RTTs) stage worlds at the off-map // portrait stage; defer like a foreign bank (see BankIsAuxPerspective). if (!own_draw_last || BankIsAuxPerspective(base, bank) || !BankRigidWorld(base, bank, item.world)) { item.pending = true; g_rigid_pending.fetch_add(1, std::memory_order_relaxed); } } if (item.skinned) { // Copy the whole possible palette span (c4..c255 = 84 bones) verbatim: // 3 float4 rows per bone, column-vector affine [R | t], model space // directly to world space (verified live: bone translations match the // camera focus). The shader applies rows with explicit dot products, // sidestepping HLSL matrix packing. Registers beyond the mesh's real // bone count hold stale bank data but are never indexed. World stays // identity. // // The bank only holds this mesh's palette at c4 if its own draws ran // inside the call AND used the pre-pass layout. Multi-pass (deferred) // meshes draw later, and main-pass-layout banks have the camera at c4; // in both cases leave the palette pending for the post-draw fixup. const uint32_t effect_list = REX_LOAD_U32(ctx + kCtxEffectList); uint32_t passes = 1; if (GuestReadableApprox(base, effect_list)) { passes = REX_LOAD_U32(effect_list + kEffectListPassCount); } const uint32_t palette_base = BankPaletteBase(base, bank); // World-path captures come from the sort-list hook BEFORE any of the // mesh's draws ran; the bank belongs to some other mesh; always defer // to the post-draw fixup. Same when the last draw inside the submit // call was not this mesh's own (deferred mesh, foreign inline draws): // a stale/foreign bank can still hold plausible bone rows. item.pending = world_path || !own_draw_last || (passes > 1 && passes < 16) || palette_base == 0; if (!item.pending) { if (CaptureSkinnedState(base, bank, palette_base, item)) { g_palette_snapshots.fetch_add(1, std::memory_order_relaxed); item.dbg_src = 1; item.caster_bank = BankIsOrtho(base, bank); } else { item.pending = true; } } g_skinned_items.fetch_add(1, std::memory_order_relaxed); } if (g_recording.load(std::memory_order_relaxed)) { // Preserve the buffer payload for offline decode (streaming arenas are // recycled long before the end-of-window memory snapshot). std::lock_guard lock(g_record_mutex); const uint64_t key = uint64_t(item.vb_addr) * 1099511628211ull ^ item.fingerprint; if (item.vb_bytes <= (1u << 20) && g_recorded_buffer_bytes < (512u << 20) && g_recorded_buffer_keys.insert(key).second) { RecordedBuffer buf; buf.vb_addr = item.vb_addr; buf.ib_addr = item.ib_addr; buf.fingerprint = item.fingerprint; buf.vb.resize(item.vb_bytes); std::memcpy(buf.vb.data(), base + item.vb_addr, item.vb_bytes); buf.ib.resize(size_t(item.ib_count) * 2); std::memcpy(buf.ib.data(), base + item.ib_addr, buf.ib.size()); g_recorded_buffer_bytes += buf.vb.size() + buf.ib.size(); g_recorded_buffers.push_back(std::move(buf)); } } 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); item.dyn_entity = true; g_frame_dynitems.push_back(std::move(item)); const size_t index = g_frame_dynitems.size() - 1; if (g_frame_dynitems[index].pending || (g_frame_dynitems[index].skinned && g_frame_dynitems[index].caster_bank && !g_frame_dynitems[index].ropa)) { // Pending items wait for their first fixup; caster-bank captures stay // registered so the mesh's later main-pass draw REFRESHES the palette // (see DrawItem::caster_bank, stale wheel spin in the shadow banks). const DrawItem& d = g_frame_dynitems[index]; g_frame_pending_by_buffers.emplace((uint64_t(d.ib_obj) << 32) | d.vb_obj, index); } else if (g_frame_dynitems[index].char_family != 0 && g_frame_char_refresh.size() < 256) { // Character captured at submit-exit: the PS bank there can predate this // character's main pass; refresh the lighting rows on its later draws // (last successful capture wins). const DrawItem& d = g_frame_dynitems[index]; g_frame_char_refresh.emplace((uint64_t(d.ib_obj) << 32) | d.vb_obj, index); } return uint32_t(index + 1); } uint32_t CaptureClothDraw(uint8_t* base, uint32_t r4, uint32_t r5, uint32_t r6, uint32_t r7, uint32_t* out_key) { (void)r4; (void)r7; if (!SceneEnabled() || r6 != 0x80000000u || !REXCVAR_GET(skate3_native_render_scene_quadlists)) { return 0; } // Quad-list draw: stream 0 is a dynamic ping-pong object whose vertex // fetch block (+0x18 dword0 = base|flags, +0x20 = size in bytes) points at // the CPU-simulated vertices for this frame: stride 24 = {float3 world // position (BE), packed normal, float2 uv}, quad-list topology (verified // offline from recorded payloads). NOTE: every capture examined so far is // a PARTICLE system (disjoint 2-4cm sprites), which is why rendering is // gated off by default; see the cvar. const uint32_t vb_obj = g_cur_vb.load(std::memory_order_relaxed); if (!GuestReadableApprox(base, vb_obj)) { return 0; } const uint32_t addr = REX_LOAD_U32(vb_obj + 0x18) & 0xFFFFFFFC; const uint32_t size = REX_LOAD_U32(vb_obj + 0x20); constexpr uint32_t kStride = 24; if (addr < 0x10000 || size < kStride * 4 || size > (1u << 20) || size % kStride != 0) { return 0; } // The garment's live vertices start at the buffer head; r5 is the live // vertex count (verified offline: the zero-fill run starts exactly at r5 // for every garment). Ring slack past it must not be drawn. uint32_t verts = size / kStride; if (r5 >= 4 && r5 <= verts) { verts = r5; } const uint32_t quads = verts / 4; if (quads == 0) { return 0; } const uint32_t start = 0; const uint32_t garment_key = vb_obj ^ (start * 2654435761u); DrawItem item{}; item.mesh = garment_key; item.vb_obj = vb_obj; item.ib_obj = 0; item.vb_addr = addr; item.vb_bytes = verts * kStride; item.ib_addr = 0; item.ib_count = quads * 6; item.diffuse_tex = 0; item.lightmap_tex = 0; item.pos_offset = 0; item.pos_fmt = 57; // float3 item.uv_offset = 16; item.uv_fmt = 38; // float2 item.uv2_offset = 0; item.uv2_fmt = 0; item.bw_offset = 0; item.bi_offset = 0; item.stride = kStride; item.skinned = false; item.pending = false; item.cloth_quads = true; std::memset(item.world, 0, sizeof(item.world)); item.world[0] = item.world[5] = item.world[10] = item.world[15] = 1.0f; for (int axis = 0; axis < 3; ++axis) { item.bbox_min[axis] = -20000.0f; item.bbox_max[axis] = 20000.0f; } item.draws.push_back({4, 0, 0, quads * 6}); // Content fingerprint (simulation output changes every frame -> the // renderer re-decodes each frame). uint64_t h = 1469598103934665603ull; const auto mix = [&h](uint64_t v) { h = (h ^ v) * 1099511628211ull; }; mix(addr); mix(item.vb_bytes); for (uint32_t k = 0; k < 16; ++k) { const uint32_t off = uint32_t(uint64_t(item.vb_bytes - 8) * k / 15u) & ~7u; mix(REX_LOAD_U64(addr + off)); } item.fingerprint = h; // Distinct draw ranges within one ring buffer are distinct garments. *out_key = garment_key; item.dyn_entity = true; std::lock_guard lock(g_palette_mutex); g_frame_dynitems.push_back(std::move(item)); return uint32_t(g_frame_dynitems.size()); } namespace { // Guest shader objects carry their compiled-source debug path at +0x54 // ("D:\P4\xbox2-ww-f\...\spline_darkenPS.updb"); the spline renderer's pixel // shaders identify the in-world neon guide elements. Cached per object; // the current pixel/vertex object trackers are checked both ways because the // hook labels are swapped relative to the real shader types. uint32_t ClassifySplineShader(uint8_t* base, uint32_t obj) { if (obj < 0x10000) { return 0; } static std::mutex mu; static std::unordered_map cache; std::lock_guard lock(mu); auto it = cache.find(obj); if (it != cache.end()) { return it->second; } char path[97] = {}; uint32_t kind = 0; if (GuestTryCopy(path, base + obj + 0x54, 96)) { path[96] = '\0'; if (std::strstr(path, "spline_darken") != nullptr) { kind = 1; } else if (std::strstr(path, "spline_default") != nullptr) { kind = 2; } } cache.emplace(obj, kind); return kind; } } // namespace void OnSetIndices(uint32_t ib_obj) { g_cur_ib.store(ib_obj, std::memory_order_relaxed); } void OnSetStreamSource(uint32_t stream, uint32_t vb_obj, uint32_t offset, uint32_t stride) { if (stream == 0) { g_cur_vb.store(vb_obj, std::memory_order_relaxed); g_cur_vb_offset.store(offset, std::memory_order_relaxed); g_cur_vb_stride.store(stride, std::memory_order_relaxed); } if (stream < 4) { g_cur_streams[stream][0].store(vb_obj, std::memory_order_relaxed); g_cur_streams[stream][1].store(offset, std::memory_order_relaxed); g_cur_streams[stream][2].store(stride, std::memory_order_relaxed); } } void OnDrawDone(uint8_t* base, uint32_t func, uint32_t r4, uint32_t r5, uint32_t r6, uint32_t r7) { g_draw_seq.fetch_add(1, std::memory_order_relaxed); const uint32_t flags2d = Phase2dFlags(); if (flags2d != 0) { g_draws_2d.fetch_add(1, std::memory_order_relaxed); } // Last-draw provenance for the submit-exit capture (see g_last_draw_ibvb): // only an indexed 3D draw leaves a bank the palette/world capture may // trust, keyed by the buffers it bound. g_last_draw_ibvb.store( (func == 0 && flags2d == 0) ? ((uint64_t(g_cur_ib.load(std::memory_order_relaxed)) << 32) | g_cur_vb.load(std::memory_order_relaxed)) : 0, std::memory_order_relaxed); const uint32_t bank = g_vs_bank.load(std::memory_order_relaxed); if (bank == 0) { return; } // Fog rows: grab c5/c6 from the first 3D draw whose bank c4 row matches // the last built scene's camera (main-pass layout; tolerant of one frame // of camera motion). See g_fog_rows. The same camera-keyed draws also // carry the frame's CSM shadow constants in the PIXEL bank (c0..c8, // pass-global on environment-family draws), captured here with an // independent done-flag: the first main-pass draw can be a character/tree // whose PS allocates differently (rejected by the sanity gate below). if ((!g_fog_frame_done || !g_shadow_frame_done || !g_sky_frame_done || !g_tree_frame_done || !g_proxy_frame_done || !g_dynobj_frame_done || !g_water_frame_done || !g_ocean_frame_done || !g_oceanrefl_frame_done || !g_scroll_frame_done) && func == 0 && flags2d == 0 && SceneEnabled() && (g_fog_cam[0] != 0.0f || g_fog_cam[1] != 0.0f || g_fog_cam[2] != 0.0f)) { const float dx = LoadGuestF32(base, bank + 16 * 4) - g_fog_cam[0]; const float dy = LoadGuestF32(base, bank + 17 * 4) - g_fog_cam[1]; const float dz = LoadGuestF32(base, bank + 18 * 4) - g_fog_cam[2]; // The sky draw's bank: c4.xz == camera, c4.y = the fixed level sky // elevation (dy ~ +160). dy > 50 excludes normal draws (dy == 0) and // any reflection pass (y mirrored DOWN); the bound keeps out garbage. if (!g_sky_frame_done && dx * dx + dz * dz < 25.0f && dy > 50.0f && dy < 2000.0f) { 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) { const uint32_t ps_bank = g_ps_bank.load(std::memory_order_relaxed); // POSITIVE family check for the receiver-row capture, by shader debug // path: the value gates below cannot fully discriminate; // flowingwater_defaultPS keeps the ENTIRE baseenvironment receiver // layout (CSM rows, sun c6, camera c7, dim c8) but its material // multiplier c11.y is 0.2, not 1.0. When the canal was in view its // draw won the first-pass race and the whole world tone chain ran at // x0.2 linear, the "world goes dark at some camera rotations" bug // (native exactly 0.50x emulated, uniform). Only the environment // families that share the c10.x/c11.y layout are eligible. const auto env_receiver_ps = [&]() -> bool { const auto check = [&](uint32_t obj) -> int { // 0 unknown, 1 no, 2 yes if (obj < 0x10000 || !GuestReadableApprox(base, obj)) { return 0; } static std::unordered_map cache; auto it = cache.find(obj); if (it != cache.end()) { return it->second; } char text[120] = {}; for (int k = 0; k < 119; ++k) { text[k] = char(REX_LOAD_U8(obj + 0x54 + k)); if (text[k] == '\0') break; } const bool hit = std::strstr(text, "\\baseenvironment") != nullptr || std::strstr(text, "\\defaultenvironment") != nullptr || std::strstr(text, "\\decalenvironment") != nullptr; // Empty/garbled paths stay unknown (0) and are not cached-in as // negatives forever. const int result = text[0] == '\0' ? 0 : (hit ? 2 : 1); if (result != 0 && cache.size() < 4096) { cache.emplace(obj, result); } return result; }; const int a = check(g_cur_ps_obj.load(std::memory_order_relaxed)); if (a != 0) { return a == 2; } return check(g_cur_vs_obj.load(std::memory_order_relaxed)) == 2; }; // Fog rows (VS c5 ramp / c6 color): POSITIVE family gate like the // receiver rows below, for the same reason: the camera-keyed c4 check // alone lets ANY main-pass-layout draw win the first-draw race, and // the dam spillway's water shaders keep the camera at c4 with a // water-teal where fog c6 lives, values that PASSED the range gate // and tinted every distance-fogged surface (the bank mist band, the // far dirt hill) saturated blue for exactly the one frame that // capture served (the approach-flicker blue flash; F7 scene-ring // proved composition/textures identical across the artifact frame). // Same failure class as the flowingwater tone hijack documented at // env_receiver_ps. if (!g_fog_frame_done && env_receiver_ps()) { float rows[8]; for (int i = 0; i < 8; ++i) { rows[i] = LoadGuestF32(base, bank + (20 + i) * 4); } // Range gate (kept as a second line of defense): ramp scale is a // tiny per-meter slope, the exponent is a small power, the fog color // is a dim linear-space rgb and the transmittance scale small. const bool sane = rows[0] >= 0.0f && rows[0] < 0.1f && std::fabs(rows[1]) < 16.0f && rows[2] > 0.0f && rows[2] <= 8.0f && rows[4] >= 0.0f && rows[4] <= 4.0f && rows[5] >= 0.0f && rows[5] <= 4.0f && rows[6] >= 0.0f && rows[6] <= 4.0f && std::fabs(rows[7]) <= 1.0f; if (sane) { std::memcpy(g_fog_rows, rows, sizeof(rows)); g_fog_have = true; g_fog_frame_done = true; } } else if (!g_fog_frame_done) { // Confirmation probe for the blue-flash fix: a NON-env draw whose // rows would have passed the old value-only gate with a fog color // far from the current one is exactly the frame that used to flash // - each hit here is one prevented flash, naming the hijacker. float rows[8]; for (int i = 0; i < 8; ++i) { rows[i] = LoadGuestF32(base, bank + (20 + i) * 4); } const bool would = rows[0] >= 0.0f && rows[0] < 0.1f && std::fabs(rows[1]) < 16.0f && rows[2] > 0.0f && rows[2] <= 8.0f && rows[4] >= 0.0f && rows[4] <= 4.0f && rows[5] >= 0.0f && rows[5] <= 4.0f && rows[6] >= 0.0f && rows[6] <= 4.0f && std::fabs(rows[7]) <= 1.0f; if (would) { const float dr = rows[4] - g_fog_rows[4]; const float dg = rows[5] - g_fog_rows[5]; const float db = rows[6] - g_fog_rows[6]; if (dr * dr + dg * dg + db * db > 0.01f) { // Rolling cap (the flat 32 burned at the load screen). static std::atomic s_fog_rejects{0}; static std::atomic s_fog_win{0}; const int64_t now_s = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); int64_t win = s_fog_win.load(std::memory_order_relaxed); if (now_s - win >= 5 && s_fog_win.compare_exchange_strong(win, now_s)) { s_fog_rejects.store(0, std::memory_order_relaxed); } if (s_fog_rejects.fetch_add(1, std::memory_order_relaxed) < 8) { REXLOG_INFO( "native-scene: fog capture REJECTED by family gate " "(prevented flash): vs_obj={:08X} ps_obj={:08X} " "color=({:.3f},{:.3f},{:.3f}) vs current ({:.3f},{:.3f},{:.3f})", g_cur_vs_obj.load(std::memory_order_relaxed), g_cur_ps_obj.load(std::memory_order_relaxed), rows[4], rows[5], rows[6], g_fog_rows[4], g_fog_rows[5], g_fog_rows[6]); } } } } // flowingwateralpha m_params + animation time (PS c11..c14, c15.x), // POSITIVE debug-path gate like the receiver rows: the water bank // keeps the whole baseenvironment receiver layout, so only the name // discriminates. One material's rows serve the frame; every // flowingwateralpha draw in a capture carried identical m_params, and // the animation time is pass-global anyway. if (!g_water_frame_done && ps_bank != 0) { const auto water_ps = [&]() -> bool { const auto check = [&](uint32_t obj) -> bool { if (obj < 0x10000 || !GuestReadableApprox(base, obj)) { return false; } char text[120] = {}; for (int k = 0; k < 119; ++k) { text[k] = char(REX_LOAD_U8(obj + 0x54 + k)); if (text[k] == '\0') break; } return std::strstr(text, "\\flowingwateralpha") != nullptr; }; return check(g_cur_ps_obj.load(std::memory_order_relaxed)) || check(g_cur_vs_obj.load(std::memory_order_relaxed)); }; if (water_ps()) { float rows[17]; for (int i = 0; i < 16; ++i) { rows[i] = LoadGuestF32(base, ps_bank + (44 + i) * 4); // c11..c14 } rows[16] = LoadGuestF32(base, ps_bank + 60 * 4); // c15.x = time // Range gate: normal scales O(1), material multiplier small // positive, scroll speeds/UV scales bounded, spec power a real // exponent, alpha floor a blend factor, time non-negative. bool sane = rows[1] > 0.0f && rows[1] <= 8.0f && // c11.y mult rows[13] >= 0.0f && rows[13] <= 1.0f && // c14.y thr rows[14] >= 1.0f && rows[14] <= 1000.0f && // c14.z pow rows[15] >= 0.0f && rows[15] <= 1.0f && // c14.w floor rows[16] >= 0.0f && rows[16] < 1e7f; // time for (int i = 0; i < 4 && sane; ++i) { sane = std::fabs(rows[0 + i]) <= 8.0f && // c11 std::fabs(rows[4 + i]) <= 64.0f && // c12 speeds std::fabs(rows[8 + i]) <= 256.0f; // c13 scales } if (sane) { const bool first = !g_water_have; std::memcpy(g_water_rows, rows, sizeof(rows)); g_water_have = true; g_water_frame_done = true; if (first) { REXLOG_INFO( "native-scene: water m_params captured: p0=({:.3g},{:.3g}," "{:.3g},{:.3g}) speed=({:.3g},{:.3g},{:.3g},{:.3g}) " "scale=({:.3g},{:.3g},{:.3g},{:.3g}) thr={:.3g} pow={:.3g} " "floor={:.3g} t={:.2f}", rows[0], rows[1], rows[2], rows[3], rows[4], rows[5], rows[6], rows[7], rows[8], rows[9], rows[10], rows[11], rows[13], rows[14], rows[15], rows[16]); } } else { static std::atomic s_water_rejects{0}; if (s_water_rejects.fetch_add(1, std::memory_order_relaxed) < 4) { REXLOG_INFO( "native-scene: water m_params REJECTED by range gate: " "mult={:.3g} thr={:.3g} pow={:.3g} floor={:.3g} t={:.3g}", rows[1], rows[13], rows[14], rows[15], rows[16]); } } } } // scrollincandescent.fx (the emissive time-scrolled LED chyron): // g_fAnimationTime from the VERTEX bank (c9.x) and the material // multiplier m_params[0].y from the PIXEL bank (c3.y). Same POSITIVE // debug-path gate as the water rows; the bank values alone cannot // discriminate this family. if (!g_scroll_frame_done && ps_bank != 0) { const auto scroll_ps = [&]() -> bool { const auto check = [&](uint32_t obj) -> bool { if (obj < 0x10000 || !GuestReadableApprox(base, obj)) { return false; } char text[120] = {}; for (int k = 0; k < 119; ++k) { text[k] = char(REX_LOAD_U8(obj + 0x54 + k)); if (text[k] == '\0') break; } return std::strstr(text, "\\scrollincandescent") != nullptr; }; return check(g_cur_ps_obj.load(std::memory_order_relaxed)) || check(g_cur_vs_obj.load(std::memory_order_relaxed)); }; if (scroll_ps()) { const float t = LoadGuestF32(base, bank + 36 * 4); // VS c9.x const float mult = LoadGuestF32(base, ps_bank + 13 * 4); // PS c3.y // Range gate: the animation clock is a non-negative seconds // counter, the multiplier a small positive scale. if (t >= 0.0f && t < 1e7f && mult > 0.0f && mult <= 8.0f) { const bool first = !g_scroll_have; g_scroll_rows[0] = t; g_scroll_rows[1] = mult; g_scroll_have = true; g_scroll_frame_done = true; if (first) { REXLOG_INFO( "native-scene: scroll rows captured: t={:.2f} mult={:.3g}", t, mult); } } } } // ocean_defaultPS material rows (PCA mean/weights at c2..c8, // m_params[0..2] at c12..c14) and the oceanreflection sheet row (c3). // Same POSITIVE debug-path gating as the water rows. if ((!g_ocean_frame_done || !g_oceanrefl_frame_done) && ps_bank != 0) { const auto ps_name_has = [&](const char* needle) -> bool { const auto check = [&](uint32_t obj) -> bool { if (obj < 0x10000 || !GuestReadableApprox(base, obj)) { return false; } char text[120] = {}; for (int k = 0; k < 119; ++k) { text[k] = char(REX_LOAD_U8(obj + 0x54 + k)); if (text[k] == '\0') break; } return std::strstr(text, needle) != nullptr; }; return check(g_cur_ps_obj.load(std::memory_order_relaxed)) || check(g_cur_vs_obj.load(std::memory_order_relaxed)); }; if (!g_ocean_frame_done && ps_name_has("\\ocean_default")) { float c[64]; for (int i = 0; i < 64; ++i) { c[i] = LoadGuestF32(base, ps_bank + (8 + i) * 4); // c2..c17 } const float* m0 = c + (12 - 2) * 4; // c12 const float* m1 = c + (13 - 2) * 4; // c13 const float* m2 = c + (14 - 2) * 4; // c14 bool sane = m1[1] > 0.0f && m1[1] <= 8.0f && // multiplier m1[2] >= 0.0f && m1[2] <= 8.0f && // tonedown scale m1[3] > 0.0f && m1[3] <= 4096.0f && // uv scale m2[0] > 0.01f && m2[0] <= 1000.0f && // spread u m2[1] > 0.01f && m2[1] <= 1000.0f && // spread v m2[2] > 0.001f && m2[2] <= 100.0f; // roughness for (int i = 0; i < 4 && sane; ++i) { sane = c[i] >= -2.0f && c[i] <= 2.0f; // PCA mean row } if (sane) { float* o = g_ocean_rows; // mean pre-swizzled for (R,G,B): PS c2.x, c2.z, c2.y o[0] = c[0]; o[1] = c[2]; o[2] = c[1]; o[3] = 0.0f; // weight rows in model order: c3, c4, c7, c8, c5, c6 const int worder[6] = {3, 4, 7, 8, 5, 6}; for (int w = 0; w < 6; ++w) { std::memcpy(o + 4 + w * 4, c + (worder[w] - 2) * 4, 4 * sizeof(float)); } std::memcpy(o + 28, m0, 4 * sizeof(float)); std::memcpy(o + 32, m1, 4 * sizeof(float)); std::memcpy(o + 36, m2, 4 * sizeof(float)); const bool first = !g_ocean_have; g_ocean_have = true; g_ocean_frame_done = true; if (first) { REXLOG_INFO( "native-scene: ocean rows captured: mult={:.3g} " "tonedown={:.3g} uvscale={:.3g} ward=({:.3g},{:.3g},{:.3g})", m1[1], m1[2], m1[3], m2[0], m2[1], m2[2]); } } } if (!g_oceanrefl_frame_done && ps_name_has("\\oceanreflection")) { float row[4]; for (int i = 0; i < 4; ++i) { row[i] = LoadGuestF32(base, ps_bank + (12 + i) * 4); // c3 } // multiplier small positive; the fade bounds must not coincide. if (row[0] > 0.0f && row[0] <= 8.0f && std::fabs(row[1] - row[2]) > 1e-4f) { std::memcpy(g_oceanrefl_rows, row, sizeof(row)); const bool first = !g_oceanrefl_have; g_oceanrefl_have = true; g_oceanrefl_frame_done = true; if (first) { REXLOG_INFO( "native-scene: oceanreflection row captured: mult={:.3g} " "fade=({:.3g},{:.3g})", row[0], row[1], row[2]); } } } } // Not gated on the shadows cvar: the captured rows also carry the // scene exposure / material multiplier / sun direction consumed by the // exact world shading (rows 40/45/24..26); shading must not die when // dynamic shadows are toggled off. if (!g_shadow_frame_done && ps_bank != 0 && env_receiver_ps()) { float rows[48]; for (int i = 0; i < 48; ++i) { rows[i] = LoadGuestF32(base, ps_bank + i * 4); } // Receiver-layout sanity: // c0/c3 are the light-space X/Y rows with equal magnitude (the // cascade-0 extent), c1/c2 the square cascade scale+offsets with // scale2 < scale1 < 1, c4 the depth row (a pure height ramp: x/z // components tiny), c8 a dim shadow color in [0,1]. Environment // family only; other families fail these checks. const float mx2 = rows[0] * rows[0] + rows[1] * rows[1] + rows[2] * rows[2]; const float my2 = rows[12] * rows[12] + rows[13] * rows[13] + rows[14] * rows[14]; const float s1 = rows[4], s2 = rows[8]; bool sane = mx2 > 0.01f && mx2 < 4.0f && std::fabs(mx2 - my2) < 0.05f * mx2 && s1 > 0.0f && s1 < 1.0f && std::fabs(rows[5] - s1) < 1e-4f && s2 > 0.0f && s2 < s1 && std::fabs(rows[9] - s2) < 1e-4f && std::fabs(rows[16]) < 0.02f && std::fabs(rows[18]) < 0.02f && std::fabs(rows[17]) > 0.005f && std::fabs(rows[17]) < 1.0f && rows[32] >= 0.0f && rows[32] <= 1.0f && rows[33] >= 0.0f && rows[33] <= 1.0f && rows[34] >= 0.0f && rows[34] <= 1.0f; // c5..c8 are NOT pass-global like c0..c4: only the baseenvironment / // defaultenvironment-style bank keeps (bias, ..., sun dir, camera, // shadow color) there. Verified in capture: the // dynamicobject/livingworld/cacstamp banks pass the geometric checks // above with garbage in those rows (c8 ~ (0.0025,0.005,0.012) -> a // near-black shadow, c5.x = 0.0245 -> a 29 cm receiver height bias // that culled the feet/board shadow); advertisement keeps the color // at c7 (c8 = 0 -> pure black); wateralpha has c8 ~ (0.5,0.99,0.5) // (-> no shadow at all). Discriminate on the family-specific rows: // c6 = unit sun direction with normalize(cross(c0,c3)) == -c6 (the // oblique projection axis), c7 = the camera position, c8 dim. if (sane) { const float cxx = rows[1] * rows[14] - rows[2] * rows[13]; const float cxy = rows[2] * rows[12] - rows[0] * rows[14]; const float cxz = rows[0] * rows[13] - rows[1] * rows[12]; const float cn = std::sqrt(cxx * cxx + cxy * cxy + cxz * cxz); const float sn = std::sqrt(rows[24] * rows[24] + rows[25] * rows[25] + rows[26] * rows[26]); const float align = cn > 1e-6f && sn > 1e-6f ? (cxx * rows[24] + cxy * rows[25] + cxz * rows[26]) / (cn * sn) : 0.0f; const float dcx = rows[28] - g_fog_cam[0]; const float dcy = rows[29] - g_fog_cam[1]; const float dcz = rows[30] - g_fog_cam[2]; sane = align < -0.9f && sn > 0.9f && sn < 1.1f && dcx * dcx + dcy * dcy + dcz * dcz < 25.0f && rows[32] <= 0.4f && rows[33] <= 0.4f && rows[34] <= 0.4f && // c10.x = scene exposure, c11.y = material multiplier, // consumed by the exact world tone chain. rows[40] > 0.1f && rows[40] < 16.0f && rows[45] > 0.0f && rows[45] < 16.0f; } if (sane) { std::memcpy(g_shadow_rows, rows, sizeof(rows)); g_shadow_have = true; g_shadow_frame_done = true; } } // tree / proxyworld frame rows (see FrameScene::family_rows): their PS // banks keep family-global lighting constants in different registers // than the environment layout. Identified by the shader debug path // (cached, like the blur trigger); the camera-keyed VS c4 gate above // already filtered to main-pass draws. if ((!g_tree_frame_done || !g_proxy_frame_done || !g_dynobj_frame_done) && ps_bank != 0) { const auto world_family = [&](uint32_t obj) -> int { if (obj < 0x10000 || !GuestReadableApprox(base, obj)) { return 0; } static std::unordered_map cache; auto it = cache.find(obj); if (it != cache.end()) { return it->second; } char text[96] = {}; for (int k = 0; k < 95; ++k) { text[k] = char(REX_LOAD_U8(obj + 0x54 + k)); if (text[k] == '\0') break; } int fam = 0; if (std::strstr(text, "\\tree_defaultPS") != nullptr || std::strstr(text, "\\treeanimate_defaultPS") != nullptr) { fam = 1; } else if (std::strstr(text, "\\proxyworld_defaultPS") != nullptr) { fam = 2; } else if (std::strstr(text, "\\dynamicobject_defaultPS") != nullptr || std::strstr(text, "\\alphatestdynamicobject_defaultPS") != nullptr) { fam = 3; } if (cache.size() < 4096) { cache.emplace(obj, fam); } return fam; }; int fam = world_family(g_cur_ps_obj.load(std::memory_order_relaxed)); if (fam == 0) { fam = world_family(g_cur_vs_obj.load(std::memory_order_relaxed)); } if (fam == 3 && !g_dynobj_frame_done) { // Sun dir c9, exposure c13.x, ambient c15.xyz + bounce c15.w, // material multiplier c14.y, static world-shadow floor c8.w. float sun[3]; float n2 = 0.0f; for (int k = 0; k < 3; ++k) { sun[k] = LoadGuestF32(base, ps_bank + (9 * 4 + k) * 4); n2 += sun[k] * sun[k]; } const float expo = LoadGuestF32(base, ps_bank + (13 * 4 + 0) * 4); const float mult = LoadGuestF32(base, ps_bank + (14 * 4 + 1) * 4); if (n2 > 0.9f && n2 < 1.1f && expo > 0.1f && expo < 16.0f && mult > 0.0f && mult < 16.0f) { g_dynobj_rows[0] = sun[0]; g_dynobj_rows[1] = sun[1]; g_dynobj_rows[2] = sun[2]; g_dynobj_rows[3] = expo; for (int k = 0; k < 3; ++k) { g_dynobj_rows[4 + k] = LoadGuestF32(base, ps_bank + (15 * 4 + k) * 4); } g_dynobj_rows[7] = LoadGuestF32(base, ps_bank + (15 * 4 + 3) * 4); g_dynobj_rows[8] = mult; g_dynobj_rows[9] = LoadGuestF32(base, ps_bank + (8 * 4 + 3) * 4); // Static world-shadow transform (c5 = u row, c6 = v row, // c7 = ray depth row): consumed by the native world-shadow // re-render + the PS world term. Region-stable rows; a change // re-primes the native map (see RenderShadowAtlas). float ws[12]; bool ws_ok = true; float uvmag = 0.0f; for (int k = 0; k < 12; ++k) { ws[k] = LoadGuestF32(base, ps_bank + ((5 + k / 4) * 4 + k % 4) * 4); if (!(ws[k] == ws[k]) || std::fabs(ws[k]) > 1e6f) { ws_ok = false; } if (k < 8 && (k % 4) < 3) { uvmag += std::fabs(ws[k]); } } if (ws_ok && uvmag > 1e-6f) { std::memcpy(g_dynobj_ws, ws, sizeof(ws)); g_dynobj_ws_have = true; } if (!g_dynobj_have) { REXLOG_INFO( "native-scene: dynamicobject rows captured sun=({:.3f},{:.3f}," "{:.3f}) expo={:.2f} amb=({:.3f},{:.3f},{:.3f}) bounce={:.3f} " "mult={:.2f} floor={:.3f}", g_dynobj_rows[0], g_dynobj_rows[1], g_dynobj_rows[2], g_dynobj_rows[3], g_dynobj_rows[4], g_dynobj_rows[5], g_dynobj_rows[6], g_dynobj_rows[7], g_dynobj_rows[8], g_dynobj_rows[9]); } g_dynobj_have = true; g_dynobj_frame_done = true; } } else if (fam == 1 && !g_tree_frame_done) { const float c0x = LoadGuestF32(base, ps_bank + 0 * 4); const float c0y = LoadGuestF32(base, ps_bank + 1 * 4); const float c4y = LoadGuestF32(base, ps_bank + 17 * 4); if (c0x > 0.0f && c0x < 4.0f && c0y >= 0.0f && c0y < 1.0f && c4y > 0.0f && c4y < 8.0f) { g_family_rows[0] = c0x; g_family_rows[1] = c0y; g_family_rows[2] = c4y; g_tree_frame_done = true; } } else if (fam == 2 && !g_proxy_frame_done) { const float c3y = LoadGuestF32(base, ps_bank + 13 * 4); if (c3y > 0.0f && c3y < 4.0f) { g_family_rows[3] = c3y; g_proxy_frame_done = true; } } } } } // Photo-editor postfx capture (SOLE site): read the game's staging banks // at draw time, the same reads the F11 per-draw records use, verified // correct for every postfx pass in captures 1783957072/1783963636. The // former SetPending upload-time capture mixed neighbouring passes' rows // (the game stages the next pass's constants before rebinding shaders) // and has been removed. if (flags2d == 0 && SceneEnabled() && g_photo_flow_frame.load(std::memory_order_relaxed)) { int pfx_pass = ClassifyPfxShader(base, g_cur_ps_obj.load(std::memory_order_relaxed)); if (pfx_pass < 0) { pfx_pass = ClassifyPfxShader(base, g_cur_vs_obj.load(std::memory_order_relaxed)); } if (pfx_pass >= 0) { const uint32_t ps_bank = g_ps_bank.load(std::memory_order_relaxed); const uint32_t vs_bank = g_vs_bank.load(std::memory_order_relaxed); const uint32_t dev = g_device.load(std::memory_order_relaxed); if (ps_bank != 0) { CapturePfxConstants(base, ps_bank, dev, /*pixel=*/true); } if (vs_bank != 0) { CapturePfxConstants(base, vs_bank, dev, /*pixel=*/false); } } } // UI background blur: while a frontend popup is up, the game appends a // dedicated pass chain after the postfx uber: blur_hBlurPS + blur_vBlurPS // (func-2 inline quads OUTSIDE the 2D phase) then a postfx_basictex // fullscreen replace. The blur_hBlurPS draw itself is the trigger; its // PS c0.x is the kernel scale (8 in every capture). See kBlurShaderSource. if (func == 2 && flags2d == 0 && SceneEnabled()) { const auto is_hblur = [&](uint32_t obj) -> bool { if (obj == 0 || !GuestReadableApprox(base, obj)) { return false; } // Guest-render-thread only (like the fog capture globals). static std::unordered_map cache; auto it = cache.find(obj); if (it != cache.end()) { return it->second; } // Debug path at +0x54, e.g. ".../blur_hBlurPS.updb". char text[96] = {}; for (int k = 0; k < 95; ++k) { text[k] = char(REX_LOAD_U8(obj + 0x54 + k)); if (text[k] == '\0') break; } const bool hit = std::strstr(text, "blur_hBlurPS") != nullptr; if (cache.size() < 1024) { cache.emplace(obj, hit); } return hit; }; // Shader labels can be swapped in the hook; accept either slot. if (is_hblur(g_cur_ps_obj.load(std::memory_order_relaxed)) || is_hblur(g_cur_vs_obj.load(std::memory_order_relaxed))) { // Kernel scale read LIVE under a plausibility gate (k0 == k1, in // (0, 8]) so the game's own pause-open ramp animates the native blur // like the emulated frame; the old pin-to-8 popped the backdrop to // full blur on the first frame, and its exact-(8,8) gate on the fade // rejected every mid-ramp c1 (the tint then snapped in late). A // failed gate keeps the last values (stale bank mid-rewrite); raw // 1-frame value flaps (the tinted-backdrop flicker) are absorbed by // the render-thread smoothing at the blur pass. g_ui_blur_seen = true; const uint32_t ps_bank = g_ps_bank.load(std::memory_order_relaxed); if (ps_bank != 0) { const float k0 = LoadGuestF32(base, ps_bank + 0); const float k1 = LoadGuestF32(base, ps_bank + 4); float c1[3]; for (int a = 0; a < 3; ++a) { c1[a] = LoadGuestF32(base, ps_bank + 16 + a * 4); } const bool kernel_ok = k0 == k1 && k0 > 0.0f && k0 <= 8.0f; const bool fade_ok = c1[0] >= 0.0f && c1[0] <= 1.0f && c1[1] >= 0.0f && c1[1] <= 1.0f && c1[2] >= 0.0f && c1[2] <= 1.0f; if (kernel_ok) { g_ui_blur = k0; if (fade_ok) { std::memcpy(g_ui_blur_color, c1, sizeof(g_ui_blur_color)); } } // Open-ramp tracer (guest render thread only): the first draws // after each blur-ON edge print the RAW kernel/fade so the game's // actual animate-in curve lands in the log (for an exactness pass // if the smoothed approximation reads wrong). static int64_t s_last_draw_ns = 0; static uint32_t s_edge_logged = 0; static uint32_t s_total_logged = 0; const int64_t trace_now = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); if (trace_now - s_last_draw_ns > 1'000'000'000ll) { s_edge_logged = 0; } s_last_draw_ns = trace_now; if (s_edge_logged < 20 && s_total_logged < 100) { ++s_edge_logged; ++s_total_logged; REXLOG_DEBUG( "native-scene: blur ramp k=({:.3f},{:.3f}) c1=({:.3f},{:.3f},{:.3f}) {}", k0, k1, c1[0], c1[1], c1[2], kernel_ok ? "accepted" : "held"); } } } } // Selected-object outline capture (see g_frame_selected): the sky draw // opens the post-sky window; environmentpark/dynamicobject draws inside it // are the selection re-draws (the game excludes the selected object from // the main pass and stencil-marks it here). The postfx_edgedetectstencil // draw refreshes the outline color. if (flags2d == 0 && SceneEnabled() && REXCVAR_GET(skate3_native_render_scene_selection_outline)) { // Debug-path family, cached per shader object (guest render thread only, // like the blur classifier). 1 = sky, 2 = park piece / dynamic object, // 3 = postfx_edgedetectstencil. const auto family_of = [&](uint32_t obj) -> int { if (obj == 0 || !GuestReadableApprox(base, obj)) { return 0; } static std::unordered_map cache; auto it = cache.find(obj); if (it != cache.end()) { return it->second; } char text[120] = {}; for (int k = 0; k < 119; ++k) { text[k] = char(REX_LOAD_U8(obj + 0x54 + k)); if (text[k] == '\0') break; } int fam = 0; if (std::strstr(text, "\\sky_") != nullptr) { fam = 1; } else if (std::strstr(text, "\\environmentpark") != nullptr || std::strstr(text, "\\dynamicobject") != nullptr) { fam = 2; } else if (std::strstr(text, "postfx_edgedetectstencil") != nullptr) { fam = 3; } if (cache.size() < 4096) { cache.emplace(obj, fam); } return fam; }; // Shader labels can be swapped in the hook; accept either slot. int fam = family_of(g_cur_ps_obj.load(std::memory_order_relaxed)); if (fam == 0) { fam = family_of(g_cur_vs_obj.load(std::memory_order_relaxed)); } if (func == 0 && fam == 1) { g_sky_seen_this_frame = true; } else if (func == 0 && fam == 2 && g_sky_seen_this_frame) { // World matrix in the VS bank: 3 rotation rows (w == 0) followed by // the translation row (w == 1): c11..c14 on the decal variant, // c9..c12 on the diffuse variant. Scan for the first such group. for (int r = 8; r <= 16; ++r) { if (LoadGuestF32(base, bank + (r * 4 + 3) * 4) != 1.0f) continue; bool rot = true; for (int p = 1; p <= 3 && rot; ++p) { rot = LoadGuestF32(base, bank + ((r - p) * 4 + 3) * 4) == 0.0f; } if (!rot) continue; float t[3]; for (int a = 0; a < 3; ++a) { t[a] = LoadGuestF32(base, bank + (r * 4 + a) * 4); } if (std::fabs(t[0]) < 100000.0f && std::fabs(t[1]) < 100000.0f && std::fabs(t[2]) < 100000.0f) { const uint32_t ib = g_cur_ib.load(std::memory_order_relaxed); const uint32_t vb = g_cur_vb.load(std::memory_order_relaxed); bool merged = false; for (SelectedDrawKey& k : g_frame_selected) { if (k.ib == ib && k.vb == vb && std::fabs(k.t[0] - t[0]) < 0.05f && std::fabs(k.t[1] - t[1]) < 0.05f && std::fabs(k.t[2] - t[2]) < 0.05f) { ++k.count; merged = true; break; } } if (!merged && g_frame_selected.size() < 64) { g_frame_selected.push_back({ib, vb, {t[0], t[1], t[2]}, 1}); } } break; } } else if (fam == 3) { // postfx edge-detect: the presence of this draw is what authorizes the // native outline this frame (see g_outline_edge_seen); its PS c0 = the // outline color as staged (the park-editor blue (0.216, 0.647, 1.0) in // every capture). g_outline_edge_seen = true; const uint32_t ps_bank = g_ps_bank.load(std::memory_order_relaxed); if (ps_bank != 0) { float c[4]; for (int a = 0; a < 4; ++a) { c[a] = LoadGuestF32(base, ps_bank + a * 4); } if (c[0] >= 0.0f && c[0] <= 8.0f && c[1] >= 0.0f && c[1] <= 8.0f && c[2] >= 0.0f && c[2] <= 8.0f) { std::memcpy(g_outline_color, c, sizeof(g_outline_color)); } } } } // Live 2D overlay capture: the HUD renders exclusively through the // BeginVertices inline path (func 2). Vertex payloads are read at frame // end; everything else (transform constants, texture fetch) is staged now. // (An "unbracketed capture" for out-of-bracket boot draws lived here // briefly, removed: every real boot/menu UI draw is bracketed, including // the intro video quad, and it demonstrably ingested blur/postfx pass // quads during takeover-hold windows.) if (flags2d != 0 && SceneEnabled() && REXCVAR_GET(skate3_native_render_scene_2d)) { if (func == 2) { const uint32_t device = g_device.load(std::memory_order_relaxed); if (device != 0 && r7 >= 0x10000 && r5 != 0 && r5 <= 65536 && r6 >= 8 && r6 <= 256 && (r6 & 3) == 0) { Draw2d d; d.prim = r4; d.count = r5; d.stride = r6; d.addr = r7; d.flags = flags2d; // Slots 0-2 (18 dwords): slot 0 is the draw's texture; slots 1-2 // matter only for video quads, whose YUV shader binds the U and V // planes there (the self-contained YUV-triple detection at replay). for (int i = 0; i < 18; ++i) { d.fetch[i] = REX_LOAD_U32(device + 0x480 + i * 4); } for (int i = 0; i < 36; ++i) { d.consts[i] = LoadGuestF32(base, bank + i * 4); } std::lock_guard lock(g_2d_mutex); if (g_frame_2d.size() < 4096) { g_frame_2d.push_back(std::move(d)); } else { g_draws_2d_dropped.fetch_add(1, std::memory_order_relaxed); } } } else { g_draws_2d_other.fetch_add(1, std::memory_order_relaxed); } } // In-world neon spline capture (see SplineDraw): a DrawVertices strip of // 12-byte float3 params on the spline shaders, bound VB object carrying // the payload fetch block (+0x18 base | flags, +0x20 size). if (func == 1 && flags2d == 0 && SceneEnabled() && REXCVAR_GET(skate3_native_render_scene_splines) && r5 >= 4 && r5 <= 8192) { uint32_t kind = ClassifySplineShader(base, g_cur_ps_obj.load(std::memory_order_relaxed)); if (kind == 0) { kind = ClassifySplineShader(base, g_cur_vs_obj.load(std::memory_order_relaxed)); } const uint32_t vb_obj = g_cur_vb.load(std::memory_order_relaxed); if (kind != 0 && GuestReadableApprox(base, vb_obj)) { const uint32_t addr = REX_LOAD_U32(vb_obj + 0x18) & 0xFFFFFFFCu; const uint32_t size = REX_LOAD_U32(vb_obj + 0x20); if (addr >= 0x10000 && size >= r5 * 12) { SplineDraw s; s.pass = kind; s.count = r5; s.verts.resize(size_t(r5) * 12); if (GuestTryCopy(s.verts.data(), base + addr, s.verts.size())) { const uint32_t device = g_device.load(std::memory_order_relaxed); for (int i = 0; i < 6; ++i) { // The gradient texture lives in fetch shadow slot 3 (24 bytes // per slot; the spline PS samples tf3). s.fetch[i] = device != 0 ? REX_LOAD_U32(device + 0x480 + 3 * 24 + i * 4) : 0; } for (int i = 0; i < 153 * 4; ++i) { s.consts[i] = LoadGuestF32(base, bank + i * 4); } g_draws_spline.fetch_add(1, std::memory_order_relaxed); std::lock_guard lock(g_2d_mutex); if (g_frame_spline.size() < 64) { g_frame_spline.push_back(std::move(s)); } } } } } const uint32_t cur_ib = g_cur_ib.load(std::memory_order_relaxed); const uint32_t cur_vb = g_cur_vb.load(std::memory_order_relaxed); if (g_recording.load(std::memory_order_relaxed)) { // Sample the full draw stream on a couple of recorded frames out of // every 60, spread across the window (ground-truth coverage for the // whole recording), and only for frames that are themselves recorded. // 2D-phase draws are recorded on EVERY recorded frame; the HUD stream // is small and is exactly what the 2D reconstruction needs. std::lock_guard lock(g_record_mutex); const bool frame_recorded = (g_frames_seen + 1) % g_record_stride == 0; const bool all_draws = REXCVAR_GET(skate3_native_render_snapshot_all_draws); // 600k: a 360-frame all-draws photo-compose trace is ~470k records (a // 200k cap cuts such a trace around frame 160, before the // late-firing card compose). const size_t draw_cap = all_draws ? 600000 : 32768; if (frame_recorded && (all_draws || flags2d != 0 || (g_record_frame % 60) < 2) && g_recorded_draws.size() < draw_cap) { auto rec = std::make_unique(); rec->func = func; rec->ib = cur_ib; rec->vb = cur_vb; rec->vb_offset = g_cur_vb_offset.load(std::memory_order_relaxed); rec->vb_stride = g_cur_vb_stride.load(std::memory_order_relaxed); for (int s = 0; s < 4; ++s) { for (int k = 0; k < 3; ++k) { rec->streams[s][k] = g_cur_streams[s][k].load(std::memory_order_relaxed); } } const uint32_t device = g_device.load(std::memory_order_relaxed); for (int i = 0; i < 12; ++i) { rec->vfetch[i] = device != 0 ? REX_LOAD_U32(device + 0x480 + i * 4) : 0; } rec->args[0] = r4; rec->args[1] = r5; rec->args[2] = r6; rec->args[3] = r7; for (uint32_t i = 0; i < 1024; ++i) { rec->bank[i] = LoadGuestF32(base, bank + i * 4); } const uint32_t ps_bank = g_ps_bank.load(std::memory_order_relaxed); for (uint32_t i = 0; i < 256; ++i) { rec->ps[i] = ps_bank != 0 ? LoadGuestF32(base, ps_bank + i * 4) : 0.0f; } rec->frame = g_record_frame; rec->flags2d = flags2d; rec->ps_obj = g_cur_ps_obj.load(std::memory_order_relaxed); rec->vs_obj = g_cur_vs_obj.load(std::memory_order_relaxed); for (int i = 0; i < 6; ++i) { rec->viewport[i] = g_cur_viewport[i].load(std::memory_order_relaxed); } for (int i = 0; i < 4; ++i) { rec->scissor[i] = g_cur_scissor[i].load(std::memory_order_relaxed); } const uint32_t rs_bank = g_rs_bank.load(std::memory_order_relaxed); for (int i = 0; i < 256; ++i) { rec->rstates[i] = rs_bank != 0 ? REX_LOAD_U32(rs_bank + i * 4) : 0; } for (int i = 0; i < 192; ++i) { rec->vfetch_all[i] = device != 0 ? REX_LOAD_U32(device + 0x480 + i * 4) : 0; } rec->vb_dump = ~0u; rec->ib_dump = ~0u; if (flags2d != 0) { // 2D payload capture. The 2D pass runs on transient dynamic buffers // (glyph/shape vertices regenerated per frame); dump them now. // One dump per (guest address, frame); recording mode, so the // GuestRangeReadable VAD cost is acceptable. const auto dump_buffer = [&](uint32_t obj, uint32_t size_off) -> uint32_t { if (obj == 0) { return ~0u; } const uint32_t addr = REX_LOAD_U32(obj + 0x18) & 0xFFFFFFFCu; if (addr < 0x10000) { return ~0u; } uint32_t bytes = REX_LOAD_U32(obj + size_off); if (bytes < 4 || bytes > (8u << 20)) { return ~0u; } const uint64_t key = (uint64_t(g_record_frame) << 32) | addr; auto it = g_frame_dump_ids.find(key); if (it != g_frame_dump_ids.end()) { return it->second; } if (g_recorded_buffer_bytes + bytes > (512u << 20) || !GuestRangeReadable(base, addr, bytes)) { return ~0u; } RecordedBuffer buf; buf.vb_addr = addr; buf.ib_addr = 0; buf.fingerprint = key; buf.vb.resize(bytes); std::memcpy(buf.vb.data(), base + addr, bytes); const uint32_t dump_id = uint32_t(g_recorded_buffers.size()); g_recorded_buffer_bytes += bytes; g_recorded_buffers.push_back(std::move(buf)); g_frame_dump_ids.emplace(key, dump_id); return dump_id; }; if (func == 0) { rec->vb_dump = dump_buffer(cur_vb, 0x20); rec->ib_dump = dump_buffer(cur_ib, 0x1C); } else if (func == 1) { rec->vb_dump = dump_buffer(cur_vb, 0x20); } else if (func == 2 && r7 >= 0x10000 && r5 != 0 && r6 != 0 && uint64_t(r5) * r6 <= (4u << 20)) { // Inline-ring vertices (r5 = count, r6 = stride, r7 = the write // pointer BeginVertices returned): the CPU writes them after the // call; dump at frame end. g_pending_inline_dumps.push_back({r7, r5 * r6, g_recorded_draws.size()}); } } if (func == 1 && cur_vb != 0 && flags2d == 0) { // Non-indexed (cloth) draw: the bound object holds two ping-pong // vertex fetch blocks whose ring payloads are recycled long before // frame end; dump both now, keyed back to this draw via rec->ib. const uint32_t dump_id = uint32_t(g_recorded_buffers.size()); for (uint32_t block = 0; block < 2; ++block) { const uint32_t fetch0 = REX_LOAD_U32(cur_vb + 0x18 + block * 0x30); const uint32_t addr = fetch0 & 0xFFFFFFFC; if (addr < 0x10000) continue; uint32_t bytes = REX_LOAD_U32(cur_vb + 0x20 + block * 0x30); if (bytes < 64 || bytes > (256u << 10)) bytes = 128u << 10; if (g_recorded_buffer_bytes + bytes > (512u << 20)) break; while (bytes >= 4096 && !GuestRangeReadable(base, addr, bytes)) { bytes /= 2; } if (bytes < 4096) continue; RecordedBuffer buf; buf.vb_addr = addr; buf.ib_addr = 0; buf.fingerprint = (uint64_t(g_record_frame) << 32) | dump_id | (block << 30); buf.vb.resize(bytes); std::memcpy(buf.vb.data(), base + addr, bytes); g_recorded_buffer_bytes += bytes; g_recorded_buffers.push_back(std::move(buf)); } rec->ib = 0x80000000u | dump_id; } g_recorded_draws.push_back(std::move(rec)); } } if (func != 0) { return; // palette fixup below matches indexed draws only } const uint64_t key = (uint64_t(cur_ib) << 32) | cur_vb; std::lock_guard lock(g_palette_mutex); // Record this draw's texture fetch slots 3+4 (lightmap + diffuse on the // environment families) for the frame-end streamed-artwork diffuse // override (see g_frame_draw_fetch). Last writer wins: the z-prepass // draws these buffers first with stale bindings, the main pass overwrites. if (flags2d == 0 && SceneEnabled()) { const uint32_t device = g_device.load(std::memory_order_relaxed); if (device != 0 && g_frame_draw_fetch.size() < 4096) { auto& w = g_frame_draw_fetch[key]; for (int i = 0; i < 12; ++i) { w[size_t(i)] = REX_LOAD_U32(device + 0x480 + 3 * 24 + uint32_t(i) * 4); } } } // Character-lighting refresh: items already fixed up re-read their PS rows // from later draws with the same buffers (commit-on-success, so the // main-pass draw's rows win over the stale caster-pass bank). CLONES share // (ib,vb) with per-instance constants (livingworld stamp tints, hair // colors, SH rows); refreshing every registered item on every matching // draw collapsed all clones onto the LAST instance's rows (the teal-vest // twins). Pair draw to instance by the bone palette: the draw's VS bank // holds the instance's palette, which matches exactly one item's captured // bones. auto refresh = g_frame_char_refresh.equal_range(key); if (refresh.first != refresh.second) { const uint32_t pb = BankPaletteBase(base, bank); float row0[4] = {}; if (pb != 0) { for (int i = 0; i < 4; ++i) { row0[i] = LoadGuestF32(base, bank + (pb * 4 + uint32_t(i)) * 4); } } for (auto it = refresh.first; it != refresh.second; ++it) { if (it->second >= g_frame_dynitems.size()) continue; DrawItem& d = g_frame_dynitems[it->second]; if (d.bones.size() >= 4 && pb != 0) { bool match = true; for (int i = 0; i < 4 && match; ++i) { // The refined palette can sit one register past BankPaletteBase // (cloth/morph layouts); compare against both candidate rows. match = std::fabs(d.bones[size_t(i)] - row0[i]) < 1e-4f; } if (!match) { bool match1 = true; for (int i = 0; i < 4 && match1; ++i) { match1 = std::fabs(d.bones[size_t(i)] - LoadGuestF32(base, bank + ((pb + 1) * 4 + uint32_t(i)) * 4)) < 1e-4f; } match = match1; } if (!match) continue; } CaptureCharLighting(base, d); } } auto range = g_frame_pending_by_buffers.equal_range(key); if (range.first == range.second) { return; } // This draw's constants belong to the OLDEST still-PENDING item with // these buffers (clones share mesh assets; the deferred list draws in // submit order, so FIFO one-shot pairing keeps clones' palettes apart). // With none pending, the oldest CASTER-sourced item instead gets a // REFRESH from this later draw: palettes captured from the ortho // caster-cascade banks carry stale fine animation (vehicle wheel spin, // ~40 ms behind in bursts); publishing them made the car's pose stream // jump, tripping the smoothing ring's discontinuity reset (the traffic // judder). The refresh entry is only retired by a perspective-bank // (z/main-pass) capture. auto oldest = range.second; for (auto it = range.first; it != range.second; ++it) { if (it->second >= g_frame_dynitems.size() || !g_frame_dynitems[it->second].pending) { continue; } if (oldest == range.second || it->second < oldest->second) { oldest = it; } } bool caster_refresh = false; if (oldest == range.second) { if (REXCVAR_GET(skate3_native_render_scene_caster_refresh_all)) { // Pick the refresh target by PALETTE IDENTITY, across every // caster-sourced skinned candidate, including skinned-published ROPA // garments. Two structural gaps hid here: (1) ropa items were excluded // outright, so the player's tee/hair kept their ortho-bank captures all // frame; the caster banks' fine animation runs ticks stale in bursts // (measured 0.47-0.60 m translation deltas), so those pieces published // poses visibly behind the body (the high-fps hair wedge / vanishing // garment: head-joint pieces 0.3-0.4 m behind the same frame's own // banks while the body matched bit-exact); (2) oldest-only selection // probed the wrong clone for // mesh-sharing NPCs, the <1.5 m guard refused, and the right clone // never refreshed (the NPC blink). Rank by the new bank's bone-0 // translation; the commit block below re-guards with the full capture. const uint32_t pb_sel = BankPaletteBase(base, bank); if (pb_sel == 0) { return; } const float nt[3] = { LoadGuestF32(base, bank + (pb_sel * 4 + 3) * 4), LoadGuestF32(base, bank + ((pb_sel + 1) * 4 + 3) * 4), LoadGuestF32(base, bank + ((pb_sel + 2) * 4 + 3) * 4)}; float best_d2 = 2.25f; // same 1.5 m identity bound as the commit guard for (auto it = range.first; it != range.second; ++it) { if (it->second >= g_frame_dynitems.size()) { continue; } const DrawItem& c = g_frame_dynitems[it->second]; if (!c.caster_bank || !c.skinned || c.bones.size() < 12) { continue; // rigid-resolved ropa has no palette; world capture owns it } const float dx = c.bones[3] - nt[0]; const float dy = c.bones[7] - nt[1]; const float dz = c.bones[11] - nt[2]; const float d2 = dx * dx + dy * dy + dz * dz; if (d2 < best_d2) { best_d2 = d2; oldest = it; } } } else { for (auto it = range.first; it != range.second; ++it) { if (it->second >= g_frame_dynitems.size()) { continue; } const DrawItem& c = g_frame_dynitems[it->second]; if (!c.caster_bank || !c.skinned || c.ropa) { continue; } if (oldest == range.second || it->second < oldest->second) { oldest = it; } } } if (oldest == range.second) { return; } caster_refresh = true; } DrawItem& d = g_frame_dynitems[oldest->second]; if (caster_refresh) { // Refresh of a caster-sourced capture from a later (ideally main-pass) // draw. Re-capture into a PROBE and require the fresher palette to be // THIS entity's pose: same-mesh clones share these buffers, and a far // twin's later draw otherwise refreshes the near car onto the twin's // position (bone-signal recorded 151 m palette teleports; the near // car renders across the map, i.e. invisible where it stands). const uint32_t palette_base = BankPaletteBase(base, bank); if (palette_base == 0) { return; } DrawItem probe = d; if (!CaptureSkinnedState(base, bank, palette_base, probe)) { return; } if (probe.bones.size() < 12 || probe.bones.size() != d.bones.size()) { return; } const float dx = probe.bones[3] - d.bones[3]; const float dy = probe.bones[7] - d.bones[7]; const float dz = probe.bones[11] - d.bones[11]; if (dx * dx + dy * dy + dz * dz > 2.25f) { return; // > 1.5 m: a twin's draw, not this entity's } probe.caster_bank = BankIsOrtho(base, bank); probe.pending = false; probe.dbg_src = 2; d = std::move(probe); if (d.char_family != 0 && g_frame_char_refresh.size() < 256) { g_frame_char_refresh.emplace(key, oldest->second); } if (!d.caster_bank) { g_frame_pending_by_buffers.erase(oldest); } return; } if (d.skinned) { // Locate the palette for this draw's layout; a bank without plausible // bone rows (parameter blocks, camera rows) must not be consumed; that // was the source of screen-wide stretched characters. const uint32_t palette_base = BankPaletteBase(base, bank); if (palette_base == 0) { return; } if (!CaptureSkinnedState(base, bank, palette_base, d)) { return; // stale bank refused: wait for a later draw with these buffers } d.caster_bank = BankIsOrtho(base, bank); } else { // Deferred rigid prop: the world matrix is wherever this draw's layout // keeps it (pre-pass c4..c7, main-pass c8..c11). Not plausible -> wait // for a later draw with these buffers. Aux perspective passes (portrait // RTTs) stage off-map portrait-stage worlds; skip those draws too. if (BankIsAuxPerspective(base, bank) || !BankRigidWorld(base, bank, d.world)) { return; } d.caster_bank = false; } d.pending = false; d.dbg_src = 2; if (d.char_family != 0 && g_frame_char_refresh.size() < 256) { g_frame_char_refresh.emplace(key, oldest->second); } if (!d.caster_bank) { g_frame_pending_by_buffers.erase(oldest); } } // ---- Camera re-timing (skate3_native_render_scene_smooth_camera) ---------- // The guest publishes camera poses on its own sim tick (~170-240 Hz, // irregular, measured cam[chg/rep] streaks of 10 rendered frames on one // pose at 400 fps) while the render loop free-runs: several rendered frames // reuse one pose, then the camera steps; the world "judders / skips to // catch up" while panning. The native renderer owns the view transform, so // each rendered frame samples a TIME-CORRECT pose instead: interpolate // between the last two distinct guest poses at (now - one sim interval). // A few ms of camera latency, no overshoot; teleports/cuts snap. // Standard 3x3 <-> quaternion pair (self-consistent on the view's row-vector // rotation; only round-tripping matters). void QuatFromView(const float view[16], float q[4]) { const float m00 = view[0], m01 = view[1], m02 = view[2]; const float m10 = view[4], m11 = view[5], m12 = view[6]; const float m20 = view[8], m21 = view[9], m22 = view[10]; const float tr = m00 + m11 + m22; if (tr > 0.0f) { const float s = std::sqrt(tr + 1.0f) * 2.0f; q[3] = 0.25f * s; q[0] = (m21 - m12) / s; q[1] = (m02 - m20) / s; q[2] = (m10 - m01) / s; } else if (m00 > m11 && m00 > m22) { const float s = std::sqrt(1.0f + m00 - m11 - m22) * 2.0f; q[3] = (m21 - m12) / s; q[0] = 0.25f * s; q[1] = (m01 + m10) / s; q[2] = (m02 + m20) / s; } else if (m11 > m22) { const float s = std::sqrt(1.0f + m11 - m00 - m22) * 2.0f; q[3] = (m02 - m20) / s; q[0] = (m01 + m10) / s; q[1] = 0.25f * s; q[2] = (m12 + m21) / s; } else { const float s = std::sqrt(1.0f + m22 - m00 - m11) * 2.0f; q[3] = (m10 - m01) / s; q[0] = (m02 + m20) / s; q[1] = (m12 + m21) / s; q[2] = 0.25f * s; } } void ViewRotFromQuat(const float q[4], float view[16]) { const float x = q[0], y = q[1], z = q[2], w = q[3]; view[0] = 1 - 2 * (y * y + z * z); view[1] = 2 * (x * y - z * w); view[2] = 2 * (x * z + y * w); view[4] = 2 * (x * y + z * w); view[5] = 1 - 2 * (x * x + z * z); view[6] = 2 * (y * z - x * w); view[8] = 2 * (x * z - y * w); view[9] = 2 * (y * z + x * w); view[10] = 1 - 2 * (x * x + y * y); } void QuatSlerp(const float a[4], const float b_in[4], float t, float out[4]) { float b[4] = {b_in[0], b_in[1], b_in[2], b_in[3]}; float dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; if (dot < 0.0f) { for (float& v : b) v = -v; dot = -dot; } float wa = 1.0f - t, wb = t; if (dot < 0.9995f) { const float theta = std::acos(std::min(dot, 1.0f)); const float s = std::sin(theta); wa = std::sin((1.0f - t) * theta) / s; wb = std::sin(t * theta) / s; } float n2 = 0.0f; for (int i = 0; i < 4; ++i) { out[i] = wa * a[i] + wb * b[i]; n2 += out[i] * out[i]; } const float inv = n2 > 1e-12f ? 1.0f / std::sqrt(n2) : 1.0f; for (int i = 0; i < 4; ++i) out[i] *= inv; } struct CamPose { double t = 0.0; float q[4] = {0, 0, 0, 1}; // view rotation (rows 0..2) float c[3] = {}; // camera world position }; // Shared playback clock (guest render thread): set by SmoothCamera each // frame it produces a re-timed pose; dynamic items (bone palettes, rigid // worlds) interpolate at the SAME time so the skater/NPCs/props stay in // phase with the smoothed camera; smoothing only the camera concentrated // all the sim stepping on the skater ("he definitively judders now"). double g_smooth_play = 0.0; bool g_smooth_active = false; // Last pose SmoothCamera produced (guest render thread), read by the // synthetic-pan probe (mode 3) to measure reconstruction error against the // known ideal pose. CamPose g_smooth_pose; // Synthetic-pan probe state/helpers: native/skate3_native_diag.h (the // engage/step orchestration stays in BuildFrameScene below; it reads the // live camera and scene locals). // ---- Off-screen retention (edge-of-view teardown fix) ---------------------- // The game culls its submission stream to ITS camera pose; the native frame // renders with the re-timed (smoothed) pose, which TRAILS the guest pose by // up to the boxcar window (~25-50 ms). During a pan the guest stops // submitting items that left its (leading) frustum while the (trailing) // rendered view still contains them; world geometry visibly tore down right // at the screen edges (the emulated frame can never show this: it IS the // guest frame). Recently seen statics stay retained here and are re-appended // while the RAW guest frustum can NOT see them (i.e. the cull was // view-driven). An item the guest frustum CAN see but did not submit was // really removed (LOD switch, despawn, streaming) and drops immediately; // retaining those would z-fight the replacement LOD. Statics only: dynamic // poses go stale the moment they stop being captured. Guest render thread // only (BuildFrameScene); flips request a clear via the atomic. struct RetainedItem { DrawItem item; uint64_t last_seen = 0; // g_guest_frame of the last live submission }; std::unordered_map g_retained_items; // Dynamic sibling for traffic VEHICLES (char families 6/7): a vehicle // passing CLOSE leaves the guest frustum while the trailing rendered pose // still shows it; its captures stop and it tore down in view (and the // frames just before that alternated perspective / stale caster-bank // captures; see the caster ingest guard in InterpolateDynamicItems). // Entries hold the last live NON-caster capture; matched by mesh + // bone-derived position (clones). Guest render thread only. Characters/ // NPCs are deliberately excluded: their ropa garments cannot be retained // coherently (a body without its shirt is worse than the teardown). struct DynRetained { DrawItem item; uint64_t last_seen = 0; float pos[3] = {}; // plausible-bone average (world) float half = 2.0f; // bbox-diagonal half-extent for the frustum test }; std::vector g_dyn_retained; // True when all 8 world-space corners fall outside one clip plane of `vp` // (row-vector view*proj). `margin` scales the tested frustum: < 1 shrinks // it (bounds poking just inside an edge still count as outside; the // game's own cull volumes are tighter than a mesh bbox), > 1 widens it // (only clearly-outside counts). bool CornersOutsideFrustum(const float (&corners)[8][3], const float vp[16], float margin) { int outside[6] = {}; for (int c = 0; c < 8; ++c) { const float* p = corners[c]; float clip[4]; for (int k = 0; k < 4; ++k) { clip[k] = p[0] * vp[0 * 4 + k] + p[1] * vp[1 * 4 + k] + p[2] * vp[2 * 4 + k] + vp[3 * 4 + k]; } // D3D clip volume: -w <= x <= w, -w <= y <= w, 0 <= z <= w. The game's // negative projection x-scale only swaps left/right; the tests are // symmetric. Corners behind the camera land in z < 0. const float m = margin * clip[3]; if (clip[0] < -m) ++outside[0]; if (clip[0] > m) ++outside[1]; if (clip[1] < -m) ++outside[2]; if (clip[1] > m) ++outside[3]; if (clip[2] < 0.0f) ++outside[4]; if (clip[2] > clip[3]) ++outside[5]; } for (int k = 0; k < 6; ++k) { if (outside[k] == 8) { return true; } } return false; } // The item's world-space bbox against the frustum (statics: bbox is // mesh-local, world transforms it; fmt-57 absolute geometry carries an // identity world with world-space bounds). bool ItemOutsideFrustum(const DrawItem& it, const float vp[16], float margin) { float corners[8][3]; for (int c = 0; c < 8; ++c) { const float l[3] = {c & 1 ? it.bbox_max[0] : it.bbox_min[0], c & 2 ? it.bbox_max[1] : it.bbox_min[1], c & 4 ? it.bbox_max[2] : it.bbox_min[2]}; const float* w = it.world; for (int k = 0; k < 3; ++k) { corners[c][k] = l[0] * w[0 * 4 + k] + l[1] * w[1 * 4 + k] + l[2] * w[2 * 4 + k] + w[3 * 4 + k]; } } return CornersOutsideFrustum(corners, vp, margin); } // Axis-aligned cube of half-extent `half` around `center` against the // frustum, the skinned-item variant (a palette's world position lives in // its bone translations, not the identity item world). bool BoxOutsideFrustum(const float center[3], float half, const float vp[16], float margin) { float corners[8][3]; for (int c = 0; c < 8; ++c) { corners[c][0] = center[0] + (c & 1 ? half : -half); corners[c][1] = center[1] + (c & 2 ? half : -half); corners[c][2] = center[2] + (c & 4 ? half : -half); } return CornersOutsideFrustum(corners, vp, margin); } // SynPanAngleDeg / SynPanView: native/skate3_native_diagnostics.cpp. // ---- High-rate camera sampler --------------------------------------------- // Pose samples taken once per RENDERED frame alias as soon as the render // rate drops near/below the sim's camera tick (~200 Hz): at the 140 fps // pacing cap some frames contain one sim step of rotation and some two, all // timestamped on the even frame grid; the smoother then faithfully renders // a 140-vs-200 Hz beat as CONSTANT judder. This thread samples the guest // ViewCamera at ~1 kHz with precise host timestamps, so the reconstruction // signal is always finer than the sim tick regardless of render cadence. // Torn reads (the sim thread writes the matrix concurrently) are rejected // by a double-read compare. struct RawCamSample { double t; float view[16]; float proj[16]; }; std::atomic g_sampler_viewcam{0}; // published by BuildFrameScene std::mutex g_cam_samples_mutex; std::vector g_cam_samples; // pending; drained by SmoothCamera std::atomic g_cam_sampler_started{false}; std::atomic g_cam_sampler_pushes{0}; // telemetry void CamSamplerLoop() { float last_view[16] = {}; double syn_next_emit = 0.0; int syn_cadence_i = 0; for (;;) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); if (!SceneEnabled()) { std::this_thread::sleep_for(std::chrono::milliseconds(250)); continue; } // Synthetic pan mode 3: synthesize guest-like pose samples instead of // reading the guest camera, ~200 Hz with a deliberately irregular // cadence (the real sim tick is irregular; the smoother must cope). The // downstream path (ring, period EMA, playback clock, slerp) runs // unmodified on them, and its output is compared numerically against // the ideal pose in BuildFrameScene. if (g_synpan_active.load(std::memory_order_acquire) == 3) { static constexpr double kCadenceMs[6] = {4.0, 5.0, 6.0, 4.0, 6.0, 5.0}; const double now = std::chrono::duration(PerfClock::now().time_since_epoch()).count(); if (syn_next_emit == 0.0) { syn_next_emit = now; } if (now < syn_next_emit) { continue; } syn_next_emit += kCadenceMs[syn_cadence_i++ % 6] * 1e-3; if (syn_next_emit < now) { syn_next_emit = now; // fell behind (OS scheduling hitch): re-anchor } RawCamSample s; s.t = now; { std::lock_guard lock(g_synpan_mutex); const double rate = REXCVAR_GET(skate3_native_render_scene_synthetic_pan_rate); const double amp = REXCVAR_GET(skate3_native_render_scene_synthetic_pan_amp); SynPanView(SynPanAngleDeg((now - g_synpan_t0) * rate, amp), s.view); std::memcpy(s.proj, g_synpan_proj0, sizeof(s.proj)); } std::lock_guard lock(g_cam_samples_mutex); if (g_cam_samples.size() < 256) { g_cam_samples.push_back(s); g_cam_sampler_pushes.fetch_add(1, std::memory_order_relaxed); } continue; } syn_next_emit = 0.0; const uint32_t vc = g_sampler_viewcam.load(std::memory_order_relaxed); uint8_t* base = g_guest_base.load(std::memory_order_relaxed); if (vc == 0 || base == nullptr) { continue; } // view at +0x20, proj at +0x60: one 128-byte guarded copy covers both. uint32_t raw_a[32], raw_b[32]; if (!GuestTryCopy(raw_a, base + vc + 0x20, sizeof(raw_a)) || !GuestTryCopy(raw_b, base + vc + 0x20, sizeof(raw_b)) || std::memcmp(raw_a, raw_b, sizeof(raw_a)) != 0) { continue; // unreadable or torn mid-write: try again next tick } const double now = std::chrono::duration( PerfClock::now().time_since_epoch()) .count(); RawCamSample s; s.t = now; for (int i = 0; i < 16; ++i) { s.view[i] = std::bit_cast(__builtin_bswap32(raw_a[i])); s.proj[i] = std::bit_cast(__builtin_bswap32(raw_a[16 + i])); } // Sanity: perspective proj + a plausible rotation row (stale viewcam // addresses after a map change read garbage until re-published). const float r0n = s.view[0] * s.view[0] + s.view[1] * s.view[1] + s.view[2] * s.view[2]; if (s.proj[2 * 4 + 3] != 1.0f || r0n < 0.9f || r0n > 1.1f) { continue; } if (std::memcmp(last_view, s.view, sizeof(last_view)) == 0) { continue; // pose unchanged } std::memcpy(last_view, s.view, sizeof(last_view)); // Camera-signal recorder: every DISTINCT pose the guest produced, with // the sampler's precise host timestamp (kind 0). if (now < g_camsig_deadline.load(std::memory_order_relaxed)) { CamSigPush(now, 0.0, YawFromViewRows(s.view), 0); } std::lock_guard lock(g_cam_samples_mutex); if (g_cam_samples.size() < 256) { g_cam_samples.push_back(s); g_cam_sampler_pushes.fetch_add(1, std::memory_order_relaxed); } } } void EnsureCamSampler() { if (g_cam_sampler_started.exchange(true, std::memory_order_acq_rel)) { return; } std::thread(CamSamplerLoop).detach(); REXLOG_INFO("native-scene: camera sampler thread started (~1 kHz)"); } // Rebuild a row-vector view matrix from pose (rows 0..2 = R, row 3 = -c*R) // and produce view_proj = view x proj. void ComposeViewProj(const CamPose& p, const float proj[16], float vp_out[16], float cam_out[3]) { float v[16] = {}; ViewRotFromQuat(p.q, v); v[3] = v[7] = v[11] = 0.0f; for (int k = 0; k < 3; ++k) { v[12 + k] = -(p.c[0] * v[0 * 4 + k] + p.c[1] * v[1 * 4 + k] + p.c[2] * v[2 * 4 + k]); } v[15] = 1.0f; for (int r = 0; r < 4; ++r) { for (int col = 0; col < 4; ++col) { float sum = 0.0f; for (int k = 0; k < 4; ++k) { sum += v[r * 4 + k] * proj[k * 4 + col]; } vp_out[r * 4 + col] = sum; } } std::memcpy(cam_out, p.c, 3 * sizeof(float)); } // Drone / free-fly camera: overrides the published pose with a user-flown // one while the game keeps simulating on its own camera. Engaged from the // current guest pose (roll is dropped; the gameplay camera never rolls), // the projection is frozen at engage so gameplay FOV changes don't disturb // the shot, and both linear and angular key input drive exponentially // smoothed velocities for drone-like motion. // // Guest-side culling is handled by taking over the game's own render // camera: the frame build publishes the flown view matrix below, and the // Sk8::Render::ViewCamera::SetViewMatrix override (bottom of this file) // rewrites the game's main-camera argument with it, so the game derives // its view-proj, frustum planes and cull/submit lists from the drone pose // itself; statics, characters and props are all submitted exactly as if // the game were looking there. (A draw-item union like the synthetic-pan // probe's cannot do this: it can only re-append items the game already // submitted from poses it actually rendered, and rigid props that ride // entity animation smear at their stale transforms.) std::mutex g_freecam_guest_mutex; float g_freecam_guest_view[16] = {}; // row-vector view, host endianness float g_freecam_guest_pos[3] = {}; // camera world position std::atomic g_freecam_guest_active{0}; std::atomic g_freecam_guest_rewrites{0}; struct FreecamState { bool engaged = false; double yaw = 0.0, pitch = 0.0; // radians: yaw about world Y, then pitch double pos[3] = {}; double vel[3] = {}; // smoothed world-space velocity double yaw_vel = 0.0, pitch_vel = 0.0; // smoothed key-look rates (rad/s) double zoom = 1.0; // projection scale (Z/X keys) float proj0[16] = {}; // projection frozen at engage // Basis and screen-direction signs resolved numerically at engage (the // view right/up column signs against cross-product candidates, and the // projection's x/y scale signs), so no handedness convention is assumed; // the game's projection carries a negative x scale. float sign_right = 1.0f, sign_up = 1.0f; double look_sign_x = 1.0, look_sign_y = 1.0; double last_t = 0.0; #if defined(_WIN32) bool mouse_anchored = false; POINT mouse_last = {}; #endif }; FreecamState g_freecam; // Guest render thread only. Returns true while engaged; scene.view_proj, // scene.proj and scene.cam_pos then hold the flown pose. bool UpdateFreecam(FrameScene& scene, const float cam_view[16], double now) { FreecamState& fc = g_freecam; if (!REXCVAR_GET(skate3_native_render_scene_freecam)) { if (fc.engaged) { fc.engaged = false; g_freecam_guest_active.store(0, std::memory_order_release); REXLOG_INFO( "native-scene freecam: off (guest camera restored; {} guest " "SetViewMatrix rewrites while engaged)", g_freecam_guest_rewrites.exchange(0, std::memory_order_relaxed)); } return false; } if (!fc.engaged) { // Engage from this frame's raw guest pose. The view's upper-3x3 columns // are the camera axes in world space (row-vector convention): // 0 = right, 1 = up, 2 = forward. const float f0[3] = {cam_view[2], cam_view[6], cam_view[10]}; fc.yaw = std::atan2(double(f0[0]), double(f0[2])); fc.pitch = std::asin(std::clamp(double(f0[1]), -1.0, 1.0)); for (int j = 0; j < 3; ++j) { fc.pos[j] = -(cam_view[12] * cam_view[j * 4 + 0] + cam_view[13] * cam_view[j * 4 + 1] + cam_view[14] * cam_view[j * 4 + 2]); } // Right candidate cross(world_up, forward) = (f.z, 0, -f.x): its sign // against the live right column fixes the basis handedness. fc.sign_right = f0[2] * cam_view[0] - f0[0] * cam_view[8] >= 0.0f ? 1.0f : -1.0f; const float r0[3] = {f0[2] * fc.sign_right, 0.0f, -f0[0] * fc.sign_right}; const float u0[3] = {f0[1] * r0[2] - f0[2] * r0[1], f0[2] * r0[0] - f0[0] * r0[2], f0[0] * r0[1] - f0[1] * r0[0]}; fc.sign_up = u0[0] * cam_view[1] + u0[1] * cam_view[5] + u0[2] * cam_view[9] >= 0.0f ? 1.0f : -1.0f; std::memcpy(fc.proj0, scene.proj, sizeof(fc.proj0)); // Screen-relative look/strafe directions: view +x maps to screen right // only when the projection x scale is positive (it isn't in this game), // so fold the projection signs in once. +yaw rotates forward toward +x // (the right column direction times sign_right), hence the product. fc.look_sign_x = double(fc.sign_right) * (fc.proj0[0] >= 0.0f ? 1.0 : -1.0); fc.look_sign_y = double(fc.sign_up) * (fc.proj0[5] >= 0.0f ? 1.0 : -1.0); fc.vel[0] = fc.vel[1] = fc.vel[2] = 0.0; fc.yaw_vel = fc.pitch_vel = 0.0; fc.zoom = 1.0; fc.last_t = now; #if defined(_WIN32) fc.mouse_anchored = false; #endif fc.engaged = true; REXLOG_INFO( "native-scene freecam: ENGAGED at ({:.1f}, {:.1f}, {:.1f}); WASD fly, " "E/Space up, Q/C down, arrows/right-drag look, Z/X zoom, Shift fast, " "Ctrl slow", fc.pos[0], fc.pos[1], fc.pos[2]); } const double dt = std::clamp(now - fc.last_t, 0.0, 0.1); fc.last_t = now; double mv_f = 0.0, mv_r = 0.0, mv_u = 0.0; // move intent (camera-relative) double lk_yaw = 0.0, lk_pitch = 0.0; // arrow-key look intent double mouse_yaw = 0.0, mouse_pitch = 0.0; // right-drag deltas (radians) double speed_mult = 1.0, zoom_dir = 0.0; #if defined(_WIN32) const auto down = [](int vk) { return (GetAsyncKeyState(vk) & 0x8000) != 0; }; mv_f = (down('W') ? 1.0 : 0.0) - (down('S') ? 1.0 : 0.0); mv_r = (down('D') ? 1.0 : 0.0) - (down('A') ? 1.0 : 0.0); mv_u = ((down('E') || down(VK_SPACE)) ? 1.0 : 0.0) - ((down('Q') || down('C')) ? 1.0 : 0.0); lk_yaw = (down(VK_RIGHT) ? 1.0 : 0.0) - (down(VK_LEFT) ? 1.0 : 0.0); lk_pitch = (down(VK_UP) ? 1.0 : 0.0) - (down(VK_DOWN) ? 1.0 : 0.0); zoom_dir = (down('Z') ? 1.0 : 0.0) - (down('X') ? 1.0 : 0.0); if (down(VK_SHIFT)) { speed_mult = 4.0; } else if (down(VK_CONTROL)) { speed_mult = 0.2; } if (down(VK_RBUTTON)) { POINT p; if (GetCursorPos(&p)) { if (fc.mouse_anchored) { constexpr double kRadPerPixel = 0.0022; // ~0.13 deg per pixel mouse_yaw = (p.x - fc.mouse_last.x) * kRadPerPixel; mouse_pitch = -(p.y - fc.mouse_last.y) * kRadPerPixel; } fc.mouse_last = p; fc.mouse_anchored = true; } } else { fc.mouse_anchored = false; } #endif // Look: arrow keys drive a smoothed angular velocity (cinematic ease-in/ // out); mouse deltas apply directly. Pitch is clamped short of the poles. const double look_rate = REXCVAR_GET(skate3_native_render_scene_freecam_look_speed) * (3.14159265358979323846 / 180.0); const double ang_k = dt > 0.0 ? 1.0 - std::exp(-dt / 0.08) : 0.0; fc.yaw_vel += (lk_yaw * look_rate - fc.yaw_vel) * ang_k; fc.pitch_vel += (lk_pitch * look_rate - fc.pitch_vel) * ang_k; fc.yaw += (fc.yaw_vel * dt + mouse_yaw) * fc.look_sign_x; fc.pitch += (fc.pitch_vel * dt + mouse_pitch) * fc.look_sign_y; constexpr double kPitchMax = 89.0 * 3.14159265358979323846 / 180.0; fc.pitch = std::clamp(fc.pitch, -kPitchMax, kPitchMax); if (zoom_dir != 0.0) { // Zoom-in only: the guest camera (and therefore the game's culling) // keeps the unzoomed projection, so a narrower rendered FOV is always a // subset of what was culled; zooming wider would show unculled edges. fc.zoom = std::clamp(fc.zoom * std::exp(zoom_dir * dt), 1.0, 4.0); } // Basis from yaw/pitch (unit length by construction, signs from engage). const double cy = std::cos(fc.yaw), sy = std::sin(fc.yaw); const double cp = std::cos(fc.pitch), sp = std::sin(fc.pitch); const float fwd[3] = {float(sy * cp), float(sp), float(cy * cp)}; const float right[3] = {float(cy) * fc.sign_right, 0.0f, float(-sy) * fc.sign_right}; const float up[3] = {float(-sp * sy) * fc.sign_right * fc.sign_up, float(cp) * fc.sign_right * fc.sign_up, float(-sp * cy) * fc.sign_right * fc.sign_up}; // Move: forward along the view direction (true drone flight), strafe // along screen right, vertical along world up; combined intent normalized // so diagonals don't fly faster. Exponential ease toward the target. const double ilen = std::sqrt(mv_f * mv_f + mv_r * mv_r + mv_u * mv_u); const double speed = REXCVAR_GET(skate3_native_render_scene_freecam_speed) * speed_mult * (ilen > 1.0 ? 1.0 / ilen : 1.0); const double strafe = mv_r * fc.look_sign_x * speed; const double tvel[3] = {mv_f * speed * fwd[0] + strafe * right[0], mv_f * speed * fwd[1] + mv_u * speed, mv_f * speed * fwd[2] + strafe * right[2]}; const double vel_k = dt > 0.0 ? 1.0 - std::exp(-dt / 0.25) : 0.0; for (int j = 0; j < 3; ++j) { fc.vel[j] += (tvel[j] - fc.vel[j]) * vel_k; fc.pos[j] += fc.vel[j] * dt; } // Compose and publish: view columns = right/up/forward, translation row // = -pos * R; projection = the engage-frozen one with the zoom folded in // (published to scene.proj too so depth-based post passes unproject with // what was actually rendered). float view[16] = {}; for (int i = 0; i < 3; ++i) { view[i * 4 + 0] = right[i]; view[i * 4 + 1] = up[i]; view[i * 4 + 2] = fwd[i]; } const float posf[3] = {float(fc.pos[0]), float(fc.pos[1]), float(fc.pos[2])}; for (int k = 0; k < 3; ++k) { view[12 + k] = -(posf[0] * view[0 * 4 + k] + posf[1] * view[1 * 4 + k] + posf[2] * view[2 * 4 + k]); } view[15] = 1.0f; // Hand the flown view to the guest-camera takeover (the SetViewMatrix // override): the game's next camera update culls/submits with this pose. { std::lock_guard lock(g_freecam_guest_mutex); std::memcpy(g_freecam_guest_view, view, sizeof(view)); std::memcpy(g_freecam_guest_pos, posf, sizeof(posf)); } g_freecam_guest_active.store(1, std::memory_order_release); float pr[16]; std::memcpy(pr, fc.proj0, sizeof(pr)); pr[0] *= float(fc.zoom); pr[5] *= float(fc.zoom); std::memcpy(scene.proj, pr, sizeof(pr)); for (int r = 0; r < 4; ++r) { for (int col = 0; col < 4; ++col) { float sum = 0.0f; for (int k = 0; k < 4; ++k) { sum += view[r * 4 + k] * pr[k * 4 + col]; } scene.view_proj[r * 4 + col] = sum; } } std::memcpy(scene.cam_pos, posf, sizeof(posf)); return true; } // While the freecam is engaged, fills out_pos with the flown camera world // position and returns true. Consumed by the draw-distance hooks to // recenter the guest distance culls on the drone. bool FreecamGuestPose(float out_pos[3]) { if (g_freecam_guest_active.load(std::memory_order_acquire) == 0) { return false; } std::lock_guard lock(g_freecam_guest_mutex); std::memcpy(out_pos, g_freecam_guest_pos, 3 * sizeof(float)); return true; } bool LoadingOrFrontendActive() { if (rex::kernel::guest_presence::GameplayContextValue() != 0) { return false; } // Same publish-staleness window as YieldForMenus: the pause menu keeps // the world resubmitting perspective scenes behind it, loading screens // and the frontend stop publishing. const int64_t last_ns = g_last_publish_ns.load(std::memory_order_relaxed); if (last_ns < 0) { return true; } const int64_t now_ns = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); return now_ns - last_ns >= 300'000'000; } // Guest render thread only. Returns true when vp_out/cam_out hold a // re-timed pose for `now`; false = keep the raw guest pose (no history yet, // or a cut/teleport snapped). // // Dejitter structure (the naive last-two-samples lerp still juddered): // sample DETECTION times are quantized to render frames (+-2.5 ms of noise // on a ~4.5 ms camera tick), and re-basing the timeline on each new sample // jerks the output whenever sample spacing varies. Instead: a pose RING // timestamped at detection, an EMA of the sample period, and a playback // clock that advances on real frame time and only slews gently toward // (newest - 2 periods). The bracketing ring samples are slerped at the // playback time; sample-time noise is absorbed by the slew instead of // feeding straight into the pose velocity. // Most recent camera sim-tick time (guest render thread only): entities that // changed pose this frame changed on the same sim tick; timestamping their // pose rings with it instead of the frame time avoids the same aliasing. double g_latest_cam_tick = 0.0; bool SmoothCamera(const float view[16], const float proj[16], const float raw_vp[16], const float raw_cam[3], double now, float vp_out[16], float cam_out[3]) { constexpr int kRing = 64; // ~1 kHz sampler: cover well past the lag window static CamPose s_ring[kRing]; static int s_count = 0; // -1 = convention drift, never smooth this run static int s_newest = 0; static float s_last_view[16] = {}; static double s_period = 1.0 / 200.0; // EMA of the guest camera tick static double s_play = 0.0; // playback clock (guest-pose time axis) static double s_last_now = 0.0; static bool s_play_valid = false; const auto push_pose = [&](const CamPose& p) { if (s_count < 0) { return; } const CamPose& latest = s_ring[s_newest]; const float dot = std::fabs(p.q[0] * latest.q[0] + p.q[1] * latest.q[1] + p.q[2] * latest.q[2] + p.q[3] * latest.q[3]); const float dx = p.c[0] - latest.c[0], dy = p.c[1] - latest.c[1], dz = p.c[2] - latest.c[2]; const float dist2 = dx * dx + dy * dy + dz * dz; if (s_count == 0 || p.t - latest.t > 0.1 || dist2 > 25.0f || dot < 0.9f) { // First sample / stale history / teleport / hard cut: snap. s_ring[0] = p; s_newest = 0; s_count = 1; s_play_valid = false; } else { const double dt = p.t - latest.t; if (dt <= 0.0) { return; // out-of-order/duplicate } if (dt > 0.0005 && dt < 0.05) { s_period = s_period * 0.9 + dt * 0.1; } s_newest = (s_newest + 1) % kRing; s_ring[s_newest] = p; s_count = std::min(s_count + 1, kRing); } g_latest_cam_tick = p.t; }; // Primary source: the ~1 kHz sampler thread (precise timestamps: pose // samples taken per RENDERED frame alias against the ~200 Hz sim tick as // soon as the render rate is paced down; see CamSamplerLoop). { static std::vector s_pending; { std::lock_guard lock(g_cam_samples_mutex); s_pending.swap(g_cam_samples); } for (const RawCamSample& s : s_pending) { CamPose p; p.t = s.t; QuatFromView(s.view, p.q); for (int j = 0; j < 3; ++j) { p.c[j] = -(s.view[12] * s.view[j * 4 + 0] + s.view[13] * s.view[j * 4 + 1] + s.view[14] * s.view[j * 4 + 2]); } push_pose(p); } s_pending.clear(); } if (std::memcmp(s_last_view, view, sizeof(s_last_view)) != 0) { std::memcpy(s_last_view, view, sizeof(s_last_view)); CamPose p; p.t = now; QuatFromView(view, p.q); std::memcpy(p.c, raw_cam, sizeof(p.c)); // One-shot reconstruction self-check: the rebuilt view_proj at this // exact pose must match the guest's own (catches any convention drift). static bool s_checked = false; if (!s_checked) { s_checked = true; float test_vp[16], test_cam[3]; ComposeViewProj(p, proj, test_vp, test_cam); float max_rel = 0.0f; for (int i = 0; i < 16; ++i) { const float mag = std::max(1.0f, std::fabs(raw_vp[i])); max_rel = std::max(max_rel, std::fabs(test_vp[i] - raw_vp[i]) / mag); } REXLOG_INFO("native-scene: smooth-camera reconstruction check max_rel={:.6f}{}", max_rel, max_rel > 0.01f ? " (BAD - smoothing disabled this run)" : ""); if (max_rel > 0.01f) { s_count = -1; } } // Fallback only: if the sampler has fed the ring recently, the frame- // grid pose is a stale duplicate of a sampler pose; pushing it would // corrupt the timestamps. if (s_count >= 0 && (s_count == 0 || now - s_ring[s_newest].t > 0.05)) { push_pose(p); } } if (s_count < 3 || now - s_ring[s_newest].t > 0.1) { s_play_valid = false; g_smooth_active = false; return false; } // Playback clock: advance by real frame time; slew gently toward the // target lag point so detection-time noise never becomes pose velocity. const double frame_dt = s_play_valid ? std::clamp(now - s_last_now, 0.0, 0.05) : 0.0; s_last_now = now; // Boxcar pose filter (see below): the playback point must lag far enough // that the CENTERED window has ring samples on both sides. const double filter_w = std::clamp( REXCVAR_GET(skate3_native_render_scene_smooth_camera_filter_ms), 0.0, 200.0) * 1e-3; const double lag = std::max(2.0 * s_period, filter_w * 0.5 + s_period); const double target = s_ring[s_newest].t - lag; if (!s_play_valid) { s_play = target; s_play_valid = true; } else { s_play += frame_dt; const double err = target - s_play; if (err > 0.1 || err < -0.1) { s_play = target; // fell far behind/ahead (hitch): snap the clock } else { s_play += err * 0.06; } } // Never play past the newest sample: when panning STOPS, samples stop // arriving while the clock keeps advancing; extrapolating overshot the // stop pose and the staleness cutoff then snapped back ("catch-up judder // when I stop panning"). Parked at the newest sample, the smoothed pose // settles exactly onto the raw stop pose and every later handoff is // seamless. s_play = std::min(s_play, s_ring[s_newest].t); // Evaluate the piecewise-linear pose signal at an arbitrary time by // bracketing in the ring (samples are time-ordered oldest -> newest) and // slerping. Clamps to the ring's ends. const auto eval_ring = [&](double t_eval) { int hi = s_newest; int lo = (s_newest + kRing - 1) % kRing; for (int step = 1; step < s_count - 1; ++step) { if (s_ring[lo].t <= t_eval) { break; } hi = lo; lo = (lo + kRing - 1) % kRing; } const CamPose& p0 = s_ring[lo]; const CamPose& p1 = s_ring[hi]; const double span = std::max(p1.t - p0.t, 0.0005); const double alpha = std::clamp((t_eval - p0.t) / span, 0.0, 1.0); CamPose r; r.t = t_eval; QuatSlerp(p0.q, p1.q, float(alpha), r.q); for (int k = 0; k < 3; ++k) { r.c[k] = p0.c[k] + (p1.c[k] - p0.c[k]) * float(alpha); } return r; }; CamPose p; if (filter_w > 0.0005 && s_count >= 4) { // Boxcar pose filter: average the interpolated signal over a window // CENTERED on the playback point (zero phase error at constant // velocity). Root-caused need (camera-signal recordings): the // game's camera pose VALUES advance in 60 Hz-quantized lumps at high // render rates; during a measured constant stick pan the per-tick // angular velocity had sd 144 deg/s on a 172 deg/s mean, +-2.2 deg off // a constant-rate path. A 50 ms window (three 60 Hz periods) nulls the // quantization at any render rate: measured frame-to-frame velocity // jitter fell 185 -> 7 deg/s rms on the recorded signal. Taps past the // newest sample clamp to it, so a pan STOP still settles exactly onto // the raw stop pose (never extrapolates). constexpr int kTaps = 16; double qacc[4] = {}; double cacc[3] = {}; float qref[4] = {}; for (int k = 0; k < kTaps; ++k) { const double tt = std::min(s_play - filter_w * 0.5 + (k + 0.5) * filter_w / kTaps, s_ring[s_newest].t); const CamPose s = eval_ring(tt); float sq[4] = {s.q[0], s.q[1], s.q[2], s.q[3]}; if (k == 0) { std::memcpy(qref, sq, sizeof(qref)); } else if (sq[0] * qref[0] + sq[1] * qref[1] + sq[2] * qref[2] + sq[3] * qref[3] < 0.0f) { for (float& v : sq) v = -v; } for (int j = 0; j < 4; ++j) qacc[j] += sq[j]; for (int j = 0; j < 3; ++j) cacc[j] += s.c[j]; } const double qn = std::sqrt(qacc[0] * qacc[0] + qacc[1] * qacc[1] + qacc[2] * qacc[2] + qacc[3] * qacc[3]); p.t = s_play; for (int j = 0; j < 4; ++j) { p.q[j] = float(qacc[j] / std::max(qn, 1e-12)); } for (int j = 0; j < 3; ++j) { p.c[j] = float(cacc[j] / kTaps); } } else { p = eval_ring(s_play); } ComposeViewProj(p, proj, vp_out, cam_out); g_smooth_play = s_play; g_smooth_pose = p; g_smooth_active = true; return true; } // Minimum time spacing (seconds) between dynamic-pose ring ingests. The // ring is consumed per CHANGE, and above ~200 fps the guest re-packs poses // every rendered frame; without a spacing floor the ring's fixed capacity // covers ever less real time as the frame rate climbs, until the smoothing // kernel's ~55 ms reach falls off the old end (the high-fps skater/board // rubberband). 3.5 ms keeps the kept signal finer than the guest's // ~200 Hz pose tick while bounding capacity use; at ~285 fps and below // every change still ingests, so the tuned mid-rate behavior is untouched. // Applies to POSE ingests only: ropa cloth-decode enqueues must NOT be // decimated (see the enqueue block; pose/shape pairing relies on their // per-frame cadence). constexpr double kMinDynIngestSpacing = 0.0035; // Interpolate the DYNAMIC items' per-draw state (bone palettes; rigid // non-identity world matrices) at the camera's playback time. Without this // the smoothed camera glides while the skater/NPCs/props snap at the guest // sim tick; the stepping that used to be hidden (camera and entities // stepped IN PHASE) becomes visible entity judder. History pairs poses by // (mesh, k-th occurrence this frame): clones publish in stable submit // 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(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 // poses, visible judder and motion blur). The camera filter // pushes the play clock ~(W/2 + period) back AND the entity boxcar // (below) reaches another W/2 past that (~55 ms total at the default // 50 ms window). With ingests spaced at least kMinDynIngestSpacing apart, // 24 slots guarantee >= ~80 ms of coverage at ANY render rate. The shared // g_smooth_play keeps entities in phase with the camera. constexpr int kRing = 24; struct DynPose { double t = 0.0; std::vector b; // bone palette (skinned), raw captured rows float w[16] = {}; // world (rigid) // ROPA: the newest cloth-shape generation (dyn job seq) that existed // when this pose was captured, the shape that belongs WITH this pose // (constant enqueue offset; the draw lerps the bracketing generations). uint64_t shape_seq = 0; }; struct DynHist { DynPose ring[kRing]; int count = 0; int newest = 0; uint64_t seen = 0; // EMA of this entity's own pose-change period. Characters/board update // at the 60 Hz sim value cadence (~16.7 ms); traffic vehicles update // SLOWER (their own sim rate); those entities need their evaluation // point delayed by one own-period or the playback clock runs past // their newest sample and they render raw stepped poses (see play_e). double period = 0.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; // Timestamp of the last PERSPECTIVE-sourced (non-caster, non-retained) // sample ingested: decides whether a caster pose tracks (caster-only // stream) or holds (perspective stream fresh; interleaving the two // sawtooths the wheel phase). double last_persp_t = 0.0; }; static std::unordered_map s_hist; static uint64_t s_frame = 0; ++s_frame; // Bone-signal recorder window state (see BoneSigAppend). Written on a // detached thread once the window closes. const bool bs_rec = BoneSigTick(now); static const float kIdent[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; // ---- char_track diagnostics (skate3_native_render_scene_char_track) ---- // One log line per frame following the main character's ropa garment and // body through this function: which events fired, what the ring held, and // where the item actually rendered. Selection: garment = nearest ropa // item to the camera; body = the highest-bone-count skinned non-ropa // character item within 5 m (bone count separates the body from the // board/accessories at equal distance). struct CharTrack { int idx = -1; float raw[3] = {}; // pre-interp reference position (bone 0 / world t) int ing = 0; // 1 = pose ingested, 2 = change skipped (spacing) int rst = 0; // discontinuity reset fired int clm = 0; // 1 = claimed another ring, 2 = fresh ring slot int hold = 0; // caster/retained hold replaced the pose int raw_render = 0; // 0 = interpolated; 1 = ring<3/stale, 2 = size // mismatch, 3 = hidden (unrepairable) int ring_n = 0; // ring occupancy at entry float age_ms = -1.0f; // ring-newest age at entry float per_ms = -1.0f; // ring period EMA float ba = -1.0f; // pair-lerp alpha at the playback point int box = 0; // took the 8-tap boxcar int detach = 0; // ropa DETACH repair fired float sw = 0.0f; // shape-kernel total weight (garment only) }; CharTrack trk_shirt, trk_body; const bool trk_on = REXCVAR_GET(skate3_native_render_scene_char_track); if (trk_on) { float best_s = 25.0f; // 5 m gate, squared uint32_t best_b_bones = 0; float best_b_d2 = 25.0f; for (size_t i = 0; i < scene.items.size(); ++i) { const DrawItem& it = scene.items[i]; const bool skn = it.skinned && it.bones.size() >= 12; float p[3]; if (skn) { p[0] = it.bones[3]; p[1] = it.bones[7]; p[2] = it.bones[11]; } else { p[0] = it.world[12]; p[1] = it.world[13]; p[2] = it.world[14]; } const float dx = p[0] - scene.cam_pos[0]; const float dy = p[1] - scene.cam_pos[1]; const float dz = p[2] - scene.cam_pos[2]; const float d2 = dx * dx + dy * dy + dz * dz; if (it.ropa) { if (d2 < best_s) { best_s = d2; trk_shirt.idx = int(i); std::memcpy(trk_shirt.raw, p, sizeof(p)); } } else if (skn && it.char_family >= 1 && it.char_family <= 5 && d2 < 25.0f) { const uint32_t nb = uint32_t(it.bones.size() / 12); if (nb > best_b_bones || (nb == best_b_bones && d2 < best_b_d2)) { best_b_bones = nb; best_b_d2 = d2; trk_body.idx = int(i); std::memcpy(trk_body.raw, p, sizeof(p)); } } } } std::unordered_map occurrence; for (DrawItem& item : scene.items) { CharTrack* trk = nullptr; if (trk_on) { const int idx = int(&item - scene.items.data()); if (idx == trk_shirt.idx) { trk = &trk_shirt; } else if (idx == trk_body.idx) { trk = &trk_body; } } const bool skinned = item.skinned && !item.bones.empty(); const bool rigid_dyn = !skinned && std::memcmp(item.world, kIdent, sizeof(kIdent)) != 0; if (!skinned && !rigid_dyn) { continue; } const uint32_t k = occurrence[item.mesh]++; // Skinned pose-to-pose translation distance^2 at a NON-SPINNING // reference. Fast wheel bones sweep multi-meter circles between ~8 ms // samples (the model->world affine's translation carries the axle- // pivot compensation; measured 2.9 m single-sample swings on traffic // at speed), and bone 0 IS a wheel on the vehicle meshes, so keying // the teleport gate on it reset the ring every few samples and // traffic spent most of its time on raw stepped poses ("the whole // vehicle lags then jumps to catch up"; the board's centimeter wheel // offsets never trip it). A genuine teleport/mispair moves EVERY // bone, spin moves only the wheels: with the entity's vertex-weighted // bone set known (the centroid table), take the MINIMUM jump over // real bones; until it exists, bone 0. const auto skinned_dist2 = [&](const std::vector& a, const std::vector& b, const DynHist& hh) -> float { const auto bone_d2 = [&](size_t bone) -> float { const size_t bi = bone * 12; const float dx = a[bi + 3] - b[bi + 3]; const float dy = a[bi + 7] - b[bi + 7]; const float dz = a[bi + 11] - b[bi + 11]; return dx * dx + dy * dy + dz * dz; }; const size_t nbones = a.size() / 12; float best = 1e30f; const size_t ncen = std::min(hh.cen.size(), nbones); for (size_t bone = 0; bone < ncen; ++bone) { if (hh.cen[bone][0] > 0.5f) { best = std::min(best, bone_d2(bone)); } } return best < 1e30f ? best : bone_d2(0); }; // Distance^2 from this item's new pose to a history's newest pose; // 1e30 when the history is empty, stale, or a different palette size. const auto hist_dist2 = [&](const DynHist& hh) -> float { if (hh.count == 0) { return 1e30f; } const DynPose& lp = hh.ring[hh.newest]; if (now - lp.t > 0.1) { return 1e30f; } if (skinned) { if (lp.b.size() != item.bones.size() || lp.b.size() < 12) { return 1e30f; } return skinned_dist2(item.bones, lp.b, hh); } const float dx = item.world[12] - lp.w[12]; const float dy = item.world[13] - lp.w[13]; const float dz = item.world[14] - lp.w[14]; return dx * dx + dy * dy + dz * dz; }; // One-time bind-space vertex-centroid table for this entity (see the // pivot-boxcar comment at the collapse guard). Also computed EAGERLY on // an entity's first pose so the teleport gate's non-spinning reference // (skinned_dist2) exists before the first interpolated frame; a car // first seen at full speed otherwise reset its ring off the wheel // swings forever and never reached the collapse path that used to // build this table. const auto ensure_cen = [&](DynHist& hh, size_t nbones) { if ((hh.cen_vb != item.vb_addr || hh.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. hh.cen_vb = item.vb_addr; hh.cen_bytes = item.vb_bytes; hh.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) { SkinSampleVert sv; if (!ReadSkinVertGuest(base, item, vtx, &sv)) { break; // unsupported position format } for (int k = 0; k < 4; ++k) { const uint32_t wgt = sv.w[k]; if (wgt == 0) { continue; } const uint32_t bone = sv.bone[k]; if (bone >= nbones) { continue; } auto& cb = hh.cen[bone]; const float wf = float(wgt) * (1.0f / 255.0f); cb[0] += wf; cb[1] += wf * sv.p[0]; cb[2] += wf * sv.p[1]; cb[3] += wf * sv.p[2]; } } for (auto& cb : hh.cen) { if (cb[0] > 0.5f) { cb[1] /= cb[0]; cb[2] /= cb[0]; cb[3] /= cb[0]; } } } }; // LW-mapped items key their ring by the game's own per-instance // identity (the MeshContext); sort- // list clone reshuffles cannot mispair an identity key, so the whole // positional claim search below is skipped for them. Mesh stays in the // key so a model/LOD swap on the same instance starts a fresh ring // (pose sizes differ). Legacy (mesh, occurrence) pairing continues to // serve everything without a store entry (player, CAC, non-LW). bool lw_keyed = item.lw_entity != 0 && item.ctx != 0 && REXCVAR_GET(skate3_native_render_scene_lw_identity); uint64_t key = lw_keyed ? ((1ull << 63) | (uint64_t(item.ctx) << 32) | uint64_t(item.mesh)) : ((uint64_t(item.mesh) << 8) | (k & 0xFF)); DynHist* hp = &s_hist[key]; if (lw_keyed && hp->seen == s_frame) { // One ctx published twice in a frame (should not happen; dyn_slot // dedups per ctx): fall back to the legacy pairing for this copy // rather than double-ingesting the identity ring. lw_keyed = false; key = (uint64_t(item.mesh) << 8) | (k & 0xFF); hp = &s_hist[key]; } const float own_d2 = hp->seen == s_frame ? 1e30f : hist_dist2(*hp); if (!lw_keyed && own_d2 > 1e-4f) { // The k-th slot mispairs: the game's sort lists RESHUFFLE same-mesh // clones as they and the camera move. For static props the // reset-on-jump guard below was enough (a mispair rendered raw for a // few frames), but driving traffic reshuffles CONSTANTLY; the rings // never accumulated 3 poses and every moving car rendered raw // stepped poses (the vehicle judder/catch-up). Re-pair by POSITION // instead: claim the unclaimed history of this mesh whose newest // pose is nearest, within the same 1.5 m one-tick jump gate. // The search runs whenever the slot is not an (almost) EXACT // continuation, not only past the 1.5 m gate: clone placements // CLOSER than 1.5 m (the paired newspaper holders, 0.78 m apart) // otherwise inherit each other's history on every reshuffle, and the // interpolator renders them sliding between the two placements ("the // props jitter in and out of position"). Seeding `best` with the own // slot's distance keeps a claim strictly-nearer-only, so a genuinely // moving entity still prefers its own ring. // RIGID claims get a far tighter cap than the vehicles' 1.5 m: when a // close clone's OWN ring goes stale (its submission flapped for a few // frames), strictly-nearer alone still claims the TWIN's fresh ring // (own = 1e30) and slides once, the residual single jitter seen // after the strictly-nearer fix. No rigid prop moves 0.3 m in one sim // tick (18 m/s at 60 Hz), while clone placements sit >= 0.78 m apart; // a rigid item with no history inside 0.3 m starts a fresh ring and // renders raw at its correct placement instead. const float claim_cap = skinned || item.ropa ? 2.25f : 0.09f; float best = std::min(own_d2, claim_cap); DynHist* alt = nullptr; uint64_t alt_key = key; uint32_t fresh_k = 256; // first index past the dense key range for (uint32_t k2 = 0; k2 < 256; ++k2) { const uint64_t key2 = (uint64_t(item.mesh) << 8) | k2; const auto it2 = s_hist.find(key2); if (it2 == s_hist.end()) { fresh_k = k2; // occurrence keys are dense per mesh break; } if (k2 == (k & 0xFF) || it2->second.seen == s_frame) { continue; } const float d2 = hist_dist2(it2->second); if (d2 < best) { best = d2; alt = &it2->second; alt_key = key2; } } if (alt != nullptr) { hp = alt; key = alt_key; if (trk != nullptr) { trk->clm = 1; } } else if (hp->seen == s_frame && fresh_k < 256) { if (trk != nullptr) { trk->clm = 2; } // The k-th slot already belongs to another clone this frame and no // history matches: start a fresh ring instead of corrupting the // claimed one with interleaved poses. key = (uint64_t(item.mesh) << 8) | fresh_k; hp = &s_hist[key]; } } DynHist& h = *hp; h.seen = s_frame; if (trk != nullptr) { trk->ring_n = h.count; trk->per_ms = float(h.period * 1e3); if (h.count > 0) { trk->age_ms = float((now - h.ring[h.newest].t) * 1e3); } } if (skinned) { ensure_cen(h, item.bones.size() / 12); } const DynPose& latest = h.ring[h.newest]; // Per-bone palette repair (vehicles): the sighting captures carried // garbage on SOME weighted bones, world positions in the rotation // rows, bone 0 gliding along the vehicle's own path, while every // sampled-vert gate upstream judged only the bones its samples happen // to reference. The survivors rendered as mangled vehicles with zero // refusals in the telemetry. Repair the insane bones from the ring's // newest pose (the body keeps its live motion; a repaired wheel // freezes for the frames its rows are junk); with no sane source for // a weighted bone, hide the item for the frame instead. if (skinned && (item.char_family == 6 || item.char_family == 7) && !h.cen.empty()) { const size_t nbones = item.bones.size() / 12; const size_t ncen = std::min(h.cen.size(), nbones); const bool ring_ok = h.count > 0 && latest.b.size() == item.bones.size(); const auto rows_sane = [](const float* bones, size_t bi) { for (int r = 0; r < 3; ++r) { const float* row = bones + bi + size_t(r) * 4; const float n2 = row[0] * row[0] + row[1] * row[1] + row[2] * row[2]; if (n2 < 0.04f || n2 > 25.0f) { return false; } } return true; }; uint32_t repaired = 0; bool unrepairable = false; for (size_t b = 0; b < ncen && !unrepairable; ++b) { if (h.cen[b][0] <= 0.5f) { continue; // not vertex-weighted: staging leftovers are normal } const size_t bi = b * 12; if (rows_sane(item.bones.data(), bi)) { continue; } if (ring_ok && rows_sane(latest.b.data(), bi)) { std::memcpy(item.bones.data() + bi, latest.b.data() + bi, 12 * sizeof(float)); ++repaired; } else { unrepairable = true; } } if (repaired != 0 || unrepairable) { static std::atomic s_repairs{0}; const uint64_t n = s_repairs.fetch_add(1, std::memory_order_relaxed); if (n < 24 || (n & 255u) == 0) { REXLOG_INFO( "native-scene: vehicle palette {} mesh={:08X} fam={} src={} " "caster={} bones={} (n={})", unrepairable ? "UNREPAIRABLE (hidden)" : "bone-repair", item.mesh, item.char_family, item.dbg_src, item.caster_bank ? 1 : 0, repaired, n); } if (unrepairable) { item.draws.clear(); continue; } } } // Caster-bank palettes carry ~40 ms-stale FINE animation but a fresh // GROSS pose, when they are the only capture stream they must keep // TRACKING (the old 0.5 s ring-hold froze passing vehicles mid-motion // in plain view, and the seed/hold/drift-out cycle re-seeded every // ~100 ms: "cars stop in their tracks and vanish"). Only while // PERSPECTIVE samples are fresh does a caster pose hold the ring pose // instead (ingesting both interleaves a wheel-phase sawtooth), a // <= 50 ms hold until the next perspective sample, imperceptible. // Retained re-publishes always hold: their stored pose is frames old // and ingesting it would step the ring backward. Ropa garments // included: the gate requires SKINNED mode with an identical palette // size, so the substitution cannot mix modes. // Stale caster bank: the ortho banks occasionally hold a genuinely OLD // palette (not just 40 ms of wheel phase), rendered raw, the vehicle // momentarily ghosted 10-20 m back along its own trail. A pose > 3 m // from a ring pose younger than 100 ms is physically impossible // (> 30 m/s of error); hold the fresh ring pose for the frame. The // 30 m ceiling keeps clone ring-swaps (45-75 m in the sighting logs, // valid poses of DIFFERENT vehicles) rendering raw. const bool caster_stale_jump = [&] { if (!skinned || !item.caster_bank || h.count == 0 || latest.b.size() != item.bones.size() || now - latest.t > 0.1) { return false; } const float d2 = skinned_dist2(item.bones, latest.b, h); return d2 > 9.0f && d2 < 900.0f; }(); // dbg_src 10 = LW-authoritative palette substitution: the pose IS the // entity's current sim tick; holding the ring's (older) pose over it // would re-stale exactly what the substitution fixed. if (skinned && h.count > 0 && latest.b.size() == item.bones.size() && item.dbg_src != 10 && ((item.caster_bank && now - h.last_persp_t <= 0.05) || item.retained || caster_stale_jump)) { if (caster_stale_jump) { static std::atomic s_stale_caster{0}; const uint64_t n = s_stale_caster.fetch_add(1, std::memory_order_relaxed); if (n < 16 || (n & 255u) == 0) { REXLOG_INFO( "native-scene: stale caster pose held mesh={:08X} fam={} " "ring_age_ms={:.0f} (n={})", item.mesh, item.char_family, (now - latest.t) * 1e3, n); } } item.bones = latest.b; if (trk != nullptr) { trk->hold = caster_stale_jump ? 2 : 1; } } const bool changed = h.count == 0 || (skinned ? latest.b != item.bones : std::memcmp(latest.w, item.world, sizeof(item.world)) != 0); // Timestamp for a new ring pose: the camera sampler's latest sim tick // when fresh; this entity's pose changed on the same sim tick, and // frame-grid timestamps alias against the sim rate once the render // loop is paced (the same problem the camera sampler solves). Frame // time otherwise. const double stamp = (g_latest_cam_tick > 0.0 && now - g_latest_cam_tick < 0.02) ? g_latest_cam_tick : now; // Ingest spacing floor: past ~285 fps the pose changes every rendered // frame, FASTER than the ~200 Hz camera tick, so consecutive changed // poses would stamp with the SAME tick time (degenerate interpolation // spans: the collapsed-bone / cloth-shape pair-lerp alpha pins and the // output steps once per slot) while the ring's real-time coverage // shrinks below the kernel's reach. Changes closer than the floor to // the newest slot are not ingested (their bytes still feed the repair // and teleport gates); the next qualifying change carries the signal. if (changed && !(h.count > 0 && stamp - latest.t < kMinDynIngestSpacing)) { // Discontinuities reset the history (pose-size change = LOD/garment // swap; long gap = the entity was gone). CRUCIALLY also a translation // jump no entity makes in one sim tick (> 1.5 m): clones pair by // occurrence order, and the game's sort lists RESHUFFLE clones as the // camera moves; without this guard a vending machine inherited its // twin's history and visibly slid/teleported between the two // placements. Mispairs now render the raw pose (a no-op for the // static props where it was visible). if (h.count > 0) { bool discontinuity = now - latest.t > 0.1 || (skinned && latest.b.size() != item.bones.size()); if (!discontinuity) { // Translation jump at the non-spinning reference (see // skinned_dist2; bone 0 is a WHEEL on vehicles); rigid worlds // carry t in row 3. float d2; if (skinned) { d2 = skinned_dist2(item.bones, latest.b, h); } else { const float dx = item.world[12] - latest.w[12]; const float dy = item.world[13] - latest.w[13]; const float dz = item.world[14] - latest.w[14]; d2 = dx * dx + dy * dy + dz * dz; } // Rigid uses the same tight one-tick bound as the claim cap // above: 0.78 m clone placements sit inside the vehicles' 1.5 m // gate, and lerping across a mispair IS the prop jitter. Ropa // garments are EXEMPT from the tight bound even in rigid mode: // they ride a character (verified entity-exact worlds, no clone // twins to mispair with), and with ingests spaced at the floor a // fast skater's world legitimately moves > 0.3 m between ring // poses; the tight gate then resets the ring every ingest at // speed and the garment renders raw/zero-lag while the body keeps // its smoothed lag (the speed-scaled garment jumping). Genuine // teleports/respawns still clear the 1.5 m character gate. discontinuity = d2 > (skinned || item.ropa ? 2.25f : 0.09f); if (discontinuity && !skinned && d2 < 2.25f) { // A rigid step that only the tightened bound caught = a // close-clone mispair that would have LERPED (the prop jitter). static std::atomic s_rigid_mispair{0}; const uint64_t n = s_rigid_mispair.fetch_add(1, std::memory_order_relaxed); if (n < 24 || (n & 255u) == 0) { REXLOG_INFO( "native-scene: rigid close-clone mispair reset mesh={:08X} " "k={} d={:.2f}m (n={})", item.mesh, k, std::sqrt(d2), n); } } } if (discontinuity) { h.count = 0; if (trk != nullptr) { trk->rst = 1; } } } if (trk != nullptr) { trk->ing = 1; } const double prev_t = h.count > 0 ? h.ring[h.newest].t : 0.0; h.newest = h.count == 0 ? 0 : (h.newest + 1) % kRing; DynPose& p = h.ring[h.newest]; p.t = stamp; if (prev_t > 0.0) { // Track the entity's OWN pose-change period (see DynHist::period). const double dt = p.t - prev_t; if (dt > 0.0005 && dt < 0.1) { h.period = h.period == 0.0 ? dt : h.period * 0.75 + dt * 0.25; } } p.b = item.bones; std::memcpy(p.w, item.world, sizeof(p.w)); p.shape_seq = 0; if (item.ropa) { // Guest thread only, like the enqueue that writes it. const auto sit = g_ropa_last_seq.find(item.mesh); p.shape_seq = sit != g_ropa_last_seq.end() ? sit->second : 0; } h.count = std::min(h.count + 1, kRing); if (skinned && !item.caster_bank && !item.retained) { h.last_persp_t = p.t; } if (bs_rec) { BoneSigAppend(0, key, p.t, 0.0, skinned ? item.bones.data() : item.world, skinned ? uint32_t(item.bones.size()) : 16u); } } else if (changed && trk != nullptr) { trk->ing = 2; // change withheld by the ingest spacing floor } if (h.count < 3 || now - h.ring[h.newest].t > 0.1) { if (trk != nullptr) { trk->raw_render = 1; } continue; // not enough history yet: raw stepped pose (one-time snap) } // Evaluate the ring's piecewise-linear pose signal at time tt into // `out_b` (skinned, weighted-accumulated) / `out_w` (rigid), scaled by // `weight`. Returns false on a palette-size mismatch inside the window // (LOD/garment swap); the caller keeps the raw pose that frame. const auto accum_at = [&](double tt, float weight, float* out_b, float out_w[16]) { int hi = h.newest; int lo = (h.newest + kRing - 1) % kRing; for (int step = 1; step < h.count - 1; ++step) { if (h.ring[lo].t <= tt) { break; } hi = lo; lo = (lo + kRing - 1) % kRing; } const DynPose& p0 = h.ring[lo]; const DynPose& p1 = h.ring[hi]; if (skinned && (p0.b.size() != item.bones.size() || p1.b.size() != item.bones.size())) { return false; } const double span = std::max(p1.t - p0.t, 0.0005); // Clamp at 1.0 (no extrapolation): entities that stop moving must // settle exactly onto their raw pose, like the camera. const float a = float(std::clamp((tt - p0.t) / span, 0.0, 1.0)); if (skinned) { for (size_t i = 0; i < item.bones.size(); ++i) { out_b[i] += (p0.b[i] + (p1.b[i] - p0.b[i]) * a) * weight; } } else { for (int i = 0; i < 16; ++i) { out_w[i] += (p0.w[i] + (p1.w[i] - p0.w[i]) * a) * weight; } } return true; }; // Boxcar filter, same as the camera's: the guest's ANIMATION poses are // 60 Hz-quantized like its camera; once the camera glides, the 60-vs- // render-rate pose alternation reads as skater judder/ghosting against // the smooth background. Averaging bone affines componentwise over the // window slightly shrinks fast-swinging limb rotations (a subtle motion // blur), the trade the game's own 60 Hz presentation makes anyway. const double filter_w = std::clamp( REXCVAR_GET(skate3_native_render_scene_smooth_camera_filter_ms), 0.0, 200.0) * 1e-3; // Per-entity playback point. Entities whose own pose stream is SLOWER // than the 60 Hz character cadence (traffic vehicles tick on their own // sim rate) pin the shared playback clock past their newest sample; // alpha clamps at 1.0, the whole smoothing machinery degenerates to // raw stepped poses, and every new sample renders as a visible jump // (the vehicle judder/catch-up that survived the entity boxcar). // Evaluating one own-period earlier keeps the eval point BRACKETED by // samples: the staircase renders as continuous piecewise-linear // motion, at the cost of that entity lagging one of ITS sim updates // behind the world, invisible for background traffic, and never // applied to 60 Hz entities (the skater/NPCs keep the shared clock). const double play_e = g_smooth_play - (h.period > 0.020 ? std::min(h.period - 1.0 / 60.0, 0.1) : 0.0); static std::vector acc; // guest render thread only float wacc[16] = {}; bool ok = true; // Did the rigid world take the 8-tap boxcar this frame (vs the plain // pair-lerp)? The ROPA shape kernel below must match it exactly. bool rigid_world_boxcar = false; if (filter_w > 0.0005 && h.count >= 4) { constexpr int kTaps = 8; acc.assign(skinned ? item.bones.size() : 0, 0.0f); for (int tap = 0; tap < kTaps && ok; ++tap) { const double tt = std::min(play_e - filter_w * 0.5 + (tap + 0.5) * filter_w / kTaps, h.ring[h.newest].t); ok = accum_at(tt, 1.0f / kTaps, acc.data(), wacc); } } else { acc.assign(skinned ? item.bones.size() : 0, 0.0f); ok = accum_at(play_e, 1.0f, acc.data(), wacc); } if (!ok) { if (trk != nullptr) { trk->raw_render = 2; } continue; // palette-size mismatch in the window: raw pose this frame } if (trk != nullptr) { trk->box = filter_w > 0.0005 && h.count >= 4 ? 1 : 0; } // Fast-spinning bones (skateboard wheels: hundreds of degrees inside // the window) COLLAPSE under componentwise averaging; the rotation // entries cancel toward zero and the wheel shrinks into the truck / // sinks into the ground. Guard per bone: if the averaged 3x3's // Frobenius norm dropped versus a raw sample's, the window spans too // much rotation; fall back to the plain TWO-ADJACENT-SAMPLE lerp at // the playback point for that bone (the pre-filter behavior: samples // ~7.5 ms apart lerp with only a few % shrink, and rotation + // translation come from the SAME pose pair). Failed alternatives, do // not revisit: whole-bone nearest-sample snap = wheels lag the filtered // board and snap to catch up; nearest ROTATION + averaged TRANSLATION = // wheels ORBIT the axle ~10 cm (palette affines are model->world; the // rotation pivots about the MODEL origin and the translation carries // the axle-pivot compensation; the pair must stay consistent). Slow // bones (the judder sources) pass untouched. int b_lo = h.newest, b_hi = h.newest; { int hi2 = h.newest; int lo2 = (h.newest + kRing - 1) % kRing; for (int step = 1; step < h.count - 1; ++step) { if (h.ring[lo2].t <= play_e) { break; } hi2 = lo2; lo2 = (lo2 + kRing - 1) % kRing; } b_lo = lo2; b_hi = hi2; } const DynPose& q0 = h.ring[b_lo]; const DynPose& q1 = h.ring[b_hi]; const double bspan = std::max(q1.t - q0.t, 0.0005); const float ba = float(std::clamp((play_e - q0.t) / bspan, 0.0, 1.0)); if (trk != nullptr) { trk->ba = ba; } if (skinned) { if (q0.b.size() == item.bones.size() && q1.b.size() == item.bones.size()) { // Pass 1: flag collapsed bones (the churning staged-constant rows // past the real skeleton, e.g. rows 53..83 of the 84-row character // bank, flag every frame too; they are unreferenced by vertices, // so rewriting them is harmless). static std::vector s_collapsed; // guest render thread only const size_t nbones = acc.size() / 12; s_collapsed.assign(nbones, 0); size_t ncollapsed = 0; for (size_t b = 0; b < nbones; ++b) { const size_t bi = b * 12; double fa = 0.0, fr = 0.0; for (int r = 0; r < 3; ++r) { for (int c = 0; c < 3; ++c) { const float av = acc[bi + r * 4 + c]; const float rv = q1.b[bi + r * 4 + c]; fa += double(av) * av; fr += double(rv) * rv; } } if (fa < fr * 0.94) { // norm ratio < ~0.97: rotation collapsed s_collapsed[b] = 1; ++ncollapsed; } } if (ncollapsed > 0) { // Collapsed bone (rotating too fast inside the window to // 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. ensure_cen(h, nbones); 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) { 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; // UNDERSAMPLED spin: traffic wheels at speed turn > 90 deg // between adjacent ~8 ms samples; the pair-lerp rotation is // then meaningless, and with a car wheel's bind centroid // meters from the model origin the (Rbar - Ro)*c pin wobbled // the wheel ~1 m around its well (bone-signal measured; the // board's slow wheels never hit this). When the pair spans // more than ~50 deg (Frobenius dot of the 3x3s: trace(R0^T // R1) = 1 + 2cos(theta) for rotations), render the pair's // NEWEST rotation instead; spin phase snaps once per // sample, invisible at those rev rates, while the centroid // pin still holds the wheel exactly on its smoothed path. double pair_tr = 0.0; for (int i = 0; i < 12; ++i) { if ((i & 3) == 3) { continue; // translation column } pair_tr += double(q0.b[bi + i]) * q1.b[bi + i]; } const bool snap_spin = pair_tr < 2.28; // 1 + 2cos(50 deg) const float* rsrc = snap_spin ? q1.b.data() + bi : lp; // Orthonormalize the source rotation (rows): recovers the // full-norm midpoint rotation from the lerp's shrunk chord // (or just cleans up the raw newest sample in snap mode). double r0[3] = {rsrc[0], rsrc[1], rsrc[2]}; double r1[3] = {rsrc[4], rsrc[5], rsrc[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; } } } std::copy(acc.begin(), acc.end(), item.bones.begin()); } else { double fa = 0.0, fr = 0.0; for (int r = 0; r < 3; ++r) { for (int c = 0; c < 3; ++c) { fa += double(wacc[r * 4 + c]) * wacc[r * 4 + c]; fr += double(q1.w[r * 4 + c]) * q1.w[r * 4 + c]; } } if (fa < fr * 0.94) { for (int i = 0; i < 16; ++i) { item.world[i] = q0.w[i] + (q1.w[i] - q0.w[i]) * ba; } } else { std::memcpy(item.world, wacc, sizeof(wacc)); rigid_world_boxcar = filter_w > 0.0005 && h.count >= 4; } // Post-interpolation identity anchor (ropa garments): the published // world and the guest draws are proven bit-exact against the owner // entity, so a rendered world grossly diverged from the entity here // can only be ring corruption (mispaired claim / stale pose blended // in). Motion smoothing legitimately offsets by centimeters; the // thresholds (2.5 m translation, ~60 deg rotation) only catch the // detached/perpendicular class. Repair = the entity's live matrix // (raw for one frame beats a detached garment), and the log names // the ring state for diagnosis. if (item.ropa) { float ent_rows[12]; if (skate3::native_entity::ReadEntityWorldRows(base, item.ctx, ent_rows)) { const float dx = item.world[12] - ent_rows[3]; const float dy = item.world[13] - ent_rows[7]; const float dz = item.world[14] - ent_rows[11]; float rot = 0.0f; for (int i = 0; i < 3; ++i) { for (int j = 0; j < 3; ++j) { const float d = item.world[i * 4 + j] - ent_rows[j * 4 + i]; rot += d * d; } } if (dx * dx + dy * dy + dz * dz > 2.5f * 2.5f || rot > 1.5f) { static std::atomic s_fix{0}; const uint32_t ln = s_fix.fetch_add(1, std::memory_order_relaxed); if (ln < 16 || (ln & 255u) == 0) { REXLOG_INFO( "native-scene: ropa interp DETACH repaired mesh={:08X} " "ctx={:08X} dt=({:.2f},{:.2f},{:.2f}) rot2={:.2f} ba={:.2f} " "ring[n={} q0t=({:.1f},{:.1f},{:.1f}) " "q1t=({:.1f},{:.1f},{:.1f})] (n={})", item.mesh, item.ctx, dx, dy, dz, rot, ba, h.count, q0.w[12], q0.w[13], q0.w[14], q1.w[12], q1.w[13], q1.w[14], ln); } for (int i = 0; i < 3; ++i) { for (int j = 0; j < 3; ++j) { item.world[i * 4 + j] = ent_rows[j * 4 + i]; } item.world[i * 4 + 3] = 0.0f; item.world[12 + i] = ent_rows[i * 4 + 3]; } item.world[15] = 1.0f; rigid_world_boxcar = false; if (trk != nullptr) { trk->detach = 1; } } } } } // ROPA shape pairing: express the EXACT temporal kernel the garment's // world (and the body's bones) were just evaluated with as weights over // the decoded shape generations (DynDecodeJob seq). The previous plain // 2-generation lerp removed the stepping but reconstructed the 60 Hz // limb signal SHARPLY against the 50 ms boxcar-rounded body, two // different frequency responses to the same signal, diverging at every // piecewise-linear corner by an excursion that scales with the per-tick // step size (the residual tee jelly, worse at a 60 fps guest cap; // emulated applies no filter at all and shows none). The kernel follows // the world's LIVE decision: boxcar taps only when the world actually // took the boxcar this frame (not the spin-collapse pair-lerp // fallback), so cloth and body always ride the same filter. if (item.ropa && !skinned) { item.shape_count = 0; const int bias = std::clamp( REXCVAR_GET(skate3_native_render_scene_ropa_bias), -2, 2); // Bracket the ring at tt exactly like accum_at (same walk, same span // clamp) and return the bracketing pair's shape generations + alpha. const auto shape_at = [&](double tt, uint64_t& s0, uint64_t& s1, float& a) { int hi = h.newest; int lo = (h.newest + kRing - 1) % kRing; for (int step = 1; step < h.count - 1; ++step) { if (h.ring[lo].t <= tt) { break; } hi = lo; lo = (lo + kRing - 1) % kRing; } // Live pairing trim (skate3_native_render_scene_ropa_bias): step // the shape source N ring poses fresher/older than the bracket. int blo = lo, bhi = hi; for (int b = 0; b < bias; ++b) { if (bhi == h.newest) break; blo = bhi; bhi = (bhi + 1) % kRing; } for (int b = 0; b > bias; --b) { bhi = blo; blo = (blo + kRing - 1) % kRing; } const DynPose& p0 = h.ring[blo]; const DynPose& p1 = h.ring[bhi]; if (p0.shape_seq == 0 || p1.shape_seq == 0) { return false; } const double span = std::max(h.ring[hi].t - h.ring[lo].t, 0.0005); s0 = p0.shape_seq; s1 = p1.shape_seq; a = float(std::clamp((tt - h.ring[lo].t) / span, 0.0, 1.0)); return true; }; const auto add_gen = [&](uint64_t seq, float wgt) { if (wgt <= 0.0f) { return; } for (int k = 0; k < item.shape_count; ++k) { if (item.shape_seq[k] == seq) { item.shape_w[k] += wgt; return; } } if (item.shape_count < DrawItem::kShapeGens) { item.shape_seq[item.shape_count] = seq; item.shape_w[item.shape_count] = wgt; ++item.shape_count; } // Unreachable: the 8 taps contribute at most two distinct // generations each, and kShapeGens holds all 16. }; if (rigid_world_boxcar && REXCVAR_GET(skate3_native_render_scene_ropa_boxcar)) { constexpr int kShapeTaps = 8; // == the body kernel's kTaps for (int tap = 0; tap < kShapeTaps; ++tap) { const double tt = std::min( play_e - filter_w * 0.5 + (tap + 0.5) * filter_w / kShapeTaps, h.ring[h.newest].t); uint64_t s0 = 0, s1 = 0; float a = 0.0f; if (shape_at(tt, s0, s1, a)) { add_gen(s0, (1.0f - a) / kShapeTaps); add_gen(s1, a / kShapeTaps); } } } else { uint64_t s0 = 0, s1 = 0; float a = 0.0f; if (shape_at(play_e, s0, s1, a)) { add_gen(s0, 1.0f - a); add_gen(s1, a); } } if (trk != nullptr) { for (int k = 0; k < item.shape_count; ++k) { trk->sw += item.shape_w[k]; } } } if (bs_rec) { BoneSigAppend(1, key, now, play_e, skinned ? item.bones.data() : item.world, skinned ? uint32_t(item.bones.size()) : 16u); } } // char_track emit: two lines per frame (body, then garment). rp = the // pre-interp capture, fp = the post-interp (rendered) reference position, // off = |garment - body| rendered offset; its frame-to-frame stability // IS the artifact metric. if (trk_on && (trk_shirt.idx >= 0 || trk_body.idx >= 0)) { const auto fpos = [&](const CharTrack& t, const DrawItem*& it, float p[3]) { it = nullptr; p[0] = p[1] = p[2] = 0.0f; if (t.idx < 0 || size_t(t.idx) >= scene.items.size()) { return; } it = &scene.items[size_t(t.idx)]; if (it->skinned && it->bones.size() >= 12) { p[0] = it->bones[3]; p[1] = it->bones[7]; p[2] = it->bones[11]; } else { p[0] = it->world[12]; p[1] = it->world[13]; p[2] = it->world[14]; } }; const DrawItem* si = nullptr; const DrawItem* bi = nullptr; float sp[3], bp[3]; fpos(trk_shirt, si, sp); fpos(trk_body, bi, bp); if (bi != nullptr) { const CharTrack& t = trk_body; REXLOG_INFO( "char-track f={} body mesh={:08X} ctx={:08X} fam={} src={} cb={} " "rp=({:.3f},{:.3f},{:.3f}) fp=({:.3f},{:.3f},{:.3f}) ring[n={} " "age={:.1f} per={:.1f} ing={} rst={} clm={} hold={} raw={}] " "ba={:.2f} box={} play={:.1f}", s_frame, bi->mesh, bi->ctx, bi->char_family, bi->dbg_src, bi->caster_bank ? 1 : 0, t.raw[0], t.raw[1], t.raw[2], bp[0], bp[1], bp[2], t.ring_n, t.age_ms, t.per_ms, t.ing, t.rst, t.clm, t.hold, t.raw_render, t.ba, t.box, (now - g_smooth_play) * 1e3); } if (si != nullptr) { const CharTrack& t = trk_shirt; float ent_t[3] = {0.0f, 0.0f, 0.0f}; int ent_ok = 0; if (si->ctx != 0) { float rows[12]; if (skate3::native_entity::ReadEntityWorldRows(base, si->ctx, rows)) { ent_t[0] = rows[3]; ent_t[1] = rows[7]; ent_t[2] = rows[11]; ent_ok = 1; } } const float off = bi != nullptr ? std::sqrt((sp[0] - bp[0]) * (sp[0] - bp[0]) + (sp[1] - bp[1]) * (sp[1] - bp[1]) + (sp[2] - bp[2]) * (sp[2] - bp[2])) : -1.0f; REXLOG_INFO( "char-track f={} shirt mesh={:08X} ctx={:08X} fam={} src={} skn={} " "cb={} rt={} rp=({:.3f},{:.3f},{:.3f}) fp=({:.3f},{:.3f},{:.3f}) " "ent=({:.3f},{:.3f},{:.3f},{}) ring[n={} age={:.1f} per={:.1f} " "ing={} rst={} clm={} hold={} raw={}] ba={:.2f} box={} sc={} " "sw={:.2f} det={} off={:.3f}", s_frame, si->mesh, si->ctx, si->char_family, si->dbg_src, si->skinned && !si->bones.empty() ? 1 : 0, si->caster_bank ? 1 : 0, si->retained ? 1 : 0, t.raw[0], t.raw[1], t.raw[2], sp[0], sp[1], sp[2], ent_t[0], ent_t[1], ent_t[2], ent_ok, t.ring_n, t.age_ms, t.per_ms, t.ing, t.rst, t.clm, t.hold, t.raw_render, t.ba, t.box, si->shape_count, t.sw, t.detach, off); } } // Prune entities not seen recently (map otherwise grows with streaming). if (s_hist.size() > 2048) { for (auto it = s_hist.begin(); it != s_hist.end();) { it = it->second.seen + 60 < s_frame ? s_hist.erase(it) : std::next(it); } } } // Publish the frame's 2D overlay draws (BuildFrameScene, before any early // return; menu and empty frames still carry 2D). The inline-ring vertex // payloads are complete by frame end; convert them to little-endian and // expand quad lists into triangle lists so the render side stays trivial. void Publish2dDraws(uint8_t* base) { std::vector frame_2d; { std::lock_guard lock(g_2d_mutex); frame_2d.swap(g_frame_2d); } static thread_local std::vector scratch_2d; std::vector published; published.reserve(frame_2d.size()); for (Draw2d& d : frame_2d) { // OFFSCREEN COMPOSITION draws: bracket bits carrying ONLY SimpleDraw // (0x20) / font (0x10) with none of the screen-pass brackets (bit 0 // FrontEndManager::Render2D, bit 1 AptMovieIntegration, bit 2 // DrawRenderingUnit, bit 3 the HUD render-to-texture pass) are the // game's internal render-target helpers, not screen UI; the // skater-portrait generator composes the card through bare SimpleDraw // quads in the TARGET's coordinate space (traced: // flags=20 fullscreen 1152x640 postfx blit + centered // 324x640 portrait compose quads on textures 03f47054/03f3f054, // replayed on screen they were the centered "poster" flash on every // menu entry / skater switch, sampling whatever the resolve arena // still held). Every real UI draw in gameplay, pause and the frontend // carries at least one screen bracket (observed flags 0d/19/29/2b/2d). // flags == 0 (unbracketed boot/loading capture) keeps its own gate. if (d.flags != 0 && (d.flags & 0x0Fu) == 0) { static std::atomic s_offscreen_2d{0}; const uint32_t n = s_offscreen_2d.fetch_add(1, std::memory_order_relaxed); if (n < 8 || (n & 2047u) == 0) { REXLOG_INFO( "native-scene: offscreen 2D compose draw dropped (flags={:02x} " "tex={:08x} count={}) (n={})", d.flags, d.fetch[1], d.count, n); } g_draws_2d_dropped.fetch_add(1, std::memory_order_relaxed); continue; } const uint32_t bytes = d.count * d.stride; scratch_2d.resize(bytes); if (!GuestTryCopy(scratch_2d.data(), base + d.addr, bytes)) { continue; } // Guest dwords are big-endian. for (size_t i = 0; i + 4 <= scratch_2d.size(); i += 4) { uint32_t v; std::memcpy(&v, scratch_2d.data() + i, 4); v = BSwap32(v); std::memcpy(scratch_2d.data() + i, &v, 4); } // Verified capture-side vertex layouts, all normalized to one 28-byte // renderer layout {float4 pos, float2 uv, u32 rgba}: // 24-byte {float4 pos, float2 uv} - APT elements // 20-byte {float3 pos, float2 uv} - glyph text (bit 4) // 20-byte {float4 pos, u32 color} - SimpleDraw untextured // 28-byte {float4 pos, u32 color, float2 uv} - SimpleDraw textured // 16-byte {float4 pos} - SimpleDraw solid fill // (color rides in VS c8 = m[8]; the popup panel strips, Rewards // title bar / teal body / tan footer, are these) // (SimpleDraw = bit 5; its DrawParameters ctor orders colours before // texcoords.) { static thread_local std::vector norm_2d; norm_2d.resize(size_t(d.count) * 28); const float one = 1.0f; const float zero = 0.0f; const uint32_t white = 0xFFFFFFFFu; const bool font = (d.flags & 0x10u) != 0; const bool simple = (d.flags & 0x20u) != 0; bool ok = true; for (uint32_t v = 0; v < d.count && ok; ++v) { uint8_t* dst = norm_2d.data() + size_t(v) * 28; const uint8_t* src = scratch_2d.data() + size_t(v) * d.stride; // Unbracketed captures (d.flags == 0, native loading/boot frames) // take the SimpleDraw layout readings for 16/28: EA's inline-quad // helpers order color before texcoords everywhere. if (d.stride == 16 && (simple || d.flags == 0)) { // pos4, untextured std::memcpy(dst, src, 16); std::memcpy(dst + 16, &zero, 4); std::memcpy(dst + 20, &zero, 4); std::memcpy(dst + 24, &white, 4); } else if (d.stride == 24) { // APT: pos4 + uv (with or without the // SimpleDraw bracket; the compass needle/icons are 24-byte // quads issued through SimpleDraw::Draw inside the HUD pass, // same simpledraw_SimpleDrawUVSC shader as plain APT elements; // requiring !simple here dropped them, a regression from adding // bracket bit 5) std::memcpy(dst, src, 24); std::memcpy(dst + 24, &white, 4); } else if (d.stride == 20 && font) { // glyphs: pos3 + uv std::memcpy(dst, src, 12); std::memcpy(dst + 12, &one, 4); std::memcpy(dst + 16, src + 12, 8); std::memcpy(dst + 24, &white, 4); } else if (d.stride == 20 && simple) { // SimpleDraw: pos4 + color std::memcpy(dst, src, 16); std::memcpy(dst + 16, &zero, 4); std::memcpy(dst + 20, &zero, 4); // The dword byteswap reversed the color's guest byte order // (r,g,b,a); restore it for R8G8B8A8_UNORM. dst[24] = src[19]; dst[25] = src[18]; dst[26] = src[17]; dst[27] = src[16]; } else if (d.stride == 28 && (simple || d.flags == 0)) { // pos4+color+uv std::memcpy(dst, src, 16); std::memcpy(dst + 16, src + 20, 8); dst[24] = src[19]; dst[25] = src[18]; dst[26] = src[17]; dst[27] = src[16]; } else { ok = false; } } if (!ok) { g_draws_2d_other.fetch_add(1, std::memory_order_relaxed); continue; } if (d.stride == 16) { // Untextured fill: the captured fetch words are leftover state from // the previous textured draw; zero them so the replay binds the // white texture instead of tinting the fill with a stale texel. std::memset(d.fetch, 0, sizeof(d.fetch)); } scratch_2d = norm_2d; d.src_stride = d.stride; d.stride = 28; } if (d.prim == 13 && d.count % 4 == 0) { // Quad list -> triangle list (v0,v1,v2)(v0,v2,v3). const uint32_t quads = d.count / 4; d.verts.resize(size_t(quads) * 6 * d.stride); static constexpr uint32_t kOrder[6] = {0, 1, 2, 0, 2, 3}; for (uint32_t q = 0; q < quads; ++q) { for (uint32_t t = 0; t < 6; ++t) { std::memcpy(d.verts.data() + (size_t(q) * 6 + t) * d.stride, scratch_2d.data() + (size_t(q) * 4 + kOrder[t]) * d.stride, d.stride); } } d.prim = 4; d.count = quads * 6; } else if (d.prim == 4 || d.prim == 5) { d.verts.assign(scratch_2d.begin(), scratch_2d.end()); } else { continue; } // BIG-QUAD TRACER (reported symptom: an unrelated "mongo poster" texture // flashes portrait-shaped at screen center on team/import entry and // skater switches; idle F11s never catch it). Edge-triggered: log each // TEXTURED replayed 2D draw whose transformed extent covers >= 8% of // the 1280x720 APT space (the portrait box itself is ~8%), once per // texture base per 5 s window. Names the quad's texture / bracket / // geometry / timing for the fix. if (d.fetch[0] != 0) { float mn[2] = {1e9f, 1e9f}; float mx[2] = {-1e9f, -1e9f}; const float* m = d.consts; // c0..c8; c4..c7 = 2D transform rows const uint32_t nv = d.count; for (uint32_t v = 0; v < nv; ++v) { const uint8_t* p = d.verts.data() + size_t(v) * d.stride; float pos[4]; std::memcpy(pos, p, 16); for (int c = 0; c < 2; ++c) { const float t = pos[0] * m[16 + c] + pos[1] * m[20 + c] + pos[2] * m[24 + c] + pos[3] * m[28 + c]; mn[c] = std::min(mn[c], t); mx[c] = std::max(mx[c], t); } } const float w = mx[0] - mn[0]; const float h = mx[1] - mn[1]; if (w > 0.0f && h > 0.0f && w * h >= 0.08f * 1280.0f * 720.0f && w * h < 1e8f) { static std::unordered_set s_seen; static int64_t s_window_s = 0; static std::atomic s_big{0}; const int64_t now_s = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); if (now_s - s_window_s >= 5) { s_window_s = now_s; s_seen.clear(); } if (s_seen.size() < 24 && s_seen.insert(d.fetch[1]).second) { REXLOG_DEBUG( "native-scene: BIG 2D quad tex=({:08x},{:08x},{:08x}) flags={:02x} " "src_stride={} count={} bbox=({:.0f},{:.0f})-({:.0f},{:.0f}) " "c8=({:.2f},{:.2f},{:.2f},{:.2f}) (n={})", d.fetch[0], d.fetch[1], d.fetch[2], d.flags, d.src_stride, d.count, mn[0], mn[1], mx[0], mx[1], m[32], m[33], m[34], m[35], s_big.fetch_add(1, std::memory_order_relaxed)); } } } // TRANSITION-FADE TRACER: the game's screen-to-screen fades are // fullscreen SimpleDraw fills (RenderMan::FinalQuadFade -> // Draw_QuadListColoured, stride 16, color+ramping alpha in VS c8; the // fade color/alpha global sits at [0x83083C38]+31376+848/864, enable // byte +880). A mid-ramp alpha logged here proves the fade is captured // and replaying natively; if transitions still look like hard cuts // with these lines present, the problem is render pacing, not capture. // Rolling-capped: 6 lines per 2 s window. if (d.src_stride == 16 && d.count >= 4) { float alpha = d.consts[35]; if (alpha > 0.02f && alpha < 0.98f) { float x0, y0, x1, y1; std::memcpy(&x0, d.verts.data(), 4); std::memcpy(&y0, d.verts.data() + 4, 4); std::memcpy(&x1, d.verts.data() + size_t(2) * d.stride, 4); std::memcpy(&y1, d.verts.data() + size_t(2) * d.stride + 4, 4); if (std::fabs(x1 - x0) >= 1200.0f && std::fabs(y1 - y0) >= 680.0f) { static std::atomic s_fade_logs{0}; static std::atomic s_fade_win{0}; const int64_t now_s = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); int64_t win = s_fade_win.load(std::memory_order_relaxed); if (now_s - win >= 2 && s_fade_win.compare_exchange_strong(win, now_s)) { s_fade_logs.store(0, std::memory_order_relaxed); } if (s_fade_logs.fetch_add(1, std::memory_order_relaxed) < 6) { REXLOG_DEBUG( "native-scene: transition fade fill alpha={:.2f} " "rgb=({:.2f},{:.2f},{:.2f}) flags={:02x}", alpha, d.consts[32], d.consts[33], d.consts[34], d.flags); } } } } // 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); g_scene_2d = std::move(published); ++g_scene_2d_generation; } // Publish the frame's in-world spline draws: evaluate the guest B-spline // 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. void PublishSplineDraws(uint8_t* base) { std::vector frame_spline; { std::lock_guard lock(g_2d_mutex); frame_spline.swap(g_frame_spline); } std::vector published; published.reserve(frame_spline.size()); for (SplineDraw& s : frame_spline) { const float* c = s.consts; const auto row = [&](int r) { return c + r * 4; }; std::vector out(size_t(s.count) * 28); float* dst = reinterpret_cast(out.data()); const uint8_t* src = s.verts.data(); bool ok = true; for (uint32_t v = 0; v < s.count; ++v, src += 12, dst += 7) { float p[3]; for (int k = 0; k < 3; ++k) { uint32_t w; std::memcpy(&w, src + k * 4, 4); w = BSwap32(w); std::memcpy(&p[k], &w, 4); } if (!(p[0] >= 0.0f && p[0] < 142.0f)) { ok = false; break; } const int idx = int(p[0]); const float t = p[0] - float(idx); const int side = p[2] >= 0.5f ? 1 : 0; // Uniform cubic B-spline basis (matches the shader's embedded // coefficients exactly). const float t2 = t * t; const float t3 = t2 * t; const float wgt[4] = {1.0f - 3.0f * t + 3.0f * t2 - t3, 3.0f * t3 - 6.0f * t2 + 4.0f, -3.0f * t3 + 3.0f * t2 + 3.0f * t + 1.0f, t3}; // Blend the world-transformed control points (world columns c4..c6, // translation in .w), /6, plus the world-rotated extrusion offset. float wp[3] = {0.0f, 0.0f, 0.0f}; for (int k = 0; k < 4; ++k) { const float* cp = row(7 + idx + k); for (int a = 0; a < 3; ++a) { const float* wr = row(4 + a); wp[a] += wgt[k] * (wr[0] * cp[0] + wr[1] * cp[1] + wr[2] * cp[2] + wr[3] * cp[3]); } } const float* off = row(151 + side); for (int a = 0; a < 3; ++a) { const float* wr = row(4 + a); wp[a] = wp[a] * (1.0f / 6.0f) + (wr[0] * off[0] + wr[1] * off[1] + wr[2] * off[2] + wr[3] * off[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. const float* cv = row(150); const auto ramp = [](float z, float start, float range) { if (range > 1e-20f || range < -1e-20f) { const float r = (z - start) / range; return r < 0.0f ? 0.0f : (r > 1.0f ? 1.0f : r); } return z - start > 0.0f ? 1.0f : 0.0f; }; const float fade = 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; } if (!ok) { continue; } s.verts.swap(out); published.push_back(std::move(s)); } std::lock_guard lock(g_2d_mutex); g_scene_spline = std::move(published); } void GetCapturedSunDir(float out[3]) { out[0] = g_sun_captured[0].load(std::memory_order_relaxed); out[1] = g_sun_captured[1].load(std::memory_order_relaxed); out[2] = g_sun_captured[2].load(std::memory_order_relaxed); } // Rebuild a world->light affine row (xyz = axis * scale, w = translation) // around a new axis, preserving the row's scale and its value at the camera // - cascade centering and bias conventions carry over unchanged. static void RebasisLightRow(float* row, const float axis[3], const float cam[3]) { const float scale = std::sqrt(row[0] * row[0] + row[1] * row[1] + row[2] * row[2]); if (scale < 1e-8f) { return; } const float at_cam = row[0] * cam[0] + row[1] * cam[1] + row[2] * cam[2] + row[3]; row[0] = axis[0] * scale; row[1] = axis[1] * scale; row[2] = axis[2] * scale; row[3] = at_cam - (row[0] * cam[0] + row[1] * cam[1] + row[2] * cam[2]); } // Rebuild the frame's captured shadow transforms around the given unit // direction TOWARD the sun: the light basis rows via RebasisLightRow // (preserving each row's scale, so cascade sizes and bias conventions // carry over) plus a re-center of the base cascade and the sub-boxes // (c1/c2 scale.xy + offset.zw) around the camera, biased along the shadow // direction; the game fits its offsets for ITS axis each frame, and // under a rotated basis the finest cascade drifts off the player and // their shadow (fallback to the coarser tiles reads visibly blocky). // Caster pass and receivers share these rows, so the re-fit stays // self-consistent. Sole caller: the lighting-lab sun override. static void RebasisShadowRows(FrameScene& scene, const float sun[3], bool include_ws) { // Light basis: depth axis points away from the sun; X/Y span the shadow // plane. Handedness is irrelevant; casters and receivers share the rows. const float zl[3] = {-sun[0], -sun[1], -sun[2]}; float xl[3] = {zl[2], 0.0f, -zl[0]}; // cross((0,1,0), zl) const float xll = std::sqrt(xl[0] * xl[0] + xl[2] * xl[2]); if (xll > 1e-5f) { xl[0] /= xll; xl[2] /= xll; } else { xl[0] = 1.0f; xl[2] = 0.0f; } const float yl[3] = {zl[1] * xl[2] - zl[2] * xl[1], zl[2] * xl[0] - zl[0] * xl[2], zl[0] * xl[1] - zl[1] * xl[0]}; const float* cam = scene.cam_pos; // Shadows extend along the sun's ground projection, so each cascade // centers ~35% of its radius in that direction instead of symmetrically // on the camera; a rotated low sun otherwise stretches character // shadows out of the fine cascade into the 4x/16x coarser tiles within // a few meters (the moved-sun blocky shadow). Near-vertical suns get no // bias (shadows stay under their casters). float sd[3] = {-sun[0], 0.0f, -sun[2]}; const float sdl = std::sqrt(sd[0] * sd[0] + sd[2] * sd[2]); if (sdl > 0.1f) { sd[0] /= sdl; sd[2] /= sdl; } else { sd[0] = 0.0f; sd[2] = 0.0f; } if (scene.shadow_valid) { RebasisLightRow(scene.shadow_rows + 0, xl, cam); // c0 light-space X RebasisLightRow(scene.shadow_rows + 12, yl, cam); // c3 light-space Y RebasisLightRow(scene.shadow_rows + 16, zl, cam); // c4 depth ramp float* rows = scene.shadow_rows; const float xlen = std::sqrt(rows[0] * rows[0] + rows[1] * rows[1] + rows[2] * rows[2]); const float ylen = std::sqrt(rows[12] * rows[12] + rows[13] * rows[13] + rows[14] * rows[14]); // Base cascade: center on the camera pushed along the shadow // direction by 35% of its own radius (the base tile's world radius is // 1 / row scale). const float rbase = 1.0f / std::max(std::max(xlen, ylen), 1e-8f); const float pb[3] = {cam[0] + sd[0] * 0.35f * rbase, cam[1], cam[2] + sd[2] * 0.35f * rbase}; rows[3] = -(rows[0] * pb[0] + rows[1] * pb[1] + rows[2] * pb[2]); rows[15] = -(rows[12] * pb[0] + rows[13] * pb[1] + rows[14] * pb[2]); // Sub-boxes: same construction at their own (wider) radii. for (int c = 0; c < 2; ++c) { const float sx = rows[4 + c * 4]; const float sy = rows[5 + c * 4]; const float rc = 1.0f / std::max(std::max(xlen * std::fabs(sx), ylen * std::fabs(sy)), 1e-8f); const float pc[3] = {cam[0] + sd[0] * 0.35f * rc, cam[1], cam[2] + sd[2] * 0.35f * rc}; const float lsx = rows[0] * pc[0] + rows[1] * pc[1] + rows[2] * pc[2] + rows[3]; const float lsy = rows[12] * pc[0] + rows[13] * pc[1] + rows[14] * pc[2] + rows[15]; rows[6 + c * 4] = -lsx * sx; // offset.x rows[7 + c * 4] = -lsy * sy; // offset.y } } if (include_ws && scene.dynobj_ws_valid) { RebasisLightRow(scene.dynobj_ws + 0, xl, cam); RebasisLightRow(scene.dynobj_ws + 4, yl, cam); RebasisLightRow(scene.dynobj_ws + 8, zl, cam); } } // Lighting-lab sun override (skate3_native_render_scene_sun_override): // rotates every captured per-frame light transform in the published scene // to the azimuth/elevation-driven direction. The native CSM caster pass, // the material receivers, the static world-shadow map (which re-primes // automatically when its rows change) and the volumetric shafts all read // these rows, so the whole dynamic lighting stack follows the moved sun. // Baked lightmap shade and the sky dome's painted sun are game content and // stay put. static void ApplySunOverride(FrameScene& scene) { if (!REXCVAR_GET(skate3_native_render_scene_sun_override)) { return; } const float kDeg = 0.01745329252f; const float az = float(REXCVAR_GET(skate3_native_render_scene_sun_azimuth)) * kDeg; const float el = float(REXCVAR_GET(skate3_native_render_scene_sun_elevation)) * kDeg; // Unit vector TOWARD the sun (y up; azimuth 0 = +Z, 90 = +X). const float sun[3] = {std::cos(el) * std::sin(az), std::sin(el), std::cos(el) * std::cos(az)}; RebasisShadowRows(scene, sun, /*include_ws=*/true); if (scene.shadow_valid) { scene.shadow_rows[24] = sun[0]; // c6 sun direction scene.shadow_rows[25] = sun[1]; scene.shadow_rows[26] = sun[2]; } if (scene.dynobj_valid) { scene.dynobj_rows[0] = sun[0]; scene.dynobj_rows[1] = sun[1]; scene.dynobj_rows[2] = sun[2]; } if (scene.sky_sun_valid) { scene.sky_sun[0] = sun[0]; scene.sky_sun[1] = sun[1]; scene.sky_sun[2] = sun[2]; } } // Hor+ ultrawide: clip-space X scale that widens the game's screen-shaped // projection to the wide guest-output aspect (see the SDK's // ApplyNativeGuestOutputWideAspect). 1.0 while the output is not wide. The // projection aspect is read off the matrix itself (|m11/m00|) so the FOV // override and freecam zoom compose naturally. float WideOutputHorScale(const float proj[16]) { const double wide_aspect = rex::graphics::GetNativeGuestOutputWideAspect(); if (wide_aspect <= 0.0 || !rex::graphics::IsNativeGuestOutputActive()) { return 1.0f; } const float m00 = std::fabs(proj[0]); const float m11 = std::fabs(proj[5]); if (!(m00 > 1e-6f) || !(m11 > 1e-6f)) { return 1.0f; } const double base_aspect = double(m11) / double(m00); if (wide_aspect <= base_aspect + 0.01) { return 1.0f; } return float(base_aspect / wide_aspect); } // Applies the Hor+ scale to the published camera: column 0 of the row-vector // projection and view*proj (clip.x = dot(v, column 0)). Every consumer (draw // MVPs, post-pass unprojection, splines, frustum tests) reads these published // matrices, so the frame stays self-consistent, exactly as if the game camera // itself had the wide aspect. void WidenPublishedCamera(FrameScene& scene, float scale) { if (scale == 1.0f) { return; } for (int r = 0; r < 4; ++r) { scene.proj[r * 4 + 0] *= scale; scene.view_proj[r * 4 + 0] *= scale; } } void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { if (!SceneEnabled()) { return; } // Published every frame (not just on world submissions): boot/menu frames // carry only 2D, and the render thread's 2D texture decodes need the // guest base from the very first natively rendered boot frame. g_guest_base.store(base, std::memory_order_relaxed); ++g_guest_frame; // paces the world-item cache revalidation // Perf telemetry: guest frame interval + this frame's capture-hook cost. static PerfClock::time_point s_last_frame_tp{}; // Previous frame's phase costs, for the slow-frame attribution below (a // frame's dt is only known at the NEXT frame's entry). static uint64_t s_prev_build_ns = 0, s_prev_b2d_ns = 0, s_prev_bspl_ns = 0, s_prev_bpal_ns = 0, s_prev_pal_tail_ns = 0, s_prev_cap_ns = 0; const auto build_t0 = PerfClock::now(); if (s_last_frame_tp.time_since_epoch().count() != 0) { const uint64_t dt_ns = uint64_t( std::chrono::duration_cast(build_t0 - s_last_frame_tp) .count()); g_pw_guest_dt.Add(dt_ns); const double dt_ms = double(dt_ns) * 1e-6; g_dt_hist[dt_ms < 3.6 ? 0 : dt_ms < 4.5 ? 1 : dt_ms < 6.0 ? 2 : dt_ms < 10.0 ? 3 : 4] .fetch_add(1, std::memory_order_relaxed); // Attribute dips: the previous frame stretched the interval - log its // phase breakdown. "rest" = the game's own frame work + hook overhead // outside the build (dt minus our measured blocks). if (dt_ms >= 4.5 && dt_ms < 100.0 && g_slow_frame_log_budget.load(std::memory_order_relaxed) > 0 && g_slow_frame_log_budget.fetch_sub(1, std::memory_order_relaxed) > 0) { const double ours_ms = double(s_prev_build_ns + s_prev_cap_ns) * 1e-6; REXLOG_DEBUG( "native-scene: slow guest frame dt={:.2f}ms prev[cap={:.2f} build={:.2f} " "(2d={:.2f} spl={:.2f} pal={:.2f} ptail={:.2f} walk={:.2f}) rest={:.2f}]ms", dt_ms, double(s_prev_cap_ns) * 1e-6, double(s_prev_build_ns) * 1e-6, double(s_prev_b2d_ns) * 1e-6, double(s_prev_bspl_ns) * 1e-6, double(s_prev_bpal_ns) * 1e-6, double(s_prev_pal_tail_ns) * 1e-6, double(s_prev_build_ns - std::min(s_prev_build_ns, s_prev_b2d_ns + s_prev_bspl_ns + s_prev_bpal_ns + s_prev_pal_tail_ns)) * 1e-6, dt_ms - ours_ms); } } s_last_frame_tp = build_t0; g_pw_capture.Add(g_capture_frame_ns); s_prev_cap_ns = g_capture_frame_ns; g_capture_frame_ns = 0; g_frame_b2d_ns = 0; g_frame_bspl_ns = 0; g_frame_bpal_ns = 0; g_frame_pal_tail_ns = 0; struct BuildPerf { PerfClock::time_point t0; uint64_t* prev_build_ns; uint64_t* prev_b2d_ns; uint64_t* prev_bspl_ns; uint64_t* prev_bpal_ns; uint64_t* prev_pal_tail_ns; ~BuildPerf() { const uint64_t build_ns = uint64_t( std::chrono::duration_cast(PerfClock::now() - t0) .count()); g_pw_build.Add(build_ns); *prev_build_ns = build_ns; *prev_b2d_ns = g_frame_b2d_ns; *prev_bspl_ns = g_frame_bspl_ns; *prev_bpal_ns = g_frame_bpal_ns; *prev_pal_tail_ns = g_frame_pal_tail_ns; } } build_perf{build_t0, &s_prev_build_ns, &s_prev_b2d_ns, &s_prev_bspl_ns, &s_prev_bpal_ns, &s_prev_pal_tail_ns}; if (g_recording.load(std::memory_order_relaxed)) { // Flush this frame's deferred inline-ring payloads (2D BeginVertices // draws): the CPU has finished writing them by frame end, and the ring // has not yet been reused. std::lock_guard lock(g_record_mutex); for (const PendingInlineDump& p : g_pending_inline_dumps) { if (p.draw_index >= g_recorded_draws.size() || g_recorded_buffer_bytes + p.bytes > (512u << 20) || !GuestRangeReadable(base, p.addr, p.bytes)) { continue; } RecordedBuffer buf; buf.vb_addr = p.addr; buf.ib_addr = 0; buf.fingerprint = (uint64_t(g_recorded_draws[p.draw_index]->frame) << 32) | p.addr; buf.vb.resize(p.bytes); std::memcpy(buf.vb.data(), base + p.addr, p.bytes); g_recorded_draws[p.draw_index]->vb_dump = uint32_t(g_recorded_buffers.size()); g_recorded_buffer_bytes += p.bytes; g_recorded_buffers.push_back(std::move(buf)); } g_pending_inline_dumps.clear(); } { const auto t0 = PerfClock::now(); Publish2dDraws(base); const auto t1 = PerfClock::now(); PublishSplineDraws(base); const auto t2 = PerfClock::now(); g_frame_b2d_ns = uint64_t( std::chrono::duration_cast(t1 - t0).count()); g_frame_bspl_ns = uint64_t( std::chrono::duration_cast(t2 - t1).count()); g_pw_b2d.Add(g_frame_b2d_ns); g_pw_bspl.Add(g_frame_bspl_ns); } // Take this frame's hook-time dynamic items regardless of how we exit, // 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(); } // Take this frame's selection re-draw captures and re-arm the post-sky // window (must happen on every exit path, like the dynitems swap). std::vector frame_selected; frame_selected.swap(g_frame_selected); g_sky_seen_this_frame = false; const bool outline_edge_seen = g_outline_edge_seen; g_outline_edge_seen = false; if (count == 0) { return; } // Multiple SceneRenderViews can submit per frame (main, shadow cascades, // reflections). Pick the perspective one (proj[2][3] == 1 in row-vector // convention) and only take its items, deduplicated (the same context can // appear in several of the view's sort lists). uint32_t view = 0; uint32_t viewcam = 0; for (size_t i = 0; i < count; ++i) { const SubmitRecord& r = records[i]; if (r.kind != 1 || r.c == 0) { continue; } const uint32_t cam = REX_LOAD_U32(r.c + kViewCameraFromView); if (!GuestReadableApprox(base, cam)) { continue; } const float persp_w = LoadGuestF32(base, cam + 0x60 + (2 * 4 + 3) * 4); if (persp_w == 1.0f) { // Screen-shaped views only. The skater-portrait render-to-texture // passes (team menu boxes, Import Skater card) submit their OWN // perspective SceneRenderView with a tall narrow projection; picking // it here published the portrait as the world scene: the skater // flashed FULL SCREEN behind the menu on every entry/scroll, and the // publish refreshed g_last_publish_ns so the mode flapped // pause-native <-> loading for the 300 ms freshness window each time. // aspect(w/h) = m11/m00 of the // raw projection; every real screen view is >= 4:3. const float m00 = std::fabs(LoadGuestF32(base, cam + 0x60 + 0 * 4)); const float m11 = std::fabs(LoadGuestF32(base, cam + 0x60 + (1 * 4 + 1) * 4)); if (!(m00 > 1e-6f) || m11 < m00 * 1.2f) { static std::atomic s_aux_views{0}; const uint64_t n = s_aux_views.fetch_add(1, std::memory_order_relaxed); if (n < 4 || (n & 255u) == 0) { REXLOG_DEBUG( "native-scene: aux perspective view skipped (portrait RTT " "pass, proj aspect {:.2f}) (n={})", m00 > 1e-6f ? m11 / m00 : 0.0f, n); } continue; } view = r.c; viewcam = cam; break; } } if (view == 0) { return; } FrameScene scene; scene.items.reserve(count); std::unordered_set seen; // Pre-size the per-frame bookkeeping: these fill with thousands of // entries every frame, and growing from empty rehashes repeatedly. seen.reserve(count); // Dynamic contexts are submitted several times per frame (once per pass); // each submission carries that pass's culled island list. Keep the fullest // one; a shadow-pass list can be missing body parts the main view needs. std::unordered_map dyn_slot; // First refused/pending ropa capture per mesh, candidates for the // post-merge rescue, which must restore last frame's resolved MODE // (rigid vs skinned), never blindly re-skin (see g_ropa_state_cache). std::unordered_map pending_ropa_by_mesh; // Per-INSTANCE skinned candidates (ctx-keyed, character families): a // refused capture re-publishes with the instance's own last palette // (see g_bones_cache_ctx). std::unordered_map pending_skinned_by_ctx; const auto total_indices = [](const DrawItem& d) { uint64_t n = 0; for (const DrawEntry& e : d.draws) n += e.index_count; return n; }; const bool wloop_prof = REXCVAR_GET(skate3_native_render_scene_perf_items); const auto wloop_t0 = wloop_prof ? PerfClock::now() : PerfClock::time_point{}; if (wloop_prof) { g_bi_records.fetch_add(count, std::memory_order_relaxed); } // Build-side occlusion skip: the render thread's culled-ctx set (the same // sticky snapshot the guest dispatch filter uses, expiring with it when // the native render idles or the cull's motion gate stands down). A ctx // in it skips the whole item rebuild on 3 of every 4 frames; the // staggered rebuild frame re-enters it in scene.items so the render side // re-tests occlusion (disocclusion drops it from the set) and the shadow // caster caches keep refreshing. static std::vector s_build_culled; // guest render thread only s_build_culled.clear(); if (REXCVAR_GET(skate3_native_render_scene_occlusion_cull_build)) { CopyOcclusionCulledCtxs(s_build_culled); if (!s_build_culled.empty()) { scene.occl_build_skipped.reserve(s_build_culled.size()); } } for (size_t i = 0; i < count; ++i) { const SubmitRecord& r = records[i]; // Primary opaque list of the chosen view only; other lists (shadow // culling, transparents, z-prepass) duplicate the same geometry through // different MeshContext objects and z-fight. if (r.kind == 1 && (r.c != view || r.b != 20160)) { continue; } if (r.kind == 2 && r.b != view) { // World-path capture from another view (shadow cascade): rendering it // duplicates the entity as a ghost. seen.insert(r.a); continue; } if (r.kind == 0 || r.kind == 2 || r.kind == 3) { // Dynamic entity (kind 0), main-view world-path capture (kind 2) or // quad-list capture (kind 3): the complete item was built at hook time. seen.insert(r.a); if (r.c == 0) { g_rej_no_dynstate.fetch_add(1, std::memory_order_relaxed); continue; } if (r.c > dynitems.size()) { g_rej_dyn_range.fetch_add(1, std::memory_order_relaxed); continue; } const DrawItem& cand = dynitems[r.c - 1]; if (cand.pending) { // Deferred mesh whose draw never came, or a capture the palette // acceptance gates refused. Remember it: if the instance does not // publish this frame, the post-merge rescue re-publishes it with // LAST frame's palette; one frame of pose lag beats a // one-frame-missing hat. if (cand.ropa) { pending_ropa_by_mesh.try_emplace(cand.mesh, &cand); } else if (cand.skinned && cand.ctx != 0 && cand.char_family != 0) { pending_skinned_by_ctx.try_emplace(cand.ctx, &cand); } (cand.skinned ? g_skinned_skipped : g_rigid_dropped) .fetch_add(1, std::memory_order_relaxed); continue; } if (!REXCVAR_GET(skate3_native_render_scene_dynamic_items)) { continue; } auto [slot, inserted] = dyn_slot.try_emplace(r.a, scene.items.size()); if (inserted) { scene.items.push_back(cand); } else { // Merge the per-pass captures of one context: PALETTE from the // perspective (z/main-pass) bank; the ortho caster-cascade banks // carry stale fine animation (see DrawItem::caster_bank), but // GEOMETRY from the fullest culled island list. The two must be // decided independently: a shadow list can be missing body parts // the main view needs, and conversely the main view CULLS most of // a vehicle the camera is standing inside/next to (wholesale // "prefer main pass" replacement published that partial list and // near vehicles went invisible). DrawItem& cur = scene.items[slot->second]; const bool fresher = !cand.caster_bank && cur.caster_bank; const bool staler = cand.caster_bank && !cur.caster_bank; const bool fuller = total_indices(cand) > total_indices(cur); // State/geometry grafts require the same resolved mode: ropa // garments flip between rigid and skinned per capture, and mixing // one copy's palette with another's interpretation is the // mangled-ribbon bug. Same-mode ropa IS graftable; excluding ropa // wholesale meant a fuller caster-cascade list wholesale-won the // merge and the garment published the ortho bank's ~40 ms-stale // bone rows while the body meshes (graftable) got fresh palettes: // the garment rode ~10 cm off the moving body (speed x 40 ms), the // visible successor of the invisible-torso bug once the near-camera // acceptance landed. The mode equality check is what prevents the // ribbon, not the ropa flag. const bool graftable = cand.skinned == cur.skinned && cand.ropa == cur.ropa && cand.mesh == cur.mesh; if (fresher && fuller) { cur = cand; } else if (fresher && graftable) { // Fresher palette, smaller list: adopt the state, keep the // fuller geometry (same mesh and buffers, lists differ only in // which islands each pass kept). cur.bones = cand.bones; std::memcpy(cur.world, cand.world, sizeof(cur.world)); std::memcpy(cur.char_rows, cand.char_rows, sizeof(cur.char_rows)); std::memcpy(cur.tint, cand.tint, sizeof(cur.tint)); cur.caster_bank = false; } else if (staler && fuller && graftable) { // Fuller caster list vs a fresher partial item: keep the fresh // palette, adopt the full geometry. const DrawItem state = cur; cur = cand; cur.bones = state.bones; std::memcpy(cur.world, state.world, sizeof(cur.world)); std::memcpy(cur.char_rows, state.char_rows, sizeof(cur.char_rows)); std::memcpy(cur.tint, state.tint, sizeof(cur.tint)); cur.caster_bank = false; } else if (!fresher && !staler && fuller) { cur = cand; // same pass class: fullest wins, as before } else if (staler && fuller) { cur = cand; // ungraftable (ropa): pre-arbitration fullest-wins } } continue; } if (!seen.insert(r.a).second) { continue; } if (!s_build_culled.empty() && ((g_guest_frame + ((r.a >> 4) * 2654435761u >> 8)) & 3u) != 0u && std::binary_search(s_build_culled.begin(), s_build_culled.end(), r.a)) { scene.occl_build_skipped.push_back(r.a); g_occl_build_skipped.fetch_add(1, std::memory_order_relaxed); continue; } if (!REXCVAR_GET(skate3_native_render_scene_world_items)) { continue; } DrawItem item; if (BuildItemGeometry(base, r.a, item)) { if (item.skinned) { // Skinned meshes reached through the world sort lists (flags, // banners) have no captured palette, bind-pose garbage; skip. g_skinned_skipped.fetch_add(1, std::memory_order_relaxed); continue; } if (item.pos_fmt != 57) { // Model-space prop (vending machines, dispensers): its vertices need // the per-draw transform, which only the kind-2 hook-time capture // has; rendered here with identity it collapses at the world // origin. The capture handles it (or it is dropped when pending). g_world_props.fetch_add(1, std::memory_order_relaxed); continue; } scene.items.push_back(std::move(item)); } else { // Silent world-item drop attribution (the F7 rings show world meshes // MISSING for 8-11 frame episodes, the mesh // flicker): a transient BuildItemGeometry failure (guarded reads // fail while the streamer relocates the mesh/material structures) // is indistinguishable from a game-culled record without this log. static std::atomic s_wbuild_logs{0}; static std::atomic s_wbuild_win{0}; const int64_t now_s = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); int64_t win = s_wbuild_win.load(std::memory_order_relaxed); if (now_s - win >= 5 && s_wbuild_win.compare_exchange_strong(win, now_s)) { s_wbuild_logs.store(0, std::memory_order_relaxed); } if (s_wbuild_logs.fetch_add(1, std::memory_order_relaxed) < 8) { REXLOG_INFO("native-scene: world item build FAILED ctx={:08X}", r.a); } } } if (wloop_prof) { g_pw_bi_wloop.Add(PerfNsSince(wloop_t0)); } // Cross-frame palette rescue + cache refresh (see g_bones_cache_ctx and // g_ropa_state_cache): published copies refresh their cache entries; a // refused/pending capture re-publishes with the cached palette (one frame // of pose lag instead of a one-frame disappearance). if (REXCVAR_GET(skate3_native_render_scene_dynamic_items)) { // Authoritative palette serve: give every mapped skinned character item // its instance's own packed m_matrices rows (snapshotted at the game's // Pack/UpdateBoneTransforms exits, the exact bytes the VS upload // consumes; per piece, one sim tick, coherent by construction). Runs // FIRST so the sanity gate below validates the authoritative rows and // the rescue caches learn them; unmapped items keep the bank pipeline. const auto pal_t0 = PerfClock::now(); g_pal_served_frame = skate3::native_palette::ServeAuthoritativePalettes(base, scene); g_frame_bpal_ns = uint64_t(std::chrono::duration_cast( PerfClock::now() - pal_t0) .count()); g_pw_bpal.Add(g_frame_bpal_ns); g_pal_served_total.fetch_add(g_pal_served_frame, std::memory_order_relaxed); // Dense publish-time coherence gate (see PublishedPaletteSane): a // 1-frame junk palette that passed the 6-sample capture gates would // otherwise draw the map-length-ribbon flash AND poison the rescue // caches (this runs before the refresh below). Re-publish the cached // state when a same-mode one exists; else drop the draws; a 1-frame // blink beats the ribbon. for (DrawItem& item : scene.items) { if (!item.skinned || item.bones.empty() || item.pending || item.dbg_src == 10 || // LW-substituted palettes are authoritative item.dbg_src == 11) { // pack-served palettes are authoritative continue; } float spread = 0.0f; if (PublishedPaletteSane(base, item, &spread)) { continue; } g_pub_incoherent.fetch_add(1, std::memory_order_relaxed); static std::atomic incoh_logged{0}; const uint32_t ln = incoh_logged.fetch_add(1, std::memory_order_relaxed); if (ln < 32 || (ln & 255u) == 0) { REXLOG_DEBUG( "native-scene: publish INCOHERENT palette mesh={:08X} fam={} " "ropa={} src={} spread={:.2f} bind=({:.2f},{:.2f},{:.2f}) " "bone0_t=({:.1f},{:.1f},{:.1f})", item.mesh, item.char_family, item.ropa ? 1 : 0, item.dbg_src, spread, item.bbox_max[0] - item.bbox_min[0], item.bbox_max[1] - item.bbox_min[1], item.bbox_max[2] - item.bbox_min[2], item.bones[3], item.bones[7], item.bones[11]); } bool healed = false; if (item.ropa) { const auto cit = g_ropa_state_cache.find(item.mesh); if (cit != g_ropa_state_cache.end() && cit->second.skinned && cit->second.bones.size() == item.bones.size() && g_guest_frame - cit->second.frame <= 30) { item.bones = cit->second.bones; item.dbg_src = 6; healed = true; } } else { // Per-instance cache only (clone-exact); healing a clone with its // TWIN's palette is a teleport. const auto cit = item.ctx != 0 ? g_bones_cache_ctx.find(item.ctx) : g_bones_cache_ctx.end(); if (cit != g_bones_cache_ctx.end() && cit->second.bones.size() == item.bones.size() && g_guest_frame - cit->second.frame <= 10) { item.bones = cit->second.bones; item.dbg_src = 6; healed = true; } } if (!healed) { item.draws.clear(); // CRITICAL: also drop the junk palette. Leaving it on the item let // the cache-refresh loop below store it into the rescue caches; // the NEXT frame's trip then "healed" with the poisoned entry and // published the ribbon with draws intact (log 1284/1285: the // fam=6/fam=1 spread 183-405 bind-local palettes, bone0_t=(0,0,-.5), // were re-captured every frame by the src=2 fixup). item.bones.clear(); } } std::unordered_map pub_count; for (const DrawItem& item : scene.items) { // Ropa garments count in EITHER resolved mode (a rigid-resolved copy // has no bones but is very much alive; rescuing a pending clone next // to it would draw a duplicate garment). if (item.ropa || (item.skinned && !item.bones.empty())) { ++pub_count[item.mesh]; } if (item.ropa && item.caster_bank) { g_ropa_caster.fetch_add(1, std::memory_order_relaxed); } } for (const DrawItem& item : scene.items) { if (!(item.ropa || (item.skinned && !item.bones.empty())) || pub_count[item.mesh] != 1) { continue; } if (item.draws.empty()) { // Vetoed/dropped this frame (incoherent-palette trip): whatever // state it carries must not become the "last good" rescue entry. continue; } if (item.ropa) { // Remember the resolved mode + transform (see g_ropa_state_cache). if (g_ropa_state_cache.size() < 512) { RopaResolvedState& c = g_ropa_state_cache[item.mesh]; c.skinned = item.skinned && !item.bones.empty(); std::memcpy(c.world, item.world, sizeof(c.world)); c.bones = item.bones; c.frame = g_guest_frame; } } } // Per-instance palette cache refresh (see g_bones_cache_ctx): every // published skinned character-family item keeps its OWN last palette // keyed by ctx: no pub_count gate needed, the key IS the instance. for (const DrawItem& item : scene.items) { if (!item.skinned || item.bones.empty() || item.caster_bank || item.ctx == 0 || item.char_family == 0 || item.ropa || item.draws.empty()) { continue; } if (g_bones_cache_ctx.size() > 2048) { for (auto it = g_bones_cache_ctx.begin(); it != g_bones_cache_ctx.end();) { it = g_guest_frame - it->second.frame > 60 ? g_bones_cache_ctx.erase(it) : std::next(it); } } CachedBones& cb = g_bones_cache_ctx[item.ctx]; cb.bones = item.bones; cb.frame = g_guest_frame; } // Per-instance rescue: a refused/pending skinned character capture // whose ctx did not publish this frame re-publishes with ITS OWN last // palette (<= 10 frames fresh), regardless of how many clones of the // mesh are alive. for (const auto& [ctxk, cand] : pending_skinned_by_ctx) { if (dyn_slot.find(ctxk) != dyn_slot.end()) { continue; // this instance published live } const auto bit = g_bones_cache_ctx.find(ctxk); if (bit == g_bones_cache_ctx.end() || g_guest_frame - bit->second.frame > 10) { continue; // stale = an old pose; one missing frame beats a teleport } scene.items.push_back(*cand); DrawItem& rescued = scene.items.back(); rescued.bones = bit->second.bones; rescued.pending = false; rescued.dbg_src = 3; ++pub_count[rescued.mesh]; // Mark the ctx published so the LW gap fill below cannot double- // publish the same instance this frame. dyn_slot.try_emplace(ctxk, scene.items.size() - 1); g_lw_ctx_rescued.fetch_add(1, std::memory_order_relaxed); } // Refused ropa captures re-publish last frame's resolved state: mode, // world AND palette together (mixing frames' interpretations is the // mangled-ribbon bug; see g_ropa_state_cache). for (const auto& [mesh, cand] : pending_ropa_by_mesh) { if (pub_count.find(mesh) != pub_count.end()) { continue; // a live copy published; nothing to rescue } // Dropped-garment gate: when the owning entity's garment table no // longer claims this VB (the game removed the cloth model; its // pieces render skinned with the body now), the cached rigid state // is a ghost: the retained drape tracks the live character as a // floating garment. Suppress instead of rescuing. if (skate3::native_entity::RopaGarmentDropped(base, cand->ctx, cand->vb_obj)) { // The game renders a dropped garment SKINNED with the body (its // sim-inactive path); the cached rigid state is a ghost. Resolve // with the owning instance's own live packed palette; when that // fails, suppress (invisible beats floating). float rows[96 * 12]; const uint32_t n = skate3::native_entity::ServeInstancePalette( base, cand->ctx, rows, 96); static std::atomic s_drop_logged{0}; const uint32_t ln = s_drop_logged.fetch_add(1, std::memory_order_relaxed); if (ln < 16 || (ln & 511u) == 0) { REXLOG_INFO( "native-scene: ropa rescue DROPPED-GARMENT {} mesh={:08X} " "ctx={:08X} vb={:08X} rows={} (n={})", n ? "resolved SKINNED" : "suppressed", cand->mesh, cand->ctx, cand->vb_obj, n, ln); } if (n != 0) { scene.items.push_back(*cand); DrawItem& resolved = scene.items.back(); resolved.skinned = true; resolved.bones.assign(rows, rows + size_t(n) * 12); resolved.pending = false; resolved.caster_bank = false; resolved.dbg_src = 4; if (resolved.ctx != 0) { dyn_slot.try_emplace(resolved.ctx, scene.items.size() - 1); } g_ropa_rescued.fetch_add(1, std::memory_order_relaxed); } continue; } const auto rit = g_ropa_state_cache.find(mesh); if (rit == g_ropa_state_cache.end() || g_guest_frame - rit->second.frame > 30) { continue; } scene.items.push_back(*cand); DrawItem& rescued = scene.items.back(); rescued.skinned = rit->second.skinned; rescued.bones = rit->second.bones; std::memcpy(rescued.world, rit->second.world, sizeof(rescued.world)); rescued.pending = false; rescued.dbg_src = 4; if (rescued.ctx != 0) { dyn_slot.try_emplace(rescued.ctx, scene.items.size() - 1); } g_ropa_rescued.fetch_add(1, std::memory_order_relaxed); } // LW gap fill (see g_lw_last_items): republish live entities' items // whose ctx skipped this frame's records entirely, the class the // pending rescues cannot see (no capture happened at all). if (REXCVAR_GET(skate3_native_render_scene_lw_gap_fill)) { const uint64_t now = g_guest_frame; for (auto it = g_lw_last_items.begin(); it != g_lw_last_items.end();) { if (dyn_slot.find(it->first) != dyn_slot.end()) { ++it; // published live this frame; refreshed below continue; } LwRetained& r = it->second; float alpha = 1.0f; uint32_t entity = 0; if (now - r.frame > 2 || !skate3::native_lw::LookupLwCtx(it->first, &alpha, &entity)) { it = g_lw_last_items.erase(it); continue; } scene.items.push_back(r.item); scene.items.back().dbg_src = 9; // gap fill (refresh below skips it) g_lw_gap_filled.fetch_add(1, std::memory_order_relaxed); static std::atomic s_fill_logged{0}; const uint32_t ln = s_fill_logged.fetch_add(1, std::memory_order_relaxed); if (ln < 16 || (ln & 255u) == 0) { REXLOG_INFO( "native-scene: LW gap fill ctx={:08X} mesh={:08X} fam={} " "age={} alpha={:.2f} (n={})", it->first, r.item.mesh, r.item.char_family, now - r.frame, alpha, ln); } ++it; } for (const DrawItem& item : scene.items) { // NOTE: lw_entity is not stamped yet here (the stamp pass runs just // before the smoothing block); LW membership is enforced at FILL // time by the store lookup; non-LW entries simply expire unused. if (item.char_family == 0 || item.ctx == 0 || item.ropa || item.pending || item.caster_bank || item.dbg_src == 9 || !item.skinned || item.bones.empty() || item.draws.empty()) { continue; } if (g_lw_last_items.size() > 512 && g_lw_last_items.find(item.ctx) == g_lw_last_items.end()) { continue; // growth backstop } LwRetained& r = g_lw_last_items[item.ctx]; r.item = item; r.frame = now; } } else if (!g_lw_last_items.empty()) { g_lw_last_items.clear(); } } // Per-INSTANCE character-lighting fallback (see g_char_rows_cache_inst): // the general rescue for capture-failed frames, keyed (mesh,ctx) and // entity-checked. Runs before the legacy single-instance mesh fallback so // instanced pieces (and the player, which is not LW-mapped) hold their // last validated rows, keeping hair in the blended sub-pass instead of // flipping to the legacy opaque path (the high-fps hair/garment flicker). if (REXCVAR_GET(skate3_native_render_scene_char_rows_inst)) { for (DrawItem& item : scene.items) { if (item.char_family == 0 || item.ctx == 0) { continue; } float lw_alpha_unused = 1.0f; uint32_t lw_entity = 0; skate3::native_lw::LookupLwCtx(item.ctx, &lw_alpha_unused, &lw_entity); const uint64_t key = (uint64_t(item.mesh) << 32) | item.ctx; if (item.char_rows[14 * 4 + 1] > 0.0f) { if (g_char_rows_cache_inst.size() > 8192) { g_char_rows_cache_inst.clear(); } CharRowsInst& ci = g_char_rows_cache_inst[key]; std::memcpy(ci.rows.data(), item.char_rows, sizeof(item.char_rows)); ci.entity = lw_entity; if (item.ropa) { skate3::native_entity::CtxInfo ident; if (skate3::native_entity::LookupCtx(item.ctx, &ident)) { if (g_char_rows_cache_ent.size() > 4096) { g_char_rows_cache_ent.clear(); } CharRowsEnt& ce = g_char_rows_cache_ent[ident.entity]; std::memcpy(ce.rows.data(), item.char_rows, sizeof(item.char_rows)); ce.fam = uint8_t(item.char_family); } } } else { const auto iit = g_char_rows_cache_inst.find(key); if (iit != g_char_rows_cache_inst.end() && iit->second.entity == lw_entity) { std::memcpy(item.char_rows, iit->second.rows.data(), sizeof(item.char_rows)); g_char_rows_inst_served.fetch_add(1, std::memory_order_relaxed); } else if (item.ropa) { // Sim-switch bridge: rows from the garment's sibling renderable // via the common owning entity (see g_char_rows_cache_ent). The // row layout is family-specific, so a cross-family serve routes // the receiver through the donor family's shading path too. skate3::native_entity::CtxInfo ident; if (skate3::native_entity::LookupCtx(item.ctx, &ident)) { const auto eit = g_char_rows_cache_ent.find(ident.entity); if (eit != g_char_rows_cache_ent.end()) { std::memcpy(item.char_rows, eit->second.rows.data(), sizeof(item.char_rows)); if (eit->second.fam != 0 && eit->second.fam != item.char_family) { static std::atomic s_fam_logged{0}; const uint32_t ln = s_fam_logged.fetch_add(1, std::memory_order_relaxed); if (ln < 8 || (ln & 1023u) == 0) { REXLOG_DEBUG( "native-scene: rows bridge cross-family serve " "mesh={:08X} fam {}->{} (n={})", item.mesh, item.char_family, eit->second.fam, ln); } item.char_family = eit->second.fam; } g_char_rows_ent_served.fetch_add(1, std::memory_order_relaxed); } } } } } } // Cross-frame character-lighting fallback (see g_char_rows_cache): items // whose capture chain never validated THIS frame reuse their garment's // last good rows instead of dropping to the empirical look (and, for // hair, out of the blended sub-pass). Single-instance meshes only; // clones carry per-instance rows. { std::unordered_map char_mesh_count; for (const DrawItem& item : scene.items) { if (item.char_family != 0) { ++char_mesh_count[item.mesh]; } } for (DrawItem& item : scene.items) { if (item.char_family == 0 || item.char_rows[14 * 4 + 1] > 0.0f || char_mesh_count[item.mesh] != 1) { continue; } const auto cit = g_char_rows_cache.find(item.mesh); if (cit != g_char_rows_cache.end()) { std::memcpy(item.char_rows, cit->second.data(), sizeof(item.char_rows)); g_char_rows_reused.fetch_add(1, std::memory_order_relaxed); } } } // Publish-gap telemetry and fill ("skater flickered invisible for a // moment"): a skinned/ropa/character mesh that published recently can // vanish from the publish stream for 1-2 built frames at high render // rates (the body serve races the sim; more frequent the faster the // renderer runs, so Vulkan flickered worst). Re-publish the previous // frame's capture for up to two missed frames - a one-frame-old pose is // imperceptible at those rates, and a real despawn stops the fill at the // cap. The rate-limited log keeps naming mesh and gap so sightings stay // diagnosable. { struct PubTrack { uint64_t frame = 0; DrawItem item; }; static std::unordered_map s_last_pub; static uint64_t s_pub_frame = 0; ++s_pub_frame; if (s_last_pub.size() > 4096) { s_last_pub.clear(); } for (DrawItem& item : scene.items) { if (!(item.ropa || item.char_family != 0 || (item.skinned && !item.bones.empty()))) { continue; } PubTrack& t = s_last_pub[item.mesh]; const uint64_t gap = t.frame != 0 ? s_pub_frame - t.frame : 0; if (item.char_family != 0 && item.caster_bank) { // The main-pass fixup missed and this piece reached publish as its // raw ortho-bank capture (shadow-pass constants, no lighting rows): // consumers reading its rows misjudge it for the frame (the caster // fade gate skipped the piece, blinking the skater's shadow off). // Serve the previous frame's main-view capture instead, and never // store shadow-bank state as the good copy. if (t.frame != 0 && gap <= 2 && !t.item.caster_bank) { item = t.item; t.frame = s_pub_frame; static std::atomic s_bank_swaps{0}; const uint64_t n = s_bank_swaps.fetch_add(1, std::memory_order_relaxed); if (n < 8 || (n & 255u) == 0) { REXLOG_INFO( "native-scene: caster-bank publish swapped for last main-view " "capture mesh={:08X} fam={} (n={})", item.mesh, item.char_family, n + 1); } } continue; } t.frame = s_pub_frame; t.item = item; if (gap >= 2 && gap <= 4) { g_dyn_gap.fetch_add(1, std::memory_order_relaxed); static std::atomic gap_logged{0}; const uint32_t ln = gap_logged.fetch_add(1, std::memory_order_relaxed); if (ln < 16 || (ln & 255u) == 0) { REXLOG_INFO( "native-scene: dyn publish GAP mesh={:08X} missing {} frame(s) " "ropa={} skinned={} fam={} src={}", item.mesh, gap - 1, item.ropa ? 1 : 0, item.skinned ? 1 : 0, item.char_family, item.dbg_src); } } } if (REXCVAR_GET(skate3_native_render_scene_dyn_gap_fill)) { for (auto& [mesh, t] : s_last_pub) { if (t.frame == 0 || t.frame == s_pub_frame) { continue; // published this frame (or placeholder) } const uint64_t gap = s_pub_frame - t.frame; if (gap <= 2) { // t.frame stays at the last REAL publish, so the fill self-limits // to two frames and the telemetry above still sees the true gap // when the stream resumes. scene.items.push_back(t.item); static std::atomic s_gap_filled{0}; const uint64_t n = s_gap_filled.fetch_add(1, std::memory_order_relaxed); if (n < 8 || (n & 1023u) == 0) { REXLOG_INFO( "native-scene: dyn gap fill mesh={:08X} age={} ropa={} fam={} (n={})", mesh, gap, t.item.ropa ? 1 : 0, t.item.char_family, n + 1); } } } } } if (scene.items.empty()) { return; } // Selected-object outline: flag items matching this frame's post-sky // re-draw captures. >= 2 identical draws = the stencil-marking pair; a // single occurrence is a legitimately late-drawn object, not a selection. // BOTH signals are required: the guest's own postfx_edgedetectstencil draw // must have run this frame too (park editor with an active selection); // gameplay draws some small props twice after the sky as well, which used // to outline distant objects during normal play. { uint32_t outline_items = 0; for (const SelectedDrawKey& k : frame_selected) { if (!outline_edge_seen || k.count < 2) { continue; } for (DrawItem& item : scene.items) { if (item.ib_obj == k.ib && item.vb_obj == k.vb && std::fabs(item.world[12] - k.t[0]) < 0.05f && std::fabs(item.world[13] - k.t[1]) < 0.05f && std::fabs(item.world[14] - k.t[2]) < 0.05f && !item.selected) { item.selected = true; ++outline_items; } } } std::memcpy(scene.outline_color, g_outline_color, sizeof(scene.outline_color)); static uint32_t s_outline_items = 0; if (outline_items != s_outline_items) { REXLOG_INFO("native-scene: selection outline {} item(s) (captures={})", outline_items, frame_selected.size()); s_outline_items = outline_items; } } for (int i = 0; i < 16; ++i) { scene.view_proj[i] = LoadGuestF32(base, viewcam + kViewCamViewProj + i * 4); } // The raw projection (+0x60) rides along for the depth-based post passes // (SSAO linearize/unproject). Camera smoothing below replaces only the // view; the projection is whatever the game rendered this frame with. for (int i = 0; i < 16; ++i) { scene.proj[i] = LoadGuestF32(base, viewcam + 0x60 + i * 4); } // RAW guest view*proj, the pose the game culled its submissions with, // kept for the off-screen retention frustum tests below (smoothing // replaces scene.view_proj with the re-timed pose). float guest_vp[16]; std::memcpy(guest_vp, scene.view_proj, sizeof(guest_vp)); // Camera cadence telemetry (see g_cam_changes): does the guest publish a // NEW camera every rendered frame, or step it on a slower sim tick? { static float s_last_vp[16] = {}; static uint32_t s_streak = 0; if (std::memcmp(s_last_vp, scene.view_proj, sizeof(s_last_vp)) == 0) { ++s_streak; g_cam_repeats.fetch_add(1, std::memory_order_relaxed); uint64_t prev = g_cam_max_streak.load(std::memory_order_relaxed); while (s_streak > prev && !g_cam_max_streak.compare_exchange_weak( prev, s_streak, std::memory_order_relaxed)) { } } else { s_streak = 0; g_cam_changes.fetch_add(1, std::memory_order_relaxed); std::memcpy(s_last_vp, scene.view_proj, sizeof(s_last_vp)); } } // Camera position from the view matrix (+0x20, row-vector convention): // cam = -t * R^T. float cam_view[16]; for (int i = 0; i < 16; ++i) { cam_view[i] = LoadGuestF32(base, viewcam + 0x20 + i * 4); } for (int j = 0; j < 3; ++j) { scene.cam_pos[j] = -(cam_view[12] * cam_view[j * 4 + 0] + cam_view[13] * cam_view[j * 4 + 1] + cam_view[14] * cam_view[j * 4 + 2]); } // The game's projection uses a negative x scale which already yields // correct D3D NDC orientation; use the view*proj matrix as captured. // (Negating column 0 here mirrors the image left-right.) // Vehicle retention (see g_dyn_retained): re-publish the last live // capture of a view-culled vehicle for a short window. Runs BEFORE the // smoothing block on purpose; the re-published copy re-claims its pose // ring in InterpolateDynamicItems (position re-pairing) and renders the // ring's coherent history instead of a frozen step. if (REXCVAR_GET(skate3_native_render_scene_retain_offscreen) && REXCVAR_GET(skate3_native_render_scene_dynamic_items) && g_synpan_active.load(std::memory_order_relaxed) == 0 && !REXCVAR_GET(skate3_native_render_scene_freecam)) { // Covers the camera-smoothing lag only; vehicle pose data just ages. constexpr uint64_t kDynRetainFrames = 10; const uint64_t rnow = g_guest_frame; // World center from the palette: average the plausible bone // translations (junk rows past the real skeleton carry garbage; gate // on finiteness and distance to bone 0). const auto bone_center = [](const DrawItem& it, float out[3]) -> bool { const size_t nb = it.bones.size() / 12; if (nb == 0) { return false; } const float b0[3] = {it.bones[3], it.bones[7], it.bones[11]}; double sum[3] = {0.0, 0.0, 0.0}; int n = 0; for (size_t b = 0; b < nb; ++b) { const float t[3] = {it.bones[b * 12 + 3], it.bones[b * 12 + 7], it.bones[b * 12 + 11]}; if (!std::isfinite(t[0]) || !std::isfinite(t[1]) || !std::isfinite(t[2])) { continue; } const float dx = t[0] - b0[0], dy = t[1] - b0[1], dz = t[2] - b0[2]; if (dx * dx + dy * dy + dz * dz > 100.0f) { continue; // > 10 m from bone 0: junk row } sum[0] += t[0]; sum[1] += t[1]; sum[2] += t[2]; ++n; } if (n == 0) { return false; } out[0] = float(sum[0] / n); out[1] = float(sum[1] / n); out[2] = float(sum[2] / n); return true; }; struct LivePos { uint32_t mesh; float p[3]; }; std::vector live; live.reserve(16); for (const DrawItem& it : scene.items) { if ((it.char_family != 6 && it.char_family != 7) || !it.skinned || it.bones.empty() || it.pending || it.retained) { continue; } float p[3]; if (!bone_center(it, p)) { continue; } live.push_back({it.mesh, {p[0], p[1], p[2]}}); if (it.caster_bank) { continue; // stale ortho pose: never store as the rescue state } DynRetained* slot = nullptr; for (DynRetained& r : g_dyn_retained) { const float dx = r.pos[0] - p[0], dy = r.pos[1] - p[1], dz = r.pos[2] - p[2]; if (r.item.mesh == it.mesh && dx * dx + dy * dy + dz * dz < 4.0f) { slot = &r; break; } } if (slot == nullptr) { if (g_dyn_retained.size() >= 64) { continue; } g_dyn_retained.emplace_back(); slot = &g_dyn_retained.back(); } slot->item = it; slot->item.retained = true; slot->item.selected = false; std::memcpy(slot->pos, p, sizeof(slot->pos)); const float ex = it.bbox_max[0] - it.bbox_min[0]; const float ey = it.bbox_max[1] - it.bbox_min[1]; const float ez = it.bbox_max[2] - it.bbox_min[2]; slot->half = 0.5f * std::sqrt(ex * ex + ey * ey + ez * ez); slot->last_seen = rnow; } for (size_t i = 0; i < g_dyn_retained.size();) { DynRetained& r = g_dyn_retained[i]; if (r.last_seen == rnow) { ++i; continue; } bool keep = rnow - r.last_seen <= kDynRetainFrames; bool publish = keep; if (keep) { for (const LivePos& lp : live) { const float dx = lp.p[0] - r.pos[0], dy = lp.p[1] - r.pos[1], dz = lp.p[2] - r.pos[2]; if (lp.mesh == r.item.mesh && dx * dx + dy * dy + dz * dz < 36.0f) { // A live copy nearby (caster-only capture frame, or the 2 m // matcher missed a fast mover): keep the entry warm for the // frame the captures stop, but never double the vehicle. r.last_seen = rnow; publish = false; break; } } } if (publish) { // Unsubmitted + visible to the guest camera = really gone // (despawn); only view-culled vehicles re-publish. if (!BoxOutsideFrustum(r.pos, r.half, guest_vp, 0.97f)) { keep = false; } } if (!keep) { g_dyn_retained[i] = std::move(g_dyn_retained.back()); g_dyn_retained.pop_back(); continue; } if (publish) { scene.items.push_back(r.item); static std::atomic s_dyn_retained_pub{0}; const uint64_t n = s_dyn_retained_pub.fetch_add(1, std::memory_order_relaxed); if (n < 8 || (n & 1023u) == 0) { REXLOG_INFO( "native-scene: vehicle retention re-publish mesh={:08X} fam={} " "age_frames={} (n={})", r.item.mesh, r.item.char_family, rnow - r.last_seen, n); } } ++i; } } // (Moved BEFORE the smoothing block: the interp ring's pose ingestion // pairs each pose with g_ropa_last_seq; the shape snapshot of the SAME // frame must be enqueued first or every pose pairs with the previous // frame's shape, a constant one-period drape lag. The snapshot also now // reads RAW captured items, matching the raw VB it snapshots.) // Dynamic cloth decode jobs (see DynDecodeJob): snapshot CHANGED (or // first-seen) skinned/ropa payloads for the workers; the render thread // never decodes them inline; a first-sight NPC/garment appears 1-2 frames // late instead of hitching the frame it streams in on. GuestTryCopy is // safe here: the game just drew from these payloads this frame. { // mesh -> last enqueued dedup key (guest render thread only). Static // skinned meshes enqueue once; ropa re-enqueues whenever its cloth // payload changed: EVERY rendered frame while the sim runs. That // cadence is load-bearing: each pose the interp ring ingests pairs with // the SAME-frame generation via g_ropa_last_seq (a constant zero // enqueue offset). Do not decimate these enqueues on a timer of their // own; an enqueue clock that slips independently of the pose-ring // ingest clock makes the pairing offset jitter by a frame, and the // drape blends against shapes from the wrong instant (speed-scaled // garment flicker). High-rate capacity lives in the CONSUMERS instead // (the resident generation ring and kShapeGens). static std::unordered_map s_dyn_fp_sent; static uint64_t s_dyn_seq = 0; if (s_dyn_fp_sent.size() > 4096) { s_dyn_fp_sent.clear(); } std::vector jobs; for (const DrawItem& item : scene.items) { if (!(item.skinned || item.ropa) || item.cloth_quads || item.ib_addr == 0) { continue; } if (item.ropa && REXCVAR_GET(skate3_native_render_scene_ropa_inline)) { continue; // ropa decodes inline on the render thread (zero-lag) } if (item.ropa && item.dbg_src == 4) { // Rescued ropa re-publishes LAST frame's resolved state (mode + // palette + world); decoding THIS frame's payload under it mixes // frames, at a sim flip that is the ribbon. Keep the previous // decode on the GPU: a fully coherent N-1 garment (the draw path // tolerates the fingerprint mismatch for dynamic payloads). continue; } // The decode is MODE-dependent for ropa (rigid decodes zero the blend // weight/index attributes, see DecodeMesh), so a mode flip must // re-enqueue even when the payload bytes did not change: fold the // resolved mode into the dedup key. const uint64_t fp_key = item.fingerprint ^ ((item.ropa && item.skinned && !item.bones.empty()) ? 1u : 0u); const auto prev = s_dyn_fp_sent.find(item.mesh); const bool first_sight = prev == s_dyn_fp_sent.end(); if (!first_sight && prev->second == fp_key) { continue; } DynDecodeJob job; job.item = item; job.item.bones.clear(); job.seq = ++s_dyn_seq; job.vb.resize(item.vb_bytes); if (!GuestTryCopy(job.vb.data(), base + item.vb_addr, item.vb_bytes)) { continue; } job.ib.resize(size_t(item.ib_count) * 2); if (!GuestTryCopy(job.ib.data(), base + item.ib_addr, job.ib.size())) { continue; } s_dyn_fp_sent[item.mesh] = fp_key; if (item.ropa) { // The pose <-> shape pairing key (see DynPose::shape_seq). Recorded // at CREATION (the delay queue below postpones submission, not // identity). g_ropa_last_seq[item.mesh] = job.seq; if (g_ropa_last_seq.size() > 256) { g_ropa_last_seq.clear(); } } jobs.push_back(std::move(job)); } // ROPA phase alignment (skate3_native_render_scene_ropa_delay): the // body renders on the motion-smoothing play clock, ~2 guest periods // behind now; a cloth snapshot submitted immediately decodes into a // shape ~2 frames AHEAD of the rendered body (the drape hangs where // the body WILL be; jelly / clip-through on direction changes; the // console pairs body N with shape N). Ropa snapshots pass through a // small per-mesh delay queue so the committed shape lands on the same // clock as the interpolated body. { const int32_t delay = REXCVAR_GET(skate3_native_render_scene_ropa_delay); static std::unordered_map> s_ropa_delay; if (delay > 0) { std::vector ready; ready.reserve(jobs.size()); for (DynDecodeJob& j : jobs) { if (!j.item.ropa) { ready.push_back(std::move(j)); continue; } auto& q = s_ropa_delay[j.item.mesh]; q.push_back(std::move(j)); while (q.size() > size_t(delay)) { ready.push_back(std::move(q.front())); q.pop_front(); } } jobs.swap(ready); if (s_ropa_delay.size() > 256) { s_ropa_delay.clear(); // outfit-change growth backstop } } else if (!s_ropa_delay.empty()) { s_ropa_delay.clear(); } } if (!jobs.empty()) { std::lock_guard lock(g_prewarm_mutex); for (DynDecodeJob& j : jobs) { if (g_dyn_jobs.size() >= 32) { break; // workers behind: the cloth skips a sim frame } g_dyn_jobs.push_back(std::move(j)); } g_prewarm_cv.notify_all(); } } // LivingWorld entity store stamp: map // each character-family item's MeshContext to its owning LW entity and // stamp the entity's authoritative opacity + per-instance identity. Runs // after every dyn publish path (captures, merges, rescues, vehicle // retention) and before the smoothing block so the pose rings can key by // identity. Items with no fresh store entry (player skater - a different // fade system, CAC entities, despawned/retained leftovers) keep the // captured-row behavior untouched. if (REXCVAR_GET(skate3_native_render_scene_lw_fade) || REXCVAR_GET(skate3_native_render_scene_lw_identity)) { // Fade serving also honors the master entity-fade switch: with it off // the routing ignores fades entirely, so the alpha row must keep its // captured value (the shader still reads it on some paths). const bool serve_fade = REXCVAR_GET(skate3_native_render_scene_lw_fade) && REXCVAR_GET(skate3_native_render_scene_entity_fade); const bool serve_id = REXCVAR_GET(skate3_native_render_scene_lw_identity); for (DrawItem& item : scene.items) { if (item.char_family == 0 || item.ctx == 0) { continue; } float alpha = 1.0f; uint32_t entity = 0; if (!skate3::native_lw::LookupLwCtx(item.ctx, &alpha, &entity)) { continue; } g_lw_stamped.fetch_add(1, std::memory_order_relaxed); if (serve_id) { item.lw_entity = entity; } // Per-ctx lighting/paint rows (see g_char_rows_cache_ctx): refresh // from validated captures, serve on capture-failed frames, entity- // checked so a recycled instance never inherits foreign paint. This // keeps edge-of-view vehicles (caster-only capture stretches, every // read rejected) on their own shading instead of legacy flat. if (item.char_rows[14 * 4 + 1] > 0.0f) { if (g_char_rows_cache_ctx.size() > 4096) { g_char_rows_cache_ctx.clear(); } CharRowsCtx& cr = g_char_rows_cache_ctx[item.ctx]; std::memcpy(cr.rows.data(), item.char_rows, sizeof(item.char_rows)); cr.entity = entity; } else { const auto rit = g_char_rows_cache_ctx.find(item.ctx); if (rit != g_char_rows_cache_ctx.end() && rit->second.entity == entity) { std::memcpy(item.char_rows, rit->second.rows.data(), sizeof(item.char_rows)); g_lw_rows_served.fetch_add(1, std::memory_order_relaxed); } } // Authoritative palette for every vehicle pose that did NOT come // from a fresh perspective capture: caster-only frames (ortho banks // = GUESSED palette base + ~40 ms-stale animation, the edge-of-view // mangle class), retention re-publishes (last capture aging up to 10 // frames; at driving speed a stale republish renders a GHOST // meters BEHIND the live position right after the vehicle exits the // view; a garbage last capture makes the ghost sideways), rescues // and gap fills. The entity's OWN packed palette (m_matrices, // written by the pack writer this sim tick) is its true current // pose: a genuinely-exited vehicle lands off-screen (no ghost), a // pan-trailing retention case lands exactly right. if (item.skinned && !item.ropa && (item.char_family == 6 || item.char_family == 7) && (item.caster_bank || item.retained || item.dbg_src == 3 || item.dbg_src == 6 || item.dbg_src == 9) && !item.bones.empty() && REXCVAR_GET(skate3_native_render_scene_lw_palette)) { float rows[96 * 12]; const uint32_t n = skate3::native_lw::LookupLwPalette(item.ctx, rows, 96 * 12); if (n != 0 && size_t(n) * 12 <= item.bones.size()) { std::memcpy(item.bones.data(), rows, size_t(n) * 12 * sizeof(float)); item.caster_bank = false; item.dbg_src = 10; // lw palette substitution g_lw_pal_sub.fetch_add(1, std::memory_order_relaxed); static std::atomic s_sub_logged{0}; const uint32_t ln = s_sub_logged.fetch_add(1, std::memory_order_relaxed); if (ln < 16 || (ln & 511u) == 0) { REXLOG_DEBUG( "native-scene: LW palette substituted ctx={:08X} mesh={:08X} " "fam={} rows={} (n={})", item.ctx, item.mesh, item.char_family, n, ln); } } } if (serve_fade) { item.lw_alpha = alpha; // The exact shading path reads the alpha ROW (cbuffer CH row 14.x) // - overwrite it with the entity value for the families where that // row IS the raw entity fade on console (c13.x / c21.x / c22.x / // c20.x). Hair (strand-scale composed) and vehicle glass // (tint-composed) keep their captured rows; CharFadeAlpha bounds // them by the entity alpha instead. if (item.char_rows[14 * 4 + 1] > 0.0f && (item.char_family == 1 || item.char_family == 2 || item.char_family == 3 || item.char_family == 6)) { item.char_rows[14 * 4 + 0] = std::clamp(alpha, 0.0f, 1.0f); } } } } // Presentation-entity identity store observer // (native/skate3_native_entity.h): per-item coverage of the // ctx -> entity map built from the game's BindConstants walks, and the // skater-family entity+496 opacity vs the alpha the routing decided on. // Read-only: the ident[] stats line is the evidence for flipping any // serve over to the direct fields. { const bool serve_ent_fade = REXCVAR_GET(skate3_native_render_scene_entity_fade); for (DrawItem& item : scene.items) { if (item.char_family == 0 || item.ctx == 0) { continue; } // Skater-family fade from the entity's own opacity (+496): the // value the game binds as the shader's alpha parameter. Same serving // contract as the LW store (lw_alpha preferred by CharFadeAlpha; // families whose shader composes the fade keep their captured rows // bounded by it). LW-mapped items were already stamped above. float ent_alpha = 1.0f; if (serve_ent_fade && item.lw_alpha < 0.0f && skate3::native_entity::ReadSkaterFade(base, item.ctx, &ent_alpha)) { item.lw_alpha = std::clamp(ent_alpha, 0.0f, 1.0f); if (item.char_rows[14 * 4 + 1] > 0.0f && (item.char_family == 1 || item.char_family == 2 || item.char_family == 3 || item.char_family == 6)) { item.char_rows[14 * 4 + 0] = item.lw_alpha; } } skate3::native_entity::ObserveCharItem(base, item.ctx, item.char_family, item.lw_entity != 0, CharFadeAlpha(item)); } skate3::native_entity::EmitStats(); } // Camera re-timing (see SmoothCamera): replace the guest's sim-stepped // pose with a host-clock-interpolated one so panning is smooth at render // rate. All consumers (items, sky follow, sorting, outline) use the // smoothed pose coherently. if (REXCVAR_GET(skate3_native_render_scene_smooth_camera)) { // Publish the ViewCamera for the ~1 kHz sampler thread and make sure it // runs (see CamSamplerLoop). g_sampler_viewcam.store(viewcam, std::memory_order_relaxed); EnsureCamSampler(); float proj[16]; for (int i = 0; i < 16; ++i) { proj[i] = LoadGuestF32(base, viewcam + 0x60 + i * 4); } const double now_s = std::chrono::duration(build_t0.time_since_epoch()).count(); // Auto-armed bone-signal recordings (diagnosis, see the cvar): first // window ~30 s after the scene comes up, re-armed every 90 s, 3 max. { const double auto_s = REXCVAR_GET(skate3_native_render_scene_bonesig_auto); if (auto_s > 0.0) { static int s_auto_count = 0; static double s_auto_next = 0.0; if (s_auto_next == 0.0) { s_auto_next = now_s + 30.0; } else if (s_auto_count < 3 && now_s >= s_auto_next) { ++s_auto_count; s_auto_next = now_s + 90.0; REXLOG_INFO("native-scene: auto bone-signal recording {} ({} s)", s_auto_count, auto_s); RecordBoneSignal(std::min(auto_s, 30.0)); } } } // Walking-vehicle detector (diagnosis): a livingworld_vehicles item // whose bone-0 translation sits on top of a character item's bone-0 is // the "player becomes the vehicle" bug. Log the coincidence WITH the // first bone rows of both palettes: identical rows = the same bank was // captured for both (a capture-attribution bug); distinct rows = the // vehicle's own palette tracks the character (a different mechanism). { static double s_last_attach_log = 0.0; if (now_s - s_last_attach_log > 2.0) { bool logged = false; for (const DrawItem& v : scene.items) { if ((v.char_family != 6 && v.char_family != 7) || !v.skinned || v.bones.size() < 12) { continue; } for (const DrawItem& c : scene.items) { if (&c == &v || !c.skinned || c.bones.size() < 12 || c.char_family == 0 || c.char_family >= 6) { continue; } const float dx = v.bones[3] - c.bones[3]; const float dy = v.bones[7] - c.bones[7]; const float dz = v.bones[11] - c.bones[11]; const float d2 = dx * dx + dy * dy + dz * dz; if (d2 < 4.0f) { const bool same_rows = std::memcmp(v.bones.data(), c.bones.data(), 12 * sizeof(float)) == 0; REXLOG_INFO( "native-scene ATTACH: vehicle mesh={:08X} fam={} src={} " "pend={} d={:.2f} char mesh={:08X} fam={} src={} " "same_bone0={} v_r0=({:.3f},{:.3f},{:.3f},{:.2f}) " "c_r0=({:.3f},{:.3f},{:.3f},{:.2f})", v.mesh, v.char_family, v.dbg_src, v.pending, std::sqrt(d2), c.mesh, c.char_family, c.dbg_src, same_rows, v.bones[0], v.bones[1], v.bones[2], v.bones[3], c.bones[0], c.bones[1], c.bones[2], c.bones[3]); s_last_attach_log = now_s; logged = true; break; } } if (logged) { break; } } } } float smooth_vp[16], smooth_cam[3]; if (SmoothCamera(cam_view, proj, scene.view_proj, scene.cam_pos, now_s, smooth_vp, smooth_cam)) { std::memcpy(scene.view_proj, smooth_vp, sizeof(smooth_vp)); 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(base, scene, now_s); } } // Hor+ ultrawide scale for this frame's camera (1.0 when the output is not // wide). The matrices are widened after the camera overrides below; the // retention frustum guards divide by it here so their tests already match // the frame's real (wider) view. const float wide_hor_scale = WideOutputHorScale(scene.proj); // Off-screen retention (see g_retained_items): re-append statics the game // view-culled this frame while the trailing rendered pose can still see // them. Runs AFTER the smoothing block so retained copies never enter the // dynamic pose histories. Stands down while the synthetic-pan probe // maintains its own full-surround union, and while the freecam has taken // over the guest camera (the game then culls around the rendered pose // directly, so re-appends would only risk double draws). if (REXCVAR_GET(skate3_native_render_scene_retain_offscreen) && g_synpan_active.load(std::memory_order_relaxed) == 0 && !REXCVAR_GET(skate3_native_render_scene_freecam)) { const auto retain_t0 = PerfClock::now(); uint64_t retain_appended = 0; if (g_retained_clear.exchange(false, std::memory_order_relaxed)) { g_retained_items.clear(); g_dyn_retained.clear(); } const uint64_t now = g_guest_frame; std::unordered_set submitted; const size_t published = scene.items.size(); submitted.reserve(published); for (size_t i = 0; i < published; ++i) { const DrawItem& it = scene.items[i]; if (it.skinned || it.cloth_quads || it.ropa || it.pending || !it.bones.empty()) { continue; } const uint64_t key = SynPanItemKey(it); submitted.insert(key); if (g_retained_items.size() >= 20000 && g_retained_items.find(key) == g_retained_items.end()) { continue; // growth backstop } auto [slot, inserted] = g_retained_items.try_emplace(key); slot->second.last_seen = now; // Copy the item core on first sight and whenever its payload identity // or transform moved on; a retained copy with a stale fingerprint // skips at draw (see draw_item's retained gate) and would re-tear. if (inserted || slot->second.item.fingerprint != it.fingerprint || std::memcmp(slot->second.item.world, it.world, sizeof(it.world)) != 0) { slot->second.item = it; slot->second.item.retained = true; slot->second.item.selected = false; } } // TTL bounds how long a never-resubmitted entry lives (streaming reuses // arena addresses; the render side additionally fingerprint-gates // retained draws). ~90 guest frames = 0.6-1.5 s across fps caps, // far beyond the smoothing lag it needs to cover. constexpr uint64_t kRetainTtlFrames = 90; for (auto rit = g_retained_items.begin(); rit != g_retained_items.end();) { if (submitted.find(rit->first) != submitted.end()) { ++rit; continue; } const RetainedItem& r = rit->second; // Unsubmitted + visible to the guest camera = the game really removed // it. The 0.97 margin shrinks the tested frustum so bounds poking // just inside an edge still count as view-culled. if (now - r.last_seen > kRetainTtlFrames || !ItemOutsideFrustum(r.item, guest_vp, 0.97f / wide_hor_scale)) { rit = g_retained_items.erase(rit); continue; } // Draw it only if the RENDERED pose can actually see it (widened // frustum: only clearly-outside skips); after a fast 180 the trail // behind the camera stays retained but costs nothing. if (!ItemOutsideFrustum(r.item, scene.view_proj, 1.05f / wide_hor_scale)) { scene.items.push_back(r.item); ++retain_appended; } ++rit; } g_pw_bi_retain.Add(PerfNsSince(retain_t0)); g_retained_appended.fetch_add(retain_appended, std::memory_order_relaxed); g_retained_live.store(g_retained_items.size(), std::memory_order_relaxed); } // Camera-signal recorder: per-frame raw + smoothed heading while the // window is open; write + reset when it closes (CamSigFrameTick). if (g_camsig_deadline.load(std::memory_order_relaxed) > 0.0) { const double rec_now = std::chrono::duration(build_t0.time_since_epoch()).count(); float rot[16] = {}; const float* smoothed = nullptr; if (g_smooth_active) { ViewRotFromQuat(g_smooth_pose.q, rot); smoothed = rot; } CamSigFrameTick(rec_now, cam_view, smoothed, g_smooth_play); } // Synthetic camera pan probe (see the synthetic_pan cvar comment for the // mode semantics). Runs AFTER the smoothing block: modes 1/2 override the // published pose outright (their point is to bypass the guest pose path); // mode 3 leaves the smoothed pose in place, the sampler thread is feeding // the smoother synthetic samples, and measures reconstruction error // against the known ideal. { const double syn_now = std::chrono::duration(build_t0.time_since_epoch()).count(); int syn_mode = std::clamp(int(REXCVAR_GET(skate3_native_render_scene_synthetic_pan)), 0, 3); if (syn_mode == 3 && !REXCVAR_GET(skate3_native_render_scene_smooth_camera)) { static bool s_warned = false; if (!s_warned) { s_warned = true; REXLOG_WARN( "native-scene synthetic-pan: mode 3 needs smooth_camera ON; " "running mode 1 instead"); } syn_mode = 1; } static int s_engaged_mode = 0; if (syn_mode != s_engaged_mode) { s_engaged_mode = syn_mode; if (syn_mode == 0) { g_synpan_active.store(0, std::memory_order_release); g_synpan_union.clear(); REXLOG_INFO("native-scene synthetic-pan: off (guest camera restored)"); } else { // (Re-)engage from THIS frame's raw guest pose: heading, position // and projection are frozen; only the synthetic yaw moves. std::lock_guard lock(g_synpan_mutex); std::memcpy(g_synpan_view0, cam_view, sizeof(g_synpan_view0)); for (int i = 0; i < 16; ++i) { g_synpan_proj0[i] = LoadGuestF32(base, viewcam + 0x60 + i * 4); } for (int j = 0; j < 3; ++j) { g_synpan_c0[j] = -(cam_view[12] * cam_view[j * 4 + 0] + cam_view[13] * cam_view[j * 4 + 1] + cam_view[14] * cam_view[j * 4 + 2]); } g_synpan_t0 = syn_now; g_synpan_step_phase = 0.0; g_synpan_ema_dt = 0.0; g_synpan_frames = 0; g_synpan_last_build = 0.0; g_synpan_dt_sum = g_synpan_dt_sum2 = 0.0; g_synpan_dt_min = g_synpan_dt_max = 0.0; g_synpan_err_sum2 = g_synpan_err_max = 0.0; g_synpan_err_n = 0; g_synpan_union.clear(); g_synpan_active.store(syn_mode, std::memory_order_release); static const char* kModeNames[] = {"off", "time-based", "fixed-step", "through-smoother"}; REXLOG_INFO( "native-scene synthetic-pan: ENGAGED mode={} ({}) rate={:.1f} deg/s " "amp=+-{:.1f} deg", syn_mode, kModeNames[syn_mode], REXCVAR_GET(skate3_native_render_scene_synthetic_pan_rate), REXCVAR_GET(skate3_native_render_scene_synthetic_pan_amp)); } } if (syn_mode != 0) { const double rate = REXCVAR_GET(skate3_native_render_scene_synthetic_pan_rate); const double amp = REXCVAR_GET(skate3_native_render_scene_synthetic_pan_amp); const double prev_build = g_synpan_last_build; g_synpan_last_build = syn_now; const double dt = prev_build > 0.0 ? std::clamp(syn_now - prev_build, 0.0, 0.05) : 0.0; if (dt > 0.0) { g_synpan_dt_sum += dt; g_synpan_dt_sum2 += dt * dt; g_synpan_dt_min = g_synpan_dt_min == 0.0 ? dt : std::min(g_synpan_dt_min, dt); g_synpan_dt_max = std::max(g_synpan_dt_max, dt); } if (syn_mode == 1 || syn_mode == 2) { double phase; if (syn_mode == 1) { phase = (syn_now - g_synpan_t0) * rate; } else { // Fixed step: constant angle per published frame. The step is // rate * (slow EMA of dt) so deg/s stays roughly honest while the // per-frame advance is effectively constant over any short window. if (dt > 0.0) { g_synpan_ema_dt = g_synpan_ema_dt == 0.0 ? dt : g_synpan_ema_dt * 0.995 + dt * 0.005; } g_synpan_step_phase += rate * g_synpan_ema_dt; phase = g_synpan_step_phase; } float sview[16]; SynPanView(SynPanAngleDeg(phase, amp), sview); CamPose pose; QuatFromView(sview, pose.q); std::memcpy(pose.c, g_synpan_c0, sizeof(pose.c)); ComposeViewProj(pose, g_synpan_proj0, scene.view_proj, scene.cam_pos); } else if (g_smooth_active) { // Mode 3: the smoother just reconstructed a pose from the synthetic // samples at playback time g_smooth_play; compare against the ideal // pose at that exact time (both are functions of the same clock). float iview[16]; SynPanView(SynPanAngleDeg((g_smooth_play - g_synpan_t0) * rate, amp), iview); float qi[4]; QuatFromView(iview, qi); const float dq = std::fabs(qi[0] * g_smooth_pose.q[0] + qi[1] * g_smooth_pose.q[1] + qi[2] * g_smooth_pose.q[2] + qi[3] * g_smooth_pose.q[3]); const double err_deg = 2.0 * std::acos(std::min(dq, 1.0f)) * (180.0 / 3.14159265358979323846); g_synpan_err_sum2 += err_deg * err_deg; g_synpan_err_max = std::max(g_synpan_err_max, err_deg); ++g_synpan_err_n; } // World union: accumulate this frame's static items and append every // previously seen one the game didn't submit this frame (it culls to // ITS frustum; the probe camera looks elsewhere). Statics only: // skinned/cloth poses go stale immediately. { std::unordered_set cur; cur.reserve(scene.items.size()); const size_t published = scene.items.size(); for (size_t i = 0; i < published; ++i) { const DrawItem& it = scene.items[i]; if (it.skinned || it.cloth_quads || it.ropa || it.pending || !it.bones.empty()) { continue; } const uint64_t key = SynPanItemKey(it); cur.insert(key); if (g_synpan_union.size() < 20000) { auto [slot, inserted] = g_synpan_union.try_emplace(key, it); if (!inserted && std::memcmp(slot->second.world, it.world, sizeof(it.world)) != 0) { slot->second = it; // a movable prop moved: refresh } } } for (const auto& [key, it] : g_synpan_union) { if (cur.find(key) == cur.end()) { scene.items.push_back(it); } } } if (++g_synpan_frames % 600 == 0) { const double n = std::max(1.0, double(g_synpan_frames - 1)); const double avg = g_synpan_dt_sum / n; const double sd = std::sqrt(std::max(0.0, g_synpan_dt_sum2 / n - avg * avg)); if (syn_mode == 3) { REXLOG_INFO( "native-scene synthetic-pan: mode=3 frames={} build_dt_ms[avg/min/max/sd]=" "{:.2f}/{:.2f}/{:.2f}/{:.2f} smoother_err_deg[rms/max]={:.4f}/{:.4f} (n={}) " "union={}", g_synpan_frames, avg * 1e3, g_synpan_dt_min * 1e3, g_synpan_dt_max * 1e3, sd * 1e3, std::sqrt(g_synpan_err_sum2 / std::max(1, g_synpan_err_n)), g_synpan_err_max, g_synpan_err_n, g_synpan_union.size()); } else { REXLOG_INFO( "native-scene synthetic-pan: mode={} frames={} build_dt_ms[avg/min/max/sd]=" "{:.2f}/{:.2f}/{:.2f}/{:.2f} union={}", syn_mode, g_synpan_frames, avg * 1e3, g_synpan_dt_min * 1e3, g_synpan_dt_max * 1e3, sd * 1e3, g_synpan_union.size()); } } } } // Drone / free-fly camera (skate3_native_render_scene_freecam, End key): // runs after the smoothing and synthetic-pan blocks so the flown pose // wins while engaged. No draw-item union here; the SetViewMatrix // override hands the flown pose to the game, whose own culling then // submits exactly what the drone sees (statics AND animated entities). UpdateFreecam(scene, cam_view, std::chrono::duration(build_t0.time_since_epoch()).count()); // Hor+ ultrawide: widen the published camera to the wide output aspect. // Applied after every camera override (smoothing, synthetic pan, freecam) // so it survives their view_proj/proj rewrites. WidenPublishedCamera(scene, wide_hor_scale); // Publish the frame's captured fog rows and re-arm the OnDrawDone capture // (keyed to this frame's camera) for the next frame. if (g_fog_have) { std::memcpy(scene.fog_ramp, g_fog_rows, 4 * sizeof(float)); std::memcpy(scene.fog_color, g_fog_rows + 4, 4 * sizeof(float)); } if (g_water_have) { std::memcpy(scene.water_rows, g_water_rows, sizeof(scene.water_rows)); scene.water_valid = true; } if (g_ocean_have) { std::memcpy(scene.ocean_rows, g_ocean_rows, sizeof(scene.ocean_rows)); scene.ocean_valid = true; } if (g_oceanrefl_have) { std::memcpy(scene.oceanrefl_rows, g_oceanrefl_rows, sizeof(scene.oceanrefl_rows)); scene.oceanrefl_valid = true; } if (g_scroll_have) { std::memcpy(scene.scroll_rows, g_scroll_rows, sizeof(scene.scroll_rows)); scene.scroll_valid = true; } if (g_shadow_have) { 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)); scene.dynobj_valid = true; if (g_dynobj_ws_have) { std::memcpy(scene.dynobj_ws, g_dynobj_ws, sizeof(g_dynobj_ws)); scene.dynobj_ws_valid = true; } } 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; } // Publish the captured sun direction BEFORE any override (the debug // dialog seeds the override sliders from it). { const float* cap = scene.shadow_valid ? scene.shadow_rows + 24 : scene.sky_sun; if (scene.shadow_valid || scene.sky_sun_valid) { g_sun_captured[0].store(cap[0], std::memory_order_relaxed); g_sun_captured[1].store(cap[1], std::memory_order_relaxed); g_sun_captured[2].store(cap[2], std::memory_order_relaxed); } } ApplySunOverride(scene); // The game's own popup blur chain (pause menu etc.), untouched by the // host settings menu, whose gaussian reads host state directly at render // time (see ApplyMenuBlurPass call sites). const bool blur_active = g_ui_blur_seen || g_ui_blur_hold > 0; scene.ui_blur = blur_active ? g_ui_blur : 0.0f; std::memcpy(scene.ui_blur_color, g_ui_blur_color, sizeof(scene.ui_blur_color)); if (g_ui_blur_seen) { g_ui_blur_hold = 2; } else if (g_ui_blur_hold > 0) { --g_ui_blur_hold; } { static bool s_blur_was_active = false; if (blur_active != s_blur_was_active) { REXLOG_INFO( "native-scene: popup background blur {} (kernel scale {:.1f}, fade " "{:.2f}/{:.2f}/{:.2f})", blur_active ? "ON" : "off", scene.ui_blur, scene.ui_blur_color[0], scene.ui_blur_color[1], scene.ui_blur_color[2]); s_blur_was_active = blur_active; if (!blur_active) { // Don't carry one popup's fade into the next popup's first frame // if its own c1 read misses the coherence gate. g_ui_blur_color[0] = g_ui_blur_color[1] = g_ui_blur_color[2] = 1.0f; } } } g_ui_blur_seen = false; // Photo-editor postfx: publish the live pass captures when the photo // EDITOR itself is up (not the wider TakePhoto readback window: the // chain must never run over ordinary gameplay frames) and every pass has // fresh rows (the game stages its postfx constants each frame regardless // of draw suppression). The vignette and grain fetch words come from the // fisheye/uber fetch-shadow snapshots (slot 2 / slot 6 per the // ring-verified bindings). if (uint8_t* pfx_base = g_guest_base.load(std::memory_order_relaxed); pfx_base != nullptr && g_photo_flow_frame.load(std::memory_order_relaxed) && PhotoEditorSignal(pfx_base) != nullptr) { const int64_t now_ns = std::chrono::duration_cast( PerfClock::now().time_since_epoch()) .count(); bool fresh = true; for (int p = 0; p < kPfxPassCount; ++p) { if (g_pfx_cap[p].ps_ns == 0 || now_ns - g_pfx_cap[p].ps_ns > 1'000'000'000 || !g_pfx_cap[p].vs_seen) { fresh = false; } } if (!fresh) { // Name the missing pass(es), once per ~2 s while in the window. static int64_t s_diag_ns = 0; if (now_ns - s_diag_ns > 2'000'000'000) { s_diag_ns = now_ns; char buf[160]; int off = 0; for (int p = 0; p < kPfxPassCount; ++p) { const int64_t age_ms = g_pfx_cap[p].ps_ns == 0 ? -1 : (now_ns - g_pfx_cap[p].ps_ns) / 1'000'000; off += std::snprintf(buf + off, sizeof(buf) - off, " p%d[ps%lldms vs%d]", p, (long long)age_ms, g_pfx_cap[p].vs_seen ? 1 : 0); if (off >= int(sizeof(buf)) - 24) break; } REXLOG_INFO("native-scene: pfx captures NOT fresh:{}", buf); } } if (fresh) { scene.photo_fx.valid = true; for (int p = 0; p < kPfxPassCount; ++p) { std::memcpy(scene.photo_fx.ps[p], g_pfx_cap[p].ps, sizeof(scene.photo_fx.ps[p])); std::memcpy(scene.photo_fx.vs[p], g_pfx_cap[p].vs, sizeof(scene.photo_fx.vs[p])); } std::memcpy(scene.photo_fx.vignette_fetch, g_pfx_cap[kPfxFisheye].fetch[2], sizeof(scene.photo_fx.vignette_fetch)); std::memcpy(scene.photo_fx.grain_fetch, g_pfx_cap[kPfxUber].fetch[6], sizeof(scene.photo_fx.grain_fetch)); static bool s_pfx_logged = false; if (!s_pfx_logged) { s_pfx_logged = true; REXLOG_INFO( "native-scene: photo postfx captures LIVE (visualfx c0=({:.3f},{:.3f}," "{:.3f},{:.3f}) dof c0.x={:.4f} uber c5=({:.3f},{:.3f},{:.3f},{:.3f}) " "fisheye c1=({:.3f},{:.3f},{:.3f}) vignette=[{:08X} {:08X}] " "grain=[{:08X} {:08X}])", g_pfx_cap[kPfxVisualFx].ps[0][0], g_pfx_cap[kPfxVisualFx].ps[0][1], g_pfx_cap[kPfxVisualFx].ps[0][2], g_pfx_cap[kPfxVisualFx].ps[0][3], g_pfx_cap[kPfxDof].ps[0][0], g_pfx_cap[kPfxUber].ps[5][0], g_pfx_cap[kPfxUber].ps[5][1], g_pfx_cap[kPfxUber].ps[5][2], g_pfx_cap[kPfxUber].ps[5][3], g_pfx_cap[kPfxFisheye].ps[1][0], g_pfx_cap[kPfxFisheye].ps[1][1], g_pfx_cap[kPfxFisheye].ps[1][2], scene.photo_fx.vignette_fetch[0], scene.photo_fx.vignette_fetch[1], scene.photo_fx.grain_fetch[0], scene.photo_fx.grain_fetch[1]); } } } std::memcpy(g_fog_cam, scene.cam_pos, sizeof(g_fog_cam)); g_fog_frame_done = false; g_shadow_frame_done = false; g_sky_frame_done = false; g_tree_frame_done = false; g_proxy_frame_done = false; g_dynobj_frame_done = false; g_water_frame_done = false; g_ocean_frame_done = false; g_oceanrefl_frame_done = false; g_scroll_frame_done = false; // Draw-time STRETCH VETO: the last line of defense, judging what the GPU // will ACTUALLY draw: the RESIDENT decode's sample verts (g_skin_probe, // cached by DecodeMesh) skinned with the FINAL palette (after the merge, // rescues and interpolation above). Every upstream gate judges the LIVE // guest VB, so a decode-content/palette pairing mismatch, or junk // introduced by an interpolation substitution, passes all of them and // still flashes the 1-frame map-length ribbon. Trip: clear the item's // draws (a blink, and the caster pass shares the item so no ribbon // shadow either), log, and dump palette + samples for offline diagnosis. if (REXCVAR_GET(skate3_native_render_scene_stretch_guard)) { for (DrawItem& item : scene.items) { if (!item.skinned || item.bones.size() < 12 || item.pending || item.draws.empty()) { continue; } SkinProbe probe; { std::lock_guard lock(g_skin_probe_mutex); const auto pit = g_skin_probe.find(item.mesh); if (pit == g_skin_probe.end()) { continue; // not decoded yet: nothing will be drawn either } probe = pit->second; } const float bind_diag = BindDiag(item); // 6x like PublishedPaletteSane: judging accepted palettes, where junk // measures 100-400x and legit small-mesh articulation reaches ~3.3x. const float max_spread = std::max(6.0f * bind_diag, bind_diag + 2.0f); // Decoded-buffer convention: component k = byte k (see DecodeMesh's // SwapU32 + per-byte extraction); unpack into the shared sample form. std::vector sverts(probe.s.size()); for (size_t i = 0; i < probe.s.size(); ++i) { const SkinProbeSample& ps = probe.s[i]; SkinSampleVert& sv = sverts[i]; sv.p[0] = ps.p[0]; sv.p[1] = ps.p[1]; sv.p[2] = ps.p[2]; sv.pos_finite = true; for (int k = 0; k < 4; ++k) { sv.w[k] = uint8_t((ps.bw >> (8 * k)) & 0xFF); sv.bone[k] = uint8_t((ps.bi >> (8 * k)) & 0xFF); } } float spread = 0.0f; int n = 0; if (SkinnedSpreadHostRows(sverts.data(), uint32_t(sverts.size()), item.bones.data(), item.bones.size(), /*min_n=*/4, /*garbage_fails=*/false, &spread, &n) != 1) { continue; } if (spread <= max_spread) { continue; } g_stretch_veto.fetch_add(1, std::memory_order_relaxed); static std::atomic s_stretch_logged{0}; const uint32_t ln = s_stretch_logged.fetch_add(1, std::memory_order_relaxed); if (ln < 24 || (ln & 255u) == 0) { REXLOG_INFO( "native-scene: STRETCH veto mesh={:08X} fam={} ropa={} src={} " "caster={} spread={:.1f} bind={:.2f} n={} fp_match={} " "bone0_t=({:.1f},{:.1f},{:.1f})", item.mesh, item.char_family, item.ropa ? 1 : 0, item.dbg_src, item.caster_bank ? 1 : 0, spread, bind_diag, n, probe.fp == item.fingerprint ? 1 : 0, item.bones[3], item.bones[7], item.bones[11]); } if (ln < 6 && REXCVAR_GET(skate3_native_render_scene_stretch_guard_dump)) { // Full palette + probe dump for offline diagnosis (which rows are // junk, which bones the stretched samples weight to). char path[128]; std::snprintf(path, sizeof(path), "logs/stretch_%u_%08X.txt", ln, item.mesh); if (FILE* f = std::fopen(path, "wb")) { std::fprintf(f, "mesh=%08X fam=%u ropa=%d src=%u caster=%d spread=%f " "bind=%f fp=%016llX probe_fp=%016llX vb=%08X\n", item.mesh, item.char_family, item.ropa ? 1 : 0, item.dbg_src, item.caster_bank ? 1 : 0, spread, bind_diag, (unsigned long long)item.fingerprint, (unsigned long long)probe.fp, item.vb_addr); const uint32_t bones = uint32_t(item.bones.size() / 12); for (uint32_t b = 0; b < bones; ++b) { const float* r = item.bones.data() + b * 12; std::fprintf(f, "bone %3u | %9.4f %9.4f %9.4f %10.3f | %9.4f %9.4f " "%9.4f %10.3f | %9.4f %9.4f %9.4f %10.3f\n", b, r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10], r[11]); } for (const SkinProbeSample& ps : probe.s) { std::fprintf(f, "vert p=(%f,%f,%f) bw=%08X bi=%08X\n", ps.p[0], ps.p[1], ps.p[2], ps.bw, ps.bi); } std::fclose(f); } } item.draws.clear(); } } if (g_recording.load(std::memory_order_relaxed)) { std::lock_guard lock(g_record_mutex); if (++g_frames_seen % g_record_stride == 0) { RecordedFrame rf; rf.generation = g_generation + 1; std::memcpy(rf.view_proj, scene.view_proj, sizeof(rf.view_proj)); std::memcpy(rf.cam_pos, scene.cam_pos, sizeof(rf.cam_pos)); rf.dynitems = dynitems; rf.items = scene.items; g_recorded_frames.push_back(std::move(rf)); ++g_record_frame; } } // The draw-time fetch map served this frame's item builds (streamed-artwork // diffuse override); next frame's draws repopulate it. Cleared HERE, not at // the top with the other per-frame structures; the world items that // consume it are built above, after that swap. { std::lock_guard lock(g_palette_mutex); g_frame_draw_fetch.clear(); } // Frame tail: rotate the bone-palette snapshot ring. Timed as its own // block in the perf line; this tail runs on the guest render thread, // where spikes are visible stutter. { const auto pal_tail_t0 = PerfClock::now(); skate3::native_palette::OnFrameBuilt(); g_frame_pal_tail_ns = uint64_t(std::chrono::duration_cast( PerfClock::now() - pal_tail_t0) .count()); g_pw_pal_tail.Add(g_frame_pal_tail_ns); } // AUX-publish gate: the skater-portrait render-to-texture passes submit a // real perspective SceneRenderView, and on the frontend menu screens // (team boxes, Import Skater) it is the ONLY publisher; publishing it // rendered the portrait FULL SCREEN behind the menu for the whole // pause-freshness window on every entry/scroll (flapping // pause-native <-> loading in 300 ms cycles). Its projection is // screen-shaped (the aspect gate at view selection above never fired), so // identify it by CONTENT: a menu-context (presence 0) scene consisting of // nothing but character-family pieces (skater + board, every material // "character.*") is a portrait pass, never the visible frame. Real menu // backdrops always carry world geometry (the pause plaza ~700 items, the // CAS editor room has env-family walls). Skipped publishes also skip the // freshness stamp, so the mode never flips. // CAS-editor exemption: the STARTUP-flow editor's scene is skater-only // (no garage world pre-gameplay: 10 all-char items, exactly a portrait // pass's shape; without the exemption this gate ate it, 3D black // behind a live menu). The editor's _nis shader heartbeat separates the // two: editor draws stamp it every frame, portrait passes use the // _default compiles and never do. if (rex::kernel::guest_presence::GameplayContextValue() == 0 && !scene.items.empty() && scene.items.size() <= 48 && !CasEditorActive(base)) { bool all_char = true; for (const DrawItem& it : scene.items) { if (it.char_family == 0) { all_char = false; break; } } if (all_char) { static std::atomic s_aux_pub{0}; const uint32_t n = s_aux_pub.fetch_add(1, std::memory_order_relaxed); if (n < 8 || (n & 255u) == 0) { const float m00 = LoadGuestF32(base, viewcam + 0x60 + 0 * 4); const float m11 = LoadGuestF32(base, viewcam + 0x60 + (1 * 4 + 1) * 4); REXLOG_INFO( "native-scene: portrait-pass publish skipped ({} char items, cam " "({:.1f},{:.1f},{:.1f}), proj m00={:.3f} m11={:.3f}) (n={})", scene.items.size(), scene.cam_pos[0], scene.cam_pos[1], scene.cam_pos[2], m00, m11, n); } return; } } // Game shadow-pass parity (see g_frame_ortho_ctx): mark the items whose // ctx the game submitted through an ortho caster bank. Runs after every // item source (merge, rescues, gap fill) and is OR-only. The ortho // capture races the publish (more often the higher the render rate), and // a fresh capture defaults false: marking from THIS frame's set alone // blinked the skater's shadow off for single frames while the body kept // rendering. A ctx therefore counts as a caster while it was // ortho-submitted within the last kOrthoHoldFrames build frames; // never-submitted pieces (the trucker hat) stay non-casters forever. { static std::unordered_map s_ortho_last; static uint64_t s_ortho_frame = 0; ++s_ortho_frame; if (s_ortho_last.size() > 4096) { s_ortho_last.clear(); } for (uint32_t ctx : ortho_ctx) { s_ortho_last[ctx] = s_ortho_frame; } constexpr uint64_t kOrthoHoldFrames = 60; for (DrawItem& item : scene.items) { if (item.ctx == 0) { continue; } const auto it = s_ortho_last.find(item.ctx); if (it != s_ortho_last.end() && s_ortho_frame - it->second <= kOrthoHoldFrames) { item.shadow_caster = true; } } } g_last_publish_ns.store(std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(), std::memory_order_relaxed); std::lock_guard lock(g_scene_mutex); scene.generation = ++g_generation; g_scene = std::make_shared(std::move(scene)); } // StartRecording / WriteRecording: native/skate3_native_diagnostics.cpp. } // namespace skate3::native_scene // Sk8::Render::ViewCamera::SetViewMatrix(const rw::math::Matrix44&): the // game passes each frame's freshly computed view matrix here and derives // the stored view (+0x20), its transpose (+0xE0), the view-proj (+0xA0) and // the frustum data used for culling from the argument. While the freecam is // engaged, rewrite the incoming matrix for the MAIN scene camera (the one // the frame build publishes as g_sampler_viewcam) with the flown view // before the game consumes it; culling, submission and every derived // consumer then operate around the drone pose natively. Other ViewCameras // (shadow cascades, portrait render-to-texture passes) pass through // untouched. extern "C" REX_FUNC(sub_82802A00) { namespace ns = skate3::native_scene; const uint32_t cam = ctx.r3.u32; const uint32_t mtx = ctx.r4.u32; if (ns::g_freecam_guest_active.load(std::memory_order_acquire) != 0 && mtx != 0 && cam == ns::g_sampler_viewcam.load(std::memory_order_relaxed)) { float v[16]; { std::lock_guard lock(ns::g_freecam_guest_mutex); std::memcpy(v, ns::g_freecam_guest_view, sizeof(v)); } for (int i = 0; i < 16; ++i) { uint32_t raw; std::memcpy(&raw, &v[i], 4); raw = __builtin_bswap32(raw); std::memcpy(base + mtx + i * 4, &raw, 4); } ns::g_freecam_guest_rewrites.fetch_add(1, std::memory_order_relaxed); } __imp__sub_82802A00(ctx, base); }