From e27923b4b5d3022a74a44e2d7a9b7d3a1169941d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 15:49:18 +0200 Subject: [PATCH] chore(diag): dense-town FPS profiling apparatus (ACDREAM_FPS_PROF) [throwaway] FrameProfiler (frame/update/render/present/gpu split + per-renderer glFinish attribution) + OnRender/OnUpdate hooks + terrain & EnvCell glFinish timers + the degrade-coverage probe test. Used to root-cause the dense-town FPS; STRIP when the fix lands (mirrors 92e95be). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/FrameProfiler.cs | 191 ++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 33 +++ .../Rendering/Wb/EnvCellRenderer.cs | 21 ++ .../Conformance/DegradeCoverageProbeTests.cs | 237 ++++++++++++++++++ 4 files changed, 482 insertions(+) create mode 100644 src/AcDream.App/Rendering/FrameProfiler.cs create mode 100644 tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs diff --git a/src/AcDream.App/Rendering/FrameProfiler.cs b/src/AcDream.App/Rendering/FrameProfiler.cs new file mode 100644 index 00000000..7d7df179 --- /dev/null +++ b/src/AcDream.App/Rendering/FrameProfiler.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// THROWAWAY perf apparatus (2026-06-23) for the dense-town FPS investigation. +/// Gated on ACDREAM_FPS_PROF=1. Answers the first fork — is the frame +/// CPU-render-bound, GPU-bound, or wait/vsync-bound — by measuring three numbers +/// per frame and printing their distribution every ~1 s: +/// +/// +/// wall: the real frame period the user sees (Silk's inter-frame +/// delta). 1000/wall = the title-bar FPS. +/// cpuRender: wall-clock CPU time spent inside OnRender +/// (the render-thread submission cost — walk, group-build, draw issue). +/// gpu: GPU time for the whole frame's GL work, via a +/// TimeElapsed query bracketing the frame (ring of 3 for async readback, +/// mirroring WbDrawDispatcher's #125-safe pattern). +/// +/// +/// Interpretation: gpu≈wall ⇒ GPU-bound (then break down by pass); cpuRender≈wall +/// & gpu≪ ⇒ CPU-render-bound (draw-call submission / batching / the walk); +/// wall≫both ⇒ wait/vsync-bound (the 60→30 quantization cliff — confirm via the +/// printed vsync state). Also prints live vsync + MSAA sample count. +/// +/// Do NOT run alongside ACDREAM_WB_DIAG=1: WB-DIAG opens its own per-pass +/// TimeElapsed queries and GL forbids nesting them inside this frame query. +/// +internal sealed class FrameProfiler +{ + private const int RingDepth = 3; + private const int SampleCap = 512; + + private readonly GL _gl; + private readonly uint[] _gpuQuery = new uint[RingDepth]; + private readonly bool[] _begun = new bool[RingDepth]; + private bool _queriesInit; + private int _frame; + + private readonly Stopwatch _cpuSw = new(); + // Free-running clock to measure the OnUpdate→OnRender gap robustly (no + // EndUpdate hook needed → survives any early-return in OnUpdate). + private readonly Stopwatch _clock = Stopwatch.StartNew(); + private double _updateStartMs; + private double _lastUpdateMs; + + private readonly double[] _wallMs = new double[SampleCap]; + private readonly double[] _cpuMs = new double[SampleCap]; + private readonly double[] _gpuMs = new double[SampleCap]; + private readonly double[] _updMs = new double[SampleCap]; + private readonly double[] _presMs = new double[SampleCap]; + private int _count; + private double _lastGpuMs; // carried forward when a slot isn't ready yet + + private double _accumSeconds; + private int _flushFrames = 1; + private const double FlushSeconds = 1.0; + + private bool _vsync; + private int _msaa; + + public FrameProfiler(GL gl) => _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + // THROWAWAY per-renderer GPU attribution (glFinish-bracketed, set true by + // ACDREAM_FPS_PROF=1). Renderers drain+time their own draw and report here; + // glFinish serializes CPU/GPU so the FRAME time inflates, but each renderer's + // GPU cost is exact while the view is held still. Read+cleared each Flush. + internal static readonly bool PassGpuEnabled = + string.Equals(Environment.GetEnvironmentVariable("ACDREAM_FPS_PROF"), "1", StringComparison.Ordinal); + private static readonly Dictionary _rendererGpu = new(); + private static readonly object _rgLock = new(); + internal static void AddRendererGpu(string name, double ms) + { + lock (_rgLock) + { + var cur = _rendererGpu.TryGetValue(name, out var v) ? v : (sum: 0.0, n: 0); + _rendererGpu[name] = (cur.sum + ms, cur.n + 1); + } + } + + /// Call at the very top of OnUpdate. Stamps the start of the update phase. + public void MarkUpdateStart() => _updateStartMs = _clock.Elapsed.TotalMilliseconds; + + /// Call at the very top of OnRender, before any GL work. + public void BeginFrame() + { + if (!_queriesInit) + { + for (int i = 0; i < RingDepth; i++) _gpuQuery[i] = _gl.GenQuery(); + _queriesInit = true; + } + // OnUpdate ran immediately before this; its duration ≈ render-start − update-start. + double nowMs = _clock.Elapsed.TotalMilliseconds; + _lastUpdateMs = _updateStartMs > 0 ? nowMs - _updateStartMs : 0; + _cpuSw.Restart(); + int slot = _frame % RingDepth; + _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQuery[slot]); + _begun[slot] = true; + } + + /// Call at the very end of OnRender, after all GL work (incl. ImGui). + public void EndFrame(double wallMs, bool vsync, int msaa, double deltaSeconds) + { + _gl.EndQuery(QueryTarget.TimeElapsed); + _cpuSw.Stop(); + double cpuMs = _cpuSw.Elapsed.TotalMilliseconds; + _vsync = vsync; + _msaa = msaa; + + // Read the oldest ring slot (written RingDepth-1 frames ago, now ready). + int readSlot = (_frame + 1) % RingDepth; + if (_begun[readSlot]) + { + _gl.GetQueryObject(_gpuQuery[readSlot], QueryObjectParameterName.ResultAvailable, out int avail); + if (avail != 0) + { + _gl.GetQueryObject(_gpuQuery[readSlot], QueryObjectParameterName.Result, out ulong ns); + _lastGpuMs = ns / 1_000_000.0; + } + } + + // Present/swap/GPU-tail wait = whatever the frame period isn't update or render CPU. + double presentMs = Math.Max(0.0, wallMs - _lastUpdateMs - cpuMs); + + if (_count < SampleCap) + { + _wallMs[_count] = wallMs; + _cpuMs[_count] = cpuMs; + _gpuMs[_count] = _lastGpuMs; + _updMs[_count] = _lastUpdateMs; + _presMs[_count] = presentMs; + _count++; + } + + _frame++; + _accumSeconds += deltaSeconds; + if (_accumSeconds >= FlushSeconds) + { + Flush(); + _accumSeconds = 0; + _count = 0; + } + } + + private void Flush() + { + if (_count == 0) return; + _flushFrames = _count; + var (wP50, wP95, wMin, wMax) = Stats(_wallMs, _count); + var (cP50, cP95, _, cMax) = Stats(_cpuMs, _count); + var (gP50, gP95, _, gMax) = Stats(_gpuMs, _count); + var (uP50, uP95, _, uMax) = Stats(_updMs, _count); + var (pP50, pP95, _, pMax) = Stats(_presMs, _count); + double fpsP50 = wP50 > 0 ? 1000.0 / wP50 : 0; + double fpsLow = wMax > 0 ? 1000.0 / wMax : 0; // worst frame → lowest fps + + Console.WriteLine( + $"[FPS-PROF] n={_count} vsync={(_vsync ? "ON" : "off")} msaa={_msaa}x | " + + $"wall={wP50:F1}/{wP95:F1}/{wMin:F1}/{wMax:F1} ms (fps {fpsP50:F0} p50, {fpsLow:F0} low)\n" + + $" update={uP50:F1}/{uP95:F1} ms (max {uMax:F1}) | " + + $"cpuRender={cP50:F1}/{cP95:F1} ms (max {cMax:F1}) | " + + $"present(wait)={pP50:F1}/{pP95:F1} ms (max {pMax:F1}) | " + + $"gpu={gP50:F1}/{gP95:F1} ms (max {gMax:F1})"); + + lock (_rgLock) + { + if (_rendererGpu.Count > 0) + { + var parts = _rendererGpu + .OrderByDescending(kv => kv.Value.sum) + .Select(kv => $"{kv.Key}={kv.Value.sum / Math.Max(1, _flushFrames):F2}ms/frame (calls/frame={kv.Value.n / (double)Math.Max(1, _flushFrames):F1})"); + Console.WriteLine("[PASS-GPU] (glFinish, frame time inflated) " + string.Join(" ", parts)); + _rendererGpu.Clear(); + } + } + } + + private static (double p50, double p95, double min, double max) Stats(double[] src, int n) + { + var buf = new double[n]; + Array.Copy(src, buf, n); + Array.Sort(buf); + double p50 = buf[(int)(n * 0.50)]; + double p95 = buf[Math.Min(n - 1, (int)(n * 0.95))]; + return (p50, p95, buf[0], buf[n - 1]); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 78141c61..3d37e743 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -62,6 +62,14 @@ public sealed class GameWindow : IDisposable private double _lastFps = 60.0; private double _lastFrameMs = 16.7; + // THROWAWAY FPS-investigation apparatus (2026-06-23, ACDREAM_FPS_PROF=1): + // frame-level CPU/GPU/wall profiler to find the dense-town bottleneck. See + // FrameProfiler. Lazy-constructed on first OnRender once _gl is live. + private readonly bool _fpsProf = + string.Equals(Environment.GetEnvironmentVariable("ACDREAM_FPS_PROF"), "1", StringComparison.Ordinal); + private FrameProfiler? _frameProfiler; + private int _msaaSamples; + // Phase I.2: per-frame counters surfaced through the ImGui DebugPanel // VM closures. Computed once per render pass alongside the frustum // walk + nearest-object scan; the VM closures just read the cached @@ -976,6 +984,7 @@ public sealed class GameWindow : IDisposable var startupDisplay = startupStore.LoadDisplay(); var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); + _msaaSamples = startupQuality.MsaaSamples; // ACDREAM_FPS_PROF apparatus var options = WindowOptions.Default with { @@ -7613,6 +7622,11 @@ public sealed class GameWindow : IDisposable private void OnUpdate(double dt) { + // THROWAWAY FPS apparatus (ACDREAM_FPS_PROF=1): stamp the start of the + // update phase so the profiler can split frame time into update vs render + // vs present. Robust to early-returns below (no matching EndUpdate needed). + if (_fpsProf) _frameProfiler?.MarkUpdateStart(); + // [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick; // flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame // boundary, independent of where in OnUpdate the applies landed) and reset. @@ -8347,6 +8361,14 @@ public sealed class GameWindow : IDisposable private void OnRender(double deltaSeconds) { + // THROWAWAY FPS apparatus (ACDREAM_FPS_PROF=1): bracket the whole frame's + // CPU (this method) + GPU (TimeElapsed query) work. Begin before any GL. + if (_fpsProf && _gl is not null) + { + _frameProfiler ??= new FrameProfiler(_gl); + _frameProfiler.BeginFrame(); + } + // Phase G.1: set the clear color from the current sky's fog // tint so the horizon band continues naturally past the // rendered geometry. Fog blends to this color at max distance @@ -9399,6 +9421,11 @@ public sealed class GameWindow : IDisposable _imguiBootstrap.Render(); } + // THROWAWAY FPS apparatus (ACDREAM_FPS_PROF=1): close the frame's CPU+GPU + // brackets after ALL GL work (incl. ImGui). wall = the real frame period. + if (_fpsProf && _frameProfiler is not null) + _frameProfiler.EndFrame(deltaSeconds * 1000.0, _window!.VSync, _msaaSamples, deltaSeconds); + // Update the window title with performance stats every ~0.5s. _perfAccum += deltaSeconds; _perfFrameCount++; @@ -10765,9 +10792,15 @@ public sealed class GameWindow : IDisposable AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); EnableClipDistances(); + // THROWAWAY FPS apparatus: glFinish-bracket the PER-SLICE terrain draw so + // [PASS-GPU] reports terrain ms/frame + calls/frame (= slice count). This + // is the suspected multiplier: terrain is redrawn fully once per slice. + System.Diagnostics.Stopwatch? _terrGpuSw = null; + if (FrameProfiler.PassGpuEnabled) { _gl!.Finish(); _terrGpuSw = System.Diagnostics.Stopwatch.StartNew(); } _terrainCpuStopwatch.Restart(); _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); _terrainCpuStopwatch.Stop(); + if (_terrGpuSw is not null) { _gl!.Finish(); FrameProfiler.AddRendererGpu("terrain", _terrGpuSw.Elapsed.TotalMilliseconds); } _terrainCpuSamples[_terrainCpuSampleCursor] = (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index a599b814..1f4e4436 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -836,6 +836,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable /// public void Render(WbRenderPass renderPass, HashSet? filter) { + // THROWAWAY FPS apparatus (ACDREAM_FPS_PROF=1): glFinish-bracket this cell + // draw so the profiler can attribute its exact GPU cost. Serializes the + // pipeline (inflates frame time) but isolates cell-draw GPU at a held view. + System.Diagnostics.Stopwatch? _passSw = null; + if (AcDream.App.Rendering.FrameProfiler.PassGpuEnabled) + { + _gl.Finish(); + _passSw = System.Diagnostics.Stopwatch.StartNew(); + } + try + { + // WB EnvCellRenderManager.cs:400: if (!_initialized || _shader is null || _shader.Program == 0) return; @@ -1030,6 +1042,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable System.Console.WriteLine(sb.ToString()); } } + } + finally + { + if (_passSw is not null) + { + _gl.Finish(); + AcDream.App.Rendering.FrameProfiler.AddRendererGpu("cells", _passSw.Elapsed.TotalMilliseconds); + } + } } // --------------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs b/tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs new file mode 100644 index 00000000..105de5da --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.Options; +using DatReaderWriter.Enums; +using DatGfxObj = DatReaderWriter.DBObjs.GfxObj; +using DatGfxObjDegradeInfo = DatReaderWriter.DBObjs.GfxObjDegradeInfo; +using DatSetup = DatReaderWriter.DBObjs.Setup; +using DatRegion = DatReaderWriter.DBObjs.Region; +using DatLandBlock = DatReaderWriter.DBObjs.LandBlock; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// DISTANCE-DEGRADE design probe (2026-06-23, throwaway). Answers the single +/// load-bearing question for the FPS distance-degrade port: do the GfxObjs that +/// a dense town actually DRAWS (procedural scenery + placed stabs) even carry a +/// DIDDegrade table, and if so, at what distance does retail HIDE them +/// (last non-zero slot's MaxDist, since past that the table degrades to +/// gfxobj id 0)? This decides whether the recommended "hide-only" phase 1 is the +/// real FPS win for scenery or a no-op. Output-only — no assertions. Delete after +/// the design lands. Run with the real client dats present (ACDREAM_DAT_DIR). +/// +public sealed class DegradeCoverageProbeTests +{ + private readonly ITestOutputHelper _out; + public DegradeCoverageProbeTests(ITestOutputHelper output) => _out = output; + + private const uint RegionDatId = 0x13000000u; + private const float Inf = float.PositiveInfinity; + + // Per-GfxObj degrade summary. + private readonly record struct GfxDeg(bool HasTable, bool DegradesToHidden, float HideDist, int NumSlots, float Slot0Max); + + [Fact] + public void Probe_DenseTown_Holtburg_DegradeCoverage() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var region = dats.Get(RegionDatId); + if (region is null) { _out.WriteLine("SKIP: Region 0x13000000 missing"); return; } + + // 3x3 landblock neighbourhood centred on Holtburg 0xA9B4 — approximates the + // scenery volume that gets drawn when facing the town. + byte[] xs = { 0xA8, 0xA9, 0xAA }; + byte[] ys = { 0xB3, 0xB4, 0xB5 }; + + // GfxObj-level degrade cache. + var gfxCache = new Dictionary(); + GfxDeg DegOf(uint gfxId) + { + if (gfxCache.TryGetValue(gfxId, out var c)) return c; + var g = dats.Get(gfxId); + GfxDeg r; + if (g is null || !g.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || g.DIDDegrade == 0) + r = new GfxDeg(false, false, Inf, 0, Inf); + else + { + var info = dats.Get(g.DIDDegrade); + if (info is null || info.Degrades.Count == 0) + r = new GfxDeg(false, false, Inf, 0, Inf); + else + { + int n = info.Degrades.Count; + int hideSlot = -1; + for (int i = 0; i < n; i++) + if ((uint)info.Degrades[i].Id == 0u) { hideSlot = i; break; } + float slot0Max = info.Degrades[0].MaxDist; + if (hideSlot < 0) + r = new GfxDeg(true, false, Inf, n, slot0Max); // LOD-only, never hides + else if (hideSlot == 0) + r = new GfxDeg(true, true, 0f, n, slot0Max); // editor-only marker + else + r = new GfxDeg(true, true, info.Degrades[hideSlot - 1].MaxDist, n, slot0Max); + } + } + gfxCache[gfxId] = r; + return r; + } + + // Resolve an object id (Setup 0x02 or GfxObj 0x01) to its part GfxObj ids. + var partsCache = new Dictionary>(); + List PartsOf(uint objId) + { + if (partsCache.TryGetValue(objId, out var c)) return c; + var list = new List(); + if ((objId >> 24) == 0x02u) + { + var s = dats.Get(objId); + if (s is not null) foreach (var p in s.Parts) list.Add((uint)p); + } + else if ((objId >> 24) == 0x01u) + list.Add(objId); + partsCache[objId] = list; + return list; + } + + // Per-entity (object) hide distance = MAX over parts' hide dist (entity is + // fully hidden only when EVERY part has degraded to nothing). A part with no + // table / no hidden slot contributes +inf → entity never fully hides. + var entityHideCache = new Dictionary(); + (float hideDist, bool anyTable) EntityHide(uint objId) + { + if (entityHideCache.TryGetValue(objId, out var c)) return c; + var parts = PartsOf(objId); + float maxHide = 0f; bool anyTable = false; bool sawNonHiding = false; + if (parts.Count == 0) { var e0 = (Inf, false); entityHideCache[objId] = e0; return e0; } + foreach (var pid in parts) + { + var d = DegOf(pid); + if (d.HasTable) anyTable = true; + if (d.DegradesToHidden) maxHide = MathF.Max(maxHide, d.HideDist); + else sawNonHiding = true; + } + float hide = sawNonHiding ? Inf : maxHide; + var e = (hide, anyTable); + entityHideCache[objId] = e; + return e; + } + + // Tally scenery placements (weighted by count) + stabs. + var sceneryCount = new Dictionary(); + var stabCount = new Dictionary(); + int lbWithTerrain = 0; + + foreach (var x in xs) + foreach (var y in ys) + { + uint lbId = ((uint)x << 24) | ((uint)y << 16); + var block = dats.Get(lbId | 0xFFFFu); + if (block is null || block.Terrain is null || block.Terrain.Length == 0) continue; + lbWithTerrain++; + + var spawns = SceneryGenerator.Generate(dats, region, block, lbId); + foreach (var sp in spawns) + sceneryCount[sp.ObjectId] = sceneryCount.GetValueOrDefault(sp.ObjectId) + 1; + + var info = dats.Get(lbId | 0xFFFEu); + if (info?.Objects is not null) + foreach (var stab in info.Objects) + stabCount[stab.Id] = stabCount.GetValueOrDefault(stab.Id) + 1; + } + + _out.WriteLine($"=== Dense-town degrade coverage (3x3 around Holtburg 0xA9B4, {lbWithTerrain}/9 lb w/ terrain) ==="); + + Summarize("PROCEDURAL SCENERY (the bulk)", sceneryCount, EntityHide); + Summarize("PLACED STABS (LandBlockInfo.Objects)", stabCount, EntityHide); + + // GfxObj-level coverage across every unique part seen. + var allObjIds = sceneryCount.Keys.Concat(stabCount.Keys).Distinct(); + var allGfx = allObjIds.SelectMany(PartsOf).Distinct().ToList(); + int gfxWithTable = allGfx.Count(g => DegOf(g).HasTable); + int gfxHides = allGfx.Count(g => DegOf(g).DegradesToHidden); + _out.WriteLine($"\n[GfxObj-level] unique part GfxObjs={allGfx.Count} withDegradeTable={gfxWithTable} ({Pct(gfxWithTable, allGfx.Count)}) degradesToHidden={gfxHides} ({Pct(gfxHides, allGfx.Count)})"); + + // Sample the top scenery objects with their tables. + _out.WriteLine("\n[Top scenery objects by placement count]"); + foreach (var kv in sceneryCount.OrderByDescending(k => k.Value).Take(20)) + { + var (hide, anyTable) = EntityHide(kv.Key); + string hideStr = float.IsPositiveInfinity(hide) ? "NEVER" : $"{hide:F0}m"; + var parts = PartsOf(kv.Key); + string slots = parts.Count > 0 + ? string.Join(",", parts.Take(3).Select(p => { var d = DegOf(p); return d.HasTable ? $"{d.NumSlots}sl/hide={(float.IsPositiveInfinity(d.HideDist) ? "inf" : d.HideDist.ToString("F0"))}" : "noTable"; })) + : "(unresolved)"; + _out.WriteLine($" 0x{kv.Key:X8} x{kv.Value,-5} parts={parts.Count} hideDist={hideStr,-7} [{slots}]"); + } + + // LOD ladder with poly counts for the top 6 scenery objects: quantifies the + // per-slot triangle savings (the LOD-degrade win) and at what distances the + // swaps land. + int PolyCount(uint gfxId) + { + var g = dats.Get(gfxId); + return g?.Polygons?.Count ?? -1; + } + _out.WriteLine("\n[LOD ladder — top scenery objects, per part: slot=(Id polys min/ideal/max mode)]"); + foreach (var kv in sceneryCount.OrderByDescending(k => k.Value).Take(6)) + { + _out.WriteLine($" OBJ 0x{kv.Key:X8} (x{kv.Value}):"); + foreach (var pid in PartsOf(kv.Key).Distinct()) + { + var g = dats.Get(pid); + int basePolys = g?.Polygons?.Count ?? -1; + if (g is null || !g.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || g.DIDDegrade == 0) + { _out.WriteLine($" part 0x{pid:X8} polys={basePolys} : NO TABLE (always full detail)"); continue; } + var info = dats.Get(g.DIDDegrade); + if (info is null || info.Degrades.Count == 0) + { _out.WriteLine($" part 0x{pid:X8} polys={basePolys} : empty table"); continue; } + var ladder = string.Join(" ", info.Degrades.Select((d, i) => + { + uint sid = (uint)d.Id; + int p = sid == 0 ? 0 : PolyCount(sid); + return $"[{i}]={(sid == 0 ? "HIDE" : $"0x{sid:X8}/{p}p")} {d.MinDist:F0}/{d.IdealDist:F0}/{d.MaxDist:F0} m{d.DegradeMode}"; + })); + _out.WriteLine($" part 0x{pid:X8} base{basePolys}p : {ladder}"); + } + } + } + + private void Summarize(string label, Dictionary counts, Func entityHide) + { + int total = counts.Values.Sum(); + if (total == 0) { _out.WriteLine($"\n[{label}] no placements"); return; } + + int placementsWithTable = 0, placementsHide = 0; + var hideBuckets = new int[] { 0, 0, 0, 0, 0, 0 }; // <25, <50, <75, <100, <150, >=150 + int hideTotalPlacements = 0; + foreach (var (id, cnt) in counts) + { + var (hide, anyTable) = entityHide(id); + if (anyTable) placementsWithTable += cnt; + if (!float.IsPositiveInfinity(hide)) + { + placementsHide += cnt; + hideTotalPlacements += cnt; + int b = hide < 25 ? 0 : hide < 50 ? 1 : hide < 75 ? 2 : hide < 100 ? 3 : hide < 150 ? 4 : 5; + hideBuckets[b] += cnt; + } + } + + _out.WriteLine($"\n[{label}] uniqueObjs={counts.Count} totalPlacements={total}"); + _out.WriteLine($" placements whose obj has a degrade table : {placementsWithTable} ({Pct(placementsWithTable, total)})"); + _out.WriteLine($" placements that HIDE at finite distance : {placementsHide} ({Pct(placementsHide, total)})"); + if (hideTotalPlacements > 0) + _out.WriteLine($" hide-distance histogram (placements): <25m={hideBuckets[0]} <50m={hideBuckets[1]} <75m={hideBuckets[2]} <100m={hideBuckets[3]} <150m={hideBuckets[4]} >=150m={hideBuckets[5]}"); + } + + private static string Pct(int n, int d) => d == 0 ? "n/a" : $"{100.0 * n / d:F1}%"; +}