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