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); +}