diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 867b9c1c..d2a71d44 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5610,6 +5610,15 @@ public sealed class GameWindow : IDisposable // Phase 2d: static objects inside the EnvCell. foreach (var stab in envCell.StaticObjects) { + // #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY- + // targeted stabs. This is the MOMENT MeshRefs are constructed — + // a degraded dat read here (setup null / placement frames short / + // part GfxObj null) permanently corrupts the entity (H-A), and + // nothing downstream ever rebuilds it. Inert when the set is empty. + bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics + .DumpEntitySourceIds.Contains(stab.Id); + int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; + var meshRefs = new List(); if ((stab.Id & 0xFF000000u) == 0x01000000u) { @@ -5620,6 +5629,10 @@ public sealed class GameWindow : IDisposable _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); } + else if (dumpStab) + { + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped"); + } } else if ((stab.Id & 0xFF000000u) == 0x02000000u) { @@ -5628,18 +5641,39 @@ public sealed class GameWindow : IDisposable { _physicsDataCache.CacheSetup(stab.Id, setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + if (dumpStab) + { + dumpSetupParts = setup.Parts.Count; + dumpPlacementFrames = setup.PlacementFrames.Count; + dumpFlattened = flat.Count; + } foreach (var mr in flat) { var gfx = _dats.Get(mr.GfxObjId); - if (gfx is null) continue; + if (gfx is null) + { + dumpDropped++; + if (dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped"); + continue; + } _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(mr); } } + else if (dumpStab) + { + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped"); + } } - if (meshRefs.Count == 0) continue; + if (meshRefs.Count == 0) + { + if (dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 -> entity dropped"); + continue; + } // Stabs inside EnvCells are already in landblock-local coordinates // (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would @@ -5656,6 +5690,21 @@ public sealed class GameWindow : IDisposable MeshRefs = meshRefs, ParentCellId = envCellId, }; + + if (dumpStab) + { + Console.WriteLine( + $"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " + + $"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " + + $"built={meshRefs.Count} dropped={dumpDropped} " + + $"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})"); + for (int i = 0; i < meshRefs.Count; i++) + { + var t = meshRefs[i].PartTransform.Translation; + Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})"); + } + } + result.Add(hydrated); } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 17a4e259..4b36c689 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -251,6 +251,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly HashSet _missRequested = new(); private readonly HashSet _missLogged = new(); + // #119 decisive probe (2026-06-11): ACDREAM_DUMP_ENTITY one-shot entity + // dump. Keyed by entity Id; the stored signature re-emits the header line + // whenever (MeshRefs count, cache batch count, zero-translation count, + // culled) changes — e.g. the Tier-1 populate landing one frame after the + // first slow-path draw. The full per-part listing prints only on first + // sight. Inert (one Count==0 check per new entity) when the env var is + // unset. Render-thread only. + private readonly Dictionary _entityDumpSig = new(); + + // Rate limiter for [dump-entity] WALK-REJECT lines: a rejected entity + // re-tests every frame; emit the first rejection per entity then every + // 300th (~5 s at 60 fps). Static because WalkEntitiesInto is static; + // render-thread only like the walk itself. + private static readonly Dictionary _walkRejectCounts = new(); + // CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1. private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new(); private readonly long[] _cpuSamples = new long[256]; // microseconds @@ -662,6 +677,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (!cellInVis) { if (shellScoped) result.BuildingShellAnchorReject++; + MaybeEmitWalkRejectDump(entity, "visibleCellIds-miss"); if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled && indoorProbeState!.ShouldEmit(cellProbeId)) { @@ -687,6 +703,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (!aabbVisible) { + MaybeEmitWalkRejectDump(entity, "frustum"); if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled && indoorProbeState!.ShouldEmit(cellProbeId)) { @@ -719,6 +736,94 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } + /// + /// #119 decisive probe: rate-limited [dump-entity] WALK-REJECT line + /// for an ACDREAM_DUMP_ENTITY-targeted entity that the walk filtered + /// out (visibleCellIds gate / per-entity frustum). Absence of any DRAW dump + /// plus presence of these lines attributes "entity exists but never reaches + /// the draw loop" to the specific gate. Inert when the target set is empty. + /// + private static void MaybeEmitWalkRejectDump(WorldEntity entity, string reason) + { + var targets = RenderingDiagnostics.DumpEntitySourceIds; + if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return; + _walkRejectCounts.TryGetValue(entity.Id, out int n); + _walkRejectCounts[entity.Id] = n + 1; + if (n % 300 != 0) return; + Console.WriteLine( + $"[dump-entity] WALK-REJECT id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " + + $"reason={reason} parentCell=0x{(entity.ParentCellId ?? 0u):X8} " + + $"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) n={n + 1}"); + } + + /// + /// #119 decisive probe: per-entity state dump at draw time for + /// ACDREAM_DUMP_ENTITY-targeted entities. First sight prints a + /// header + every MeshRef's GfxObj id, part-transform translation, and + /// loaded flag; afterwards a compact header re-emits only when the + /// (meshRefs, cacheBatches, zeroTranslations, culled) signature changes. + /// Discriminates H-A (hydration-time MeshRef corruption: translations + /// collapsed to ~zero / missing parts) from H-B (Tier-1 cache holding a + /// partial or stale batch set) from H-C (both healthy ⇒ draw-side compose). + /// + private void MaybeEmitEntityDump(WorldEntity entity, uint landblockId, bool culled) + { + var targets = RenderingDiagnostics.DumpEntitySourceIds; + if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return; + + var refs = entity.MeshRefs; + int zeroT = 0; + float tzMin = float.MaxValue, tzMax = float.MinValue; + for (int i = 0; i < refs.Count; i++) + { + var t = refs[i].PartTransform.Translation; + if (t.LengthSquared() < 1e-9f) zeroT++; + if (t.Z < tzMin) tzMin = t.Z; + if (t.Z > tzMax) tzMax = t.Z; + } + + int cacheBatches = -1; + int restZero = 0; + float rzMin = float.MaxValue, rzMax = float.MinValue; + if (_cache.TryGet(entity.Id, landblockId, out var cacheEntry)) + { + cacheBatches = cacheEntry!.Batches.Length; + foreach (var b in cacheEntry.Batches) + { + var t = b.RestPose.Translation; + if (t.LengthSquared() < 1e-9f) restZero++; + if (t.Z < rzMin) rzMin = t.Z; + if (t.Z > rzMax) rzMax = t.Z; + } + } + + var sig = (refs.Count, cacheBatches, zeroT, culled); + bool first = !_entityDumpSig.TryGetValue(entity.Id, out var prev); + if (!first && prev == sig) return; + _entityDumpSig[entity.Id] = sig; + + string cacheStr = cacheBatches < 0 + ? (_tier1CacheDisabled ? "disabled" : "miss") + : $"hit:{cacheBatches} restZero={restZero} restZ=[{rzMin:F2}..{rzMax:F2}]"; + Console.WriteLine( + $"[dump-entity] DRAW{(first ? "" : "-CHANGED")} id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " + + $"lb=0x{landblockId:X8} cell=0x{(entity.ParentCellId ?? 0u):X8} " + + $"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) scale={entity.Scale:F2} " + + $"meshRefs={refs.Count} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}"); + + if (first) + { + for (int i = 0; i < refs.Count; i++) + { + var mr = refs[i]; + var t = mr.PartTransform.Translation; + bool loaded = _meshAdapter.TryGetRenderData(mr.GfxObjId) is not null; + Console.WriteLine( + $"[dump-entity] part[{i:D2}] gfx=0x{mr.GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3}) loaded={loaded}"); + } + } + } + public void Draw( ICamera camera, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, @@ -920,6 +1025,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _cellIdToSlot, _outdoorSlot, _outdoorVisible); if (_currentEntityCulled) probeCulledEntities++; + + // #119 decisive probe: one-shot dump (+ change re-emission) for + // ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue + // so a routed-out entity still reports its state. + MaybeEmitEntityDump(entity, landblockId, _currentEntityCulled); } prevTupleEntityId = entity.Id; diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 49302d9c..9c02119b 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -373,6 +373,47 @@ public static class RenderingDiagnostics /// public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u; + /// + /// #119 tower-staircase decisive probe (2026-06-11). Comma-separated + /// Setup / GfxObj source ids (hex, optional 0x prefix) from + /// ACDREAM_DUMP_ENTITY. Any WorldEntity whose + /// SourceGfxObjOrSetupId is in this set emits: + /// (a) a [dump-entity] HYDRATE dump at MeshRef construction time + /// (GameWindow.BuildInteriorEntitiesForStreaming) — per-part + /// placement-frame translations + dropped-part accounting — discriminating + /// hydration-time corruption (H-A: SetupMesh.Flatten identity fallback / + /// silent gfx-null part drops under degraded dat reads); + /// (b) a [dump-entity] DRAW dump in WbDrawDispatcher at first + /// draw — live MeshRefs translations + Tier-1 classification cache state — + /// re-emitted compactly whenever that state changes (H-B: stale/partial + /// cached batch set); and + /// (c) rate-limited [dump-entity] WALK-REJECT lines when the + /// dispatcher's walk filters the entity out (absence-of-draw attribution). + /// Empty set = probe off; every call site early-outs on Count == 0. + /// + public static IReadOnlySet DumpEntitySourceIds { get; } = + ParseDumpEntityIds(Environment.GetEnvironmentVariable("ACDREAM_DUMP_ENTITY")); + + /// + /// Parse the ACDREAM_DUMP_ENTITY value: comma-separated hex ids, + /// optional 0x prefix, whitespace tolerated, malformed segments ignored + /// (probes are forgiving — a typo'd segment must not take the launch down). + /// Internal for unit tests. + /// + internal static IReadOnlySet ParseDumpEntityIds(string? raw) + { + var set = new HashSet(); + if (string.IsNullOrWhiteSpace(raw)) return set; + foreach (var seg in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var s = seg.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? seg[2..] : seg; + if (uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var id)) + set.Add(id); + } + return set; + } + /// /// The top-level render branch: should this frame run the indoor (DrawInside) path? /// diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index 86ff7ac8..eaf0a5c2 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -139,4 +139,48 @@ public sealed class RenderingDiagnosticsTests // Env var is absent in the test host, so the flag must default false (inert probe). Assert.False(AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled); } + + // ── ACDREAM_DUMP_ENTITY parser (#119 decisive probe) ────────────────── + + [Fact] + public void ParseDumpEntityIds_NullOrEmpty_ReturnsEmptySet() + { + Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds(null)); + Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds("")); + Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds(" ")); + } + + [Fact] + public void ParseDumpEntityIds_CommaSeparatedWithPrefixes_ParsesAll() + { + var set = RenderingDiagnostics.ParseDumpEntityIds("0x020003F2,0x020005D8"); + Assert.Equal(2, set.Count); + Assert.Contains(0x020003F2u, set); + Assert.Contains(0x020005D8u, set); + } + + [Fact] + public void ParseDumpEntityIds_NoPrefixMixedCaseAndWhitespace_Parses() + { + var set = RenderingDiagnostics.ParseDumpEntityIds(" 020003f2 , 0X020005D8 "); + Assert.Equal(2, set.Count); + Assert.Contains(0x020003F2u, set); + Assert.Contains(0x020005D8u, set); + } + + [Fact] + public void ParseDumpEntityIds_MalformedSegment_IgnoredNotFatal() + { + // A typo'd segment must not take the launch down — parse what's valid. + var set = RenderingDiagnostics.ParseDumpEntityIds("0x020003F2,zzz,,0x"); + Assert.Single(set); + Assert.Contains(0x020003F2u, set); + } + + [Fact] + public void DumpEntitySourceIds_DefaultsEmpty_WhenEnvUnset() + { + // Env var is absent in the test host → probe inert. + Assert.Empty(RenderingDiagnostics.DumpEntitySourceIds); + } }