diff --git a/src/AcDream.App/Rendering/FrameProfiler.cs b/src/AcDream.App/Rendering/FrameProfiler.cs deleted file mode 100644 index ce246725..00000000 --- a/src/AcDream.App/Rendering/FrameProfiler.cs +++ /dev/null @@ -1,219 +0,0 @@ -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); - } - } - - // THROWAWAY per-frame CPU sub-phase timing for DrawInside. Gated on - // ACDREAM_FPS_PROF in {1,2}; run under =2 (no glFinish) so the DRAW phases - // measure pure CPU submission cost, not GPU. Read+cleared each Flush. - internal static readonly bool CpuPhaseEnabled = - Environment.GetEnvironmentVariable("ACDREAM_FPS_PROF") is "1" or "2"; - private static readonly Dictionary _cpuPhase = new(); - private static readonly object _cpLock = new(); - internal static void AddCpuPhase(string name, double ms) - { - lock (_cpLock) - { - var cur = _cpuPhase.TryGetValue(name, out var v) ? v : (sum: 0.0, n: 0); - _cpuPhase[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(); - } - } - - lock (_cpLock) - { - if (_cpuPhase.Count > 0) - { - var parts = _cpuPhase - .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("[CPU-PHASE] (DrawInside CPU ms/frame; =2 no-glFinish) " + string.Join(" ", parts)); - _cpuPhase.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 6d599b95..8910e919 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -62,20 +62,6 @@ public sealed class GameWindow : IDisposable private double _lastFps = 60.0; private double _lastFrameMs = 16.7; - // THROWAWAY FPS-investigation apparatus (2026-06-23): - // frame-level CPU/GPU/wall profiler to find the dense-town bottleneck. See - // FrameProfiler. Lazy-constructed on first OnRender once _gl is live. - // ACDREAM_FPS_PROF=1 — whole-frame query + per-pass glFinish brackets - // ([PASS-GPU]); the glFinish SERIALIZES the pipe so cpuRender/gpu/wall - // collapse together — relative per-pass attribution only. - // ACDREAM_FPS_PROF=2 — whole-frame TimeElapsed query ONLY, NO per-pass - // glFinish (PassGpuEnabled stays "1"-gated). This is the honest CPU-vs-GPU - // split: gpu≈wall+present>0 ⇒ GPU-bound; cpuRender≈wall+present≈0 ⇒ CPU-bound. - private readonly bool _fpsProf = - Environment.GetEnvironmentVariable("ACDREAM_FPS_PROF") is "1" or "2"; - 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 @@ -990,8 +976,6 @@ 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 { Size = new Vector2D(1280, 720), @@ -7628,11 +7612,6 @@ 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. @@ -8367,14 +8346,6 @@ 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 @@ -9427,11 +9398,6 @@ 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++; @@ -10798,15 +10764,9 @@ 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/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index c5f5f453..e47fc338 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -123,11 +123,6 @@ public sealed unsafe class ParticleRenderer : IDisposable ParticleRenderPass renderPass = ParticleRenderPass.Scene, Func? emitterFilter = null) { - // THROWAWAY FPS apparatus: glFinish-bracket particle GPU (ACDREAM_FPS_PROF). - System.Diagnostics.Stopwatch? _pSw = null; - if (AcDream.App.Rendering.FrameProfiler.PassGpuEnabled) { _gl.Finish(); _pSw = System.Diagnostics.Stopwatch.StartNew(); } - try - { if (particles is null || camera is null) return; @@ -173,15 +168,6 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.BindVertexArray(0); _gl.DepthMask(true); _gl.Disable(EnableCap.Blend); - } - finally - { - if (_pSw is not null) - { - _gl.Finish(); - AcDream.App.Rendering.FrameProfiler.AddRendererGpu("particles", _pSw.Elapsed.TotalMilliseconds); - } - } } private List BuildDrawList( diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index 79a48d2c..4f0a6436 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -208,12 +208,6 @@ void main() { } // depth-only: color writes are masked off by the caller state ReadOnlySpan planes, bool forceFarZ) { - // THROWAWAY FPS apparatus: glFinish-bracket each punch/seal fan (accumulated - // into "punchseal") under ACDREAM_FPS_PROF. - System.Diagnostics.Stopwatch? _fSw = null; - if (AcDream.App.Rendering.FrameProfiler.PassGpuEnabled) { _gl.Finish(); _fSw = System.Diagnostics.Stopwatch.StartNew(); } - try - { if (worldVerts.Length < 3) return; int n = Math.Min(worldVerts.Length, MaxFanVerts); @@ -311,15 +305,6 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.CullFace(TriangleFace.Back); _gl.FrontFace(FrontFaceDirection.CW); _gl.UseProgram(0); - } - finally - { - if (_fSw is not null) - { - _gl.Finish(); - AcDream.App.Rendering.FrameProfiler.AddRendererGpu("punchseal", _fSw.Elapsed.TotalMilliseconds); - } - } } public void Dispose() diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 0038c896..2819c3c5 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -65,20 +65,6 @@ public sealed class RetailPViewRenderer { ArgumentNullException.ThrowIfNull(ctx); - // THROWAWAY CPU sub-phase timing (ACDREAM_FPS_PROF=2 clean-split): attribute - // which CPU phase of DrawInside eats the dense-town frame. Strip with the FPS - // apparatus (plan Task 5). Zero-alloc: GetTimestamp/GetElapsedTime + a direct- - // call local fn (struct closure over _ts, never a delegate). - long _ts = System.Diagnostics.Stopwatch.GetTimestamp(); - FrameProfiler.MarkGpu("_start"); // GPU timestamp baseline (=2; CPU-only phases diff ~0) - void Phase(string n) - { - if (FrameProfiler.CpuPhaseEnabled) - FrameProfiler.AddCpuPhase(n, System.Diagnostics.Stopwatch.GetElapsedTime(_ts).TotalMilliseconds); - FrameProfiler.MarkGpu(n); - _ts = System.Diagnostics.Stopwatch.GetTimestamp(); - } - var pvFrame = PortalVisibilityBuilder.Build( ctx.RootCell, ctx.ViewerEyePos, @@ -115,11 +101,9 @@ public sealed class RetailPViewRenderer && ctx.NearbyBuildingCells is not null && pvFrame.OutsideView.Polygons.Count > 0) BuildInteriorRootLookIns(ctx, pvFrame); - Phase("flood"); var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); UploadClipFrame(ctx.SetTerrainClipUbo); - Phase("assemble"); // R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the // assembler handed a clip-slot. This feeds the Prepare filter + entity partition, @@ -152,7 +136,6 @@ public sealed class RetailPViewRenderer centerLbX: ctx.RenderCenterLbX, centerLbY: ctx.RenderCenterLbY, renderRadius: ctx.RenderRadius); - Phase("prepare"); var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries); var result = new RetailPViewFrameResult @@ -206,18 +189,12 @@ public sealed class RetailPViewRenderer } } - Phase("partition"); DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone); - Phase("landscape"); UseIndoorMembershipOnlyRouting(); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - Phase("portalmask"); DrawEnvCellShells(pvFrame); - Phase("shells"); DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); - Phase("cellobjects"); DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode); - Phase("dynamics"); return result; } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index f7b13aa4..0a65655b 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -836,18 +836,6 @@ 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; @@ -1042,15 +1030,6 @@ 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 deleted file mode 100644 index 105de5da..00000000 --- a/tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs +++ /dev/null @@ -1,237 +0,0 @@ -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}%"; -}