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) <noreply@anthropic.com>
237 lines
11 KiB
C#
237 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>DIDDegrade</c> table, and if so, at what distance does retail HIDE them
|
|
/// (last non-zero slot's <c>MaxDist</c>, 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).
|
|
/// </summary>
|
|
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<DatRegion>(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<uint, GfxDeg>();
|
|
GfxDeg DegOf(uint gfxId)
|
|
{
|
|
if (gfxCache.TryGetValue(gfxId, out var c)) return c;
|
|
var g = dats.Get<DatGfxObj>(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<DatGfxObjDegradeInfo>(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<uint, List<uint>>();
|
|
List<uint> PartsOf(uint objId)
|
|
{
|
|
if (partsCache.TryGetValue(objId, out var c)) return c;
|
|
var list = new List<uint>();
|
|
if ((objId >> 24) == 0x02u)
|
|
{
|
|
var s = dats.Get<DatSetup>(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<uint, (float hideDist, bool anyTable)>();
|
|
(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<uint, int>();
|
|
var stabCount = new Dictionary<uint, int>();
|
|
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<DatLandBlock>(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<DatLandBlockInfo>(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<DatGfxObj>(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<DatGfxObj>(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<DatGfxObjDegradeInfo>(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<uint, int> counts, Func<uint, (float hideDist, bool anyTable)> 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}%";
|
|
}
|