diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index a8009c69..4c7eca99 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -409,7 +409,8 @@ internal sealed class FrameRootCompositionPhase
foundation.MeshAdapter,
foundation.TextureCache,
live.DrawDispatcher,
- d.FrameProfiler);
+ d.FrameProfiler,
+ content.Dats);
lifecycleAutomation =
new WorldLifecycleAutomationController(
() => session.WorldReveal.Snapshot,
diff --git a/src/AcDream.App/Diagnostics/FrameProfiler.cs b/src/AcDream.App/Diagnostics/FrameProfiler.cs
index f2fbb600..f19c2d13 100644
--- a/src/AcDream.App/Diagnostics/FrameProfiler.cs
+++ b/src/AcDream.App/Diagnostics/FrameProfiler.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
+using System.IO;
using System.Text;
using AcDream.Core.Rendering;
using Silk.NET.OpenGL;
@@ -20,6 +22,38 @@ public enum FrameStage
Pacing = 3,
}
+///
+/// One ACDREAM_FRAME_HISTORY CSV row — every field the aggregated
+/// [frame-prof] report discards when its 5-second window resets.
+/// Stage fields mirror positionally (Update /
+/// Upload / ImGui / Pacing, matching 's
+/// names array) — if grows, extend this
+/// record, , and the CSV header
+/// together. GpuUs is -1 for a frame with no available GPU
+/// sample (warm-up, or ACDREAM_WB_DIAG=1 self-disable).
+///
+internal readonly struct FrameHistoryRecord(
+ int frameIndex,
+ double timestampMs,
+ long cpuUs,
+ long gpuUs,
+ long allocBytes,
+ long updateUs,
+ long uploadUs,
+ long imGuiUs,
+ long pacingUs)
+{
+ public readonly int FrameIndex = frameIndex;
+ public readonly double TimestampMs = timestampMs;
+ public readonly long CpuUs = cpuUs;
+ public readonly long GpuUs = gpuUs;
+ public readonly long AllocBytes = allocBytes;
+ public readonly long UpdateUs = updateUs;
+ public readonly long UploadUs = uploadUs;
+ public readonly long ImGuiUs = imGuiUs;
+ public readonly long PacingUs = pacingUs;
+}
+
///
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// FrameBoundary call at the top of GameWindow.OnRender
@@ -35,11 +69,28 @@ public enum FrameStage
/// Permanent apparatus — every MP-track gate reads it; do not strip.
/// Whole-frame GPU timing self-disables under ACDREAM_WB_DIAG=1
/// (nested TimeElapsed is illegal GL; see GpuFrameTimer).
+///
+/// 2026-07-24 measurement-tooling review — the aggregated report
+/// resets its ring buffers every ~5 s (),
+/// so route-wide p50/p95/p99 distributions across a whole soak cannot be
+/// reconstructed after the fact.
+/// (ACDREAM_FRAME_HISTORY=<path>) opts into a SEPARATE
+/// per-frame history: one per frame in a
+/// preallocated, grow-as-needed (1 int + 1 double +
+/// 7 longs ≈ 72 bytes/record; a multi-hour capture at 165 fps is roughly
+/// 165 * 3600 * 72 bytes ≈ 43 MB/hour — fine for a bounded diagnostic run,
+/// not for unattended day-long capture). ZERO frame-thread I/O: the list only grows during
+/// ; the CSV is written once, from
+/// , at shutdown. Recording only takes effect while
+/// is ALSO true — it
+/// reuses that instrumentation rather than duplicating it. Does not
+/// change the [frame-prof] report format or any existing metric.
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
///
public sealed class FrameProfiler : IDisposable
{
private const int WindowCapacity = 2048; // ~12 s at 165 fps
+ private const int HistoryInitialCapacity = 4096;
private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond;
private static readonly int StageCount = Enum.GetValues().Length;
@@ -48,8 +99,12 @@ public sealed class FrameProfiler : IDisposable
private readonly FrameStatsBuffer _allocBytes = new(WindowCapacity);
private readonly FrameStatsBuffer[] _stageUs;
private readonly long[] _stageAccumTicks;
+ private readonly long[] _lastStageUs;
private readonly bool _wbDiagActive =
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1";
+ private readonly List? _history;
+ private readonly long _profilerStartTimestamp;
+ private int _historyFrameIndex;
private GpuFrameTimer? _gpuTimer;
private long _lastBoundaryTimestamp;
@@ -70,6 +125,10 @@ public sealed class FrameProfiler : IDisposable
_stageUs = new FrameStatsBuffer[StageCount];
for (int i = 0; i < StageCount; i++) _stageUs[i] = new FrameStatsBuffer(WindowCapacity);
_stageAccumTicks = new long[StageCount];
+ _lastStageUs = new long[StageCount];
+ _profilerStartTimestamp = Stopwatch.GetTimestamp();
+ if (RenderingDiagnostics.FrameHistoryPath is not null)
+ _history = new List(HistoryInitialCapacity);
}
///
@@ -105,6 +164,8 @@ public sealed class FrameProfiler : IDisposable
long now = Stopwatch.GetTimestamp();
long allocNow = GC.GetAllocatedBytesForCurrentThread();
+ bool historyFrame = false;
+ long historyCpuUs = 0, historyAllocBytes = 0;
if (!_wasEnabled)
{
// First enabled frame (startup or runtime toggle-on): establish
@@ -128,21 +189,42 @@ public sealed class FrameProfiler : IDisposable
{
long cpuUs = (now - _lastBoundaryTimestamp) * 1_000_000L / Stopwatch.Frequency;
_cpuUs.Push(cpuUs);
- _allocBytes.Push(allocNow - _lastAllocBytes);
+ long allocDelta = allocNow - _lastAllocBytes;
+ _allocBytes.Push(allocDelta);
for (int i = 0; i < StageCount; i++)
{
- _stageUs[i].Push(_stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency);
+ long stageUs = _stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency;
+ _stageUs[i].Push(stageUs);
+ _lastStageUs[i] = stageUs;
_stageAccumTicks[i] = 0;
}
_framesInWindow++;
+ historyFrame = _history is not null;
+ historyCpuUs = cpuUs;
+ historyAllocBytes = allocDelta;
}
_lastBoundaryTimestamp = now;
_lastAllocBytes = allocNow;
- if (_gpuTimer?.FrameBoundary() is long gpuUs)
+ long? gpuUsSample = _gpuTimer?.FrameBoundary();
+ if (gpuUsSample is long gpuUs)
_gpuUs.Push(gpuUs);
+ if (historyFrame)
+ {
+ _history!.Add(new FrameHistoryRecord(
+ _historyFrameIndex++,
+ (now - _profilerStartTimestamp) * 1000.0 / Stopwatch.Frequency,
+ historyCpuUs,
+ gpuUsSample ?? -1L,
+ historyAllocBytes,
+ _lastStageUs[(int)FrameStage.Update],
+ _lastStageUs[(int)FrameStage.Upload],
+ _lastStageUs[(int)FrameStage.ImGui],
+ _lastStageUs[(int)FrameStage.Pacing]));
+ }
+
long nowTicks = DateTime.UtcNow.Ticks;
if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0)
{
@@ -200,7 +282,49 @@ public sealed class FrameProfiler : IDisposable
return sb.ToString();
}
- public void Dispose() => _gpuTimer?.Dispose();
+ /// Pure CSV formatter — unit-tested; invariant culture. One header row plus one row per record.
+ internal static void WriteHistoryCsv(IEnumerable records, TextWriter writer)
+ {
+ var ci = CultureInfo.InvariantCulture;
+ writer.WriteLine("frame,timestamp_ms,cpu_us,gpu_us,alloc_bytes,update_us,upload_us,imgui_us,pacing_us");
+ foreach (FrameHistoryRecord r in records)
+ {
+ writer.Write(r.FrameIndex.ToString(ci)); writer.Write(',');
+ writer.Write(r.TimestampMs.ToString("0.000", ci)); writer.Write(',');
+ writer.Write(r.CpuUs.ToString(ci)); writer.Write(',');
+ writer.Write(r.GpuUs.ToString(ci)); writer.Write(',');
+ writer.Write(r.AllocBytes.ToString(ci)); writer.Write(',');
+ writer.Write(r.UpdateUs.ToString(ci)); writer.Write(',');
+ writer.Write(r.UploadUs.ToString(ci)); writer.Write(',');
+ writer.Write(r.ImGuiUs.ToString(ci)); writer.Write(',');
+ writer.WriteLine(r.PacingUs.ToString(ci));
+ }
+ }
+
+ ///
+ /// Shutdown-only write of the accumulated as CSV
+ /// (the ONLY I/O this feature performs — never from ).
+ /// Failures are logged, not thrown: a history-export problem must never
+ /// block the rest of the render-owner shutdown chain
+ /// (GameWindowLifetime's Hard("frame profiler", ...) stage).
+ ///
+ public void Dispose()
+ {
+ _gpuTimer?.Dispose();
+ if (_history is { Count: > 0 } && RenderingDiagnostics.FrameHistoryPath is { } path)
+ {
+ try
+ {
+ using var writer = new StreamWriter(path, append: false);
+ WriteHistoryCsv(_history, writer);
+ Console.WriteLine($"[frame-prof] wrote {_history.Count} history record(s) to '{path}'");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[frame-prof] WARNING: failed to write frame history to '{path}': {ex.Message}");
+ }
+ }
+ }
}
/// Disposable stage scope; default instance is a no-op.
diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
index 33f5fe4b..b1777747 100644
--- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
+++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
@@ -62,6 +62,18 @@ internal sealed record WorldLifecycleResourceSnapshot(
int CompositeWarmupPending,
long ManagedBytes,
long ManagedCommittedBytes,
+ long LohSizeBytes,
+ long LohFragmentationBytes,
+ long ProcessTotalAllocatedBytes,
+ long CpuMeshCacheHits,
+ long CpuMeshCacheMisses,
+ long CpuMeshCacheEvictions,
+ long DecodedTextureCacheHits,
+ long DecodedTextureCacheMisses,
+ long DecodedTextureCacheEvictions,
+ long DatObjectCacheHits,
+ long DatObjectCacheMisses,
+ long DatObjectCacheEvictions,
double Fps,
double FrameMilliseconds,
string? LastFrameProfile);
diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs
index c2d35bee..010c80cd 100644
--- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs
+++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs
@@ -3,7 +3,9 @@ using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.World;
+using AcDream.Content;
using AcDream.Core.Vfx;
+using DatReaderWriter.Lib.IO;
namespace AcDream.App.Diagnostics;
@@ -14,6 +16,10 @@ namespace AcDream.App.Diagnostics;
///
internal sealed class WorldLifecycleResourceSnapshotSource
{
+ // Generation index 3 is the large object heap; see GCMemoryInfo.GenerationInfo
+ // (verified against the installed net10.0 runtime — Gen0/1/2, LOH, POH).
+ private const int LohGenerationIndex = 3;
+
private readonly GpuWorldState _world;
private readonly LiveEntityAnimationRuntimeView
_animations;
@@ -29,6 +35,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly TextureCache _textures;
private readonly WbDrawDispatcher _dispatcher;
private readonly FrameProfiler _frameProfiler;
+ private readonly IDatReaderWriter _dats;
public WorldLifecycleResourceSnapshotSource(
GpuWorldState world,
@@ -44,7 +51,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
WbMeshAdapter meshes,
TextureCache textures,
WbDrawDispatcher dispatcher,
- FrameProfiler frameProfiler)
+ FrameProfiler frameProfiler,
+ IDatReaderWriter dats)
{
_world = world ?? throw new ArgumentNullException(nameof(world));
_animations = animations
@@ -68,6 +76,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
?? throw new ArgumentNullException(nameof(dispatcher));
_frameProfiler = frameProfiler
?? throw new ArgumentNullException(nameof(frameProfiler));
+ _dats = dats ?? throw new ArgumentNullException(nameof(dats));
}
public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome)
@@ -78,6 +87,25 @@ internal sealed class WorldLifecycleResourceSnapshotSource
var mesh = meshManager.Diagnostics;
RenderFrameDiagnosticsSnapshot render = _renderDiagnostics.Snapshot;
GCMemoryInfo memory = GC.GetGCMemoryInfo();
+ ReadOnlySpan generations = memory.GenerationInfo;
+ long lohSizeBytes = generations.Length > LohGenerationIndex
+ ? generations[LohGenerationIndex].SizeAfterBytes
+ : 0L;
+ long lohFragmentationBytes = generations.Length > LohGenerationIndex
+ ? generations[LohGenerationIndex].FragmentationAfterBytes
+ : 0L;
+
+ // The DAT-object cache lives behind IDatReaderWriter (a third-party
+ // interface from the DatReaderWriter package — we cannot extend it).
+ // RuntimeDatCollection is the one production implementation that
+ // owns the bounded per-database caches; a caller supplying a test
+ // double simply reports zero counters here.
+ CacheStats datObjectCacheStats = _dats is RuntimeDatCollection runtimeDats
+ ? runtimeDats.ObjectCacheStats
+ : default;
+ CacheStats cpuMeshCacheStats = meshManager.CpuMeshCacheStats;
+ CacheStats decodedTextureCacheStats = meshManager.DecodedTextureCacheStats;
+
return new WorldLifecycleResourceSnapshot(
LoadedLandblocks: _world.LoadedLandblockIds.Count,
WorldEntities: _world.Entities.Count,
@@ -112,6 +140,18 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_dispatcher.LastCompositeWarmupPendingCount,
ManagedBytes: GC.GetTotalMemory(forceFullCollection: false),
ManagedCommittedBytes: memory.TotalCommittedBytes,
+ LohSizeBytes: lohSizeBytes,
+ LohFragmentationBytes: lohFragmentationBytes,
+ ProcessTotalAllocatedBytes: GC.GetTotalAllocatedBytes(precise: false),
+ CpuMeshCacheHits: cpuMeshCacheStats.Hits,
+ CpuMeshCacheMisses: cpuMeshCacheStats.Misses,
+ CpuMeshCacheEvictions: cpuMeshCacheStats.Evictions,
+ DecodedTextureCacheHits: decodedTextureCacheStats.Hits,
+ DecodedTextureCacheMisses: decodedTextureCacheStats.Misses,
+ DecodedTextureCacheEvictions: decodedTextureCacheStats.Evictions,
+ DatObjectCacheHits: datObjectCacheStats.Hits,
+ DatObjectCacheMisses: datObjectCacheStats.Misses,
+ DatObjectCacheEvictions: datObjectCacheStats.Evictions,
Fps: render.Fps,
FrameMilliseconds: render.FrameMilliseconds,
LastFrameProfile: _frameProfiler.LastReport);
diff --git a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs
index 2bb880d7..8bbcd01b 100644
--- a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs
+++ b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs
@@ -271,6 +271,21 @@ internal sealed class CpuMeshUploadCache
private readonly long _byteCapacity;
private long _residentBytes;
+ // Hit/miss/eviction traffic counters (2026-07-24 measurement-tooling
+ // review). Read via Stats from the diagnostics/checkpoint thread
+ // without taking _data's lock, so plain fields with Interlocked
+ // increments — the 4 concurrent preparation workers all consult
+ // TryGetAndStage.
+ private long _hits;
+ private long _misses;
+ private long _evictions;
+
+ /// Cumulative hit/miss/eviction counts since construction.
+ internal CacheStats Stats => new(
+ Interlocked.Read(ref _hits),
+ Interlocked.Read(ref _misses),
+ Interlocked.Read(ref _evictions));
+
public CpuMeshUploadCache(int capacity, long byteCapacity = 128L * 1024 * 1024)
{
if (capacity <= 0)
@@ -292,9 +307,11 @@ internal sealed class CpuMeshUploadCache
lock (_data)
{
if (!_data.TryGetValue(id, out data)) {
+ Interlocked.Increment(ref _misses);
stageResult = default;
return false;
}
+ Interlocked.Increment(ref _hits);
_lru.Remove(id);
_lru.AddLast(id);
stageResult = staging.TryStage(data);
@@ -325,6 +342,7 @@ internal sealed class CpuMeshUploadCache
_data.Remove(oldest);
if (_bytesById.Remove(oldest, out long oldestBytes))
_residentBytes -= oldestBytes;
+ Interlocked.Increment(ref _evictions);
}
_data[data.ObjectId] = data;
diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
index 16ac2200..93527b81 100644
--- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
+++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
@@ -276,6 +276,12 @@ namespace AcDream.App.Rendering.Wb
internal (int Count, long Bytes) CpuCacheDiagnostics =>
(_cpuMeshCache.Count, _cpuMeshCache.ResidentBytes);
+ /// CPU prepared-mesh cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).
+ internal CacheStats CpuMeshCacheStats => _cpuMeshCache.Stats;
+
+ /// Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).
+ internal CacheStats DecodedTextureCacheStats => _extractor.DecodedTextureCacheStats;
+
private sealed class PreparationRequest(
ulong id,
bool isSetup,
diff --git a/src/AcDream.App/RuntimeDatCollectionFactory.cs b/src/AcDream.App/RuntimeDatCollectionFactory.cs
index e601d55e..5890b9e2 100644
--- a/src/AcDream.App/RuntimeDatCollectionFactory.cs
+++ b/src/AcDream.App/RuntimeDatCollectionFactory.cs
@@ -55,6 +55,10 @@ internal sealed class RuntimeDatCollection : IDatReaderWriter
}
public string SourceDirectory => _bounded.SourceDirectory;
+
+ /// Aggregate DAT-object cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).
+ internal CacheStats ObjectCacheStats => _bounded.ObjectCacheStats;
+
public IDatDatabase Portal => _bounded.Portal;
public IDatDatabase Cell => _bounded.Cell;
public ReadOnlyDictionary CellRegions => _bounded.CellRegions;
diff --git a/src/AcDream.Content/BoundedDatObjectCache.cs b/src/AcDream.Content/BoundedDatObjectCache.cs
index 82ad47c7..38adf845 100644
--- a/src/AcDream.Content/BoundedDatObjectCache.cs
+++ b/src/AcDream.Content/BoundedDatObjectCache.cs
@@ -1,6 +1,7 @@
using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO;
using System.Diagnostics.CodeAnalysis;
+using System.Threading;
namespace AcDream.Content;
@@ -42,6 +43,19 @@ internal sealed class BoundedDatObjectCache
private readonly object _gate = new();
private long _estimatedBytes;
+ // Hit/miss/eviction traffic counters (2026-07-24 measurement-tooling
+ // review). Read via Stats without the _gate lock, so plain fields with
+ // Interlocked increments rather than lock-protected longs.
+ private long _hits;
+ private long _misses;
+ private long _evictions;
+
+ /// Cumulative hit/miss/eviction counts since construction.
+ internal CacheStats Stats => new(
+ Interlocked.Read(ref _hits),
+ Interlocked.Read(ref _misses),
+ Interlocked.Read(ref _evictions));
+
internal BoundedDatObjectCache(
int entryLimit = DefaultEntryLimit,
long estimatedByteLimit = DefaultEstimatedByteLimit,
@@ -80,10 +94,12 @@ internal sealed class BoundedDatObjectCache
{
if (!_byKey.TryGetValue(new CacheKey(typeof(T), fileId), out var node))
{
+ Interlocked.Increment(ref _misses);
value = default;
return false;
}
+ Interlocked.Increment(ref _hits);
MarkMostRecentlyUsed(node);
value = (T)node.Value.Value;
return true;
@@ -146,6 +162,7 @@ internal sealed class BoundedDatObjectCache
_leastRecentlyUsed.RemoveFirst();
_byKey.Remove(node.Value.Key);
_estimatedBytes -= node.Value.EstimatedBytes;
+ Interlocked.Increment(ref _evictions);
}
private static long EstimateRetainedBytes(IDBObj value)
diff --git a/src/AcDream.Content/CacheStats.cs b/src/AcDream.Content/CacheStats.cs
new file mode 100644
index 00000000..d275f150
--- /dev/null
+++ b/src/AcDream.Content/CacheStats.cs
@@ -0,0 +1,18 @@
+namespace AcDream.Content;
+
+///
+/// Hit/miss/eviction counters for one bounded content cache. 2026-07-24
+/// measurement-tooling review: the canonical checkpoint JSON tracked cache
+/// residency (entry/byte counts) but never hit/miss traffic, so "does a
+/// revisit portal hit or miss the caches" was unanswerable from the
+/// artifact alone. Each cache accumulates its own counts via
+/// — mesh preparation runs on up
+/// to four concurrent workers — and exposes them through this public,
+/// cross-assembly-safe struct so AcDream.App diagnostics can read
+/// them without taking the cache's internal lock.
+///
+public readonly record struct CacheStats(long Hits, long Misses, long Evictions)
+{
+ public static CacheStats operator +(CacheStats a, CacheStats b) =>
+ new(a.Hits + b.Hits, a.Misses + b.Misses, a.Evictions + b.Evictions);
+}
diff --git a/src/AcDream.Content/DatCollectionAdapter.cs b/src/AcDream.Content/DatCollectionAdapter.cs
index c907138c..5ae3d399 100644
--- a/src/AcDream.Content/DatCollectionAdapter.cs
+++ b/src/AcDream.Content/DatCollectionAdapter.cs
@@ -56,6 +56,16 @@ public sealed class DatCollectionAdapter : IDatReaderWriter {
/// Source directory of the underlying DatCollection.
public string SourceDirectory => _dats.Options.DatDirectory ?? string.Empty;
+ ///
+ /// Aggregate hit/miss/eviction counts across the four bounded per-database
+ /// object caches (portal/cell/highRes/language). 2026-07-24
+ /// measurement-tooling review: answers "do revisit portals hit or miss
+ /// the caches" for the DAT-object layer specifically.
+ ///
+ public CacheStats ObjectCacheStats =>
+ _portal.ObjectCacheStats + _cell.ObjectCacheStats
+ + _highRes.ObjectCacheStats + _language.ObjectCacheStats;
+
public IDatDatabase Portal => _portal;
public IDatDatabase Cell => _cell;
public ReadOnlyDictionary CellRegions => _cellRegions;
@@ -173,6 +183,9 @@ public sealed class DatDatabaseWrapper : IDatDatabase {
public DatDatabase Db => _db;
public int Iteration => _db.Iteration?.CurrentIteration ?? 0;
+ /// This database's bounded DAT-object cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).
+ public CacheStats ObjectCacheStats => _cache.Stats;
+
public IEnumerable GetAllIdsOfType() where T : IDBObj =>
_db.GetAllIdsOfType();
diff --git a/src/AcDream.Content/DecodedTextureCache.cs b/src/AcDream.Content/DecodedTextureCache.cs
index ba4bf279..63da52c1 100644
--- a/src/AcDream.Content/DecodedTextureCache.cs
+++ b/src/AcDream.Content/DecodedTextureCache.cs
@@ -21,6 +21,19 @@ internal sealed class DecodedTextureCache {
private readonly int _maximumEntries;
private long _residentBytes;
+ // Hit/miss/eviction traffic counters (2026-07-24 measurement-tooling
+ // review). Read via Stats without the _gate lock, so plain fields with
+ // Interlocked increments rather than lock-protected longs.
+ private long _hits;
+ private long _misses;
+ private long _evictions;
+
+ /// Cumulative hit/miss/eviction counts since construction.
+ public CacheStats Stats => new(
+ Interlocked.Read(ref _hits),
+ Interlocked.Read(ref _misses),
+ Interlocked.Read(ref _evictions));
+
public DecodedTextureCache(long maximumBytes, int maximumEntries) {
ArgumentOutOfRangeException.ThrowIfNegative(maximumBytes);
ArgumentOutOfRangeException.ThrowIfNegative(maximumEntries);
@@ -47,10 +60,12 @@ internal sealed class DecodedTextureCache {
public bool TryGet(DecodedTextureKey key, out byte[] pixels) {
lock (_gate) {
if (!_entries.TryGetValue(key, out var node)) {
+ Interlocked.Increment(ref _misses);
pixels = null!;
return false;
}
+ Interlocked.Increment(ref _hits);
Touch(node);
pixels = node.Value.Pixels;
return true;
@@ -131,6 +146,7 @@ internal sealed class DecodedTextureCache {
_lru.Remove(node);
_entries.Remove(node.Value.Key);
_residentBytes -= node.Value.Pixels.LongLength;
+ Interlocked.Increment(ref _evictions);
}
}
diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs
index e880177c..7973304a 100644
--- a/src/AcDream.Content/MeshExtractor.cs
+++ b/src/AcDream.Content/MeshExtractor.cs
@@ -49,6 +49,9 @@ public sealed class MeshExtractor {
// by up to MaxParallelLoads (4) decode workers.
private readonly System.Collections.Concurrent.ConcurrentDictionary _solidColorTextureCache = new();
+ /// Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).
+ public CacheStats DecodedTextureCacheStats => _decodedTextureCache.Stats;
+
///
/// MP1a mechanical seam: receives particle-preload meshes staged
/// mid-extraction (see ). The App
diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
index 20fa9dac..e459fb1d 100644
--- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
+++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
@@ -767,4 +767,20 @@ public static class RenderingDiagnostics
///
public static bool FrameProfEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
+
+ ///
+ /// 2026-07-24 measurement-tooling review — opt-in per-frame history
+ /// export path for AcDream.App.Diagnostics.FrameProfiler. When
+ /// set, the profiler accumulates one record per frame (frame index,
+ /// wall timestamp, per-stage CPU microseconds, GPU microseconds,
+ /// frame-thread allocation delta) in memory and writes them as CSV to
+ /// this path on Dispose — the aggregated 5-second
+ /// [frame-prof] report is unaffected. History recording only
+ /// takes effect while is also true: it
+ /// reuses the same per-frame instrumentation rather than duplicating
+ /// it. Startup-only (not runtime-toggleable) — read once, like every
+ /// other ACDREAM_* launch flag on this owner.
+ ///
+ public static string? FrameHistoryPath { get; } =
+ Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY");
}
diff --git a/tests/AcDream.App.Tests/Diagnostics/ConnectedR6SoakContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/ConnectedR6SoakContractTests.cs
index c99354a8..5078426e 100644
--- a/tests/AcDream.App.Tests/Diagnostics/ConnectedR6SoakContractTests.cs
+++ b/tests/AcDream.App.Tests/Diagnostics/ConnectedR6SoakContractTests.cs
@@ -116,6 +116,73 @@ public sealed class ConnectedR6SoakContractTests
"$allocLimit = $pair.First.AllocP50Kb * 1.5 + 16.0"));
}
+ [Fact]
+ public void UncappedRenderDefaultsToCappedAndIsGatedByAParameter()
+ {
+ // 2026-07-24 measurement-tooling review: the soak unconditionally
+ // forced ACDREAM_UNCAPPED_RENDER=1 with no capped mode, while the
+ // sibling lifecycle gate script exposes both. Pin the fix so it
+ // cannot silently regress back to an unconditional '1'.
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "tools",
+ "run-connected-r6-soak.ps1"));
+
+ Assert.Contains("[switch]$Uncapped", source, StringComparison.Ordinal);
+ Assert.Contains(
+ "$env:ACDREAM_UNCAPPED_RENDER = if ($Uncapped) { '1' } else { $null }",
+ source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "$env:ACDREAM_UNCAPPED_RENDER = '1'",
+ source,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void StationaryDwellSamplesAfterTheLivenessDeadline()
+ {
+ // LiveEntityLivenessController.DestructionTimeoutSeconds is 25s; the
+ // stationary sample must dwell past it or a prior destination's
+ // records can masquerade as retained memory in the return gate.
+ // The script previously slept only 12s despite the comment already
+ // citing the 25s deadline.
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "tools",
+ "run-connected-r6-soak.ps1"));
+
+ AssertAppearsInOrder(
+ source,
+ "LiveEntityLivenessController's authoritative absence deadline is 25",
+ "Start-Sleep -Seconds 26");
+ }
+
+ [Fact]
+ public void LaunchConfigurationIsDisclosedToTheArtifactDirectoryBeforeLaunch()
+ {
+ // 2026-07-24 measurement-tooling review: a prior audit could only
+ // observe ACDREAM_DUMP_MOVE_TRUTH because no other ACDREAM_* launch
+ // flag the script sets was ever recorded anywhere machine-readable.
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "tools",
+ "run-connected-r6-soak.ps1"));
+
+ AssertAppearsInOrder(
+ source,
+ "Get-ChildItem Env: | Where-Object { $_.Name -like 'ACDREAM_*' }",
+ "Join-Path $artifactDir 'env-disclosure.json'",
+ "try {");
+ Assert.Contains(
+ "EnvDisclosure = Join-Path $artifactDir 'env-disclosure.json'",
+ source,
+ StringComparison.Ordinal);
+ // ACDREAM_DUMP_MOVE_TRUTH stays SET (the exercise gate greps its
+ // output) — it is now disclosed, not removed.
+ Assert.Contains("$env:ACDREAM_DUMP_MOVE_TRUTH = '1'", source, StringComparison.Ordinal);
+ }
+
private static int CountOccurrences(string source, string value)
{
int count = 0;
diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
index 1dad6542..229be3d7 100644
--- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
+++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
@@ -278,7 +278,9 @@ public sealed class WorldLifecycleAutomationControllerTests
private static WorldLifecycleResourceSnapshot EmptyResources() => new(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0L, 0, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0d, 0d, null);
+ 0, 0, 0L, 0, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
+ 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,
+ 0d, 0d, null);
private static string NewDirectory()
{
diff --git a/tests/AcDream.App.Tests/FrameProfilerReportTests.cs b/tests/AcDream.App.Tests/FrameProfilerReportTests.cs
index 084d0446..06e8c4b6 100644
--- a/tests/AcDream.App.Tests/FrameProfilerReportTests.cs
+++ b/tests/AcDream.App.Tests/FrameProfilerReportTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using AcDream.App.Diagnostics;
using Xunit;
@@ -5,6 +6,35 @@ namespace AcDream.App.Tests;
public class FrameProfilerReportTests
{
+ [Fact]
+ public void WriteHistoryCsv_WritesHeaderThenOneInvariantRowPerRecord()
+ {
+ // 2026-07-24 measurement-tooling review: ACDREAM_FRAME_HISTORY export.
+ // FrameBoundary itself needs a live GL context (GpuFrameTimer), so —
+ // matching FormatReport's precedent above — the CSV writer is a pure
+ // static method tested directly rather than driven through FrameBoundary.
+ var records = new[]
+ {
+ new FrameHistoryRecord(0, 12.5, 1000, 500, 2048, 100, 200, 30, 40),
+ new FrameHistoryRecord(1, 18.75, 1200, -1, -256, 110, 210, 31, 41),
+ };
+ var buffer = new StringWriter(CultureInfo.InvariantCulture);
+
+ FrameProfiler.WriteHistoryCsv(records, buffer);
+
+ string[] lines = buffer.ToString().Split(
+ Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(3, lines.Length);
+ Assert.Equal(
+ "frame,timestamp_ms,cpu_us,gpu_us,alloc_bytes,update_us,upload_us,imgui_us,pacing_us",
+ lines[0]);
+ Assert.Equal("0,12.500,1000,500,2048,100,200,30,40", lines[1]);
+ // gpu_us=-1 marks "no GPU sample available"; alloc_bytes can go
+ // negative (documented alloc-channel behavior, matches FrameStatsBuffer.Max).
+ Assert.Equal("1,18.750,1200,-1,-256,110,210,31,41", lines[2]);
+ }
+
+
[Fact]
public void FormatReport_IsInvariantAndComplete()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs
index 2d0bb7a0..10cc66a8 100644
--- a/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs
@@ -297,6 +297,26 @@ public sealed class MeshUploadCachesTests
Assert.True(staging.Stage(DataWithTexture(2, 1)));
}
+ [Fact]
+ public void CpuCacheStats_TracksHitsMissesAndEvictions()
+ {
+ // 2026-07-24 measurement-tooling review: the checkpoint JSON exposes
+ // cache traffic (not just residency) so a revisit's hit/miss ratio
+ // is auditable from an artifact alone.
+ var staging = new MeshUploadStagingQueue();
+ var cache = new CpuMeshUploadCache(capacity: 1);
+
+ Assert.False(cache.TryGetAndStage(1, staging, out _, out _)); // miss
+ cache.Store(DataWithTexture(1, 4));
+ Assert.True(cache.TryGetAndStage(1, staging, out _, out _)); // hit
+ cache.Store(DataWithTexture(2, 4)); // capacity=1 evicts id 1
+
+ CacheStats stats = cache.Stats;
+ Assert.Equal(1, stats.Hits);
+ Assert.Equal(1, stats.Misses);
+ Assert.Equal(1, stats.Evictions);
+ }
+
private static ObjectMeshData DataWithTexture(ulong id, int bytes)
{
var data = new ObjectMeshData { ObjectId = id };
diff --git a/tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs b/tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs
index a9d11adf..b71a7267 100644
--- a/tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs
+++ b/tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs
@@ -118,6 +118,25 @@ public sealed class BoundedDatObjectCacheTests
Assert.Equal(2, cache.Count);
}
+ [Fact]
+ public void Stats_TracksHitsMissesAndEvictions()
+ {
+ // 2026-07-24 measurement-tooling review: the checkpoint JSON exposes
+ // cache traffic (not just residency) so a revisit's hit/miss ratio
+ // is auditable from an artifact alone.
+ var cache = CreateCache(entryLimit: 1, byteLimit: 100);
+
+ Assert.False(cache.TryGet(1, out _)); // miss
+ cache.GetOrAdd(1, Surface(1));
+ Assert.True(cache.TryGet(1, out _)); // hit
+ cache.GetOrAdd(2, Surface(2)); // entryLimit=1 evicts id 1
+
+ CacheStats stats = cache.Stats;
+ Assert.Equal(1, stats.Hits);
+ Assert.Equal(1, stats.Misses);
+ Assert.Equal(1, stats.Evictions);
+ }
+
private static BoundedDatObjectCache CreateCache(int entryLimit, long byteLimit) =>
new(entryLimit, byteLimit, _ => 1);
diff --git a/tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs b/tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs
index f1a4ed3e..bd977cff 100644
--- a/tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs
+++ b/tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs
@@ -144,6 +144,25 @@ public sealed class DecodedTextureCacheTests {
Assert.Equal(new byte[] { 1 }, recovered);
}
+ [Fact]
+ public void Stats_TracksHitsMissesAndEvictions()
+ {
+ // 2026-07-24 measurement-tooling review: the checkpoint JSON exposes
+ // cache traffic (not just residency) so a revisit's hit/miss ratio
+ // is auditable from an artifact alone.
+ var cache = new DecodedTextureCache(maximumBytes: 4, maximumEntries: 1);
+
+ Assert.False(cache.TryGet(Key(1), out _)); // miss
+ cache.RetainOrUse(Key(1), new byte[4], out _);
+ Assert.True(cache.TryGet(Key(1), out _)); // hit
+ cache.RetainOrUse(Key(2), new byte[4], out _); // maximumEntries=1 evicts key 1
+
+ CacheStats stats = cache.Stats;
+ Assert.Equal(1, stats.Hits);
+ Assert.Equal(1, stats.Misses);
+ Assert.Equal(1, stats.Evictions);
+ }
+
private static byte[] AssertCacheHit(DecodedTextureCache cache, DecodedTextureKey key)
{
Assert.True(cache.TryGet(key, out byte[] pixels));
diff --git a/tools/run-connected-r6-soak.ps1 b/tools/run-connected-r6-soak.ps1
index a8ecb9a8..a05c12e0 100644
--- a/tools/run-connected-r6-soak.ps1
+++ b/tools/run-connected-r6-soak.ps1
@@ -4,6 +4,7 @@ param(
[string]$Account = $env:ACDREAM_TEST_USER,
[string]$Password = $env:ACDREAM_TEST_PASS,
[switch]$SkipBuild,
+ [switch]$Uncapped,
[int]$LoginTimeoutSeconds = 90
)
@@ -602,7 +603,10 @@ $env:ACDREAM_TEST_USER = $Account
$env:ACDREAM_TEST_PASS = $Password
$env:ACDREAM_RETAIL_UI = '1'
$env:ACDREAM_FRAME_PROF = '1'
-$env:ACDREAM_UNCAPPED_RENDER = '1'
+# Capped by default (matches the retail presentation cadence the connected
+# visual matrix is gated against); pass -Uncapped to opt into uncapped
+# render, matching run-connected-world-lifecycle-gate.ps1's pattern.
+$env:ACDREAM_UNCAPPED_RENDER = if ($Uncapped) { '1' } else { $null }
$env:ACDREAM_DEVTOOLS = '0'
$env:ACDREAM_UI_PROBE_DUMP = '0'
$env:ACDREAM_UI_PROBE_SCRIPT = $routePath
@@ -611,6 +615,24 @@ $env:ACDREAM_DUMP_MOVE_TRUTH = '1'
$env:ACDREAM_NO_AUDIO = $null
$env:ACDREAM_WB_DIAG = $null
+# Machine-readable disclosure of the exact launch configuration (2026-07-24
+# measurement-tooling review — the prior audit could only see
+# ACDREAM_DUMP_MOVE_TRUTH because nothing else the script sets was
+# recorded anywhere). Written into the automation artifact directory
+# BEFORE launch so a failed/aborted run still leaves the disclosure behind.
+$null = New-Item -ItemType Directory -Force -Path $artifactDir
+$acdreamEnvVars = Get-ChildItem Env: | Where-Object { $_.Name -like 'ACDREAM_*' } |
+ Sort-Object Name |
+ ForEach-Object { [pscustomobject]@{ Name = $_.Name; Value = $_.Value } }
+$envDisclosure = [pscustomobject][ordered]@{
+ Script = 'run-connected-r6-soak.ps1'
+ GeneratedUtc = [DateTime]::UtcNow.ToString('O')
+ Uncapped = [bool]$Uncapped
+ EnvironmentVariables = @($acdreamEnvVars)
+}
+$envDisclosure | ConvertTo-Json -Depth 4 |
+ Set-Content -LiteralPath (Join-Path $artifactDir 'env-disclosure.json') -Encoding utf8
+
try {
$process = Start-Process -FilePath $exe -WorkingDirectory $Repository `
-RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -PassThru
@@ -644,7 +666,7 @@ try {
# LiveEntityLivenessController's authoritative absence deadline is 25
# seconds. Sample only after that deadline so a previous destination's
# records cannot masquerade as retained memory in the return gate.
- Start-Sleep -Seconds 12
+ Start-Sleep -Seconds 26
$stationaryProfileStart = (Get-FrameProfiles $stdoutLog).Count
$stationaryResult = Wait-ForNextFrameProfile $process $stdoutLog $stationaryProfileStart 12
$stationarySample = Capture-Sample $process $destination.Name 'stationary' $stationaryResult.Line
@@ -719,6 +741,7 @@ finally {
SamplesCsv = $sampleCsv
ReportJson = $reportJson
CheckpointTimeline = $checkpointTimeline
+ EnvDisclosure = Join-Path $artifactDir 'env-disclosure.json'
}
}
$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportJson -Encoding utf8