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}%"; }