From b4d2e59b708e31c75ba7a391993593cbdd7a89d6 Mon Sep 17 00:00:00 2001 From: mchughalex <25671999+mchughalex@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:20:19 +0100 Subject: [PATCH] npc: LivingWorld entity store (per-instance opacity + identity + palettes) --- CMakeLists.txt | 1 + src/native/skate3_native_lw.cpp | 289 +++++++++++++++++++++ src/native/skate3_native_lw.h | 49 ++++ src/skate3_native_debug_dialog.cpp | 35 +++ src/skate3_native_render.cpp | 5 +- src/skate3_native_scene.cpp | 392 ++++++++++++++++++++++++++++- src/skate3_native_scene.h | 9 + 7 files changed, 767 insertions(+), 13 deletions(-) create mode 100644 src/native/skate3_native_lw.cpp create mode 100644 src/native/skate3_native_lw.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ebcedcc..641d823 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -229,6 +229,7 @@ set(SKATE3_COMMON_SOURCES src/skate3_iso_installer.cpp src/native/skate3_native_diagnostics.cpp src/native/skate3_native_guest_read.cpp + src/native/skate3_native_lw.cpp src/native/skate3_native_v3_shadow.cpp src/native/skate3_native_v3_shadow_mat.cpp src/native/skate3_hud_tile.cpp diff --git a/src/native/skate3_native_lw.cpp b/src/native/skate3_native_lw.cpp new file mode 100644 index 0000000..805e86d --- /dev/null +++ b/src/native/skate3_native_lw.cpp @@ -0,0 +1,289 @@ +// LivingWorld entity store; see skate3_native_lw.h. Verified guest facts +// (from the recompiled game code, generated/skate3_recomp.36.cpp): +// +// - cLivingWorldPresEntity::Update (sub_827C1188, :43097) computes the +// spawn ramp (entity+576, += const per tick, clamped [0,1]) and the +// distance term (EvaluateOpacityDistance 827C1870 / the +612/+616 +// override), stores min(ramp, distance) as the .x of the Vector4 at +// entity+528 and the "is fading" byte at +580. +// - cLivingWorldPresEntity::BindConstants (sub_827C1720, :43927) pushes +// &entity+528 into the material params of EVERY mesh ctx of every +// instance: idx=[e+16], count=[e+(idx+5)*4], array=[e+(idx+8)*4] +// stride 0x28; instance: model +0, MeshContext array +4 (end +8, +// stride 0x50), mesh count = u16 at model+0x32. This is the alpha the +// character-family shaders output (c13.x / c21.x / c22.x / c20.x). +// +// The walk below mirrors SnapshotLwEntity (skate3_native_v3_shadow.cpp), +// whose ctx containment matching against DrawItem::ctx was live-proven +// (measured zero unmapped palettes). + +#include "native/skate3_native_lw.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "native/skate3_native_guest_read.h" + +REXCVAR_DEFINE_BOOL( + skate3_native_render_scene_lw_palette, true, "Skate 3", + "Snapshot each LivingWorld entity's OWN packed per-mesh palette " + "(cModelInstance.m_matrices, written by the pack writer every sim " + "tick) into the LW entity store, and substitute it for GUESSED ortho " + "caster-bank palettes on edge-of-view vehicles (the mangle/transform " + "class). Off = the caster heuristics alone.") + .lifecycle(rex::cvar::Lifecycle::kHotReload); + +namespace skate3::native_lw { +namespace { + +using skate3::native_scene::GuestTryCopy; + +// Entity/instance layout (verified: SnapshotLwEntity + sub_827C1720). +constexpr uint32_t kLwOpacity = 528; // Vector4, x = alpha +constexpr uint32_t kInstModel = 0x00; // cModelInstance model ptr +constexpr uint32_t kInstArrCtx = 0x04; // m_arrMeshContext begin +constexpr uint32_t kInstArrCtxEnd = 0x08; // VectorBase end +constexpr uint32_t kInstMatrices = 0x14; // packed palette, 48-byte rows +constexpr uint32_t kInstStride = 0x28; +constexpr uint32_t kCtxStride = 0x50; + +struct LwCtxEntry { + float alpha = 1.0f; + uint32_t entity = 0; + uint64_t stamp_ms = 0; + // The entity's own packed palette slice for this ctx's mesh (host-order, + // 3 float4 rows per bone), snapshotted at the same tick as the alpha. + uint32_t pal_rows = 0; + uint64_t pal_stamp_ms = 0; + std::vector palette; +}; + +std::mutex g_mu; +std::unordered_map g_ctx; // MeshContext -> entity state + +// Distinct-entity rate counter (stats line): entities ticked in the +// current 1 s window. +std::atomic g_ents_window{0}; +std::atomic g_ents_last{0}; +std::atomic g_ents_win_start{0}; + +bool LoadU32(uint8_t* base, uint32_t addr, uint32_t* out) { + if (addr < 0x10000) { + return false; + } + uint32_t raw; + if (!GuestTryCopy(&raw, base + addr, 4)) { + return false; + } + *out = __builtin_bswap32(raw); + return true; +} + +bool PlausiblePtr(uint32_t p) { + return p >= 0x10000 && p < 0x84A10000 && (p & 3) == 0; +} + +uint64_t NowMs() { + return uint64_t(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +} // namespace + +void OnLwEntityTick(uint8_t* base, uint32_t entity) { + // Fresh opacity first: an unreadable/insane alpha means teardown or a + // non-conforming subclass; skip the entity, never store garbage. + uint32_t alpha_u = 0; + if (!LoadU32(base, entity + kLwOpacity, &alpha_u)) { + return; + } + const float alpha = std::bit_cast(alpha_u); + if (!(alpha >= -0.01f && alpha <= 1.01f)) { + return; + } + uint32_t idx = 0, count = 0, arr = 0; + if (!LoadU32(base, entity + 16, &idx) || idx > 1024 || + !LoadU32(base, entity + (idx + 5) * 4, &count) || + !LoadU32(base, entity + (idx + 8) * 4, &arr) || count == 0 || + count > 8 || !PlausiblePtr(arr)) { + return; + } + const uint64_t now = NowMs(); + { + uint64_t win = g_ents_win_start.load(std::memory_order_relaxed); + if (now - win >= 1000) { + if (g_ents_win_start.compare_exchange_strong(win, now, + std::memory_order_relaxed)) { + g_ents_last.store(g_ents_window.exchange(0, std::memory_order_relaxed), + std::memory_order_relaxed); + } + } + g_ents_window.fetch_add(1, std::memory_order_relaxed); + } + const bool want_pal = REXCVAR_GET(skate3_native_render_scene_lw_palette); + struct CtxRec { + uint32_t ctx; + uint32_t pal_ofs; // float offset into pal[] (UINT32_MAX = no slice) + uint32_t pal_rows; // bone rows + }; + CtxRec ctxs[8 * 64]; + uint32_t nctx = 0; + // Palette staging: copied + byteswapped OUTSIDE the lock (the sim thread + // holding g_mu through a multi-KB swap loop stalled the render thread in + // the shadow reader's history, same rule here). + float pal[8 * 96 * 12]; + uint32_t pal_used = 0; + for (uint32_t i = 0; i < count; ++i) { + const uint32_t rec = arr + i * kInstStride; + uint32_t cbegin = 0, cend = 0; + if (!LoadU32(base, rec + kInstArrCtx, &cbegin) || + !LoadU32(base, rec + kInstArrCtxEnd, &cend) || !PlausiblePtr(cbegin) || + cend <= cbegin || (cend - cbegin) % kCtxStride != 0) { + continue; + } + const uint32_t slots = (cend - cbegin) / kCtxStride; + if (slots > 64) { + continue; + } + // The entity's own packed palette (m_matrices): per-mesh bone counts + // from the model walk (mesh table +0x24, count u16 +0x32, bone-count + // gate u16 +0x34 <= 80, mesh bone count +0x48 - the verified + // SnapshotLwEntity conventions); mesh k's slice starts after the + // preceding meshes' rows and pairs with ctx slot k. + uint32_t bones[16]; + uint32_t nmesh = 0; + uint32_t inst_pal_ofs = UINT32_MAX; + if (want_pal) { + uint32_t model = 0, matrices = 0, count_w = 0, table = 0, gate_w = 0; + if (LoadU32(base, rec + kInstModel, &model) && + LoadU32(base, rec + kInstMatrices, &matrices) && + PlausiblePtr(model) && PlausiblePtr(matrices) && + LoadU32(base, model + 0x30, &count_w) && + LoadU32(base, model + 0x24, &table) && + LoadU32(base, model + 0x34, &gate_w) && (gate_w >> 16) <= 80) { + const uint32_t nm = count_w & 0xFFFF; + if (nm >= 1 && nm <= 16) { + uint32_t total = 0; + bool ok = true; + for (uint32_t k = 0; k < nm && ok; ++k) { + uint32_t mesh = 0, bc = 0; + ok = LoadU32(base, table + k * 4, &mesh) && PlausiblePtr(mesh) && + LoadU32(base, mesh + 0x48, &bc) && bc >= 1 && bc <= 96; + if (ok) { + bones[k] = bc; + total += bc; + } + } + uint32_t raw[96 * 12]; + if (ok && total >= 1 && total <= 96 && + pal_used + total * 12 <= 8 * 96 * 12 && + GuestTryCopy(raw, base + matrices, size_t(total) * 48)) { + for (uint32_t f = 0; f < total * 12; ++f) { + pal[pal_used + f] = + std::bit_cast(__builtin_bswap32(raw[f])); + } + inst_pal_ofs = pal_used; + pal_used += total * 12; + nmesh = nm; + } + } + } + } + uint32_t row = 0; + for (uint32_t k = 0; k < slots && nctx < 8 * 64; ++k) { + CtxRec& c = ctxs[nctx++]; + c.ctx = cbegin + k * kCtxStride; + if (k < nmesh && inst_pal_ofs != UINT32_MAX) { + c.pal_ofs = inst_pal_ofs + row * 12; + c.pal_rows = bones[k]; + row += bones[k]; + } else { + c.pal_ofs = UINT32_MAX; + c.pal_rows = 0; + } + } + } + if (nctx == 0) { + return; + } + std::lock_guard lock(g_mu); + // Growth backstop: entries stop refreshing when their entity despawns; + // sweep stale ones (2 s) before the map can grow past the cap. + if (g_ctx.size() > 8192) { + for (auto it = g_ctx.begin(); it != g_ctx.end();) { + it = now - it->second.stamp_ms > 2000 ? g_ctx.erase(it) : std::next(it); + } + } + for (uint32_t i = 0; i < nctx; ++i) { + LwCtxEntry& e = g_ctx[ctxs[i].ctx]; + e.alpha = alpha; + e.entity = entity; + e.stamp_ms = now; + if (ctxs[i].pal_ofs != UINT32_MAX) { + e.pal_rows = ctxs[i].pal_rows; + e.pal_stamp_ms = now; + e.palette.assign(pal + ctxs[i].pal_ofs, + pal + ctxs[i].pal_ofs + size_t(ctxs[i].pal_rows) * 12); + } + } +} + +bool LookupLwCtx(uint32_t ctx, float* out_alpha, uint32_t* out_entity) { + if (ctx == 0) { + return false; + } + const uint64_t now = NowMs(); + std::lock_guard lock(g_mu); + const auto it = g_ctx.find(ctx); + if (it == g_ctx.end() || now - it->second.stamp_ms > 500) { + return false; + } + if (out_alpha != nullptr) { + *out_alpha = it->second.alpha; + } + if (out_entity != nullptr) { + *out_entity = it->second.entity; + } + return true; +} + +uint32_t LookupLwPalette(uint32_t ctx, float* out, uint32_t max_floats) { + if (ctx == 0 || out == nullptr) { + return 0; + } + const uint64_t now = NowMs(); + std::lock_guard lock(g_mu); + const auto it = g_ctx.find(ctx); + // 100 ms freshness: a palette older than a few sim ticks is a FROZEN + // pose, worse than the caster heuristics it would replace. + if (it == g_ctx.end() || it->second.pal_rows == 0 || + now - it->second.pal_stamp_ms > 100 || + it->second.palette.size() != size_t(it->second.pal_rows) * 12 || + it->second.palette.size() > max_floats) { + return 0; + } + std::memcpy(out, it->second.palette.data(), + it->second.palette.size() * sizeof(float)); + return it->second.pal_rows; +} + +void QueryLwStats(uint32_t* out_ctxs, uint32_t* out_entities) { + if (out_entities != nullptr) { + *out_entities = g_ents_last.load(std::memory_order_relaxed); + } + if (out_ctxs != nullptr) { + std::lock_guard lock(g_mu); + *out_ctxs = uint32_t(g_ctx.size()); + } +} + +} // namespace skate3::native_lw diff --git a/src/native/skate3_native_lw.h b/src/native/skate3_native_lw.h new file mode 100644 index 0000000..d29d553 --- /dev/null +++ b/src/native/skate3_native_lw.h @@ -0,0 +1,49 @@ +#pragma once + +// LivingWorld entity store: the game's own +// per-instance identity + opacity for cLivingWorldPresEntity-derived +// entities (census pedestrians, traffic vehicles, spawned props), served to +// the native scene the way the console serves it. +// +// Write point: the sub_827C1188 (cLivingWorldPresEntity::Update) EXIT hook, +// per entity per sim tick: the instant the game finishes writing the +// entity's evaluated opacity Vector4 at entity+528 (x = alpha, the value +// BindConstants pushes into every mesh ctx's material params, which the +// character-family shaders output as alpha: c13.x / c21.x / c22.x / c20.x). +// The walk mirrors sub_827C1720 (BindConstants): instance array at +// [e+(idx+8)*4] stride 0x28, per-instance MeshContext array at +4 stride +// 0x50; each ctx IS a DrawItem::ctx, so lookups are exact per instance +// per mesh. Entities whose layout fails a guard simply never enter the +// store (self-limiting: they keep the captured-row fade path). + +#include + +namespace skate3::native_lw { + +// sub_827C1188 EXIT (sim thread): read the entity's fresh opacity and map +// it onto every MeshContext the entity binds. Cheap (a dozen guarded loads +// per entity); called only while the native renderer is enabled. +void OnLwEntityTick(uint8_t* base, uint32_t entity); + +// Fresh (<= 500 ms) store entry for a MeshContext. Returns false when the +// ctx is unknown or stale (despawned entity / reused arena memory). Guest +// render thread; internally locked: use the bulk pattern (one call per +// item per frame is fine, the map is small). +bool LookupLwCtx(uint32_t ctx, float* out_alpha, uint32_t* out_entity); + +// The entity's OWN packed palette slice for this ctx's mesh, snapshotted at +// the entity's last sim tick (the LivingWorld pack writer sub_827C1D38 +// writes the concatenated per-mesh 4x3 palettes into the instance's +// m_matrices; mesh k's slice starts at 3*sum(preceding bone counts), the +// same walk the live-proven SnapshotLwEntity shadow reader uses). Copies up +// to max_floats host-order floats (3 float4 rows per bone) into out and +// returns the bone-row count, or 0 when the ctx has no fresh slice. This is +// the authoritative replacement for GUESSED ortho caster-bank palettes on +// edge-of-view vehicles. +uint32_t LookupLwPalette(uint32_t ctx, float* out, uint32_t max_floats); + +// Store size counters for the periodic stats line: live ctx entries and +// distinct entities ticked in the last second. +void QueryLwStats(uint32_t* out_ctxs, uint32_t* out_entities); + +} // namespace skate3::native_lw diff --git a/src/skate3_native_debug_dialog.cpp b/src/skate3_native_debug_dialog.cpp index 72b5925..b8a68fb 100644 --- a/src/skate3_native_debug_dialog.cpp +++ b/src/skate3_native_debug_dialog.cpp @@ -21,6 +21,10 @@ REXCVAR_DECLARE(bool, skate3_native_render_scene_splines); REXCVAR_DECLARE(bool, skate3_native_render_scene_quadlists); REXCVAR_DECLARE(bool, skate3_native_render_scene_world_items); REXCVAR_DECLARE(bool, skate3_native_render_scene_dynamic_items); +REXCVAR_DECLARE(bool, skate3_native_render_scene_lw_fade); +REXCVAR_DECLARE(bool, skate3_native_render_scene_lw_identity); +REXCVAR_DECLARE(bool, skate3_native_render_scene_lw_gap_fill); +REXCVAR_DECLARE(bool, skate3_native_render_scene_lw_palette); REXCVAR_DECLARE(bool, skate3_native_render_scene_retain_offscreen); REXCVAR_DECLARE(bool, skate3_native_render_scene_tex_revalidate); REXCVAR_DECLARE(bool, skate3_native_render_scene_mesh_revalidate); @@ -217,6 +221,37 @@ void NativeDebugDialog::OnDraw(ImGuiIO& io) { REXCVAR_GET(skate3_native_render_scene_dynamic_items), "Characters, movable props, cloth (RenderMesh/world-path " "captures)")); + REXCVAR_SET( + skate3_native_render_scene_lw_fade, + CvarCheckbox("LW entity fade (store)", + REXCVAR_GET(skate3_native_render_scene_lw_fade), + "Serve NPC/traffic fade alpha from the LivingWorld entity " + "itself (per-instance store) instead of the per-draw " + "captured constant row; fixes opaque mid-air spawns, " + "missing fade-ins and clone alpha blinks")); + REXCVAR_SET( + skate3_native_render_scene_lw_identity, + CvarCheckbox("LW entity identity (pose rings)", + REXCVAR_GET(skate3_native_render_scene_lw_identity), + "Key NPC/traffic pose-smoothing rings by the game's own " + "per-instance MeshContext instead of (mesh, occurrence) " + "pairing; clone reshuffles can no longer mispair " + "(teleport/slide class)")); + REXCVAR_SET( + skate3_native_render_scene_lw_gap_fill, + CvarCheckbox("LW gap fill (1-2 frame republish)", + REXCVAR_GET(skate3_native_render_scene_lw_gap_fill), + "Republish a live NPC whose MeshContext skipped this " + "frame's submit records (the 1-3 frame publish GAPs that " + "read as blinks/small teleports)")); + REXCVAR_SET( + skate3_native_render_scene_lw_palette, + CvarCheckbox("LW authoritative caster palettes", + REXCVAR_GET(skate3_native_render_scene_lw_palette), + "Replace GUESSED ortho caster-bank palettes on " + "edge-of-view vehicles with the entity's own packed " + "palette from the pack writer (the mangle/transform " + "class)")); REXCVAR_SET(skate3_native_render_scene_quadlists, CvarCheckbox("Quad-list particles", REXCVAR_GET(skate3_native_render_scene_quadlists), diff --git a/src/skate3_native_render.cpp b/src/skate3_native_render.cpp index aed0a44..4e290bb 100644 --- a/src/skate3_native_render.cpp +++ b/src/skate3_native_render.cpp @@ -1,5 +1,6 @@ #include "skate3_native_render.h" +#include "native/skate3_native_lw.h" #include "native/skate3_native_v3_shadow.h" #include "skate3_native_scene.h" #include "skate3_screenshot.h" @@ -667,11 +668,13 @@ extern "C" REX_FUNC(sub_827A52C8) { // Sk8::cLivingWorldPresEntity::Update: post-call, this+528 holds the // entity's evaluated spawn/distance fade opacity (x = alpha), this+16 the -// current LOD index. +// current LOD index. Feeds BOTH the v3 shadow fade comparison and the +// LW entity store (per-instance ctx -> alpha/identity, the serving path). extern "C" REX_FUNC(sub_827C1188) { const uint32_t entity = ctx.r3.u32; __imp__sub_827C1188(ctx, base); if (skate3::native_render::Enabled()) { + skate3::native_lw::OnLwEntityTick(base, entity); skate3::native_v3::OnLivingWorldUpdate(base, entity); } } diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index fb501c3..92f3b4c 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -32,6 +32,7 @@ #include "native/skate3_native_diag.h" #include "native/skate3_native_guest_read.h" +#include "native/skate3_native_lw.h" #include "native/skate3_native_v3_shadow.h" #include "native/skate3_native_v3_shadow_mat.h" #include "native/skate3_hud_tile.h" @@ -230,6 +231,30 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_entity_fade, true, "Skate 3", 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 " @@ -435,6 +460,8 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_tex_mips, true, "Skate 3", // 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_lw.cpp (the LW entity store module). +REXCVAR_DECLARE(bool, skate3_native_render_scene_lw_palette); namespace skate3::native_scene { namespace { @@ -748,6 +775,50 @@ struct CachedBones { }; std::unordered_map g_bones_cache; std::atomic g_bones_rescued{0}; +// ctx -> last published palette for skinned character-family items (the +// per-INSTANCE sibling of g_bones_cache): clones share meshes, so the +// mesh-keyed rescue is gated to pub_count==1 and a refused clone capture +// next to a published twin got NOTHING: a one-frame invisibility blink. +// The MeshContext is the game's own per-instance identity, +// so this cache rescues each instance +// with ITS OWN last palette regardless of how many clones are alive. +std::unordered_map g_bones_cache_ctx; +std::atomic g_lw_ctx_rescued{0}; +// LW entity store consumption counters (stats line lw[...]). +std::atomic g_lw_stamped{0}; +std::atomic g_lw_fade0{0}; +std::atomic g_lw_gap_filled{0}; +// Authoritative caster-palette substitutions + per-ctx lighting-rows serves +// (edge-of-view vehicles; see the stamp pass). +std::atomic g_lw_pal_sub{0}; +std::atomic g_lw_rows_served{0}; +// ctx -> last VALIDATED lighting/paint rows, entity-checked (a recycled +// instance must not inherit the previous occupant's paint). The mesh-keyed +// g_char_rows_cache fallback is gated to single-instance meshes; cloned +// traffic never qualified, so a caster-only stretch (main view culls the +// vehicle at the screen edge before it leaves the screen) dropped to +// legacy flat shading: the "vehicle loses its texture/color at the edge" +// sighting. +struct CharRowsCtx { + std::array rows; + uint32_t entity = 0; +}; +std::unordered_map g_char_rows_cache_ctx; +// ctx -> last PUBLISHED item of a live LW entity (skinned character +// families, non-ropa): when a ctx drops out of the submit records for a +// frame or two while its entity is still alive in the store (observed as +// "dyn publish GAP" incidents on fam-3/5 ped meshes, 1-3 +// frames each: an in-view NPC vanishing for a frame reads as a blink or, +// moving, a small teleport), the whole item republishes. Console behavior: +// a live entity never skips a frame. Self-limiting: entries expire after 2 +// unpublished frames and refresh only from LIVE publishes (never from a +// fill, dbg_src 9), so a real despawn shows at most 2 filled frames, at +// the entity's own served alpha. +struct LwRetained { + DrawItem item; + uint64_t frame = 0; +}; +std::unordered_map g_lw_last_items; // mesh -> last RESOLVED ropa garment state (rigid world OR skinned palette). // Ropa items must NOT ride the g_bones_cache rescue above: a ropa capture is // refused exactly when the bank is stale (g_ropa_stale), and while the cloth @@ -2914,6 +2985,33 @@ void CaptureCharLighting(uint8_t* base, DrawItem& item) { // 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; } @@ -5174,10 +5272,29 @@ void InterpolateDynamicItems(uint8_t* base, FrameScene& scene, double now) { } } }; - uint64_t key = (uint64_t(item.mesh) << 8) | (k & 0xFF); + // 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 (own_d2 > 1e-4f) { + 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 @@ -5413,7 +5530,11 @@ void InterpolateDynamicItems(uint8_t* base, FrameScene& scene, double now) { 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) { @@ -6254,6 +6375,11 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { // (rigid vs skinned), never blindly re-skin (see g_ropa_state_cache). std::unordered_map pending_skinned_by_mesh; std::unordered_map pending_ropa_by_mesh; + // Per-INSTANCE candidates (ctx-keyed, character families): the mesh-keyed + // rescue above is pub_count==1-gated, so a refused CLONE capture next to + // a published twin dropped for the frame: the NPC visible/invisible + // flicker (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; @@ -6296,6 +6422,9 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { pending_ropa_by_mesh.try_emplace(cand.mesh, &cand); } else if (cand.skinned) { pending_skinned_by_mesh.try_emplace(cand.mesh, &cand); + if (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); @@ -6454,13 +6583,25 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { healed = true; } } else { - const auto bit = g_bones_cache.find(item.mesh); - if (bit != g_bones_cache.end() && - bit->second.bones.size() == item.bones.size() && - g_guest_frame - bit->second.frame <= 10) { - item.bones = bit->second.bones; + // Prefer the per-instance cache (clone-exact) over the mesh-keyed + // one; 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; + } else { + const auto bit = g_bones_cache.find(item.mesh); + if (bit != g_bones_cache.end() && + bit->second.bones.size() == item.bones.size() && + g_guest_frame - bit->second.frame <= 10) { + item.bones = bit->second.bones; + item.dbg_src = 6; + healed = true; + } } } if (!healed) { @@ -6520,6 +6661,53 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { cb.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, BEFORE the mesh-keyed one: a refused/pending + // skinned character capture whose ctx did not publish this frame + // re-publishes with ITS OWN last palette (<= 10 frames fresh, same + // bound as the mesh rescue), regardless of how many clones of the mesh + // are alive. Rescued items bump pub_count so the mesh-keyed loop below + // cannot double-publish the same candidate. + 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); + } for (const auto& [mesh, cand] : pending_skinned_by_mesh) { if (pub_count.find(mesh) != pub_count.end()) { continue; // a live copy published; nothing to rescue @@ -6537,6 +6725,9 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { rescued.bones = bit->second.bones; rescued.pending = false; rescued.dbg_src = 3; + if (rescued.ctx != 0) { + dyn_slot.try_emplace(rescued.ctx, scene.items.size() - 1); + } g_bones_rescued.fetch_add(1, std::memory_order_relaxed); } // Refused ropa captures re-publish last frame's resolved state: mode, @@ -6557,8 +6748,63 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { 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(); + } } // Cross-frame character-lighting fallback (see g_char_rows_cache): items // whose capture chain never validated THIS frame reuse their garment's @@ -6963,6 +7209,110 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { } } + // 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_INFO( + "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); + } + } + } + } + // 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 @@ -13107,6 +13457,8 @@ void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn, } } if (frames % 600 == 0) { + uint32_t lw_ctxs = 0, lw_ents = 0; + skate3::native_lw::QueryLwStats(&lw_ctxs, &lw_ents); REXLOG_INFO( "native-scene: frame {} items={} draws={} draws_2d={} drawn_2d={} " "splines[{}/{}] " @@ -13118,7 +13470,9 @@ void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn, "store[n={} routes={} evict={}] " "heal[vfail={} dfail={} demote={}] serve[sticky={} skipnew={} adstale={} adnone={}] " "shadow[valid={} ready={} draws={}] char[attempt={} valid={} drawn={} reused={} " - "bones_rescued={}] dynobj[valid={} drawn={}] refl[pair={} flat={} gate={:#x}]", + "bones_rescued={}] dynobj[valid={} drawn={}] " + "lw[ctxs={} ents={} stamp={} fade0={} resc={} fill={} pal={} rows={}] " + "refl[pair={} flat={} gate={:#x}]", frames, scene.items.size(), drawn, g_draws_2d.load(), drawn_2d, drawn_spline, g_draws_spline.load(), g_draws_2d_other.load(), g_draws_2d_dropped.load(), g_r.tex_store.size(), @@ -13144,8 +13498,10 @@ void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn, shadow_ready, shadow_draws, g_char_attempts.load(), g_char_valid.load(), g_char_drawn.load(), g_char_rows_reused.load(), g_bones_rescued.load(), scene.dynobj_valid, - g_dynobj_drawn.load(), g_refl_pair.load(), g_refl_flat.load(), - g_refl_gate.load()); + g_dynobj_drawn.load(), lw_ctxs, lw_ents, g_lw_stamped.load(), + g_lw_fade0.load(), g_lw_ctx_rescued.load(), g_lw_gap_filled.load(), + g_lw_pal_sub.load(), g_lw_rows_served.load(), + g_refl_pair.load(), g_refl_flat.load(), g_refl_gate.load()); } } @@ -14692,8 +15048,12 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat // LivingWorld entity draws at alpha 0 through the whole spawn settle // (NPCs drop ~1 m to the ground before fading in) and ramps alpha with // distance; skip invisible items entirely, blend mid-fade ones. + // LW-mapped items (lw_alpha >= 0) do not need a validated lighting + // capture to honor the fade: the entity alpha is authoritative even + // when the capture chain failed; a spawn-settling NPC is invisible + // regardless of whether its rows validated this frame. const bool entity_fade = debug_mode == 0 && item.char_family != 0 && - char_capture_ok && + (char_capture_ok || item.lw_alpha >= 0.0f) && REXCVAR_GET(skate3_native_render_scene_entity_fade); const float fade_a = entity_fade ? CharFadeAlpha(item) : 1.0f; // One-frame fade-blink guard: a mesh that rendered ~opaque last frame @@ -14705,8 +15065,13 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat // skip + the mid-fade blend routing) and log it. The last-alpha map // updates from the RAW value, so a persisting alpha 0 (real despawn / // spawn settle) only gets one repaired frame and then skips normally. + // LW-mapped items bypass the blink repair entirely (read AND write): + // the entity alpha cannot blink; the repair exists for garbage/foreign + // CAPTURED rows, and its mesh key is clone-shared, which force-drew + // spawn-settling clones OPAQUE every frame their twin was visible (the + // "NPC drops out of the sky with no fade" sighting). bool fade_blink = false; - if (item.char_family != 0 && debug_mode == 0) { + if (item.char_family != 0 && debug_mode == 0 && item.lw_alpha < 0.0f) { static std::unordered_map s_fade_opaque; // render thread uint8_t& was_opaque = s_fade_opaque[item.mesh]; if (entity_fade && fade_a <= 0.004f && was_opaque) { @@ -14730,6 +15095,9 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat } } if (entity_fade && fade_a <= 0.004f && !fade_blink) { + if (item.lw_alpha >= 0.0f) { + g_lw_fade0.fetch_add(1, std::memory_order_relaxed); + } continue; } const bool char_fade_blend = char_fade_zwrite(item) && !fade_blink; diff --git a/src/skate3_native_scene.h b/src/skate3_native_scene.h index 40163e3..dbb0658 100644 --- a/src/skate3_native_scene.h +++ b/src/skate3_native_scene.h @@ -25,6 +25,15 @@ struct DrawItem { // and the entity store: the owning cModelInstance is resolved backward by // m_arrMeshContext containment (there is no forward mesh->owner pointer). uint32_t ctx = 0; + // LivingWorld entity store mapping, + // stamped at scene build when this ctx belongs to a live LW entity: + // lw_alpha = the entity's authoritative opacity (entity+528.x: exactly + // the value BindConstants serves this ctx as the shader's output alpha; + // -1 = unmapped), lw_entity = the owning entity address (the game's own + // per-instance identity, used to key the pose rings so same-mesh clone + // reshuffles can never mispair). + float lw_alpha = -1.0f; + uint32_t lw_entity = 0; uint32_t mesh; // guest mesh object address (resource cache key) uint32_t vb_obj; // guest renderengine::VertexBuffer object (draw matching) uint32_t ib_obj; // guest renderengine::IndexBuffer object (draw matching)