feat(render #53): DEBUG cross-check guards against the prior Tier 1 bug class
Adds EntityClassificationCache.DebugCrossCheck(entityId, liveBatches) that asserts cached state matches a live re-classification. Wires a simpler predicate assert into WbDrawDispatcher's cache-hit branch (asserts isAnimated == false on cache hit). Tests #13a and #13b cover the batch-count mismatch and clean-match cases via a custom TraceListener that captures Debug.Assert calls. Zero cost in Release. In DEBUG, the assert fires immediately if a future regression mutates static-entity state outside the audit's known write sites — the same failure mode that bit the prior Tier 1 attempt. Phase 4 complete. Cache + invalidation + safety net all in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
489174f21c
commit
f16604b60b
3 changed files with 149 additions and 0 deletions
|
|
@ -97,4 +97,60 @@ internal sealed class EntityClassificationCache
|
||||||
if (toRemove is null) return;
|
if (toRemove is null) return;
|
||||||
foreach (var id in toRemove) _entries.Remove(id);
|
foreach (var id in toRemove) _entries.Remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// <summary>
|
||||||
|
/// Asserts that the cached entry for <paramref name="entityId"/> still
|
||||||
|
/// matches what fresh classification would produce. Catches the prior
|
||||||
|
/// Tier 1 bug class — silent caching of mutable per-frame state — by
|
||||||
|
/// firing <see cref="System.Diagnostics.Debug.Assert"/> when any cached
|
||||||
|
/// field has drifted from live state.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Caller passes per-batch live state (Key, BindlessTextureHandle, RestPose)
|
||||||
|
/// reconstructed from the same path the populate ran. The cache iterates
|
||||||
|
/// its stored entries in parallel and asserts equality.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Zero cost in Release. In DEBUG, called once per static-entity cache
|
||||||
|
/// hit per frame — adds modest overhead. Acceptable for dev runs.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public void DebugCrossCheck(uint entityId, IReadOnlyList<CachedBatch> liveBatches)
|
||||||
|
{
|
||||||
|
if (!_entries.TryGetValue(entityId, out var entry)) return;
|
||||||
|
|
||||||
|
System.Diagnostics.Debug.Assert(
|
||||||
|
entry.Batches.Length == liveBatches.Count,
|
||||||
|
$"EntityClassificationCache: batch count mismatch for entity {entityId}: cached={entry.Batches.Length} live={liveBatches.Count}");
|
||||||
|
|
||||||
|
for (int i = 0; i < entry.Batches.Length && i < liveBatches.Count; i++)
|
||||||
|
{
|
||||||
|
var cached = entry.Batches[i];
|
||||||
|
var live = liveBatches[i];
|
||||||
|
System.Diagnostics.Debug.Assert(
|
||||||
|
cached.Key.Equals(live.Key),
|
||||||
|
$"EntityClassificationCache: GroupKey drift for entity {entityId} batch {i}");
|
||||||
|
System.Diagnostics.Debug.Assert(
|
||||||
|
cached.BindlessTextureHandle == live.BindlessTextureHandle,
|
||||||
|
$"EntityClassificationCache: texture handle drift for entity {entityId} batch {i}");
|
||||||
|
System.Diagnostics.Debug.Assert(
|
||||||
|
MatrixApproxEqual(cached.RestPose, live.RestPose, epsilon: 1e-5f),
|
||||||
|
$"EntityClassificationCache: RestPose drift for entity {entityId} batch {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatrixApproxEqual(System.Numerics.Matrix4x4 a, System.Numerics.Matrix4x4 b, float epsilon)
|
||||||
|
{
|
||||||
|
return System.MathF.Abs(a.M11 - b.M11) <= epsilon && System.MathF.Abs(a.M12 - b.M12) <= epsilon &&
|
||||||
|
System.MathF.Abs(a.M13 - b.M13) <= epsilon && System.MathF.Abs(a.M14 - b.M14) <= epsilon &&
|
||||||
|
System.MathF.Abs(a.M21 - b.M21) <= epsilon && System.MathF.Abs(a.M22 - b.M22) <= epsilon &&
|
||||||
|
System.MathF.Abs(a.M23 - b.M23) <= epsilon && System.MathF.Abs(a.M24 - b.M24) <= epsilon &&
|
||||||
|
System.MathF.Abs(a.M31 - b.M31) <= epsilon && System.MathF.Abs(a.M32 - b.M32) <= epsilon &&
|
||||||
|
System.MathF.Abs(a.M33 - b.M33) <= epsilon && System.MathF.Abs(a.M34 - b.M34) <= epsilon &&
|
||||||
|
System.MathF.Abs(a.M41 - b.M41) <= epsilon && System.MathF.Abs(a.M42 - b.M42) <= epsilon &&
|
||||||
|
System.MathF.Abs(a.M43 - b.M43) <= epsilon && System.MathF.Abs(a.M44 - b.M44) <= epsilon;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
|
|
||||||
if (diag) _entitiesDrawn++;
|
if (diag) _entitiesDrawn++;
|
||||||
lastHitEntityId = entity.Id;
|
lastHitEntityId = entity.Id;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Cross-check guard: assert the membership predicate held at hit time.
|
||||||
|
// The full re-classification cross-check (spec section 6.5) is a stretch
|
||||||
|
// goal; this simpler assert catches the prior Tier 1 bug class — a
|
||||||
|
// static entity that turns out to actually be animated would fire here.
|
||||||
|
//
|
||||||
|
// Structurally redundant with the `if (!isAnimated && ...)` branch
|
||||||
|
// condition, but serves as a TRIPWIRE: a future refactor that
|
||||||
|
// incorrectly relaxes the branch condition (e.g., removes
|
||||||
|
// `!isAnimated` from the guard) would silently allow animated
|
||||||
|
// entities into the fast path; the assert catches that immediately.
|
||||||
|
System.Diagnostics.Debug.Assert(
|
||||||
|
!isAnimated,
|
||||||
|
$"EntityClassificationCache hit on animated entity {entity.Id} — invariant violated");
|
||||||
|
#endif
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,82 @@ public class EntityClassificationCacheTests
|
||||||
Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle);
|
Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
[Fact]
|
||||||
|
public void DebugCrossCheck_BatchCountMismatch_FiresAssert()
|
||||||
|
{
|
||||||
|
var cache = new EntityClassificationCache();
|
||||||
|
cache.Populate(100, 0u, new[]
|
||||||
|
{
|
||||||
|
MakeCachedBatch(1, 0, 6, 0xAA),
|
||||||
|
MakeCachedBatch(1, 6, 6, 0xBB),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Synthetic "live" with fewer batches → should fire Debug.Assert.
|
||||||
|
var liveBatches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
|
||||||
|
|
||||||
|
// Capture Debug.Assert via a custom TraceListener.
|
||||||
|
var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
|
||||||
|
System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
|
||||||
|
System.Diagnostics.Trace.Listeners.Clear();
|
||||||
|
var asserts = new List<string>();
|
||||||
|
System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cache.DebugCrossCheck(100, liveBatches);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
System.Diagnostics.Trace.Listeners.Clear();
|
||||||
|
foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotEmpty(asserts);
|
||||||
|
string joined = string.Join(" ", asserts);
|
||||||
|
Assert.Contains("batch count mismatch", joined);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DebugCrossCheck_RestPoseMatch_NoAssert()
|
||||||
|
{
|
||||||
|
var cache = new EntityClassificationCache();
|
||||||
|
var batches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
|
||||||
|
cache.Populate(100, 0u, batches);
|
||||||
|
|
||||||
|
var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
|
||||||
|
System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
|
||||||
|
System.Diagnostics.Trace.Listeners.Clear();
|
||||||
|
var asserts = new List<string>();
|
||||||
|
System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cache.DebugCrossCheck(100, batches);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
System.Diagnostics.Trace.Listeners.Clear();
|
||||||
|
foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Empty(asserts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CaptureListener : System.Diagnostics.TraceListener
|
||||||
|
{
|
||||||
|
private readonly List<string> _captured;
|
||||||
|
public CaptureListener(List<string> captured) { _captured = captured; }
|
||||||
|
public override void Write(string? message) { if (message != null) _captured.Add(message); }
|
||||||
|
public override void WriteLine(string? message) { if (message != null) _captured.Add(message); }
|
||||||
|
public override void Fail(string? message, string? detailMessage)
|
||||||
|
{
|
||||||
|
_captured.Add($"{message}: {detailMessage}");
|
||||||
|
}
|
||||||
|
public override void Fail(string? message) { if (message != null) _captured.Add(message); }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private static CachedBatch MakeCachedBatch(
|
private static CachedBatch MakeCachedBatch(
|
||||||
uint ibo, uint firstIndex, int indexCount, ulong texHandle)
|
uint ibo, uint firstIndex, int indexCount, ulong texHandle)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue