perf(diag): per-frame history export + checkpoint LOH/cache counters + soak capped mode (2026-07-24 audit review)

An adversarial performance review found our own instruments cannot
measure the project's own performance gates:

- FrameProfiler aggregated CPU/GPU/alloc/stage samples into ~5-second
  windows and reset the ring buffers after each report, so route-wide
  p50/p95/p99 distributions across a whole soak could not be
  reconstructed after the fact. ACDREAM_FRAME_HISTORY=<path> now opts
  into a separate per-frame history (one record per frame, ~72
  bytes/record, accumulated in memory with zero frame-thread I/O) that
  a shutdown-only Dispose() writes as CSV. The aggregated [frame-prof]
  report format and its existing metrics are unchanged.

- The canonical checkpoint JSON tracked cache residency (entry/byte
  counts) but never LOH size/fragmentation, process-wide allocated
  bytes, or cache hit/miss/eviction traffic — a committed audit JSON
  showed 65% LOH fragmentation that no tracked instrument recorded,
  and "does a revisit portal hit or miss the caches" was unanswerable
  from an artifact alone. WorldLifecycleResourceSnapshot now carries
  loh_size_bytes/loh_fragmentation_bytes (GCMemoryInfo.GenerationInfo
  index 3), process_total_allocated_bytes (GC.GetTotalAllocatedBytes),
  and Interlocked hit/miss/eviction counters for the CPU mesh cache,
  decoded-texture cache, and the four bounded DAT-object caches
  (portal/cell/highRes/language, aggregated).

- run-connected-r6-soak.ps1 unconditionally forced
  ACDREAM_UNCAPPED_RENDER=1 with no capped mode, while its sibling
  lifecycle-gate script correctly gated it behind a switch. Added
  -Uncapped (default capped, matching the sibling script's pattern),
  fixed the stationary dwell (12s -> 26s, past the 25s
  LiveEntityLivenessController deadline the adjacent comment already
  cited), and now write an env-disclosure.json into the automation
  artifact directory before every launch listing every ACDREAM_* var
  the script sets plus -Uncapped, since the prior audit could only see
  ACDREAM_DUMP_MOVE_TRUTH and nothing else was ever recorded anywhere.

Cache counters are wired via the existing composition path
(ObjectMeshManager already owns the CPU mesh cache and the mesh
extractor directly; content.Dats is threaded into
WorldLifecycleResourceSnapshotSource the same way every other
composition consumer receives it). The DAT-object cache lives behind
IDatReaderWriter, a third-party interface from the DatReaderWriter
package that cannot be extended; RuntimeDatCollection (the one
production implementation) exposes the aggregate stats directly and a
pattern match reads them, degrading to zero for any test double —
no new static registry was introduced (GpuMemoryTracker remains the
one precedented process-wide static).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1da2c33c875b41fa383dd79694ee2765f0e21896)
This commit is contained in:
Erik 2026-07-24 11:58:47 +02:00
parent bfc5e47365
commit 7b456b49d6
20 changed files with 477 additions and 9 deletions

View file

@ -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,

View file

@ -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,
}
/// <summary>
/// One <c>ACDREAM_FRAME_HISTORY</c> CSV row — every field the aggregated
/// <c>[frame-prof]</c> report discards when its 5-second window resets.
/// Stage fields mirror <see cref="FrameStage"/> positionally (Update /
/// Upload / ImGui / Pacing, matching <see cref="FrameProfiler.FormatReport"/>'s
/// <c>names</c> array) — if <see cref="FrameStage"/> grows, extend this
/// record, <see cref="FrameProfiler.WriteHistoryCsv"/>, and the CSV header
/// together. <c>GpuUs</c> is <c>-1</c> for a frame with no available GPU
/// sample (warm-up, or <c>ACDREAM_WB_DIAG=1</c> self-disable).
/// </summary>
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;
}
/// <summary>
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// <c>FrameBoundary</c> call at the top of <c>GameWindow.OnRender</c>
@ -35,11 +69,28 @@ public enum FrameStage
/// <para>Permanent apparatus — every MP-track gate reads it; do not strip.
/// Whole-frame GPU timing self-disables under <c>ACDREAM_WB_DIAG=1</c>
/// (nested TimeElapsed is illegal GL; see GpuFrameTimer).</para>
///
/// <para>2026-07-24 measurement-tooling review — the aggregated report
/// resets its ring buffers every ~5 s (<see cref="FrameStatsBuffer.Reset"/>),
/// so route-wide p50/p95/p99 distributions across a whole soak cannot be
/// reconstructed after the fact. <see cref="RenderingDiagnostics.FrameHistoryPath"/>
/// (<c>ACDREAM_FRAME_HISTORY=&lt;path&gt;</c>) opts into a SEPARATE
/// per-frame history: one <see cref="FrameHistoryRecord"/> per frame in a
/// preallocated, grow-as-needed <see cref="List{T}"/> (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
/// <see cref="FrameBoundary"/>; the CSV is written once, from
/// <see cref="Dispose"/>, at shutdown. Recording only takes effect while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is ALSO true — it
/// reuses that instrumentation rather than duplicating it. Does not
/// change the <c>[frame-prof]</c> report format or any existing metric.</para>
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
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<FrameStage>().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<FrameHistoryRecord>? _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<FrameHistoryRecord>(HistoryInitialCapacity);
}
/// <summary>
@ -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();
/// <summary>Pure CSV formatter — unit-tested; invariant culture. One header row plus one row per record.</summary>
internal static void WriteHistoryCsv(IEnumerable<FrameHistoryRecord> 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));
}
}
/// <summary>
/// Shutdown-only write of the accumulated <see cref="_history"/> as CSV
/// (the ONLY I/O this feature performs — never from <see cref="FrameBoundary"/>).
/// Failures are logged, not thrown: a history-export problem must never
/// block the rest of the render-owner shutdown chain
/// (<c>GameWindowLifetime</c>'s <c>Hard("frame profiler", ...)</c> stage).
/// </summary>
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}");
}
}
}
}
/// <summary>Disposable stage scope; default instance is a no-op.</summary>

View file

@ -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);

View file

@ -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;
/// </summary>
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<LiveEntityAnimationState>
_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<GCGenerationInfo> 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);

View file

@ -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;
/// <summary>Cumulative hit/miss/eviction counts since construction.</summary>
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;

View file

@ -276,6 +276,12 @@ namespace AcDream.App.Rendering.Wb
internal (int Count, long Bytes) CpuCacheDiagnostics =>
(_cpuMeshCache.Count, _cpuMeshCache.ResidentBytes);
/// <summary>CPU prepared-mesh cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
internal CacheStats CpuMeshCacheStats => _cpuMeshCache.Stats;
/// <summary>Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
internal CacheStats DecodedTextureCacheStats => _extractor.DecodedTextureCacheStats;
private sealed class PreparationRequest(
ulong id,
bool isSetup,

View file

@ -55,6 +55,10 @@ internal sealed class RuntimeDatCollection : IDatReaderWriter
}
public string SourceDirectory => _bounded.SourceDirectory;
/// <summary>Aggregate DAT-object cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
internal CacheStats ObjectCacheStats => _bounded.ObjectCacheStats;
public IDatDatabase Portal => _bounded.Portal;
public IDatDatabase Cell => _bounded.Cell;
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions => _bounded.CellRegions;

View file

@ -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;
/// <summary>Cumulative hit/miss/eviction counts since construction.</summary>
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)

View file

@ -0,0 +1,18 @@
namespace AcDream.Content;
/// <summary>
/// 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
/// <see cref="System.Threading.Interlocked"/> — mesh preparation runs on up
/// to four concurrent workers — and exposes them through this public,
/// cross-assembly-safe struct so <c>AcDream.App</c> diagnostics can read
/// them without taking the cache's internal lock.
/// </summary>
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);
}

View file

@ -56,6 +56,16 @@ public sealed class DatCollectionAdapter : IDatReaderWriter {
/// <summary>Source directory of the underlying DatCollection.</summary>
public string SourceDirectory => _dats.Options.DatDirectory ?? string.Empty;
/// <summary>
/// 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.
/// </summary>
public CacheStats ObjectCacheStats =>
_portal.ObjectCacheStats + _cell.ObjectCacheStats
+ _highRes.ObjectCacheStats + _language.ObjectCacheStats;
public IDatDatabase Portal => _portal;
public IDatDatabase Cell => _cell;
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions => _cellRegions;
@ -173,6 +183,9 @@ public sealed class DatDatabaseWrapper : IDatDatabase {
public DatDatabase Db => _db;
public int Iteration => _db.Iteration?.CurrentIteration ?? 0;
/// <summary>This database's bounded DAT-object cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
public CacheStats ObjectCacheStats => _cache.Stats;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
_db.GetAllIdsOfType<T>();

View file

@ -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;
/// <summary>Cumulative hit/miss/eviction counts since construction.</summary>
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);
}
}

View file

@ -49,6 +49,9 @@ public sealed class MeshExtractor {
// by up to MaxParallelLoads (4) decode workers.
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, byte[]> _solidColorTextureCache = new();
/// <summary>Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
public CacheStats DecodedTextureCacheStats => _decodedTextureCache.Stats;
/// <summary>
/// MP1a mechanical seam: receives particle-preload meshes staged
/// mid-extraction (see <see cref="CollectEmittersFromScript"/>). The App

View file

@ -767,4 +767,20 @@ public static class RenderingDiagnostics
/// </summary>
public static bool FrameProfEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
/// <summary>
/// 2026-07-24 measurement-tooling review — opt-in per-frame history
/// export path for <c>AcDream.App.Diagnostics.FrameProfiler</c>. 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 <c>Dispose</c> — the aggregated 5-second
/// <c>[frame-prof]</c> report is unaffected. History recording only
/// takes effect while <see cref="FrameProfEnabled"/> is also true: it
/// reuses the same per-frame instrumentation rather than duplicating
/// it. Startup-only (not runtime-toggleable) — read once, like every
/// other <c>ACDREAM_*</c> launch flag on this owner.
/// </summary>
public static string? FrameHistoryPath { get; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY");
}

View file

@ -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;

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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 };

View file

@ -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<Surface>(1, out _)); // miss
cache.GetOrAdd(1, Surface(1));
Assert.True(cache.TryGet<Surface>(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);

View file

@ -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));

View file

@ -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