using System; using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.Rendering; using DatReaderWriter; using DatReaderWriter.Options; using Xunit; using Xunit.Abstractions; using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; namespace AcDream.App.Tests.Rendering; /// /// #127 — per-building flood admissions are BISTABLE per frame under the /// outdoor root (the building-flap mechanism: tower roof/edges flap, #123 /// buildings vanishing when running past). Captured live with the [viewer] /// fwd= probe (flap-fwd-capture2.log): a ~4 cm eye wobble flips the merged /// flood 24↔25 with a byte-identical gaze: /// flood=25 eye=(129.051,-15.636,99.512) fwd=(-0.7565,0.5877,-0.2869) /// flood=24 eye=(129.009,-15.610,99.503) fwd=(same) /// flood=25 eye=(129.051,-15.642,99.514) fwd=(same) /// (player parked at pCell=0xA9B40019, Holtburg town, A9B4-anchored world /// frame — A9B4-local == world; A9B3 cells get a (0,-192,0) block offset.) /// /// This harness replays the captured (eye, fwd) pairs through the /// production outdoor pipeline — Build(outdoorNode) + per-building /// ConstructViewBuilding merged (RetailPViewRenderer.MergeNearbyBuildingFloods /// shape) — and DIFFS the admitted cell sets: the flipping cell names the /// bistable admission, and the bisect along the eye segment names the knife /// edge. /// public class Issue127FloodFlipReplayTests { private readonly ITestOutputHelper _out; public Issue127FloodFlipReplayTests(ITestOutputHelper output) => _out = output; // The captured flip pair (A9B4-anchored world frame). private static readonly Vector3 EyeA = new(129.051f, -15.636f, 99.512f); private static readonly Vector3 EyeB = new(129.009f, -15.610f, 99.503f); private static readonly Vector3 Fwd = Vector3.Normalize(new Vector3(-0.7565f, 0.5877f, -0.2869f)); private sealed record World( Dictionary Cells, List> BuildingGroups, Func Lookup); private static void LoadLandblockBuildings( DatCollection dats, uint landblock, Vector3 offset, Dictionary cells, List> groups) { var lbi = dats.Get(landblock | 0xFFFEu); if (lbi is null) return; // all interior cells (the lookup needs every portal-reachable cell) for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++) { uint id = landblock | low; try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id, offset); } catch (InvalidOperationException) { } } // per-building groups from the bldPortal stab lists (the dat's own // building→cells mapping; mirrors BuildingLoader's seed+walk result) if (lbi.Buildings is null) return; foreach (var bld in lbi.Buildings) { var set = new HashSet(); if (bld.Portals is not null) foreach (var bp in bld.Portals) if (bp.StabList is not null) foreach (ushort low in bp.StabList) set.Add(landblock | low); var group = new List(); foreach (uint id in set) if (cells.TryGetValue(id, out var c)) group.Add(c); if (group.Count > 0) groups.Add(group); } } private World? LoadWorld() { var datDir = CornerFloodReplayTests.ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; } using var dats = new DatCollection(datDir, DatAccessType.Read); var cells = new Dictionary(); var groups = new List>(); // A9B4 = the anchor (world == local); the live gather spans the // streaming near window, so load a 5x5 block neighbourhood with // block offsets relative to the anchor. for (int dx = -2; dx <= 2; dx++) for (int dy = -2; dy <= 2; dy++) { uint lbX = (uint)(0xA9 + dx); uint lbY = (uint)(0xB4 + dy); uint lb = (lbX << 24) | (lbY << 16); LoadLandblockBuildings(dats, lb, new Vector3(dx * 192f, dy * 192f, 0f), cells, groups); } _out.WriteLine($"loaded {cells.Count} cells in {groups.Count} building groups"); Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; return new World(cells, groups, lookup); } private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 fwd, float fovY) { var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, 1280f / 720f, 0.1f, 5000f); return view * proj; } // PortalBounds per group: the union AABB of the group's exit-portal // polygons in world space — exactly BuildingLoader's construction // (BuildingLoader.cs:114-144). private static (bool Has, Vector3 Min, Vector3 Max) PortalBoundsFor(List group) { bool has = false; var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); foreach (var cell in group) { int n = Math.Min(cell.Portals.Count, cell.PortalPolygons.Count); for (int i = 0; i < n; i++) { if (cell.Portals[i].OtherCellId != 0xFFFF) continue; var poly = cell.PortalPolygons[i]; if (poly is null) continue; foreach (var v in poly) { var world = Vector3.Transform(v, cell.WorldTransform); has = true; min = Vector3.Min(min, world); max = Vector3.Max(max, world); } } } return (has, min, max); } // The production outdoor pipeline: full-screen outdoor root + per-building // exterior floods merged as a union (RetailPViewRenderer.DrawInside + // MergeNearbyBuildingFloods + MergeBuildingFrame), INCLUDING the gather's // per-building frustum pre-gate on PortalBounds (GameWindow:7542-7545) — // controlled by so the gate itself can be // implicated or exonerated. private static HashSet AdmittedCells(World w, Vector3 eye, Vector3 fwd, float fovY, bool withPreGate) { var viewProj = ViewProjFor(eye, fwd, fovY); var frustum = FrustumPlanes.FromViewProjection(viewProj); var outdoor = OutdoorCellNode.Build(0xA9B40019u); var pv = PortalVisibilityBuilder.Build(outdoor, eye, w.Lookup, viewProj); foreach (var group in w.BuildingGroups) { if (withPreGate) { var (has, min, max) = PortalBoundsFor(group); if (has && !FrustumCuller.IsAabbVisible(frustum, min, max)) continue; } var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj); foreach (uint cellId in bf.OrderedVisibleCells) { if (!bf.CellViews.TryGetValue(cellId, out var srcView)) continue; if (pv.CellViews.TryGetValue(cellId, out var existing)) { foreach (var p in srcView.Polygons) existing.Add(p); continue; } pv.CellViews[cellId] = srcView; pv.OrderedVisibleCells.Add(cellId); } } return new HashSet(pv.OrderedVisibleCells); } /// /// Reproduce + name: the captured 4 cm pair must admit the SAME cell set /// (a stable world does not flap a building interior for a 4 cm eye /// wobble at identical gaze). The diff names the bistable cell(s); the /// bisect prints the knife-edge location and transition count. /// [Fact] public void CapturedFlipPair_AdmissionIsStable() { var w = LoadWorld(); if (w is null) return; foreach (bool preGate in new[] { false, true }) foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f, 0.9f }) { var a = AdmittedCells(w, EyeA, Fwd, fov, preGate); var b = AdmittedCells(w, EyeB, Fwd, fov, preGate); var onlyA = a.Except(b).OrderBy(x => x).ToList(); var onlyB = b.Except(a).OrderBy(x => x).ToList(); _out.WriteLine(FormattableString.Invariant( $"preGate={preGate} fov={fov:F2}: |A|={a.Count} |B|={b.Count} onlyA=[{string.Join(",", onlyA.Select(x => $"0x{x:X8}"))}] onlyB=[{string.Join(",", onlyB.Select(x => $"0x{x:X8}"))}]")); if (onlyA.Count == 0 && onlyB.Count == 0) continue; // bisect the segment: count admission transitions per flipping cell foreach (uint cell in onlyA.Concat(onlyB)) { int transitions = 0; bool? prev = null; for (int i = 0; i <= 40; i++) { var eye = Vector3.Lerp(EyeA, EyeB, i / 40f); bool admitted = AdmittedCells(w, eye, Fwd, fov, preGate).Contains(cell); if (prev.HasValue && admitted != prev.Value) transitions++; prev = admitted; } _out.WriteLine(FormattableString.Invariant( $" cell 0x{cell:X8}: {transitions} admission transition(s) along the 4 cm segment")); } Assert.Fail($"flood admission differs across the captured 4 cm pair (preGate={preGate}, fov={fov:F2}) — see output for the flipping cells"); } } }