diff --git a/CMakeLists.txt b/CMakeLists.txt index d8c3bef..73414ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -228,6 +228,7 @@ set(SKATE3_COMMON_SOURCES src/skate3_fov.cpp src/skate3_iso_installer.cpp src/native/skate3_native_diagnostics.cpp + src/native/skate3_native_entity.cpp src/native/skate3_native_guest_read.cpp src/native/skate3_native_lw.cpp src/native/skate3_native_v3_shadow.cpp diff --git a/src/native/skate3_native_diagnostics.cpp b/src/native/skate3_native_diagnostics.cpp index 8d97275..1f86b32 100644 --- a/src/native/skate3_native_diagnostics.cpp +++ b/src/native/skate3_native_diagnostics.cpp @@ -127,7 +127,16 @@ void WriteRecording(const char* dir, const char* stem) { << (d.skinned ? 1 : 0) << ",\"pending\":" << (d.pending ? 1 : 0) << ",\"decal\":" << (d.decal ? 1 : 0) << ",\"transparent\":" << (d.transparent ? 1 : 0) - << ",\"selected\":" << (d.selected ? 1 : 0) << ",\"world\":["; + << ",\"selected\":" << (d.selected ? 1 : 0) + // Provenance: which pipeline path produced this item (dbg_src + // codes in skate3_native_scene.h) and the identity keys the + // offline analyzers join on. + << ",\"ctx\":\"" << std::hex << d.ctx << std::dec + << "\",\"dbg_src\":" << int(d.dbg_src) + << ",\"ropa\":" << (d.ropa ? 1 : 0) + << ",\"fam\":" << int(d.char_family) + << ",\"caster\":" << (d.caster_bank ? 1 : 0) + << ",\"retained\":" << (d.retained ? 1 : 0) << ",\"world\":["; for (int i = 0; i < 16; ++i) out << (i ? "," : "") << d.world[i]; out << "],\"draws\":["; for (size_t i = 0; i < d.draws.size(); ++i) { diff --git a/src/native/skate3_native_entity.cpp b/src/native/skate3_native_entity.cpp new file mode 100644 index 0000000..77819bd --- /dev/null +++ b/src/native/skate3_native_entity.cpp @@ -0,0 +1,667 @@ +// Presentation-entity identity store; see skate3_native_entity.h for the +// verified guest facts behind the binding model. +// The walk below mirrors sub_827A6658 (the base BindConstants) +// exactly: it re-reads the same arrays the game just walked, so a +// conforming entity registers and a non-conforming one self-limits out. + +#include "native/skate3_native_entity.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "native/skate3_native_guest_read.h" + +REXCVAR_DEFINE_BOOL( + skate3_native_render_scene_entity_ident, true, "Skate 3", + "Build the presentation-entity identity store (MeshContext -> owning " + "entity, from the game's BindConstants walks) and compare the entity's " + "direct fields (world transform, opacity) against the renderer's " + "captured values. Observer only: emits the ident[] stats line, serves " + "nothing.") + .lifecycle(rex::cvar::Lifecycle::kHotReload); + +REXCVAR_DEFINE_BOOL( + skate3_native_render_scene_entity_ropa_world, true, "Skate 3", + "Serve a refused ROPA garment world matrix from the owner entity's " + "m_MatLtoWTrans (+416) via the identity store, instead of leaving the " + "item pending. Only activates where the draw-time bank read refused " + "(foreign bank / post-shadow clobber / torn state); accepted bank " + "reads are bit-exact equal to this field, so the accept path is " + "unchanged. Off = refused captures stay pending (rescue/fixup paths).") + .lifecycle(rex::cvar::Lifecycle::kHotReload); + +REXCVAR_DEFINE_BOOL( + skate3_native_render_scene_entity_ropa_world_primary, true, "Skate 3", + "Serve the ROPA garment world from the owner entity's m_MatLtoWTrans " + "on ACCEPTED bank captures too (bank value kept as fallback and as a " + "live bit-compare tripwire: wprim= in the ident[] line). Value-" + "identical where the bank is healthy (observer-proven bit-exact) and " + "keeps the garment on its own entity through every bank state. " + "Lighting-row capture still follows the bank's own acceptance. Off = " + "entity world only on bank refusal.") + .lifecycle(rex::cvar::Lifecycle::kHotReload); + +namespace skate3::native_entity { +namespace { + +using skate3::native_scene::GuestTryCopy; + +// PresentationEntity offsets (verified: sub_827A6658 / sub_82783D68 / +// sub_82793F70 / sub_827A68B0 walk + stores in the generated code). +constexpr uint32_t kEntL2W = 352; // m_MatLtoW (Matrix44) +constexpr uint32_t kEntL2WTrans = 416; // m_MatLtoWTrans (Matrix44) +constexpr uint32_t kEntOpacitySkater = 496; // skater family, x = alpha +constexpr uint32_t kEntOpacityLw = 528; // LivingWorld family +// SkaterPresEntity cloth fields (offsets validated live before the +// shadow-mode reader that proved them was retired). +constexpr uint32_t kRopaGate = 1921; // u8 m_bSkinningEnabled +constexpr uint32_t kRopaClothTarget = 1040; // u32 m_pModelClothTarget[i] +constexpr uint32_t kRopaCount = 1096; // i32 m_numClothModels +constexpr uint32_t kRopaVb = 1936; // u32 vb pair, + i*8 + cur*4 +constexpr uint32_t kInstCtxArr = 0x04; // cModelInstance MeshContext* +constexpr uint32_t kInstStride = 0x28; +constexpr uint32_t kCtxStride = 0x50; + +struct EntityRec { + EntClass cls = EntClass::kUnknown; + uint32_t vtable = 0; + int32_t view_refs = 0; + uint64_t bind_count = 0; +}; + +struct CtxEntry { + uint32_t entity = 0; + uint32_t instance = 0; +}; + +std::mutex g_mu; +std::unordered_map g_entities; +std::unordered_map g_ctx; + +// Deformed-VB freshness: vb object -> last DoubleBuffer completion (ms). +// Guarded by its own lock (the sim thread stamps at cloth-tick rate; the +// render thread reads per serve). +std::mutex g_vb_mu; +std::unordered_map g_vb_fresh; + +// Telemetry (600-frame window, exchanged to zero at emission). +std::atomic g_bind_calls{0}; +std::atomic g_add_events{0}; +std::atomic g_rmv_events{0}; +std::atomic g_item_hit{0}; +std::atomic g_item_miss{0}; +std::atomic g_item_lw_overlap{0}; +std::atomic g_fade_ok{0}; +std::atomic g_fade_div{0}; +std::atomic g_fade_bad{0}; +// ROPA world vs entity matrices: [0]=e+416 (transposed), [1]=e+352 (raw); +// exact / near / divergent, plus unmapped ctx and unreadable entity. +std::atomic g_w_exact[2]{}; +std::atomic g_w_near[2]{}; +std::atomic g_w_div[2]{}; +std::atomic g_ropa_unmap{0}; +std::atomic g_ropa_read_fail{0}; +// Serve flip 1: entity-world serves on bank refusal / structural rejects / +// mode-authority refusals (the garment table does not claim the VB as a +// live CPU cloth target; asserting rigid there would flip the mode). +std::atomic g_wserve{0}; +std::atomic g_wserve_rej{0}; +std::atomic g_wserve_nomode{0}; +// Primary-serve tripwire: serves on accepted captures / bank divergences. +std::atomic g_wprim{0}; +std::atomic g_wprim_div{0}; +// Per-class item hits: Unknown/LW/Skater/Colorized/Cac/SkaterAux. +std::atomic g_hit_cls[6]{}; + +std::atomic g_frame_counter{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 LoadF32Block(uint8_t* base, uint32_t addr, float* out, uint32_t count) { + if (addr < 0x10000) { + return false; + } + uint32_t raw[16]; + if (count > 16 || !GuestTryCopy(raw, base + addr, size_t(count) * 4)) { + return false; + } + for (uint32_t i = 0; i < count; ++i) { + out[i] = std::bit_cast(__builtin_bswap32(raw[i])); + } + 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()); +} + +const char* ClassName(EntClass cls) { + switch (cls) { + case EntClass::kLivingWorld: return "lw"; + case EntClass::kSkater: return "skater"; + case EntClass::kColorized: return "colorized"; + case EntClass::kCac: return "cac"; + case EntClass::kSkaterAux: return "skater-aux"; + default: return "base"; + } +} + +// Compare 3 staged rows (translation in component 3) against the first 3 +// rows of a guest Matrix44. 0 = exact, 1 = near (within one sim step), +// 2 = divergent. +int CompareRows(const float rows[12], const float mem[12]) { + bool exact = true; + bool near_ok = true; + for (int r = 0; r < 3; ++r) { + for (int i = 0; i < 4; ++i) { + const float d = std::fabs(rows[r * 4 + i] - mem[r * 4 + i]); + const float tol_near = (i == 3) ? 0.5f : 0.05f; + if (d > 1e-4f) { + exact = false; + } + if (d > tol_near) { + near_ok = false; + } + } + } + return exact ? 0 : (near_ok ? 1 : 2); +} + +} // namespace + +void OnBindConstants(uint8_t* base, uint32_t entity) { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ident) || entity == 0) { + return; + } + g_bind_calls.fetch_add(1, std::memory_order_relaxed); + uint32_t vtable = 0, idx = 0, count = 0, arr = 0; + if (!LoadU32(base, entity, &vtable) || !LoadU32(base, entity + 16, &idx) || + idx > 1024 || !LoadU32(base, entity + (idx + 5) * 4, &count) || + !LoadU32(base, entity + (idx + 8) * 4, &arr) || count == 0 || + count > 32 || !PlausiblePtr(arr)) { + return; + } + struct CtxRec { + uint32_t ctx; + uint32_t instance; + }; + CtxRec ctxs[32 * 64]; + uint32_t nctx = 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 + kInstCtxArr, &cbegin) || + !LoadU32(base, rec + kInstCtxArr + 4, &cend) || + !PlausiblePtr(cbegin) || cend <= cbegin || + (cend - cbegin) % kCtxStride != 0) { + continue; + } + const uint32_t slots = (cend - cbegin) / kCtxStride; + if (slots > 64) { + continue; + } + for (uint32_t k = 0; k < slots && nctx < 32 * 64; ++k) { + ctxs[nctx++] = {cbegin + k * kCtxStride, rec}; + } + } + if (nctx == 0) { + return; + } + std::lock_guard lock(g_mu); + // Growth backstop: binds are one-shot per entity, so dead entries never + // refresh; drop view-dead entities' ctxs when the map gets large. + if (g_ctx.size() > 16384) { + for (auto it = g_ctx.begin(); it != g_ctx.end();) { + const auto ent = g_entities.find(it->second.entity); + const bool dead = ent == g_entities.end() || ent->second.view_refs <= 0; + it = dead ? g_ctx.erase(it) : std::next(it); + } + } + if (g_entities.size() > 8192) { + for (auto it = g_entities.begin(); it != g_entities.end();) { + it = it->second.view_refs <= 0 ? g_entities.erase(it) : std::next(it); + } + } + EntityRec& er = g_entities[entity]; + er.vtable = vtable; + er.bind_count++; + for (uint32_t i = 0; i < nctx; ++i) { + g_ctx[ctxs[i].ctx] = {entity, ctxs[i].instance}; + } +} + +void OnBindClass(uint32_t entity, EntClass cls) { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ident) || entity == 0) { + return; + } + std::lock_guard lock(g_mu); + const auto it = g_entities.find(entity); + if (it == g_entities.end()) { + return; // base walk failed its guards; keep the entity out entirely + } + // Most-derived override returns last, so plain assignment converges. + it->second.cls = cls; + // One capped line per distinct (vtable, class) pair: the live class + // table, for promoting vtable-based classification later. + static std::unordered_map s_seen; + const uint64_t key = (uint64_t(it->second.vtable) << 8) | uint8_t(cls); + if (s_seen.size() < 64 && s_seen.emplace(key, true).second) { + REXLOG_INFO("native-entity: class {} vtbl={:08X} entity={:08X}", + ClassName(cls), it->second.vtable, entity); + } +} + +void OnEntityViewAdd(uint32_t entity) { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ident) || entity == 0) { + return; + } + g_add_events.fetch_add(1, std::memory_order_relaxed); + std::lock_guard lock(g_mu); + // Entities can register with views before their first bind; the record + // gets its ctxs (and class) when BindConstants fires. + g_entities[entity].view_refs++; +} + +void OnRopaDoubleBuffer(uint8_t* base, uint32_t skater, uint32_t index) { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ident) || skater == 0 || + index >= 8) { + return; + } + uint32_t vb0 = 0, vb1 = 0; + if (!LoadU32(base, skater + kRopaVb + index * 8, &vb0) || + !LoadU32(base, skater + kRopaVb + index * 8 + 4, &vb1)) { + return; + } + const uint64_t now = NowMs(); + std::lock_guard lock(g_vb_mu); + if (g_vb_fresh.size() > 512) { + for (auto it = g_vb_fresh.begin(); it != g_vb_fresh.end();) { + it = now - it->second > 2000 ? g_vb_fresh.erase(it) : std::next(it); + } + } + if (vb0 != 0) { + g_vb_fresh[vb0] = now; + } + if (vb1 != 0) { + g_vb_fresh[vb1] = now; + } +} + +void OnEntityViewRemove(uint32_t entity) { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ident) || entity == 0) { + return; + } + g_rmv_events.fetch_add(1, std::memory_order_relaxed); + std::lock_guard lock(g_mu); + const auto it = g_entities.find(entity); + if (it != g_entities.end()) { + it->second.view_refs = std::max(0, it->second.view_refs - 1); + } +} + +bool LookupCtx(uint32_t ctx, CtxInfo* out) { + if (ctx == 0) { + return false; + } + std::lock_guard lock(g_mu); + const auto it = g_ctx.find(ctx); + if (it == g_ctx.end()) { + return false; + } + if (out != nullptr) { + out->entity = it->second.entity; + out->instance = it->second.instance; + const auto ent = g_entities.find(it->second.entity); + out->cls = ent != g_entities.end() ? ent->second.cls : EntClass::kUnknown; + out->view_live = ent != g_entities.end() && ent->second.view_refs > 0; + } + return true; +} + +void ObserveRopaWorld(uint8_t* base, uint32_t ctx, const float rows[12]) { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ident)) { + return; + } + CtxInfo info; + if (!LookupCtx(ctx, &info)) { + g_ropa_unmap.fetch_add(1, std::memory_order_relaxed); + return; + } + float m416[12], m352[12]; + const bool ok416 = LoadF32Block(base, info.entity + kEntL2WTrans, m416, 12); + const bool ok352 = LoadF32Block(base, info.entity + kEntL2W, m352, 12); + if (!ok416 || !ok352) { + g_ropa_read_fail.fetch_add(1, std::memory_order_relaxed); + return; + } + const int c416 = CompareRows(rows, m416); + const int c352 = CompareRows(rows, m352); + (c416 == 0 ? g_w_exact[0] : c416 == 1 ? g_w_near[0] : g_w_div[0]) + .fetch_add(1, std::memory_order_relaxed); + (c352 == 0 ? g_w_exact[1] : c352 == 1 ? g_w_near[1] : g_w_div[1]) + .fetch_add(1, std::memory_order_relaxed); + if (c416 == 2 && c352 == 2) { + static std::atomic s_logged{0}; + const uint32_t ln = s_logged.fetch_add(1, std::memory_order_relaxed); + if (ln < 8 || (ln & 1023u) == 0) { + REXLOG_INFO( + "native-entity: ropa world DIVERGES both fields ctx={:08X} " + "cls={} entity={:08X} bank=({:.3f},{:.3f},{:.3f},{:.2f}) " + "e416=({:.3f},{:.3f},{:.3f},{:.2f}) " + "e352=({:.3f},{:.3f},{:.3f},{:.2f}) (n={})", + ctx, ClassName(info.cls), info.entity, rows[0], rows[1], rows[2], + rows[3], m416[0], m416[1], m416[2], m416[3], m352[0], m352[1], + m352[2], m352[3], ln); + } + } +} + +void ObserveCharItem(uint8_t* base, uint32_t ctx, uint32_t family, + bool lw_mapped, float used_alpha) { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ident)) { + return; + } + CtxInfo info; + if (!LookupCtx(ctx, &info)) { + g_item_miss.fetch_add(1, std::memory_order_relaxed); + return; + } + g_item_hit.fetch_add(1, std::memory_order_relaxed); + g_hit_cls[std::min(uint32_t(info.cls), 5u)].fetch_add( + 1, std::memory_order_relaxed); + if (lw_mapped) { + g_item_lw_overlap.fetch_add(1, std::memory_order_relaxed); + } + const bool skater_family = + info.cls == EntClass::kSkater || info.cls == EntClass::kColorized || + info.cls == EntClass::kCac || info.cls == EntClass::kSkaterAux; + if (!skater_family) { + return; // LW opacity is already proven/served; base classes untracked + } + float alpha = 0.0f; + if (!LoadF32Block(base, info.entity + kEntOpacitySkater, &alpha, 1) || + !(alpha >= -0.01f && alpha <= 1.01f)) { + g_fade_bad.fetch_add(1, std::memory_order_relaxed); + return; + } + if (std::fabs(alpha - used_alpha) <= 0.05f) { + g_fade_ok.fetch_add(1, std::memory_order_relaxed); + } else { + g_fade_div.fetch_add(1, std::memory_order_relaxed); + static std::atomic s_logged{0}; + const uint32_t ln = s_logged.fetch_add(1, std::memory_order_relaxed); + if (ln < 8 || (ln & 2047u) == 0) { + REXLOG_INFO( + "native-entity: fade divergence ctx={:08X} cls={} fam={} " + "e496={:.3f} used={:.3f} (n={})", + ctx, ClassName(info.cls), family, alpha, used_alpha, ln); + } + } +} + +void NoteAcceptedWorldCompare(const float bank_rows[12], + const float ent_rows[12]) { + g_wprim.fetch_add(1, std::memory_order_relaxed); + for (int i = 0; i < 12; ++i) { + if (std::fabs(bank_rows[i] - ent_rows[i]) > 1e-4f) { + g_wprim_div.fetch_add(1, std::memory_order_relaxed); + static std::atomic s_logged{0}; + const uint32_t ln = s_logged.fetch_add(1, std::memory_order_relaxed); + if (ln < 8 || (ln & 1023u) == 0) { + REXLOG_INFO( + "native-entity: PRIMARY world diverges from accepted bank " + "i={} bank={:.4f} ent={:.4f} (n={})", + i, bank_rows[i], ent_rows[i], ln); + } + return; + } + } +} + +namespace { + +// Shared structural validation: 16-float Matrix44 with a (0,0,0,1) tail +// row and bank-gate row norms; copies the 3 affine rows on success. +bool ReadWorldRowsChecked(uint8_t* base, uint32_t entity, float out_rows[12]) { + float m[16]; + if (!LoadF32Block(base, entity + kEntL2WTrans, m, 16)) { + return false; + } + const bool tail_ok = std::fabs(m[12]) <= 1e-4f && std::fabs(m[13]) <= 1e-4f && + std::fabs(m[14]) <= 1e-4f && + std::fabs(m[15] - 1.0f) <= 1e-4f; + bool rows_ok = tail_ok; + for (int r = 0; r < 3 && rows_ok; ++r) { + float n = 0.0f; + for (int i = 0; i < 3; ++i) { + const float f = m[r * 4 + i]; + rows_ok = rows_ok && f > -1e7f && f < 1e7f; + n += f * f; + } + rows_ok = rows_ok && n > 0.0025f && n < 400.0f && m[r * 4 + 3] > -20000.f && + m[r * 4 + 3] < 20000.f; + } + if (!rows_ok) { + return false; + } + std::memcpy(out_rows, m, 12 * sizeof(float)); + return true; +} + +} // namespace + +bool ReadEntityWorldRows(uint8_t* base, uint32_t ctx, float out_rows[12]) { + CtxInfo info; + return LookupCtx(ctx, &info) && + ReadWorldRowsChecked(base, info.entity, out_rows); +} + +uint32_t ServeInstancePalette(uint8_t* base, uint32_t ctx, float* out, + uint32_t max_rows) { + CtxInfo info; + if (!LookupCtx(ctx, &info) || info.instance == 0) { + return 0; + } + uint32_t matrices = 0, count = 0; + if (!LoadU32(base, info.instance + 0x14, &matrices) || + !LoadU32(base, info.instance + 0x18, &count) || !PlausiblePtr(matrices) || + count < 1 || count > 96 || count > max_rows) { + return 0; + } + uint32_t raw[96 * 12]; + if (!GuestTryCopy(raw, base + matrices, size_t(count) * 48)) { + return 0; + } + for (uint32_t i = 0; i < count * 12; ++i) { + out[i] = std::bit_cast(__builtin_bswap32(raw[i])); + } + // Row sanity (packed 3x4 rotation norms in the bank-gate range): a + // stale/unpacked buffer fails and the caller keeps its fallback. + for (uint32_t r = 0; r < count; ++r) { + float n = 0.0f; + for (int j = 0; j < 3; ++j) { + const float* m = out + r * 12; + n += m[j] * m[j] + m[4 + j] * m[4 + j] + m[8 + j] * m[8 + j]; + } + if (!(n > 0.0016f && n < 2000.0f)) { + return 0; + } + } + return count; +} + +bool RopaGarmentDropped(uint8_t* base, uint32_t ctx, uint32_t vb_obj) { + if (vb_obj == 0) { + return false; + } + CtxInfo info; + if (!LookupCtx(ctx, &info)) { + return false; + } + uint32_t gate_w = 0, n = 0; + if (!LoadU32(base, info.entity + (kRopaGate & ~3u), &gate_w) || + !LoadU32(base, info.entity + kRopaCount, &n) || n > 8) { + return false; // unreadable/implausible: never suppress on uncertainty + } + const uint8_t gate = uint8_t(gate_w >> ((3 - (kRopaGate & 3u)) * 8)); + if (gate != 0 && n >= 1) { + for (uint32_t i = 0; i < n; ++i) { + uint32_t target = 0, vb0 = 0, vb1 = 0; + if (LoadU32(base, info.entity + kRopaClothTarget + i * 4, &target) && + target != 0 && + LoadU32(base, info.entity + kRopaVb + i * 8, &vb0) && + LoadU32(base, info.entity + kRopaVb + i * 8 + 4, &vb1) && + (vb0 == vb_obj || vb1 == vb_obj)) { + return false; // still a live cloth target + } + } + } + return true; // mapped entity, garment table does not claim this vb +} + +bool ServeRopaWorld(uint8_t* base, uint32_t ctx, uint32_t vb_obj, + float out_rows[12]) { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ropa_world)) { + return false; + } + CtxInfo info; + if (!LookupCtx(ctx, &info)) { + return false; + } + // Mode authority: the entity's own garment table must claim this VB as + // a live CPU cloth target this tick. Sim-inactive garments (distance/ + // activity toggles) draw SKINNED; serving them a rigid world would + // flip the mode against the post-draw fixup every frame. + bool cpu_garment = false; + { + uint8_t gate = 0; + uint32_t n = 0; + uint32_t gate_w = 0; + if (vb_obj != 0 && + LoadU32(base, info.entity + (kRopaGate & ~3u), &gate_w) && + LoadU32(base, info.entity + kRopaCount, &n) && n >= 1 && n <= 8) { + gate = uint8_t(gate_w >> ((3 - (kRopaGate & 3u)) * 8)); + for (uint32_t i = 0; i < n && !cpu_garment && gate != 0; ++i) { + uint32_t target = 0, vb0 = 0, vb1 = 0; + cpu_garment = + LoadU32(base, info.entity + kRopaClothTarget + i * 4, &target) && + target != 0 && + LoadU32(base, info.entity + kRopaVb + i * 8, &vb0) && + LoadU32(base, info.entity + kRopaVb + i * 8 + 4, &vb1) && + (vb0 == vb_obj || vb1 == vb_obj); + } + } + } + // Tick freshness: the garment-table fields persist after the game stops + // running the cloth job (sleeping/paused sims keep their last target and + // VB pointers), so the table alone misclassifies a sleeping garment as + // CPU-simulated, and its VB holds a STALE drape (the floating shirt). + // DoubleBuffer runs once per garment per COMPLETED sim tick; require it + // within the last few ticks. + if (cpu_garment) { + std::lock_guard lock(g_vb_mu); + const auto it = g_vb_fresh.find(vb_obj); + cpu_garment = it != g_vb_fresh.end() && NowMs() - it->second <= 150; + } + if (!cpu_garment) { + g_wserve_nomode.fetch_add(1, std::memory_order_relaxed); + return false; + } + // Structural proof of a live Matrix44 ((0,0,0,1) tail + bank-gate row + // norms): a despawned entity's recycled memory does not pass both. + if (!ReadWorldRowsChecked(base, info.entity, out_rows)) { + g_wserve_rej.fetch_add(1, std::memory_order_relaxed); + return false; + } + g_wserve.fetch_add(1, std::memory_order_relaxed); + static std::atomic s_logged{0}; + const uint32_t ln = s_logged.fetch_add(1, std::memory_order_relaxed); + if (ln < 8 || (ln & 2047u) == 0) { + REXLOG_INFO( + "native-entity: ropa world SERVED ctx={:08X} cls={} entity={:08X} " + "t=({:.2f},{:.2f},{:.2f}) (n={})", + ctx, ClassName(info.cls), info.entity, out_rows[3], out_rows[7], + out_rows[11], ln); + } + return true; +} + +void EmitStats() { + if (!REXCVAR_GET(skate3_native_render_scene_entity_ident)) { + return; + } + const uint32_t frame = + g_frame_counter.fetch_add(1, std::memory_order_relaxed); + if (frame == 0 || frame % 600 != 0) { + return; + } + size_t ents = 0, ctxs = 0, live = 0; + { + std::lock_guard lock(g_mu); + ents = g_entities.size(); + ctxs = g_ctx.size(); + for (const auto& [addr, rec] : g_entities) { + live += rec.view_refs > 0 ? 1 : 0; + } + } + REXLOG_INFO( + "native-entity: ident[ents={}/{} ctx={} bind={} add={} rmv={} " + "hit={} miss={} lw_ovl={} cls(b/lw/sk/co/cac/aux)={}/{}/{}/{}/{}/{} " + "fade ok/div/bad={}/{}/{} w416 e/n/d={}/{}/{} w352 e/n/d={}/{}/{} " + "ropa unmap={} rfail={} wserve={}/{}/{} wprim={}/{}]", + live, ents, ctxs, g_bind_calls.exchange(0, std::memory_order_relaxed), + g_add_events.exchange(0, std::memory_order_relaxed), + g_rmv_events.exchange(0, std::memory_order_relaxed), + g_item_hit.exchange(0, std::memory_order_relaxed), + g_item_miss.exchange(0, std::memory_order_relaxed), + g_item_lw_overlap.exchange(0, std::memory_order_relaxed), + g_hit_cls[0].exchange(0, std::memory_order_relaxed), + g_hit_cls[1].exchange(0, std::memory_order_relaxed), + g_hit_cls[2].exchange(0, std::memory_order_relaxed), + g_hit_cls[3].exchange(0, std::memory_order_relaxed), + g_hit_cls[4].exchange(0, std::memory_order_relaxed), + g_hit_cls[5].exchange(0, std::memory_order_relaxed), + g_fade_ok.exchange(0, std::memory_order_relaxed), + g_fade_div.exchange(0, std::memory_order_relaxed), + g_fade_bad.exchange(0, std::memory_order_relaxed), + g_w_exact[0].exchange(0, std::memory_order_relaxed), + g_w_near[0].exchange(0, std::memory_order_relaxed), + g_w_div[0].exchange(0, std::memory_order_relaxed), + g_w_exact[1].exchange(0, std::memory_order_relaxed), + g_w_near[1].exchange(0, std::memory_order_relaxed), + g_w_div[1].exchange(0, std::memory_order_relaxed), + g_ropa_unmap.exchange(0, std::memory_order_relaxed), + g_ropa_read_fail.exchange(0, std::memory_order_relaxed), + g_wserve.exchange(0, std::memory_order_relaxed), + g_wserve_rej.exchange(0, std::memory_order_relaxed), + g_wserve_nomode.exchange(0, std::memory_order_relaxed), + g_wprim.exchange(0, std::memory_order_relaxed), + g_wprim_div.exchange(0, std::memory_order_relaxed)); +} + +} // namespace skate3::native_entity diff --git a/src/native/skate3_native_entity.h b/src/native/skate3_native_entity.h new file mode 100644 index 0000000..4aa470b --- /dev/null +++ b/src/native/skate3_native_entity.h @@ -0,0 +1,151 @@ +#pragma once + +// Presentation-entity identity store: +// draw-context -> owning entity for EVERY Sk8::PresentationEntity subclass, +// built from the game's own constant-binding walk. +// +// Write point: Sk8::PresentationEntity::BindConstants (sub_827A6658) EXIT. +// The game binds shader constants BY POINTER, once, near entity +// construction: the base bind walks LOD -> cModelInstance (stride 0x28) -> +// MeshContext (stride 0x50) and pushes {hash, pointer, count} records into +// each mesh's ShaderSystem param scope; cScope::DispatchParams dereferences +// them at draw time to fill the constant registers. Every subclass bind +// (skater / colorized / CAC / LivingWorld / props) calls the base, so one +// exit hook sees every renderable entity's full ctx set with the entity in +// r3: the identity link the draw-time constant bank cannot provide. +// +// Verified bindings (generated code, instruction level): +// - base pushes hash 0x7DDF552B -> &entity+416 (m_MatLtoWTrans, 4 vec4s) +// = the ROPA/rigid world block staged at c191..c194 (main pass) / +// c188..c191 (shadow pass). +// - SkaterPresEntity::BindConstants (sub_82783D68) additionally pushes +// hash 0xD5DF94E7 -> &entity+496 (opacity vector, x = alpha): the same +// hash the LivingWorld classes bind at entity+528. +// - CACPresEntity::BindConstants (sub_82793F70) RE-pushes 0x7DDF552B -> +// &entity+352 (m_MatLtoW, UNtransposed); CAC draws stage a transposed +// block relative to every other class. +// +// This module is currently an OBSERVER: it builds the store and compares +// the entity's direct fields against the values the capture heuristics +// decided on, without serving anything. Serving flips come one at a time +// once the live telemetry proves each field (ident[] stats line). + +#include + +namespace skate3::native_entity { + +// Which BindConstants override reached the base walk, most-derived wins +// (the derived binds run after the base within one virtual call). +enum class EntClass : uint8_t { + kUnknown = 0, // base bind only (world props, misc presentation) + kLivingWorld, // sub_827C1720 (already served by the LW entity store) + kSkater, // sub_82783D68 (Sk8::SkaterPresEntity) + kColorized, // sub_82785260 (ColorizedSkaterPresEntity) + kCac, // sub_82793F70 (CACPresEntity, untransposed world rebind) + kSkaterAux, // sub_82785528 (unnamed class with the skater layout) +}; + +// sub_827A6658 EXIT (guest thread): register every MeshContext the walk +// just bound against the owning entity. Idempotent; re-registration on +// LOD/model changes refreshes the mapping. +void OnBindConstants(uint8_t* base, uint32_t entity); + +// Derived-bind EXIT: tag the entity's class (most-derived override fires +// last, so plain assignment converges on the correct class). +void OnBindClass(uint32_t entity, EntClass cls); + +// RenderPresentation::AddEntityToRenderViews / RmvEntityFrmRenderViews +// (sub_827A6C50 / sub_827A6CE8, r4 = entity): per-entity view refcount, +// the direct scene-membership/lifetime signal. +void OnEntityViewAdd(uint32_t entity); +void OnEntityViewRemove(uint32_t entity); + +// SkaterPresEntity::DoubleBuffer EXIT (sub_82783038, r3 = skater, +// r4 = garment index): runs exactly once per garment per completed cloth +// sim tick: the authoritative "the sim wrote this VB this tick" signal. +// Stamps both double-buffer halves fresh. The garment-table fields +// (m_pModelClothTarget etc.) PERSIST after the game stops running the +// cloth job for an entity, so field checks alone misclassify a sleeping +// garment as CPU-simulated and draw its last (stale, mid-pose) deformed +// VB rigidly, the floating/perpendicular shirt. +void OnRopaDoubleBuffer(uint8_t* base, uint32_t skater, uint32_t index); + +struct CtxInfo { + uint32_t entity = 0; + uint32_t instance = 0; // owning cModelInstance (m_matrices at +0x14) + EntClass cls = EntClass::kUnknown; + bool view_live = false; // Add/Rmv refcount > 0 +}; +bool LookupCtx(uint32_t ctx, CtxInfo* out); + +// Observer probes (render thread, called from the scene builder). +// +// Accepted ROPA rigid world vs the entity's own matrix fields: rows = +// the 3 accepted bank rows (c-register layout, translation in component +// 3), compared against entity+416 and entity+352. +void ObserveRopaWorld(uint8_t* base, uint32_t ctx, const float rows[12]); + +// Per published character item: identity coverage (hit/miss/LW overlap) +// and, for skater-family entities, entity+496 opacity vs the alpha the +// renderer decided to use. +void ObserveCharItem(uint8_t* base, uint32_t ctx, uint32_t family, + bool lw_mapped, float used_alpha); + +// Serve flip 1: the owner entity's world matrix for a ROPA garment whose +// draw-time bank read REFUSED (foreign bank, post-shadow tail clobber, +// torn state - the pending-forever/invisible classes). The observer run +// proved the accepted bank block is a bit-exact dereference of +// entity+416 (m_MatLtoWTrans), so the entity field serves the same +// matrix through every bank state. +// +// MODE AUTHORITY: serving a rigid world implicitly asserts the garment is +// CPU-deformed, and on a refused bank the flag row proves nothing, so +// the serve additionally requires the owning entity's own garment table +// to claim this VB as a live cloth target (m_bSkinningEnabled && +// m_pModelClothTarget[i] != 0 && vb_obj is one of that garment's +// double-buffer halves). Without that proof the caller keeps its refusal +// path (pending + post-draw fixup, which re-reads the authentic flag +// from the mesh's own draw and can resolve the item SKINNED). +// +// Fills rows in the staged c-register layout (3 rows, translation in +// component 3) after structural checks (homogeneous (0,0,0,1) tail row + +// row-norm sanity). +bool ServeRopaWorld(uint8_t* base, uint32_t ctx, uint32_t vb_obj, + float out_rows[12]); + +// True when the ctx maps to an entity whose garment table no longer +// claims vb_obj as a live cloth target; the game DROPPED the garment +// (m_numClothModels back to 0, VB objects freed, memory recycled). The +// entity itself stays alive and its ctxs keep being submitted (drawn +// skinned by the game's own sim-inactive path), so any rigid ropa state +// our caches still hold for the vb is a ghost: a retained mid-sim drape +// paired with a live world tracks the character as a floating garment. +// Unmapped ctx / unreadable fields return false (never suppress on +// uncertainty). +bool RopaGarmentDropped(uint8_t* base, uint32_t ctx, uint32_t vb_obj); + +// The mapped instance's live packed palette (m_matrices, 12 floats/row), +// for resolving a dropped-garment's skinned draw when bank capture fails +// chronically. Returns the row count copied (0 = unmapped/unreadable). +uint32_t ServeInstancePalette(uint8_t* base, uint32_t ctx, float* out, + uint32_t max_rows); + +// Quiet variant for per-render-frame checks (no counters, no logs, no +// garment-table gate): the mapped owner entity's live world block in the +// staged c-register layout, with the same structural sanity checks. +// Used by the post-interpolation identity anchor; the ring lerp can only +// be validated against the entity, since both the published scene and the +// guest draws already carried the correct matrix. +bool ReadEntityWorldRows(uint8_t* base, uint32_t ctx, float out_rows[12]); + +// Tripwire for the primary world serve: on ACCEPTED bank captures the +// entity rows are proven bit-exact equal to the bank block; count any +// divergence (a nonzero count means the mapping or read point drifted, +// and shows up in the ident[] line as wprim=/). +void NoteAcceptedWorldCompare(const float bank_rows[12], + const float ent_rows[12]); + +// Rate-limited ident[] stats line; call once per built frame. +void EmitStats(); + +} // namespace skate3::native_entity diff --git a/src/skate3_native_render.cpp b/src/skate3_native_render.cpp index 46ad664..014f5aa 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_entity.h" #include "native/skate3_native_lw.h" #include "native/skate3_native_v3_shadow.h" #include "skate3_native_scene.h" @@ -472,6 +473,11 @@ void OnFrameEnd(uint8_t* base) { g_immediate = true; g_collect_counter = 0; skate3::native_scene::StartRecording(1); + // Simultaneous screenshot: the recorded scene/draw data alone cannot + // prove what was on screen (render-stage state is not captured); + // the paired PNG anchors every diagnostic to the visible frame. + skate3::screenshot::CaptureWindow(skate3::screenshot::RememberedWindow(), + "f10"); REXLOG_INFO("native-render snapshot: F10, immediate single-frame capture at frame {}", g_frame_index); } @@ -500,6 +506,8 @@ void OnFrameEnd(uint8_t* base) { g_immediate = true; g_collect_counter = 0; skate3::native_scene::StartRecording(1); + skate3::screenshot::CaptureWindow( + skate3::screenshot::RememberedWindow(), "rbx"); REXLOG_INFO( "native-render snapshot: RB+X, immediate single-frame capture " "at frame {}", @@ -790,34 +798,140 @@ extern "C" REX_FUNC(sub_82594488) { // Sk8::SkaterPresEntity::StartJobs: post-call, the per-garment ROPA CPU/GPU // mode fields and deformed-VB double buffer are the frame's live values. +// Bracketed as a pack owner: UpdateBoneTransforms calls inside stamp their +// snapshots with this entity. extern "C" REX_FUNC(sub_827825B0) { const uint32_t skater = ctx.r3.u32; + const uint32_t prev_owner = skate3::native_v3::ExchangePackOwner(skater); __imp__sub_827825B0(ctx, base); + skate3::native_v3::ExchangePackOwner(prev_owner); if (skate3::native_render::Enabled()) { skate3::native_v3::OnRopaStartJobs(base, skater); } } +// Sk8::SkaterPresEntity::DoubleBuffer(garment index): runs once per +// garment per COMPLETED cloth sim tick (the job's output memcpy + buffer +// flip). The identity store's deformed-VB freshness signal: garment-table +// fields persist after the sim stops, so this is the only per-tick proof +// the garment is really CPU-simulated right now. +extern "C" REX_FUNC(sub_82783038) { + const uint32_t skater = ctx.r3.u32; + const uint32_t index = ctx.r4.u32; + __imp__sub_82783038(ctx, base); + if (skate3::native_render::Enabled()) { + skate3::native_entity::OnRopaDoubleBuffer(base, skater, index); + } +} + +// Sk8::SkaterPresEntity::EndJobs: calls PackAndMultiplyMatricesForUpload +// per instance with the tick's final locomotion (the body pose of record). +// Bracketed as a pack owner so those snapshots carry the entity. +extern "C" REX_FUNC(sub_82782818) { + const uint32_t prev_owner = + skate3::native_v3::ExchangePackOwner(ctx.r3.u32); + __imp__sub_82782818(ctx, base); + skate3::native_v3::ExchangePackOwner(prev_owner); +} + +// Skater-family virtual UBT+Pack driver (vtable slot +16): the remaining +// palette-write path (UpdateBoneTransforms + PackAndMultiply outside the +// StartJobs/EndJobs pair). Bracketed as a pack owner. +extern "C" REX_FUNC(sub_82785778) { + const uint32_t prev_owner = + skate3::native_v3::ExchangePackOwner(ctx.r3.u32); + __imp__sub_82785778(ctx, base); + skate3::native_v3::ExchangePackOwner(prev_owner); +} + // Sk8::RenderPresentation::AddEntityToRenderViews / RmvEntityFrmRenderViews // - r3 is the RenderPresentation instance itself: the direct capture source // for the RP pointer (the 0x83063C38/0x83063C60 scene globals read ZERO at -// runtime; the chain assumption did not hold live). +// runtime; the chain assumption did not hold live). r4 is the +// PresentationEntity being (de)registered: the identity store's +// scene-membership/lifetime signal (native/skate3_native_entity.h). extern "C" REX_FUNC(sub_827A6C50) { const uint32_t rp = ctx.r3.u32; + const uint32_t entity = ctx.r4.u32; if (skate3::native_render::Enabled()) { skate3::native_v3::OnRenderPresentation(base, rp); + skate3::native_entity::OnEntityViewAdd(entity); } __imp__sub_827A6C50(ctx, base); } extern "C" REX_FUNC(sub_827A6CE8) { const uint32_t rp = ctx.r3.u32; + const uint32_t entity = ctx.r4.u32; if (skate3::native_render::Enabled()) { skate3::native_v3::OnRenderPresentation(base, rp); + skate3::native_entity::OnEntityViewRemove(entity); } __imp__sub_827A6CE8(ctx, base); } +// Sk8::PresentationEntity::BindConstants (base override): post-call, every +// MeshContext of the entity's current LOD has just received its constant +// param POINTERS (world hash 0x7DDF552B -> &entity+416, etc.; the game +// binds by pointer and dereferences at draw). Every subclass bind calls +// this base, so the exit sees every renderable presentation entity with +// its full ctx set: the ctx -> entity identity write point. +extern "C" REX_FUNC(sub_827A6658) { + const uint32_t entity = ctx.r3.u32; + __imp__sub_827A6658(ctx, base); + if (skate3::native_render::Enabled()) { + skate3::native_entity::OnBindConstants(base, entity); + } +} + +// Derived BindConstants overrides: class tags for the identity store. The +// most-derived override returns last (each calls its parent first), so the +// last tag to land is the entity's concrete class. +extern "C" REX_FUNC(sub_82783D68) { // Sk8::SkaterPresEntity + const uint32_t entity = ctx.r3.u32; + __imp__sub_82783D68(ctx, base); + if (skate3::native_render::Enabled()) { + skate3::native_entity::OnBindClass( + entity, skate3::native_entity::EntClass::kSkater); + } +} + +extern "C" REX_FUNC(sub_82785260) { // ColorizedSkaterPresEntity + const uint32_t entity = ctx.r3.u32; + __imp__sub_82785260(ctx, base); + if (skate3::native_render::Enabled()) { + skate3::native_entity::OnBindClass( + entity, skate3::native_entity::EntClass::kColorized); + } +} + +extern "C" REX_FUNC(sub_82793F70) { // CACPresEntity (untransposed world) + const uint32_t entity = ctx.r3.u32; + __imp__sub_82793F70(ctx, base); + if (skate3::native_render::Enabled()) { + skate3::native_entity::OnBindClass( + entity, skate3::native_entity::EntClass::kCac); + } +} + +extern "C" REX_FUNC(sub_82785528) { // unnamed skater-layout class + const uint32_t entity = ctx.r3.u32; + __imp__sub_82785528(ctx, base); + if (skate3::native_render::Enabled()) { + skate3::native_entity::OnBindClass( + entity, skate3::native_entity::EntClass::kSkaterAux); + } +} + +extern "C" REX_FUNC(sub_827C1720) { // cLivingWorldPresEntity + const uint32_t entity = ctx.r3.u32; + __imp__sub_827C1720(ctx, base); + if (skate3::native_render::Enabled()) { + skate3::native_entity::OnBindClass( + entity, skate3::native_entity::EntClass::kLivingWorld); + } +} + // pegasus::tRModelData::Fixup(void* model, rw::core::arena::ArenaIterator*) // - the rw-arena LOAD-time pointer resolve, fired once per model while its // arena streams in. (The `Unfix(void*, SizeAndAlignment*)` atoms are the diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index e25f8cd..482c6df 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -31,6 +31,7 @@ #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_v3_shadow.h" @@ -769,6 +770,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_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): @@ -1210,6 +1213,20 @@ struct CharRowsInst { uint32_t entity = 0; }; std::unordered_map g_char_rows_cache_inst; +// Entity-keyed lighting rows for ROPA garments only: the sim on/off switch +// swaps between a garment's two renderables (cloth-target vs original +// model, different mesh AND ctx), so the (mesh,ctx) caches never carry +// rows across the switch and the freshly resolved model rendered dark/flat +// until its own capture validated. Both models map to the same owning +// entity in the identity store, and they are the same garment; sharing +// rows across the pair is exact. Garment-to-garment only; body pieces keep +// their per-piece caches. +struct CharRowsEnt { + std::array rows; + uint8_t fam = 0; // donor family: the row layout is family-specific +}; +std::unordered_map g_char_rows_cache_ent; +std::atomic g_char_rows_ent_served{0}; std::atomic g_char_rows_inst_served{0}; // Perspective refreshes committed onto skinned-published ROPA pieces (the // caster_refresh_all fix; see the fixup's candidate selection). @@ -1267,6 +1284,12 @@ struct RopaResolvedState { bool skinned = false; float world[16] = {}; std::vector bones; + // Publish frame of the cached state: the rescues below exist to bridge + // 1-3 frame capture gaps, and re-publishing an unbounded-age state pins a + // ghost of the garment at a stale pose for as long as captures keep + // failing (the frozen-in-the-air garment behind a skater whose capture + // path was refusing every frame). + uint64_t frame = 0; }; std::unordered_map g_ropa_state_cache; std::atomic g_ropa_rescued{0}; @@ -1576,6 +1599,11 @@ std::atomic g_ropa_blend_miss{0}; // when no previous state existed) because the GPU-resident decode still // pairs with the other mode; see g_ropa_resident. std::atomic g_ropa_hold{0}; +// Published ropa garments vetoed by the detached-garment gate (transform +// more than ~2 m from every character bone in the frame: a wrong joint's +// or wrong instance's transform that the spread gates cannot reject; the +// veto log names the producing path via dbg_src). +std::atomic g_ropa_detached{0}; // Ropa garments PUBLISHED with a caster-cascade (ortho) bank's palette: // the ~40 ms-stale rows behind the garment-offset-from-body symptom. The // same-mode graft in the dyn_slot merge should keep this at ~0 whenever a @@ -1807,6 +1835,26 @@ uint32_t BankPaletteBase(uint8_t* base, uint32_t bank) { } 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 @@ -1821,18 +1869,28 @@ uint32_t BankPaletteBase(uint8_t* base, uint32_t bank) { const float dz = LoadGuestF32(base, bank + 18 * 4) - g_fog_cam[2]; if (dx * dx + dy * dy + dz * dz < 25.0f && (g_fog_cam[0] != 0.0f || g_fog_cam[1] != 0.0f || g_fog_cam[2] != 0.0f)) { - if ((param_row_at(7) || ropa_flag_row_at(7)) && bone_at(8)) return 8; + 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-z flag check must run BEFORE bone_at(7): on a pre-pass + // 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)) && bone_at(5)) return 5; + 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)) && bone_at(8)) return 8; + if ((param_row_at(7) || ropa_flag_row_at(7) || ropa_active_flag_row_at(7)) && + bone_at(8)) { + return 8; + } return 0; } @@ -4461,18 +4519,36 @@ bool CaptureSkinnedState(uint8_t* base, uint32_t bank, uint32_t palette_base, 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] = rows[j * 4 + i]; + item.world[i * 4 + j] = src_rows[j * 4 + i]; } item.world[i * 4 + 3] = 0.0f; - item.world[12 + i] = rows[i * 4 + 3]; + 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); } @@ -4481,8 +4557,28 @@ bool CaptureSkinnedState(uint8_t* base, uint32_t bank, uint32_t palette_base, } // Implausible or off-clip matrix: the bank belongs to another mesh // (the submit-exit capture runs after whatever draw happened to be - // inline). Refuse it; the caller keeps the item pending and the - // post-draw fixup re-captures from this mesh's own draw. + // 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; } @@ -7315,6 +7411,53 @@ void InterpolateDynamicItems(uint8_t* base, FrameScene& scene, double now) { 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; + } + } + } } // ROPA shape pairing: express the EXACT temporal kernel the garment's // world (and the body's bones) were just evaluated with as weights over @@ -8187,7 +8330,8 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { 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()) { + 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; @@ -8225,6 +8369,85 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { item.bones.clear(); } } + // Detached-garment gate: a ropa garment whose transform sits meters + // from EVERY character bone in the frame is wearing another joint's or + // another instance's transform (wrong-sibling pack pick, one-bone-late + // palette, frozen bank block). Each of those sources yields a coherent, + // sane-spread palette that the spread gates above cannot reject; the + // shirt renders rotated/offset beside the body while tracking it. No + // legitimate frame has a garment more than ~2 m from every character, + // so veto the draws (a blink, and the caster pass shares the item) and + // log the source path for diagnosis. + { + // Anchors = every live bone translation of the frame's character + // pieces (bone 0 alone is not enough: a tee's bone 0 is the HEAD + // joint while a body piece's bone 0 can be a foot, nearly 2 m apart + // on one legitimate character). + static std::vector> anchors; // guest thread only + anchors.clear(); + for (const DrawItem& it : scene.items) { + if (!it.skinned || it.ropa || it.pending || it.char_family == 0 || + it.bones.size() < 12 || it.draws.empty()) { + continue; + } + for (size_t b = 0; b + 12 <= it.bones.size() && anchors.size() < 2048; + b += 12) { + const float tx = it.bones[b + 3]; + const float ty = it.bones[b + 7]; + const float tz = it.bones[b + 11]; + if (tx == 0.0f && ty == 0.0f && tz == 0.0f) { + continue; // zeroed tail rows carry no position + } + anchors.push_back({tx, ty, tz}); + } + } + const int n_anchors = int(anchors.size()); + for (DrawItem& item : scene.items) { + if (n_anchors == 0) { + break; + } + if (!item.ropa || item.pending || item.draws.empty()) { + continue; + } + float p[3]; + if (item.skinned && item.bones.size() >= 12) { + p[0] = item.bones[3]; + p[1] = item.bones[7]; + p[2] = item.bones[11]; + } else if (!item.skinned) { + p[0] = item.world[12]; + p[1] = item.world[13]; + p[2] = item.world[14]; + } else { + continue; + } + float best_sq = 1e30f; + for (int a = 0; a < n_anchors && best_sq > 4.0f; ++a) { + const float dx = p[0] - anchors[size_t(a)][0]; + const float dy = p[1] - anchors[size_t(a)][1]; + const float dz = p[2] - anchors[size_t(a)][2]; + const float d = dx * dx + dy * dy + dz * dz; + best_sq = d < best_sq ? d : best_sq; + } + if (best_sq <= 4.0f) { + continue; + } + g_ropa_detached.fetch_add(1, std::memory_order_relaxed); + static std::atomic s_detach_logged{0}; + const uint32_t ln = s_detach_logged.fetch_add(1, std::memory_order_relaxed); + if (ln < 24 || (ln & 511u) == 0) { + REXLOG_INFO( + "native-scene: DETACHED ropa garment vetoed mesh={:08X} " + "ctx={:08X} src={} skinned={} caster={} pos=({:.1f},{:.1f},{:.1f}) " + "nearest_char={:.2f}m (n={})", + item.mesh, item.ctx, item.dbg_src, item.skinned ? 1 : 0, + item.caster_bank ? 1 : 0, p[0], p[1], p[2], std::sqrt(best_sq), + ln); + } + item.draws.clear(); + 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 @@ -8261,6 +8484,7 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { c.skinned = now_skinned; std::memcpy(c.world, item.world, sizeof(c.world)); c.bones = item.bones; + c.frame = g_guest_frame; } } else if (item.skinned && !item.bones.empty() && !item.caster_bank && g_bones_cache.size() < 512) { @@ -8347,8 +8571,48 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { 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()) { + if (rit == g_ropa_state_cache.end() || + g_guest_frame - rit->second.frame > 30) { continue; } scene.items.push_back(*cand); @@ -8438,6 +8702,18 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { 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() && @@ -8445,6 +8721,33 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { 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_INFO( + "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); + } + } } } } @@ -8962,6 +9265,25 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { } } + // 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. + { + for (DrawItem& item : scene.items) { + if (item.char_family == 0 || item.ctx == 0) { + continue; + } + skate3::native_entity::ObserveCharItem(base, item.ctx, + item.char_family, + item.lw_entity != 0, + CharFadeAlpha(item)); + } + skate3::native_entity::EmitStats(); + } + // Bone-palette alternation guard (the high-fps hair/garment flicker, // probe-confirmed: alt[bones] events on fam 2/4/5 pieces while rows/ // coverage/route stayed stable). A served palette that returns BIT-EXACT @@ -10352,6 +10674,12 @@ struct RendererState { // bone ring). struct RopaGen { uint64_t seq = 0; + // Host time at commit: the blend refuses to mix generations recorded + // across a sim-sleep gap (a garment whose cloth sim slept keeps its + // last drapes in the ring; blending one of those, recorded when the + // character stood elsewhere / faced differently, against the fresh + // drape renders the garment rotated/offset from the body). + double t = 0.0; std::vector verts; // num_verts x 14 floats (scene VS layout) }; std::unordered_map> ropa_shapes; @@ -14938,7 +15266,12 @@ void PrewarmCommit(const NativeGuestOutputRenderContext& context, if (r.item.ropa && r.buffers.dyn_seq != 0 && !superseded && !r.buffers.ropa_verts.empty()) { auto& ring = g_r.ropa_shapes[r.item.mesh]; - ring.push_back({r.buffers.dyn_seq, std::move(r.buffers.ropa_verts)}); + const double now_s = + std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + ring.push_back( + {r.buffers.dyn_seq, now_s, std::move(r.buffers.ropa_verts)}); // 16 generations = ~114 ms at a 140 Hz guest: the 8-tap boxcar // kernel reaches filter_w/2 (~25 ms) past the play clock (itself // ~2 guest periods behind), plus decode-latency slack. @@ -16716,7 +17049,7 @@ void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn, "native-scene: frame {} items={} draws={} draws_2d={} drawn_2d={} " "splines[{}/{}] " "2d[other={} dropped={} askip={} astale={} textures={}] cached_meshes={} textures={} " - "vs_uploads={} palettes={} palette_base_plus1={} ropa[rigid={} stale={} rescued={} flip={} mismatch={} relax={} hold={} caster={} incoh={} stretch={} blend={} blendmiss={}] dyn_gap={} skinned={} skinned_skipped={} foreign_bank={} " + "vs_uploads={} palettes={} palette_base_plus1={} ropa[rigid={} stale={} rescued={} flip={} mismatch={} relax={} hold={} caster={} incoh={} stretch={} detach={} blend={} blendmiss={}] dyn_gap={} skinned={} skinned_skipped={} foreign_bank={} " "rigid[pending={} dropped={} worldprops={}] " "rej[dyn={} range={} chain={} geom={} draws={} bbox={}] " "rr[decode_fail={} no_bones={} mesh_deferred={} tex_deferred={}] " @@ -16736,7 +17069,7 @@ void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn, g_ropa_rigid.load(), g_ropa_stale.load(), g_ropa_rescued.load(), g_ropa_flip.load(), g_ropa_mismatch.load(), g_ropa_relaxed.load(), g_ropa_hold.load(), g_ropa_caster.load(), g_pub_incoherent.load(), - g_stretch_veto.load(), + g_stretch_veto.load(), g_ropa_detached.load(), g_ropa_blend_drawn.load(), g_ropa_blend_miss.load(), g_dyn_gap.load(), g_skinned_items.load(), g_skinned_skipped.load(), g_capture_foreign_bank.load(), @@ -18425,7 +18758,15 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat const size_t want_floats = size_t(buffers.vb_view.size_bytes) / sizeof(float); const auto rit = g_r.ropa_shapes.find(item.mesh); - if (rit != g_r.ropa_shapes.end()) { + if (rit != g_r.ropa_shapes.end() && !rit->second.empty()) { + // Sim-sleep gate: generations grossly older than the ring's newest + // were recorded before a cloth-sim sleep (continuous streams span + // ~114 ms end to end). Blending one against the fresh drape draws + // the garment where/how the character USED to stand, the + // detached/perpendicular class. Dropped generations shed their + // kernel weight; if too much weight is stale the raw resident VB + // draws instead (self-consistent current shape). + const double ring_newest_t = rit->second.back().t; for (int k = 0; k < item.shape_count; ++k) { for (const RendererState::RopaGen& g : rit->second) { if (g.seq != item.shape_seq[k]) { @@ -18434,6 +18775,19 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat if (g.verts.size() != want_floats) { break; // stale-size generation (re-stream/outfit swap) } + if (ring_newest_t - g.t > 0.35) { + static std::atomic s_stale{0}; + const uint32_t ln = + s_stale.fetch_add(1, std::memory_order_relaxed); + if (ln < 16 || (ln & 1023u) == 0) { + REXLOG_INFO( + "native-scene: ropa shape STALE gen dropped mesh={:08X} " + "age={:.2f}s seq_gap={} (n={})", + item.mesh, ring_newest_t - g.t, + rit->second.back().seq - g.seq, ln); + } + break; + } gv[ng] = &g.verts; gw[ng] = item.shape_w[k]; total += item.shape_w[k];