diff --git a/src/AcDream.Core/World/Cells/CellPortal.cs b/src/AcDream.Core/World/Cells/CellPortal.cs
index 456c5bd..c82c5f3 100644
--- a/src/AcDream.Core/World/Cells/CellPortal.cs
+++ b/src/AcDream.Core/World/Cells/CellPortal.cs
@@ -5,12 +5,17 @@ using System.Numerics;
namespace AcDream.Core.World.Cells;
///
-/// Unified cell-to-cell portal edge. Superset of the three legacy portal types
-/// (render CellPortalInfo, physics PortalInfo, PortalPlane).
+/// Unified cell-to-cell portal edge for Phase-U purposes. Superset of the three
+/// legacy portal types (render CellPortalInfo, physics PortalInfo,
+/// PortalPlane). ExactMatch (packed bit 0 of )
+/// is carried in but not yet exposed as a named property.
/// Retail anchor: CCellPortal (acclient.h:32300).
///
public readonly struct CellPortal
{
+ ///
+ /// Full 32-bit (landblock-prefixed) cell id, unlike physics PortalInfo.OtherCellId which is low-16 only.
+ ///
public uint OtherCellId { get; }
public ushort OtherPortalId { get; }
public ushort PolygonId { get; }
diff --git a/src/AcDream.Core/World/Cells/EnvCell.cs b/src/AcDream.Core/World/Cells/EnvCell.cs
index 3a256b1..f4dc2e0 100644
--- a/src/AcDream.Core/World/Cells/EnvCell.cs
+++ b/src/AcDream.Core/World/Cells/EnvCell.cs
@@ -1,7 +1,9 @@
+using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics; // BSPQuery
-using DatReaderWriter.Types; // CellBSPTree
+using DatReaderWriter.Enums; // EnvCellFlags
+using DatReaderWriter.Types; // CellBSPTree, CellStruct
namespace AcDream.Core.World.Cells;
@@ -30,4 +32,55 @@ public sealed class EnvCell : ObjCell
&& local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y
&& local.Z >= LocalBoundsMin.Z && local.Z <= LocalBoundsMax.Z;
}
+
+ ///
+ /// Build an EnvCell from dat data. Mirrors the render derivation in
+ /// BuildLoadedCell (GameWindow.cs:5588-5704) so Core and render stay equivalent.
+ /// MUST be the physics-verbatim transform
+ /// (no +2 cm render lift).
+ ///
+ public static EnvCell FromDat(uint id, DatReaderWriter.DBObjs.EnvCell datCell,
+ CellStruct cellStruct, Matrix4x4 worldTransform)
+ {
+ Matrix4x4.Invert(worldTransform, out var inverse);
+
+ var min = new Vector3(float.MaxValue);
+ var max = new Vector3(float.MinValue);
+ foreach (var kvp in cellStruct.VertexArray.Vertices)
+ {
+ var p = new Vector3(kvp.Value.Origin.X, kvp.Value.Origin.Y, kvp.Value.Origin.Z);
+ min = Vector3.Min(min, p);
+ max = Vector3.Max(max, p);
+ }
+ if (min.X == float.MaxValue) { min = Vector3.Zero; max = Vector3.Zero; }
+
+ var portals = new List(datCell.CellPortals.Count);
+ foreach (var p in datCell.CellPortals)
+ {
+ portals.Add(new CellPortal(
+ otherCellId: p.OtherCellId,
+ otherPortalId: p.OtherPortalId,
+ polygonId: p.PolygonId,
+ flags: (ushort)p.Flags,
+ polygonLocal: ResolvePortalPolygon(cellStruct, p.PolygonId)));
+ }
+
+ uint lbPrefix = id & 0xFFFF0000u;
+ var stab = new List(datCell.VisibleCells.Count);
+ foreach (var low in datCell.VisibleCells) stab.Add(lbPrefix | low);
+ bool seenOutside = datCell.Flags.HasFlag(EnvCellFlags.SeenOutside);
+
+ return new EnvCell(id, worldTransform, inverse, min, max, portals, stab,
+ seenOutside, cellStruct.CellBSP);
+ }
+
+ private static IReadOnlyList ResolvePortalPolygon(CellStruct cellStruct, ushort polygonId)
+ {
+ if (!cellStruct.Polygons.TryGetValue(polygonId, out var poly)) return Array.Empty();
+ var verts = new List(poly.VertexIds.Count);
+ foreach (var vid in poly.VertexIds)
+ if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
+ verts.Add(new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z));
+ return verts;
+ }
}
diff --git a/src/AcDream.Core/World/Cells/ObjCell.cs b/src/AcDream.Core/World/Cells/ObjCell.cs
index 7fdb619..9c8f0bb 100644
--- a/src/AcDream.Core/World/Cells/ObjCell.cs
+++ b/src/AcDream.Core/World/Cells/ObjCell.cs
@@ -20,7 +20,11 @@ public abstract class ObjCell
public IReadOnlyList StabList { get; }
public bool SeenOutside { get; }
- /// Retail magnitude dispatch (CObjCell::GetVisible, pseudo_c:308215).
+ ///
+ /// Retail magnitude dispatch (CObjCell::GetVisible, pseudo_c:308215).
+ /// Note: retail's CObjCell::GetVisible tests the full id; every real prefixed EnvCell id is >= 0x100.
+ /// This masks the low-16 so it works for both bare-16 and landblock-prefixed ids.
+ ///
public bool IsEnv => (Id & 0xFFFFu) >= 0x100u;
protected ObjCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform,
diff --git a/tests/AcDream.Core.Tests/World/Cells/EnvCellFromDatTests.cs b/tests/AcDream.Core.Tests/World/Cells/EnvCellFromDatTests.cs
new file mode 100644
index 0000000..b13d5c7
--- /dev/null
+++ b/tests/AcDream.Core.Tests/World/Cells/EnvCellFromDatTests.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.World.Cells;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
+using DatCellPortal = DatReaderWriter.Types.CellPortal;
+
+namespace AcDream.Core.Tests.World.Cells;
+
+public class EnvCellFromDatTests
+{
+ [Fact]
+ public void FromDat_DerivesSeenOutside_OtherPortalId_PrefixedStab_AndBoundsFallback()
+ {
+ var cellStruct = new CellStruct
+ {
+ VertexArray = new VertexArray { Vertices = new Dictionary() }, // empty -> bounds fallback
+ Polygons = new Dictionary(),
+ }; // CellBSP defaults to null → ContainmentBsp will be null
+ var dat = new DatEnvCell
+ {
+ Flags = EnvCellFlags.SeenOutside,
+ CellPortals = new List
+ {
+ new() { OtherCellId = 0x0105, PolygonId = 0, OtherPortalId = 7, Flags = (PortalFlags)0 },
+ },
+ VisibleCells = new List { 0x0105, 0x0106 },
+ };
+
+ var env = EnvCell.FromDat(0xA9B40104u, dat, cellStruct, Matrix4x4.Identity);
+
+ Assert.True(env.SeenOutside);
+ Assert.Single(env.Portals);
+ Assert.Equal(0x0105u, env.Portals[0].OtherCellId);
+ Assert.Equal((ushort)7, env.Portals[0].OtherPortalId);
+ Assert.Equal(new[] { 0xA9B40105u, 0xA9B40106u }, env.StabList);
+ Assert.Equal(Vector3.Zero, env.LocalBoundsMin);
+ Assert.Equal(Vector3.Zero, env.LocalBoundsMax);
+ Assert.Null(env.ContainmentBsp);
+ }
+}