using System.Collections.Generic;
namespace AcDream.App.Rendering.Wb;
///
/// Cache of per-entity classification results for static entities (those NOT
/// in GameWindow._animatedEntities). Holds one
/// per cached entity. The cache is opaque
/// w.r.t. classification logic — it simply stores what callers populate.
///
///
/// Key composition: entries are keyed by the tuple
/// (EntityId, LandblockHint), NOT by EntityId alone. Issue #53
/// uncovered that entity.Id is NOT globally unique across all
/// static-entity hydration paths: scenery (0x80LLBB00 + localIndex)
/// and interior cells (0x40LLBB00 + localCounter) overflow at >256
/// items per landblock, wrapping into the lbY byte and producing
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
/// by the tuple is correct-by-construction regardless of any hydration
/// path's id strategy.
///
///
///
/// Invariants:
///
/// - overwrites any existing entry for the same (id, lb) tuple (defensive).
/// - sweeps all entries with the given EntityId
/// regardless of LandblockHint; idempotent (no-throw on missing id).
/// - walks all entries; entries whose
/// equals the argument are removed.
/// - All operations are render-thread only. No internal locking.
///
///
///
///
/// Audit foundation: see
/// docs/research/2026-05-10-tier1-mutation-audit.md for why static
/// entities can be cached and what invalidation is needed.
///
///
///
/// Accessibility: internal. and
/// both transitively reference the internal
/// ; surfacing the cache as public would create
/// inconsistent-accessibility errors. Cross-assembly access for the test
/// project comes via InternalsVisibleTo("AcDream.Core.Tests") on
/// AcDream.App.csproj.
///
///
internal sealed class EntityClassificationCache
{
private readonly Dictionary<(uint EntityId, uint LandblockHint), EntityCacheEntry> _entries = new();
/// Number of cached entities — for diagnostics.
public int Count => _entries.Count;
///
/// Look up an entity's cached classification. Keyed by both
/// AND to
/// disambiguate entities whose Ids collide across landblocks (e.g.,
/// scenery's 0x80LLBB00 + localIndex overflow at >256 items/LB).
/// Returns true with the entry on hit; false with
/// set to null on miss.
///
public bool TryGet(uint entityId, uint landblockHint, out EntityCacheEntry? entry)
=> _entries.TryGetValue((entityId, landblockHint), out entry);
///
/// Insert or overwrite a cache entry for the
/// (, )
/// tuple. Defensive: if an entry already exists, replaces it.
///
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches)
{
_entries[(entityId, landblockHint)] = new EntityCacheEntry
{
EntityId = entityId,
LandblockHint = landblockHint,
Batches = batches,
};
}
///
/// Remove all cache entries for the given ,
/// regardless of which landblock they were populated under. Sweep is
/// needed because we may have entries for the same Id under different
/// LandblockHints if any hydration path produced colliding Ids
/// historically (defensive even though current paths shouldn't produce
/// duplicates per-LB). Was O(1) before the #53 tuple-key change;
/// now O(n), but called rarely (only on entity despawn).
///
public void InvalidateEntity(uint entityId)
{
if (_entries.Count == 0) return;
List<(uint, uint)>? toRemove = null;
foreach (var key in _entries.Keys)
{
if (key.EntityId == entityId)
{
toRemove ??= new List<(uint, uint)>();
toRemove.Add(key);
}
}
if (toRemove is null) return;
foreach (var k in toRemove) _entries.Remove(k);
}
///
/// Remove every cache entry whose
/// equals . Used by the streaming pipeline
/// when a landblock demotes from near to far or unloads. No-op if no
/// entries match.
///
public void InvalidateLandblock(uint landblockId)
{
if (_entries.Count == 0) return;
// Collect the keys to remove first to avoid mutating the dict during iteration.
// Buffered locally because the typical case removes ~all entries in the LB
// (which is still small relative to the total cache).
List<(uint, uint)>? toRemove = null;
foreach (var key in _entries.Keys)
{
if (key.LandblockHint == landblockId)
{
toRemove ??= new List<(uint, uint)>();
toRemove.Add(key);
}
}
if (toRemove is null) return;
foreach (var k in toRemove) _entries.Remove(k);
}
#if DEBUG
///
/// Asserts that the cached entry for still
/// matches what fresh classification would produce. Catches the prior
/// Tier 1 bug class — silent caching of mutable per-frame state — by
/// firing when any cached
/// field has drifted from live state.
///
///
/// 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.
///
///
///
/// As of Phase 4 (commit f16604b) this method is exercised by unit tests
/// only; the dispatcher's cache-hit branch fires a simpler predicate assert
/// (!isAnimated) at production hit time. Wiring the full live-state
/// cross-check into the per-entity branch is the spec section 6.5 stretch
/// goal and remains open as a follow-up. Zero cost in Release; the method
/// stays here so the regression-class guard is locked behind tests.
///
///
public void DebugCrossCheck(uint entityId, uint landblockHint, IReadOnlyList liveBatches)
{
if (!_entries.TryGetValue((entityId, landblockHint), 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
}