diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index 55c9331..7098032 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -826,6 +826,18 @@ std::atomic g_ropa_rigid{0}; // or projected the garment off-clip (stale bank from another mesh's draw); // the item stays pending for the post-draw fixup. std::atomic g_ropa_stale{0}; +// ropa garments whose resolved mode CHANGED between frames (skinned <-> +// rigid: the cloth sim toggling with distance/activity). The flip frames +// are where mode/payload races live; see the dyn-job coherence guard. +std::atomic g_ropa_flip{0}; +// SKINNED-mode ropa payload snapshots whose frame-end VB no longer skins at +// bind size (the cloth sim rewrote it with sim-deformed vertices after the +// draw-time capture): the re-decode is skipped so the GPU keeps last +// frame's coherent verts instead of rendering the mangled ribbon. +std::atomic g_ropa_mismatch{0}; +// skinned/ropa meshes that published after a 1-3 frame publish GAP (was on +// screen, vanished, came back): the "flickered invisible" telemetry. +std::atomic g_dyn_gap{0}; std::atomic g_skinned_items{0}; std::atomic g_skinned_skipped{0}; // Rigid items whose transform had to wait for the post-draw fixup (deferred @@ -1736,9 +1748,33 @@ bool BuildItemFromMesh(uint8_t* base, uint32_t mesh, DrawItem& item) { // "livingworld" pedestrian prefix. char mat_name[40]; GuestTryReadString(base, s, mat_name, sizeof(mat_name)); + // character.*_ropa = the Ropa cloth-sim VS variant (flag-row + // switched skinned/rigid, see CaptureSkinnedState). The suffix + // composes with the base family name: cloth_ropa (player tees), + // default_cloth_ropa (NPC jackets), hair_ropa and + // default_hair_ropa all exist in the attrib table; so detect it + // generically and STRIP it, letting the family idioms below see + // the base name. Matching only the player's character.cloth_ropa + // left NPC ropa garments on the generic skinned path, where + // every palette home is refused: the layout's guess register is + // this VS's FLAG row (scores 0), and the true +1 home fails the + // (0,0,0,w)/(1,0,0,0) parameter-row signature because this + // variant's row is (1, junk, junk, junk), the persistent + // invisible-torso NPC (observed: a + // character.default_cloth_ropa mesh refused all three captures every + // frame while the draw-time banks pass the gate at c5/c8). + item.ropa = false; + { + const size_t len = strnlen(mat_name, sizeof(mat_name)); + if (len >= 5 && len < sizeof(mat_name) && + std::memcmp(mat_name, "character.", 10) == 0 && + std::memcmp(mat_name + len - 5, "_ropa", 5) == 0) { + item.ropa = true; + mat_name[len - 5] = '\0'; + } + } item.hair = std::memcmp(mat_name, "character.hair", 15) == 0; item.unlit = std::memcmp(mat_name, "sky.", 4) == 0; - item.ropa = std::memcmp(mat_name, "character.cloth_ropa", 21) == 0; // Character shading family (see DrawItem::char_family). Order // matters: "default_hair" before the "default" prefix, exact // "hair" after (memcmp includes the NUL for exact names). @@ -2701,6 +2737,111 @@ int ScoreRigidAffine(uint8_t* base, uint32_t bank, uint32_t m, const DrawItem& i return n == 0 ? -1 : (ok * 16) / n; } +// Frame-end coherence check for SKINNED-mode ropa payloads (dyn decode +// jobs). The item's mode and palette were captured at draw time, but the +// cloth VB is snapshotted at BuildFrameScene, and the game's cloth sim +// runs concurrently: when it ACTIVATES between the draws and the snapshot +// (skating NPCs toggle with distance, "when he got near"), the copied +// buffer holds sim-deformed root-local vertices, i.e. the ropa VS's OTHER +// branch's input. Skinning those with the palette is the map-length-ribbon +// interpretation (the offline-validated 0/31). Skin the acceptance gate's +// sample vertices from the JUST-COPIED payload and require the same +// bind-size spread the capture gate does; on failure the caller keeps last +// frame's decode (coherent) and retries next frame, when the capture will +// have flipped the mode to match the payload. +bool RopaPayloadCoherent(const DrawItem& item, const std::vector& vb) { + if (item.stride == 0 || item.bones.size() < 12 || item.bw_offset == 0 || + item.bi_offset == 0) { + return true; + } + const uint32_t count = item.vb_bytes / item.stride; + if (count < 2) { + return true; + } + float bind_diag = 0.0f; + for (int a = 0; a < 3; ++a) { + const float d = item.bbox_max[a] - item.bbox_min[a]; + bind_diag += d * d; + } + bind_diag = std::sqrt(bind_diag); + const float max_spread = std::max(3.0f * bind_diag, bind_diag + 1.0f); + float qmin[3] = {1e9f, 1e9f, 1e9f}; + float qmax[3] = {-1e9f, -1e9f, -1e9f}; + int n = 0; + constexpr uint32_t kSamples = 6; + for (uint32_t s = 0; s < kSamples; ++s) { + const uint32_t v = (s * (count - 1) / (kSamples - 1)) * item.stride; + if (uint64_t(v) + item.stride > vb.size()) { + return true; + } + const uint8_t* vp = vb.data() + v; + // Guest payload copied raw = big-endian attributes. + float p[3]; + switch (item.pos_fmt) { + case 57: + for (int a = 0; a < 3; ++a) { + const uint8_t* b = vp + item.pos_offset + a * 4; + const uint32_t w = uint32_t(b[0]) << 24 | uint32_t(b[1]) << 16 | + uint32_t(b[2]) << 8 | b[3]; + p[a] = std::bit_cast(w); + } + break; + case 32: + for (int a = 0; a < 3; ++a) { + const uint8_t* b = vp + item.pos_offset + a * 2; + p[a] = GuestHalfToFloat(uint16_t(uint16_t(b[0]) << 8 | b[1])); + } + break; + case 26: { + constexpr float kScale = 2.0f / 32767.0f; + for (int a = 0; a < 3; ++a) { + const uint8_t* b = vp + item.pos_offset + a * 2; + p[a] = int16_t(uint16_t(b[0]) << 8 | b[1]) * kScale + + (a == 1 ? 0.8f : 0.0f); + } + break; + } + default: + return true; + } + if (!(p[0] > -1e7f && p[0] < 1e7f && p[1] > -1e7f && p[1] < 1e7f && + p[2] > -1e7f && p[2] < 1e7f)) { + return false; // NaN/garbage positions mid-sim-write + } + // u8x4 weights/indices: component k = guest byte k (matches the + // capture path's byte-(24-8k)-of-host-load convention). + const uint8_t* bw = vp + item.bw_offset; + const uint8_t* bi = vp + item.bi_offset; + uint32_t total = 0; + float q[3] = {0.0f, 0.0f, 0.0f}; + for (int k = 0; k < 4; ++k) { + const uint32_t w = bw[k]; + if (w == 0) continue; + const uint32_t r0 = 3u * bi[k]; + if ((r0 + 3) * 4 > item.bones.size()) continue; + total += w; + for (int a = 0; a < 3; ++a) { + const float* row = item.bones.data() + (r0 + uint32_t(a)) * 4; + q[a] += float(w) * (row[0] * p[0] + row[1] * p[1] + row[2] * p[2] + row[3]); + } + } + if (total == 0) continue; + ++n; + for (int a = 0; a < 3; ++a) { + q[a] /= float(total); + qmin[a] = std::min(qmin[a], q[a]); + qmax[a] = std::max(qmax[a], q[a]); + } + } + if (n < 2) { + return true; // nothing to judge + } + const float dx = qmax[0] - qmin[0]; + const float dy = qmax[1] - qmin[1]; + const float dz = qmax[2] - qmin[2]; + return std::sqrt(dx * dx + dy * dy + dz * dz) <= max_spread; +} + // Returns false when the bank could not be consumed for this item (ropa // rigid matrix implausible or off-clip = stale bank); the caller must // leave/mark the item pending so a later matching draw re-captures it. @@ -2788,6 +2929,9 @@ bool CaptureSkinnedState(uint8_t* base, uint32_t bank, uint32_t palette_base, item.skinned = false; item.bones.clear(); g_ropa_rigid.fetch_add(1, std::memory_order_relaxed); + if (item.hair) { + CaptureHairTint(base, item); + } CaptureCharLighting(base, item); return true; } @@ -5608,8 +5752,13 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { if (item.ropa) { // Remember the resolved mode + transform (see g_ropa_state_cache). if (g_ropa_state_cache.size() < 512) { - RopaResolvedState& c = g_ropa_state_cache[item.mesh]; - c.skinned = item.skinned && !item.bones.empty(); + const bool now_skinned = item.skinned && !item.bones.empty(); + auto [cit, fresh] = g_ropa_state_cache.try_emplace(item.mesh); + RopaResolvedState& c = cit->second; + if (!fresh && c.skinned != now_skinned) { + g_ropa_flip.fetch_add(1, std::memory_order_relaxed); + } + c.skinned = now_skinned; std::memcpy(c.world, item.world, sizeof(c.world)); c.bones = item.bones; } @@ -5678,6 +5827,42 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { } } } + // Publish-gap telemetry ("skater flickered invisible for a moment"): a + // skinned/ropa/character mesh that published recently, vanished for 1-3 + // built frames, and came back. Rate-limited log names the mesh and gap so + // the next flicker sighting is diagnosable from the log alone. + { + static std::unordered_map s_last_pub; + static uint64_t s_pub_frame = 0; + ++s_pub_frame; + if (s_last_pub.size() > 4096) { + s_last_pub.clear(); + } + for (const DrawItem& item : scene.items) { + if (!(item.ropa || item.char_family != 0 || + (item.skinned && !item.bones.empty()))) { + continue; + } + auto [it, fresh] = s_last_pub.try_emplace(item.mesh, s_pub_frame); + if (fresh) { + continue; + } + const uint64_t gap = s_pub_frame - it->second; + it->second = s_pub_frame; + if (gap >= 2 && gap <= 4) { + g_dyn_gap.fetch_add(1, std::memory_order_relaxed); + static std::atomic gap_logged{0}; + const uint32_t ln = gap_logged.fetch_add(1, std::memory_order_relaxed); + if (ln < 16 || (ln & 255u) == 0) { + REXLOG_INFO( + "native-scene: dyn publish GAP mesh={:08X} missing {} frame(s) " + "ropa={} skinned={} fam={} src={}", + item.mesh, gap - 1, item.ropa ? 1 : 0, item.skinned ? 1 : 0, + item.char_family, item.dbg_src); + } + } + } + } if (scene.items.empty()) { return; } @@ -6116,11 +6301,20 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { if (!(item.skinned || item.ropa) || item.cloth_quads || item.ib_addr == 0) { continue; } - auto [fit, first_sight] = s_dyn_fp_sent.try_emplace(item.mesh, item.fingerprint); - if (!first_sight && fit->second == item.fingerprint) { + if (item.ropa && item.dbg_src == 4) { + // Rescued ropa re-publishes LAST frame's resolved state (mode + + // palette + world); decoding THIS frame's payload under it mixes + // frames, at a sim flip that is the ribbon. Keep the previous + // decode on the GPU: a fully coherent N-1 garment (the draw path + // tolerates the fingerprint mismatch for dynamic payloads). + continue; + } + const auto prev = s_dyn_fp_sent.find(item.mesh); + const bool first_sight = prev == s_dyn_fp_sent.end(); + const uint64_t prev_fp = first_sight ? 0 : prev->second; + if (!first_sight && prev_fp == item.fingerprint) { continue; } - fit->second = item.fingerprint; DynDecodeJob job; job.item = item; job.item.bones.clear(); @@ -6129,10 +6323,21 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { if (!GuestTryCopy(job.vb.data(), base + item.vb_addr, item.vb_bytes)) { continue; } + if (item.ropa && item.skinned && !item.bones.empty() && + !RopaPayloadCoherent(item, job.vb)) { + // The cloth sim rewrote this VB between the draw-time capture and + // this frame-end snapshot (mode flip in flight): decoding it under + // the skinned palette is the mangled ribbon. Keep the old decode + // this frame; next frame's capture sees the flipped flag and + // resolves mode and payload together. + g_ropa_mismatch.fetch_add(1, std::memory_order_relaxed); + continue; + } job.ib.resize(size_t(item.ib_count) * 2); if (!GuestTryCopy(job.ib.data(), base + item.ib_addr, job.ib.size())) { continue; } + s_dyn_fp_sent[item.mesh] = item.fingerprint; jobs.push_back(std::move(job)); } if (!jobs.empty()) { @@ -12720,7 +12925,7 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat "native-scene: frame {} items={} draws={} draws_2d={} drawn_2d={} " "splines[{}/{}] " "2d[other={} dropped={} textures={}] cached_meshes={} textures={} " - "vs_uploads={} palettes={} palette_base_plus1={} ropa[rigid={} stale={} rescued={}] skinned={} skinned_skipped={} foreign_bank={} " + "vs_uploads={} palettes={} palette_base_plus1={} ropa[rigid={} stale={} rescued={} flip={} mismatch={}] 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={}] " @@ -12732,6 +12937,7 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat g_r.meshes.size(), g_r.textures.size(), g_vs_uploads.load(), g_palette_snapshots.load(), g_palette_base_plus1.load(), g_ropa_rigid.load(), g_ropa_stale.load(), g_ropa_rescued.load(), + g_ropa_flip.load(), g_ropa_mismatch.load(), g_dyn_gap.load(), g_skinned_items.load(), g_skinned_skipped.load(), g_capture_foreign_bank.load(), g_rigid_pending.load(), g_rigid_dropped.load(),