feat(core): UCG Stage 1 — ObjCell base + CellPortal

Introduces AcDream.Core.World.Cells namespace with the two foundational
types for the Unified Cell Graph. CellPortal is a readonly struct
unifying the three legacy portal representations; ObjCell is the abstract
base for all traversable cells with the retail id-magnitude IsEnv
discriminator (CObjCell::GetVisible, pseudo_c:308215). Zero consumers;
zero behavior change. 5/5 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 08:51:55 +02:00
parent bd0244f203
commit 9cb15710be
3 changed files with 111 additions and 0 deletions

View file

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.World.Cells;
/// <summary>
/// Unified cell-to-cell portal edge. Superset of the three legacy portal types
/// (render <c>CellPortalInfo</c>, physics <c>PortalInfo</c>, <c>PortalPlane</c>).
/// Retail anchor: CCellPortal (acclient.h:32300).
/// </summary>
public readonly struct CellPortal
{
public uint OtherCellId { get; }
public ushort OtherPortalId { get; }
public ushort PolygonId { get; }
public ushort Flags { get; }
/// <summary>Matches the physics <c>PortalInfo.PortalSide</c> convention (PortalInfo.cs:44).</summary>
public bool PortalSide => (Flags & 0x2) == 0;
/// <summary>Cell-local portal polygon vertices. Carried now; consumed by PView at Stage 3.</summary>
public IReadOnlyList<Vector3> PolygonLocal { get; }
public CellPortal(uint otherCellId, ushort otherPortalId, ushort polygonId, ushort flags,
IReadOnlyList<Vector3>? polygonLocal = null)
{
OtherCellId = otherCellId;
OtherPortalId = otherPortalId;
PolygonId = polygonId;
Flags = flags;
PolygonLocal = polygonLocal ?? Array.Empty<Vector3>();
}
}

View file

@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.World.Cells;
/// <summary>
/// Base for every cell the player can stand in. Retail anchor: CObjCell
/// (acclient.h:30915). The id magnitude is the type discriminator
/// (<see cref="IsEnv"/>): low-16 &gt;= 0x100 =&gt; indoor <see cref="EnvCell"/>,
/// else outdoor <see cref="LandCell"/>.
/// </summary>
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<CellPortal> Portals { get; }
public IReadOnlyList<uint> StabList { get; }
public bool SeenOutside { get; }
/// <summary>Retail magnitude dispatch (CObjCell::GetVisible, pseudo_c:308215).</summary>
public bool IsEnv => (Id & 0xFFFFu) >= 0x100u;
protected ObjCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform,
Vector3 localBoundsMin, Vector3 localBoundsMax,
IReadOnlyList<CellPortal> portals, IReadOnlyList<uint> stabList,
bool seenOutside)
{
Id = id;
WorldTransform = worldTransform;
InverseWorldTransform = inverseWorldTransform;
LocalBoundsMin = localBoundsMin;
LocalBoundsMax = localBoundsMax;
Portals = portals;
StabList = stabList;
SeenOutside = seenOutside;
}
/// <summary>Retail CObjCell::point_in_cell (vtable +0x84). Is a world point inside this cell?</summary>
public abstract bool PointInCell(Vector3 worldPoint);
}

View file

@ -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<CellPortal>(), System.Array.Empty<uint>(), 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);
}
}