feat(core): UCG Stage 1 — EnvCell.FromDat derivation (mirrors BuildLoadedCell)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 09:07:18 +02:00
parent 76c9e2f07d
commit 5bc72d5cd1
4 changed files with 109 additions and 4 deletions

View file

@ -5,12 +5,17 @@ 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>).
/// Unified cell-to-cell portal edge for Phase-U purposes. Superset of the three
/// legacy portal types (render <c>CellPortalInfo</c>, physics <c>PortalInfo</c>,
/// <c>PortalPlane</c>). <c>ExactMatch</c> (packed bit 0 of <see cref="Flags"/>)
/// is carried in <see cref="Flags"/> but not yet exposed as a named property.
/// Retail anchor: CCellPortal (acclient.h:32300).
/// </summary>
public readonly struct CellPortal
{
/// <summary>
/// Full 32-bit (landblock-prefixed) cell id, unlike physics PortalInfo.OtherCellId which is low-16 only.
/// </summary>
public uint OtherCellId { get; }
public ushort OtherPortalId { get; }
public ushort PolygonId { get; }

View file

@ -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;
}
/// <summary>
/// Build an EnvCell from dat data. Mirrors the render derivation in
/// BuildLoadedCell (GameWindow.cs:5588-5704) so Core and render stay equivalent.
/// <paramref name="worldTransform"/> MUST be the physics-verbatim transform
/// (no +2 cm render lift).
/// </summary>
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<CellPortal>(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<uint>(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<Vector3> ResolvePortalPolygon(CellStruct cellStruct, ushort polygonId)
{
if (!cellStruct.Polygons.TryGetValue(polygonId, out var poly)) return Array.Empty<Vector3>();
var verts = new List<Vector3>(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;
}
}

View file

@ -20,7 +20,11 @@ public abstract class ObjCell
public IReadOnlyList<uint> StabList { get; }
public bool SeenOutside { get; }
/// <summary>Retail magnitude dispatch (CObjCell::GetVisible, pseudo_c:308215).</summary>
/// <summary>
/// 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.
/// </summary>
public bool IsEnv => (Id & 0xFFFFu) >= 0x100u;
protected ObjCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform,

View file

@ -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<ushort, SWVertex>() }, // empty -> bounds fallback
Polygons = new Dictionary<ushort, Polygon>(),
}; // CellBSP defaults to null → ContainmentBsp will be null
var dat = new DatEnvCell
{
Flags = EnvCellFlags.SeenOutside,
CellPortals = new List<DatCellPortal>
{
new() { OtherCellId = 0x0105, PolygonId = 0, OtherPortalId = 7, Flags = (PortalFlags)0 },
},
VisibleCells = new List<ushort> { 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);
}
}