using System.Numerics; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Options; using SysEnv = System.Environment; string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"DAT directory not found: {datDir}"); return 2; } using var dats = new DatCollection(datDir, DatAccessType.Read); if (args.Length > 0 && string.Equals(args[0], "buildings", StringComparison.OrdinalIgnoreCase)) { uint landblockId = args.Length > 1 ? ParseHex(args[1]) : 0xA9B40000u; int radius = args.Length > 2 ? int.Parse(args[2], System.Globalization.CultureInfo.InvariantCulture) : 0; DumpBuildings(dats, landblockId, radius); } else if (args.Length > 0 && string.Equals(args[0], "portals", StringComparison.OrdinalIgnoreCase)) { var ids = args.Length == 1 ? new uint[] { 0xA9B40171u } : args.Skip(1).Select(ParseHex).ToArray(); foreach (var envCellId in ids) DumpCellPortals(dats, envCellId); } else { var ids = args.Length == 0 ? [0xA9B4013Fu] : args.Select(ParseHex).ToArray(); foreach (var envCellId in ids) { DumpCell(dats, envCellId); } } return 0; static uint ParseHex(string text) { text = text.Trim(); if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return Convert.ToUInt32(text[2..], 16); return Convert.ToUInt32(text, 16); } static void DumpCell(DatCollection dats, uint envCellId) { Console.WriteLine($"=== EnvCell 0x{envCellId:X8} ==="); var envCell = dats.Get(envCellId); if (envCell is null) { Console.WriteLine("missing EnvCell"); return; } var envId = 0x0D000000u | envCell.EnvironmentId; var environment = dats.Get(envId); if (environment is null) { Console.WriteLine($"missing Environment 0x{envId:X8}"); return; } if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { Console.WriteLine($"missing CellStruct {envCell.CellStructure}"); return; } Console.WriteLine( $"environment=0x{envId:X8} cellStruct={envCell.CellStructure} " + $"surfaces={envCell.Surfaces.Count} verts={cellStruct.VertexArray.Vertices.Count} polys={cellStruct.Polygons.Count}"); int posSides = 0; int negSides = 0; int skipped = 0; int likelyFloor = 0; int likelyCeiling = 0; foreach (var (polyId, poly) in cellStruct.Polygons.OrderBy(p => p.Key)) { if (poly.VertexIds.Count < 3) continue; bool emitPos = !poly.Stippling.HasFlag(StipplingType.NoPos) && poly.PosSurface >= 0 && poly.PosSurface < envCell.Surfaces.Count; bool emitNeg = (poly.Stippling.HasFlag(StipplingType.Negative) || poly.Stippling.HasFlag(StipplingType.Both) || (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise)) && poly.NegSurface >= 0 && poly.NegSurface < envCell.Surfaces.Count; if (emitPos) posSides++; if (emitNeg) negSides++; if (!emitPos && !emitNeg) skipped++; var normal = ComputeNormal(cellStruct, poly); var uvRange = UvRange(cellStruct, poly); string planeHint = Math.Abs(normal.Z) > 0.9f ? normal.Z > 0 ? "floor/up" : "ceiling/down" : Math.Abs(normal.Z) > 0.15f ? "slope" : "wall"; if (normal.Z > 0.9f) likelyFloor++; if (normal.Z < -0.9f) likelyCeiling++; var posSurf = SurfaceText(envCell, poly.PosSurface); var negSurf = SurfaceText(envCell, poly.NegSurface); Console.WriteLine( $"poly=0x{polyId:X4} pts={poly.VertexIds.Count} n=({normal.X:F3},{normal.Y:F3},{normal.Z:F3}) {planeHint,-12} " + $"stip={poly.Stippling} sides={poly.SidesType} pos={poly.PosSurface}->{posSurf} neg={poly.NegSurface}->{negSurf} " + $"emitPos={emitPos} emitNeg={emitNeg} posUv={poly.PosUVIndices?.Count ?? 0} negUv={poly.NegUVIndices?.Count ?? 0} " + $"uv=({uvRange.Min.X:F3},{uvRange.Min.Y:F3})..({uvRange.Max.X:F3},{uvRange.Max.Y:F3})"); } Console.WriteLine( $"summary: posSides={posSides} negSides={negSides} skipped={skipped} " + $"likelyFloor={likelyFloor} likelyCeiling={likelyCeiling}"); Console.WriteLine(); } static void DumpCellPortals(DatCollection dats, uint envCellId) { Console.WriteLine($"=== EnvCell 0x{envCellId:X8} portals ==="); var envCell = dats.Get(envCellId); if (envCell is null) { Console.WriteLine("missing EnvCell"); return; } // Resolve cellStruct so we can replicate BuildLoadedCell's portal-polygon // vertex resolution and report what LoadedCell.PortalPolygons[i] would hold. var envId = 0x0D000000u | envCell.EnvironmentId; var environment = dats.Get(envId); DatReaderWriter.Types.CellStruct? cellStruct = null; if (environment is not null && environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) cellStruct = cs; uint lbPrefix = envCellId & 0xFFFF0000u; int exitCount = 0; int interiorCount = 0; for (int i = 0; i < envCell.CellPortals.Count; i++) { var portal = envCell.CellPortals[i]; bool isExit = portal.OtherCellId == 0xFFFF; if (isExit) exitCount++; else interiorCount++; string dest = isExit ? "EXIT(outdoor)" : $"0x{(lbPrefix | (uint)portal.OtherCellId):X8}"; // Replicate BuildLoadedCell (GameWindow.cs:5816-5838): look up the portal // polygon by portal.PolygonId, require >= 3 verts, resolve every VertexId. // If any vertex is missing the loader stores an EMPTY array, which the // PortalVisibilityBuilder's guard (poly.Length < 3) silently skips. string resolveText; if (cellStruct is null) { resolveText = "no-cellStruct"; } else if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)) { resolveText = $"polygon {portal.PolygonId} NOT in cellStruct.Polygons (keys=[{string.Join(",", cellStruct.Polygons.Keys.OrderBy(k => k))}])"; } else { int vc = poly.VertexIds.Count; int resolved = 0; var missing = new List(); for (int vi = 0; vi < vc; vi++) { if (cellStruct.VertexArray.Vertices.ContainsKey((ushort)poly.VertexIds[vi])) resolved++; else missing.Add(poly.VertexIds[vi]); } bool wouldResolve = vc >= 3 && resolved == vc; resolveText = $"polyVerts={vc} resolved={resolved} BUILDER_SEES={(wouldResolve ? "OK" : "EMPTY/SKIPPED")} " + $"vids=[{string.Join(",", poly.VertexIds)}]" + (missing.Count > 0 ? $" MISSING=[{string.Join(",", missing)}]" : ""); } // U.4c side-test validation (supersedes the earlier `retailTraversesAtCentroid` // column, which the characterization doc flagged UNRELIABLE). Builds the portal // plane EXACTLY as GameWindow.BuildLoadedCell (Cross(p1-p0,p2-p0), d=-dot(N,p0)), // derives the OLD centroid InsideSide, reads the dat PortalSide, and runs THREE // candidate side senses for a camera SWEPT from the cell centroid toward (and a // bit past) the portal plane: // O = OLD centroid sense (CameraOnInteriorSide pre-fix) // A = PortalSide, winding-corrected (docs/research/2026-05-31-u4c-initcell-pseudocode.md) // B = PortalSide, doc-§314 literal intuition (opposite polarity) // FINDING (2026-05-31): for the real Holtburg cottage cells, A is BYTE-IDENTICAL // to O at every pose (the PortalSide swap is a NO-OP for the flap), and B culls // true-interior poses (wrong polarity). No side-test sense fixes the flap — the // flap is the 3rd-person eye legitimately crossing the portal plane while still // rooted in the cell, which any plane-side test culls. See the BLOCKED report. string sideText = "no-cellStruct"; if (cellStruct is not null && cellStruct.Polygons.TryGetValue(portal.PolygonId, out var sp) && sp.VertexIds.Count >= 3 && TryGetOrigin(cellStruct, (ushort)sp.VertexIds[0], out var s0) && TryGetOrigin(cellStruct, (ushort)sp.VertexIds[1], out var s1) && TryGetOrigin(cellStruct, (ushort)sp.VertexIds[2], out var s2)) { var n = Vector3.Cross(s1 - s0, s2 - s0); n = n.LengthSquared() > 0f ? Vector3.Normalize(n) : Vector3.Zero; float d = -Vector3.Dot(n, s0); // Cell centroid = AABB center over all cellStruct verts (matches BuildLoadedCell). var mn = new Vector3(float.MaxValue); var mx = new Vector3(float.MinValue); foreach (var v in cellStruct.VertexArray.Vertices.Values) { mn = Vector3.Min(mn, v.Origin); mx = Vector3.Max(mx, v.Origin); } var centroid = (mn + mx) * 0.5f; float centroidDot = Vector3.Dot(n, centroid) + d; int ourInsideSide = centroidDot >= 0 ? 0 : 1; // GameWindow.cs:5648 bool portalSide = !portal.Flags.HasFlag(PortalFlags.PortalSide); // PortalInfo.cs:44 bool int datPortalSide = portalSide ? 0 : 1; // raw bit (0 = bool true) const float eps = 0.01f; // PortalVisibilityBuilder.PortalSideEpsilon // Nearest portal-polygon vertex = the plane-ward sweep anchor. var pnear = s0; float bestD = centroidDot; foreach (var vid in sp.VertexIds) if (TryGetOrigin(cellStruct, (ushort)vid, out var vv)) { float vd = Vector3.Dot(n, vv) + d; if (Math.Abs(vd) < Math.Abs(bestD)) { bestD = vd; pnear = vv; } } static bool OldSense(int side, float dot, float e) => side == 0 ? dot >= -e : dot <= e; static bool SenseA(bool ps, float dot, float e) => ps ? dot < e : dot > -e; static bool SenseB(bool ps, float dot, float e) => ps ? dot > -e : dot < e; bool oAll = true, aAll = true, bAll = true; var trace = new List(); foreach (float t in new[] { 0f, 0.25f, 0.5f, 0.75f, 1.0f, 1.15f, 1.3f }) { var cam = Vector3.Lerp(centroid, pnear, t); float dot = Vector3.Dot(n, cam) + d; bool os = OldSense(ourInsideSide, dot, eps); bool a = SenseA(portalSide, dot, eps); bool b = SenseB(portalSide, dot, eps); if (!os) oAll = false; if (!a) aAll = false; if (!b) bAll = false; trace.Add($"t{t:F2}:D={dot:F2}/O={(os ? "T" : "x")}/A={(a ? "T" : "x")}/B={(b ? "T" : "x")}"); } sideText = $"N=({n.X:F2},{n.Y:F2},{n.Z:F2}) d={d:F2} centroidDot={centroidDot:F3} " + $"ourInsideSide={ourInsideSide} datPortalSide={datPortalSide} " + $"sweepTraversesAll[OLD={oAll} A={aAll} B={bAll}] [{string.Join(" ", trace)}]"; } Console.WriteLine( $" portal[{i}] other=0x{portal.OtherCellId:X4} -> {dest} " + $"flags={portal.Flags} polyId={portal.PolygonId} | {resolveText}"); Console.WriteLine($" SIDES: {sideText}"); } // U.4c H3 check: local AABB over all cellStruct verts. If the cell's AABB extends // PAST one of its own portal planes, a camera eye in that region is in this cell's // AABB (so an AABB-based FindCameraCell keeps rooting here) yet geometrically on the // neighbour's side of the portal (so the per-frame side test culls that portal) → // the flap's stale-root region. if (cellStruct is not null && cellStruct.VertexArray.Vertices.Count > 0) { var mn = new Vector3(float.MaxValue); var mx = new Vector3(float.MinValue); foreach (var v in cellStruct.VertexArray.Vertices.Values) { mn = Vector3.Min(mn, v.Origin); mx = Vector3.Max(mx, v.Origin); } Console.WriteLine($" localAABB: min=({mn.X:F2},{mn.Y:F2},{mn.Z:F2}) max=({mx.X:F2},{mx.Y:F2},{mx.Z:F2})"); } Console.WriteLine( $"summary: cell=0x{envCellId:X8} portals={envCell.CellPortals.Count} " + $"exits(0xFFFF)={exitCount} interior={interiorCount} " + $"numCellPortals={envCell.CellPortals.Count} seenOutside={(envCell.Flags.HasFlag(EnvCellFlags.SeenOutside) ? "Y" : "n")}"); Console.WriteLine(); } static void DumpBuildings(DatCollection dats, uint centerLandblockId, int radius) { int centerX = (int)((centerLandblockId >> 24) & 0xFFu); int centerY = (int)((centerLandblockId >> 16) & 0xFFu); int totalRegistryBuildings = 0; int totalShellEntities = 0; for (int dy = -radius; dy <= radius; dy++) for (int dx = -radius; dx <= radius; dx++) { int x = centerX + dx; int y = centerY + dy; if (x < 0 || x > 255 || y < 0 || y > 255) continue; uint landblockId = ((uint)x << 24) | ((uint)y << 16); var info = dats.Get(landblockId | 0xFFFEu); if (info is null) continue; var (registryBuildings, shellEntities) = DumpLandblockBuildings(info, landblockId); totalRegistryBuildings += registryBuildings; totalShellEntities += shellEntities; } Console.WriteLine( $"radius-summary center=0x{(centerLandblockId & 0xFFFF0000u):X8} radius={radius} " + $"registryBuildings={totalRegistryBuildings} shellEntities={totalShellEntities}"); } static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlockInfo info, uint landblockId) { uint lbPrefix = landblockId & 0xFFFF0000u; uint stabIdBase = 0xC0000000u | (((landblockId >> 24) & 0xFFu) << 16) | (((landblockId >> 16) & 0xFFu) << 8); uint nextEntityId = stabIdBase + 1u; int supportedObjects = 0; foreach (var obj in info.Objects) { if (!IsSupported(obj.Id)) continue; supportedObjects++; nextEntityId++; } Console.WriteLine( $"=== Landblock 0x{lbPrefix:X8} info=0x{(lbPrefix | 0xFFFEu):X8} " + $"objects={info.Objects.Count} supportedObjects={supportedObjects} buildings={info.Buildings.Count} ==="); int registryBuildingId = 1; int shellEntities = 0; foreach (var (building, zeroBased) in info.Buildings.Select((b, i) => (b, i))) { if (!IsSupported(building.ModelId)) continue; uint shellEntityId = nextEntityId++; shellEntities++; var portalCells = building.Portals .Where(p => p.OtherCellId != 0xFFFF) .Select(p => lbPrefix | (uint)p.OtherCellId) .Distinct() .OrderBy(id => id) .ToArray(); string registryText = portalCells.Length == 0 ? "none" : $"0x{registryBuildingId++:X}"; string portalText = portalCells.Length == 0 ? "[]" : "[" + string.Join(",", portalCells.Select(id => $"0x{id:X8}")) + "]"; Console.WriteLine( $"buildingOrdinal={zeroBased + 1} registryId={registryText} shellEntity=0x{shellEntityId:X8} " + $"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:F2},{building.Frame.Origin.Y:F2},{building.Frame.Origin.Z:F2}) " + $"portalCells={portalText}"); } Console.WriteLine(); return (registryBuildingId - 1, shellEntities); } static bool IsSupported(uint id) { uint type = id & 0xFF000000u; return type == 0x01000000u || type == 0x02000000u; } static string SurfaceText(EnvCell envCell, short index) { if (index < 0) return "none"; if (index >= envCell.Surfaces.Count) return "out-of-range"; return $"0x{(0x08000000u | envCell.Surfaces[index]):X8}"; } static Vector3 ComputeNormal(DatReaderWriter.Types.CellStruct cellStruct, DatReaderWriter.Types.Polygon poly) { if (poly.VertexIds.Count < 3) return Vector3.Zero; if (!TryGetOrigin(cellStruct, (ushort)poly.VertexIds[0], out var a) || !TryGetOrigin(cellStruct, (ushort)poly.VertexIds[1], out var b) || !TryGetOrigin(cellStruct, (ushort)poly.VertexIds[2], out var c)) { return Vector3.Zero; } var n = Vector3.Cross(b - a, c - a); return n.LengthSquared() > 0f ? Vector3.Normalize(n) : Vector3.Zero; } static bool TryGetOrigin(DatReaderWriter.Types.CellStruct cellStruct, ushort id, out Vector3 origin) { if (cellStruct.VertexArray.Vertices.TryGetValue(id, out var vertex)) { origin = vertex.Origin; return true; } origin = Vector3.Zero; return false; } static (Vector2 Min, Vector2 Max) UvRange(DatReaderWriter.Types.CellStruct cellStruct, DatReaderWriter.Types.Polygon poly) { var min = new Vector2(float.MaxValue, float.MaxValue); var max = new Vector2(float.MinValue, float.MinValue); bool any = false; for (int i = 0; i < poly.VertexIds.Count; i++) { if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[i], out var vertex)) continue; ushort uvIdx = 0; if (poly.PosUVIndices is not null && i < poly.PosUVIndices.Count) uvIdx = poly.PosUVIndices[i]; if (uvIdx >= vertex.UVs.Count) uvIdx = 0; if (vertex.UVs.Count == 0) continue; var uv = new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V); min = Vector2.Min(min, uv); max = Vector2.Max(max, uv); any = true; } return any ? (min, max) : (Vector2.Zero, Vector2.Zero); }