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 }