using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using Xunit; namespace AcDream.Core.Tests.Conformance; /// /// Dat-reader concurrency stress apparatus (dat-race investigation 2026-06-09). /// /// acdream reads ONE shared DatCollection from four thread populations (render /// thread, streamer worker, mesh-decode pool, audio) — see the GameWindow._datLock /// vs DatDatabaseWrapper._lock split. Intermittent in-game symptoms (white cottage /// walls = silently dropped texture batches; AccessViolation crash reports in /// MemoryMappedBlockAllocator.ReadBlock) were attributed to the library not being /// thread-safe. A line-level audit of Chorizite.DatReaderWriter 2.1.7 found the /// READ path memory-safe for read-only dats (ReadBlock keeps all cursor state in /// locals over a stable mmap view; the BTree LRU node cache locks internally; /// caches are ConcurrentDictionary). This test settles the question empirically: /// /// Phase 1 (raw): TryGetFileBytes — exercises DatBTreeReaderWriter.TryGetFile + /// MemoryMappedBlockAllocator.ReadBlock + Decompress with a fresh output array /// per call (no caching at any layer), so every call re-walks the real disk path. /// /// Phase 2 (typed): TryGet<T> on a FileCachingStrategy.Never collection — /// adds the ArrayPool rent/return, DatBinReader, ObjectFactory and Unpack layers /// (the full production read path ObjectMeshManager uses). /// /// Golden fingerprints are taken single-threaded, then the same id set is hammered /// from many threads in shuffled order. ANY flip of the success flag or fingerprint /// under concurrency reproduces the in-game corruption class deterministically. /// If this test is stably green over millions of reads, concurrent same-instance /// READS are exonerated and the in-game symptoms must come from lifecycle bugs /// (e.g. dispose-during-read at teardown) or layers above the dat reader. /// [Trait("Category", "Conformance")] public class DatConcurrencyStressTests { private const int HammerThreads = 8; private const int LoopsPerThread = 25; private sealed record FileRef(DatFileSource Source, uint Id); private enum DatFileSource { Cell, Portal, HighRes } private sealed record Golden(bool Ok, int Length, ulong Fnv); [Fact] public void ConcurrentRawReads_MatchSingleThreadedGolden() { var datDir = ConformanceDats.ResolveDatDir(); if (datDir is null) return; // dats absent (CI) — skip, matching suite convention using var dats = new DatCollection(datDir, DatAccessType.Read); var refs = BuildIdSet(dats); Assert.True(refs.Count > 500, $"id set unexpectedly small ({refs.Count}) — fixture assumptions broke"); // Golden pass: single-threaded raw reads. var golden = new Dictionary(refs.Count); foreach (var r in refs) golden[r] = ReadRaw(dats, r); // Hammer: every thread re-reads the FULL set in its own shuffled order. var anomalies = HammerAndCollect(refs, r => { var got = ReadRaw(dats, r); return golden[r] == got ? null : $"{r.Source} 0x{r.Id:X8}: golden=({golden[r].Ok},{golden[r].Length},{golden[r].Fnv:X16}) got=({got.Ok},{got.Length},{got.Fnv:X16})"; }); Assert.True(anomalies.IsEmpty, $"{anomalies.Count} concurrent raw-read anomalies. First: {string.Join(" | ", anomalies.Take(10))}"); } [Fact] public void ConcurrentTypedReads_MatchSingleThreadedGolden() { var datDir = ConformanceDats.ResolveDatDir(); if (datDir is null) return; // dats absent (CI) — skip // FileCachingStrategy.Never: every TryGet re-reads + re-unpacks from disk, // matching the worst-case production path and keeping the hammer honest // (OnDemand would serve all post-first reads from the ConcurrentDictionary). using var dats = new DatCollection(new DatCollectionOptions { DatDirectory = datDir, AccessType = DatAccessType.Read, FileCachingStrategy = FileCachingStrategy.Never, }); var refs = BuildIdSet(dats); var golden = new Dictionary(refs.Count); foreach (var r in refs) golden[r] = ReadTypedFingerprint(dats, r); var anomalies = HammerAndCollect(refs, r => { var got = ReadTypedFingerprint(dats, r); return golden[r] == got ? null : $"{r.Source} 0x{r.Id:X8}: golden=0x{golden[r]:X16} got=0x{got:X16}"; }); Assert.True(anomalies.IsEmpty, $"{anomalies.Count} concurrent typed-read anomalies. First: {string.Join(" | ", anomalies.Take(10))}"); } // ---- hammer scaffolding ------------------------------------------------- private static ConcurrentBag HammerAndCollect( IReadOnlyList refs, Func probe) { var anomalies = new ConcurrentBag(); var threads = new List(); using var start = new ManualResetEventSlim(false); for (int t = 0; t < HammerThreads; t++) { int seed = 7919 * (t + 1); // deterministic per-thread shuffle var thread = new Thread(() => { var order = refs.ToArray(); var rng = new Random(seed); start.Wait(); for (int loop = 0; loop < LoopsPerThread; loop++) { // Fisher–Yates so threads disagree about visit order — maximizes // simultaneous different-file + same-file overlap. for (int i = order.Length - 1; i > 0; i--) { int j = rng.Next(i + 1); (order[i], order[j]) = (order[j], order[i]); } foreach (var r in order) { if (anomalies.Count > 50) return; // enough evidence var a = probe(r); if (a is not null) anomalies.Add(a); } } }) { IsBackground = true, Name = $"dat-hammer-{t}" }; thread.Start(); threads.Add(thread); } start.Set(); // release all threads at once foreach (var th in threads) Assert.True(th.Join(TimeSpan.FromMinutes(4)), "hammer thread did not finish in time"); return anomalies; } /// /// Mirrors the real client's id mix: Holtburg + neighbor landblocks (cell dat /// heightmaps, LandBlockInfos, EnvCells) plus the portal-dat chain those cells /// reference (Environments, Surfaces, SurfaceTextures, RenderSurfaces) and the /// highres-dat RenderSurface probes the texture path makes. /// private static List BuildIdSet(DatCollection dats) { var refs = new List(); var portalIds = new HashSet(); var highResIds = new HashSet(); // Enumerate the cell dat's real file table around the Holtburg region — // heightmaps (xxFFFF), LandBlockInfos (xxFFFE) and EnvCells (xx01xx+) — // instead of guessing per-landblock counts (rural blocks have NumCells=0). var cellIds = dats.Cell.Tree.GetFilesInRange(0xA8000000u, 0xABFFFFFFu) .Select(f => f.Id) .Take(2500) .ToList(); refs.AddRange(cellIds.Select(id => new FileRef(DatFileSource.Cell, id))); // Walk the portal-dat texture chain (Environment → Surface → SurfaceTexture // → RenderSurface) for a sample of those EnvCells — the exact chain the // white-walls symptom lives on. int chained = 0; foreach (var envCellId in cellIds.Where(id => (id & 0xFFFFu) is >= 0x0100 and < 0xFF00)) { if (chained++ >= 400) break; if (!dats.Cell.TryGet(envCellId, out var envCell)) continue; portalIds.Add(0x0D000000u | envCell.EnvironmentId); foreach (var rawSurface in envCell.Surfaces) { uint surfaceId = 0x08000000u | rawSurface; if (!portalIds.Add(surfaceId)) continue; if (!dats.Portal.TryGet(surfaceId, out var surface) || (uint)surface.OrigTextureId == 0) continue; uint surfaceTextureId = (uint)surface.OrigTextureId; if (!portalIds.Add(surfaceTextureId)) continue; if (dats.Portal.TryGet(surfaceTextureId, out var st) && st.Textures.Count > 0) { uint renderSurfaceId = (uint)st.Textures[0]; portalIds.Add(renderSurfaceId); highResIds.Add(renderSurfaceId); // texture path probes highres too } } } refs.AddRange(portalIds.Select(id => new FileRef(DatFileSource.Portal, id))); refs.AddRange(highResIds.Select(id => new FileRef(DatFileSource.HighRes, id))); return refs; } private static Golden ReadRaw(DatCollection dats, FileRef r) { var db = Db(dats, r.Source); if (!db.TryGetFileBytes(r.Id, out byte[] bytes)) return new Golden(false, 0, 0); return new Golden(true, bytes.Length, Fnv(bytes)); } /// /// Typed read through the full production unpack path; the fingerprint folds /// the stable identity-bearing fields each consumer relies on. ok=false maps /// to 0 so success-flag flips always show. /// private static ulong ReadTypedFingerprint(DatCollection dats, FileRef r) { var db = Db(dats, r.Source); if (r.Source == DatFileSource.Cell) { if ((r.Id & 0xFFFFu) == 0xFFFFu) return db.TryGet(r.Id, out var lbk) ? Mix(1, (ulong)lbk.Height.Length) : 0; if ((r.Id & 0xFFFFu) == 0xFFFEu) return db.TryGet(r.Id, out var lbi) ? Mix(2, lbi.NumCells) : 0; return db.TryGet(r.Id, out var cell) ? Mix(3, (ulong)cell.CellPortals.Count << 32 | (uint)cell.Surfaces.Count << 16 | cell.EnvironmentId) : 0; } return (r.Id >> 24) switch { 0x0D => db.TryGet(r.Id, out var env) ? Mix(4, (ulong)env.Cells.Count) : 0, 0x08 => db.TryGet(r.Id, out var s) ? Mix(5, (ulong)s.Type << 32 | (uint)s.OrigTextureId) : 0, 0x05 => db.TryGet(r.Id, out var st) ? Mix(6, (ulong)st.Textures.Count << 32 | (st.Textures.Count > 0 ? (uint)st.Textures[0] : 0u)) : 0, 0x06 => db.TryGet(r.Id, out var rs) ? Mix(7, (ulong)rs.Width << 48 | (ulong)rs.Height << 32 | (uint)rs.SourceData.Length) : 0, _ => 0xFEEDu, // unexpected namespace — constant so it can't flap }; } private static DatDatabase Db(DatCollection dats, DatFileSource source) => source switch { DatFileSource.Cell => dats.Cell, DatFileSource.Portal => dats.Portal, _ => dats.HighRes, }; private static ulong Fnv(byte[] bytes) { ulong h = 14695981039346656037UL; foreach (var b in bytes) { h ^= b; h *= 1099511628211UL; } return h; } private static ulong Mix(ulong tag, ulong value) => (tag << 56) ^ value ^ 0xA5A5_5A5A_0000_0000UL; }