diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs
new file mode 100644
index 0000000..80ee9ac
--- /dev/null
+++ b/src/AcDream.Core/World/Cells/CellGraph.cs
@@ -0,0 +1,53 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics; // TerrainSurface
+
+namespace AcDream.Core.World.Cells;
+
+///
+/// The unified cell graph: the authoritative id->cell resolver and registry.
+/// Built alongside the legacy render/physics cell systems in Stage 1 and consumed
+/// by nobody (zero behavior change). Retail anchor: CObjCell::GetVisible (pseudo_c:308209).
+/// Worker-thread populated; reads are concurrency-safe.
+///
+public sealed class CellGraph
+{
+ private readonly ConcurrentDictionary _envCells = new();
+ private readonly ConcurrentDictionary _terrain = new();
+
+ /// Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer).
+ public ObjCell? CurrCell { get; internal set; }
+
+ public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId);
+
+ public void Add(EnvCell cell) => _envCells.TryAdd(cell.Id, cell);
+
+ /// Any id in the cell's landblock; masked to (id & 0xFFFF0000).
+ public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
+ => _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin);
+
+ public void RemoveLandblock(uint landblockPrefix)
+ {
+ uint lb = landblockPrefix & 0xFFFF0000u;
+ _terrain.TryRemove(lb, out _);
+ foreach (var id in new List(_envCells.Keys))
+ if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
+ }
+
+ /// The universal id->cell resolver (retail CObjCell::GetVisible).
+ public ObjCell? GetVisible(uint id)
+ {
+ if (id == 0u) return null;
+ if ((id & 0xFFFFu) >= 0x100u)
+ return _envCells.TryGetValue(id, out var env) ? env : null;
+
+ uint low = id & 0xFFFFu;
+ if (low < 1u || low > 0x40u) return null;
+ if (!_terrain.TryGetValue(id & 0xFFFF0000u, out var t)) return null;
+ int idx = (int)(low - 1u);
+ return LandCell.Synthesize(id, t.Terrain, t.Origin, idx / 8, idx % 8);
+ }
+
+ public ObjCell? Neighbor(ObjCell cell, in CellPortal portal) => GetVisible(portal.OtherCellId);
+}
diff --git a/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs b/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs
new file mode 100644
index 0000000..ce987e1
--- /dev/null
+++ b/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs
@@ -0,0 +1,72 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.World.Cells;
+using Xunit;
+
+namespace AcDream.Core.Tests.World.Cells;
+
+public class CellGraphTests
+{
+ private static TerrainSurface FlatTerrain() => new TerrainSurface(new byte[81], new float[256]);
+
+ private static EnvCell Env(uint id) => new EnvCell(id, Matrix4x4.Identity, Matrix4x4.Identity,
+ Vector3.Zero, new Vector3(10,10,10), System.Array.Empty(),
+ System.Array.Empty(), false, null);
+
+ [Fact]
+ public void GetVisible_ZeroId_ReturnsNull()
+ => Assert.Null(new CellGraph().GetVisible(0u));
+
+ [Fact]
+ public void GetVisible_EnvId_ReturnsAddedEnvCell()
+ {
+ var g = new CellGraph();
+ var env = Env(0xA9B40174u);
+ g.Add(env);
+ Assert.Same(env, g.GetVisible(0xA9B40174u));
+ }
+
+ [Fact]
+ public void GetVisible_UnknownEnvId_ReturnsNull()
+ => Assert.Null(new CellGraph().GetVisible(0xA9B40174u));
+
+ [Fact]
+ public void GetVisible_LandId_SynthesizesFromRegisteredTerrain()
+ {
+ var g = new CellGraph();
+ g.RegisterTerrain(0xA9B40000u, FlatTerrain(), new Vector3(1000,2000,0));
+ var cell = g.GetVisible(0xA9B40014u);
+ var land = Assert.IsType(cell);
+ Assert.Equal(2, land.Cx);
+ Assert.Equal(3, land.Cy);
+ }
+
+ [Fact]
+ public void GetVisible_LandId_NoTerrain_ReturnsNull()
+ => Assert.Null(new CellGraph().GetVisible(0xA9B40014u));
+
+ [Fact]
+ public void RemoveLandblock_EvictsEnvAndTerrain()
+ {
+ var g = new CellGraph();
+ g.Add(Env(0xA9B40174u));
+ g.RegisterTerrain(0xA9B40000u, FlatTerrain(), Vector3.Zero);
+ g.RemoveLandblock(0xA9B40000u);
+ Assert.Null(g.GetVisible(0xA9B40174u));
+ Assert.Null(g.GetVisible(0xA9B40014u));
+ }
+
+ [Fact]
+ public void Neighbor_ResolvesPortalOtherCellId()
+ {
+ var g = new CellGraph();
+ var target = Env(0xA9B40175u);
+ g.Add(target);
+ var portal = new CellPortal(0xA9B40175u, 0, 0, 0);
+ Assert.Same(target, g.Neighbor(Env(0xA9B40174u), portal));
+ }
+
+ [Fact]
+ public void CurrCell_IsNull_InStage1()
+ => Assert.Null(new CellGraph().CurrCell);
+}