diff --git a/src/AcDream.Core/World/Cells/CellPortal.cs b/src/AcDream.Core/World/Cells/CellPortal.cs
new file mode 100644
index 0000000..456c5bd
--- /dev/null
+++ b/src/AcDream.Core/World/Cells/CellPortal.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+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).
+/// Retail anchor: CCellPortal (acclient.h:32300).
+///
+public readonly struct CellPortal
+{
+ public uint OtherCellId { get; }
+ public ushort OtherPortalId { get; }
+ public ushort PolygonId { get; }
+ public ushort Flags { get; }
+ /// Matches the physics PortalInfo.PortalSide convention (PortalInfo.cs:44).
+ public bool PortalSide => (Flags & 0x2) == 0;
+ /// Cell-local portal polygon vertices. Carried now; consumed by PView at Stage 3.
+ public IReadOnlyList PolygonLocal { get; }
+
+ public CellPortal(uint otherCellId, ushort otherPortalId, ushort polygonId, ushort flags,
+ IReadOnlyList? polygonLocal = null)
+ {
+ OtherCellId = otherCellId;
+ OtherPortalId = otherPortalId;
+ PolygonId = polygonId;
+ Flags = flags;
+ PolygonLocal = polygonLocal ?? Array.Empty();
+ }
+}
diff --git a/src/AcDream.Core/World/Cells/ObjCell.cs b/src/AcDream.Core/World/Cells/ObjCell.cs
new file mode 100644
index 0000000..7fdb619
--- /dev/null
+++ b/src/AcDream.Core/World/Cells/ObjCell.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.World.Cells;
+
+///
+/// Base for every cell the player can stand in. Retail anchor: CObjCell
+/// (acclient.h:30915). The id magnitude is the type discriminator
+/// (): low-16 >= 0x100 => indoor ,
+/// else outdoor .
+///
+public abstract class ObjCell
+{
+ public uint Id { get; }
+ public Matrix4x4 WorldTransform { get; }
+ public Matrix4x4 InverseWorldTransform { get; }
+ public Vector3 LocalBoundsMin { get; }
+ public Vector3 LocalBoundsMax { get; }
+ public IReadOnlyList Portals { get; }
+ public IReadOnlyList StabList { get; }
+ public bool SeenOutside { get; }
+
+ /// Retail magnitude dispatch (CObjCell::GetVisible, pseudo_c:308215).
+ public bool IsEnv => (Id & 0xFFFFu) >= 0x100u;
+
+ protected ObjCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform,
+ Vector3 localBoundsMin, Vector3 localBoundsMax,
+ IReadOnlyList portals, IReadOnlyList stabList,
+ bool seenOutside)
+ {
+ Id = id;
+ WorldTransform = worldTransform;
+ InverseWorldTransform = inverseWorldTransform;
+ LocalBoundsMin = localBoundsMin;
+ LocalBoundsMax = localBoundsMax;
+ Portals = portals;
+ StabList = stabList;
+ SeenOutside = seenOutside;
+ }
+
+ /// Retail CObjCell::point_in_cell (vtable +0x84). Is a world point inside this cell?
+ public abstract bool PointInCell(Vector3 worldPoint);
+}
diff --git a/tests/AcDream.Core.Tests/World/Cells/ObjCellBaseTests.cs b/tests/AcDream.Core.Tests/World/Cells/ObjCellBaseTests.cs
new file mode 100644
index 0000000..8dbb5a2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/World/Cells/ObjCellBaseTests.cs
@@ -0,0 +1,36 @@
+using System.Numerics;
+using AcDream.Core.World.Cells;
+using Xunit;
+
+namespace AcDream.Core.Tests.World.Cells;
+
+public class ObjCellBaseTests
+{
+ private sealed class StubCell : ObjCell
+ {
+ public StubCell(uint id)
+ : base(id, Matrix4x4.Identity, Matrix4x4.Identity,
+ Vector3.Zero, Vector3.One,
+ System.Array.Empty(), System.Array.Empty(), false) { }
+ public override bool PointInCell(Vector3 worldPoint) => false;
+ }
+
+ [Theory]
+ [InlineData(0xA9B40174u, true)]
+ [InlineData(0xA9B40005u, false)]
+ [InlineData(0xA9B40100u, true)]
+ [InlineData(0xA9B400FFu, false)]
+ public void IsEnv_DispatchesByLow16Magnitude(uint id, bool expected)
+ => Assert.Equal(expected, new StubCell(id).IsEnv);
+
+ [Fact]
+ public void Ctor_StoresBaseProperties()
+ {
+ var c = new StubCell(0xA9B40174u);
+ Assert.Equal(0xA9B40174u, c.Id);
+ Assert.Equal(Vector3.One, c.LocalBoundsMax);
+ Assert.Empty(c.Portals);
+ Assert.Empty(c.StabList);
+ Assert.False(c.SeenOutside);
+ }
+}