1459 lines
60 KiB
C#
1459 lines
60 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Numerics;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Types;
|
|
using Plane = System.Numerics.Plane;
|
|
using UcgEnvCell = AcDream.Core.World.Cells.EnvCell;
|
|
using UcgCellGraph = AcDream.Core.World.Cells.CellGraph;
|
|
using PreparedCellGraphLandblock = AcDream.Core.World.Cells.PreparedCellGraphLandblock;
|
|
|
|
namespace AcDream.Core.Physics;
|
|
|
|
/// <summary>
|
|
/// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup
|
|
/// dat objects during streaming and live-object materialization. Prepared
|
|
/// payloads are built/read off the hot path; one update-thread publisher
|
|
/// installs prepared production records without retaining the parsed DAT
|
|
/// collision graph. The public constructor remains the graph-oracle seam for
|
|
/// conformance fixtures and bake/equivalence tooling. ConcurrentDictionary
|
|
/// keeps diagnostics and tooling reads safe without a global lock.
|
|
/// </summary>
|
|
public sealed class PhysicsDataCache
|
|
{
|
|
private readonly bool _requirePreparedCollision;
|
|
private PhysicsDataCache? _readFallback;
|
|
private readonly CollisionWorldStateSlot _collisionWorld;
|
|
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
|
|
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
|
|
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
|
|
private ConcurrentDictionary<uint, CellPhysics> _cellStruct =>
|
|
_collisionWorld.Current.CellStruct;
|
|
private readonly ConcurrentDictionary<uint, FlatGfxObjCollisionAsset>
|
|
_flatGfxObj = new();
|
|
private readonly ConcurrentDictionary<uint, FlatSetupCollision>
|
|
_flatSetup = new();
|
|
private ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
|
|
_flatCellStruct => _collisionWorld.Current.FlatCellStruct;
|
|
private ConcurrentDictionary<uint, FlatEnvCellTopology>
|
|
_flatEnvCell => _collisionWorld.Current.FlatEnvCell;
|
|
|
|
public PhysicsDataCache()
|
|
: this(requirePreparedCollision: false)
|
|
{
|
|
}
|
|
|
|
private PhysicsDataCache(bool requirePreparedCollision)
|
|
: this(requirePreparedCollision, new CollisionWorldStateSlot())
|
|
{
|
|
}
|
|
|
|
private PhysicsDataCache(
|
|
bool requirePreparedCollision,
|
|
CollisionWorldStateSlot collisionWorld)
|
|
{
|
|
_requirePreparedCollision = requirePreparedCollision;
|
|
_collisionWorld = collisionWorld
|
|
?? throw new ArgumentNullException(nameof(collisionWorld));
|
|
CellGraph = new UcgCellGraph(_collisionWorld);
|
|
if (!requirePreparedCollision
|
|
&& PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
|
|
{
|
|
CollisionShadow = new CollisionShadowVerifier(
|
|
PhysicsDiagnostics.CollisionShadowSampleEvery,
|
|
PhysicsDiagnostics.CollisionShadowArtifactDirectory);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the gameplay cache with Slice I6's prepared flat collision
|
|
/// representation authoritative. The public constructor deliberately
|
|
/// remains the graph-oracle fixture seam for conformance and bake tooling.
|
|
/// </summary>
|
|
public static PhysicsDataCache CreateProduction()
|
|
{
|
|
var cache = new PhysicsDataCache(requirePreparedCollision: true)
|
|
{
|
|
CollisionTraversalMode = CollisionTraversalMode.Flat,
|
|
};
|
|
return cache;
|
|
}
|
|
|
|
internal static PhysicsDataCache CreateProduction(
|
|
CollisionWorldStateSlot collisionWorld)
|
|
{
|
|
var cache = new PhysicsDataCache(
|
|
requirePreparedCollision: true,
|
|
collisionWorld)
|
|
{
|
|
CollisionTraversalMode = CollisionTraversalMode.Flat,
|
|
};
|
|
return cache;
|
|
}
|
|
|
|
internal CollisionShadowVerifier? CollisionShadow { get; set; }
|
|
|
|
public CollisionShadowStats CollisionShadowStats =>
|
|
CollisionShadow?.Stats ?? default;
|
|
|
|
/// <summary>
|
|
/// Slice I graph/flat differential selector. Production is permanently
|
|
/// flat-authoritative. Graph remains an explicit fixture/oracle mode.
|
|
/// </summary>
|
|
internal CollisionTraversalMode CollisionTraversalMode { get; set; } =
|
|
CollisionTraversalMode.Graph;
|
|
|
|
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
|
|
private ConcurrentDictionary<uint, BuildingPhysics> _buildings =>
|
|
_collisionWorld.Current.Buildings;
|
|
|
|
/// <summary>
|
|
/// The unified cell graph (UCG): the active id->cell resolver and registry.
|
|
/// Populated by <see cref="CacheCellStruct"/> for cells with valid
|
|
/// containment (including cells with no physics root), and
|
|
/// consumed across the engine: the player render/lighting root
|
|
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
|
|
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
|
|
/// universal id->cell lookup (<c>GetVisible</c>), the 3rd-person camera cell
|
|
/// (<c>FindVisibleChildCell</c>), and the block-local terrain origin
|
|
/// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit
|
|
/// paths). No longer inert.
|
|
/// </summary>
|
|
public UcgCellGraph CellGraph { get; }
|
|
|
|
internal CollisionWorldStateSlot CollisionWorld => _collisionWorld;
|
|
|
|
/// <summary>
|
|
/// Creates the empty off-side facade used by Runtime's metered root
|
|
/// materializer. Global immutable catalogs fall through to this cache;
|
|
/// mutable world topology is installed one leaf per host step.
|
|
/// </summary>
|
|
internal PhysicsDataCache CreateEmptyCollisionStaging(
|
|
CollisionWorldStateSlot collisionWorld)
|
|
{
|
|
return new PhysicsDataCache(_requirePreparedCollision, collisionWorld)
|
|
{
|
|
CollisionTraversalMode = CollisionTraversalMode,
|
|
_readFallback = this,
|
|
};
|
|
}
|
|
|
|
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
|
|
PhysicsDataCache staging,
|
|
uint landblockId,
|
|
uint[] gfxObjectIds,
|
|
uint[] setupIds) => new(
|
|
this,
|
|
staging,
|
|
landblockId,
|
|
gfxObjectIds,
|
|
setupIds);
|
|
|
|
/// <summary>
|
|
/// Extract and cache the physics BSP + polygon data from a GfxObj,
|
|
/// PLUS always cache a visual AABB from the vertex data regardless of
|
|
/// the HasPhysics flag. The visual AABB is used as a collision fallback
|
|
/// for entities whose Setup has no retail physics data — it lets the
|
|
/// user collide with decorative meshes that don't have a CylSphere or
|
|
/// per-part BSP.
|
|
/// </summary>
|
|
public void CacheGfxObj(
|
|
uint gfxObjId,
|
|
GfxObj gfxObj,
|
|
FlatGfxObjCollisionAsset? prepared = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(gfxObj);
|
|
|
|
if (_requirePreparedCollision
|
|
&& prepared is null
|
|
&& _flatGfxObj.TryGetValue(gfxObjId, out var retainedPrepared))
|
|
{
|
|
prepared = retainedPrepared;
|
|
}
|
|
|
|
if (_requirePreparedCollision &&
|
|
gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics) &&
|
|
gfxObj.PhysicsBSP?.Root is not null &&
|
|
prepared?.PhysicsBsp is null &&
|
|
!_flatGfxObj.ContainsKey(gfxObjId))
|
|
{
|
|
throw MissingPreparedCollision("GfxObj", gfxObjId);
|
|
}
|
|
|
|
if (_requirePreparedCollision)
|
|
{
|
|
if (prepared is not null)
|
|
CachePreparedGfxObj(gfxObjId, prepared);
|
|
return;
|
|
}
|
|
|
|
if (prepared is not null)
|
|
_flatGfxObj.TryAdd(gfxObjId, prepared);
|
|
|
|
// Always cache a visual AABB from the mesh vertices — this is cheap
|
|
// and fed by the mesh data that's already loaded. It serves as the
|
|
// fallback collision shape for pure-visual entities.
|
|
if (!_visualBounds.ContainsKey(gfxObjId) && gfxObj.VertexArray != null)
|
|
{
|
|
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
|
|
}
|
|
|
|
if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing))
|
|
{
|
|
if (prepared is not null)
|
|
existing.FlatPhysicsBsp ??= prepared.PhysicsBsp;
|
|
return;
|
|
}
|
|
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
|
|
if (gfxObj.PhysicsBSP?.Root is null) return;
|
|
if (gfxObj.VertexArray is null) return;
|
|
|
|
var physics = new GfxObjPhysics
|
|
{
|
|
SourceId = gfxObjId,
|
|
BSP = gfxObj.PhysicsBSP,
|
|
PhysicsPolygons = gfxObj.PhysicsPolygons,
|
|
BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere,
|
|
Vertices = gfxObj.VertexArray,
|
|
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
|
|
FlatPhysicsBsp = prepared?.PhysicsBsp,
|
|
};
|
|
_gfxObj[gfxObjId] = physics;
|
|
|
|
if (PhysicsDiagnostics.ProbeDumpGfxObjsEnabled
|
|
&& PhysicsDiagnostics.ProbeDumpGfxObjIds.Contains(gfxObjId))
|
|
{
|
|
try
|
|
{
|
|
var dump = GfxObjDumpSerializer.Capture(gfxObjId, physics);
|
|
var path = System.IO.Path.Combine(
|
|
PhysicsDiagnostics.ProbeDumpGfxObjsPath,
|
|
System.FormattableString.Invariant($"0x{gfxObjId:X8}.gfxobj.json"));
|
|
GfxObjDumpSerializer.Write(dump, path);
|
|
Console.WriteLine(System.FormattableString.Invariant(
|
|
$"[gfxobj-dump] wrote 0x{gfxObjId:X8} polys={dump.ResolvedPolygons.Count} → {path}"));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine(System.FormattableString.Invariant(
|
|
$"[gfxobj-dump] FAILED to dump 0x{gfxObjId:X8}: {ex.GetType().Name}: {ex.Message}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes one package-prepared GfxObj without retaining its parsed DAT
|
|
/// BSP, polygon dictionary, or vertex array.
|
|
/// </summary>
|
|
public void CacheGfxObj(
|
|
uint gfxObjId,
|
|
FlatGfxObjCollisionAsset prepared)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(prepared);
|
|
CachePreparedGfxObj(gfxObjId, prepared);
|
|
}
|
|
|
|
private void CachePreparedGfxObj(
|
|
uint gfxObjId,
|
|
FlatGfxObjCollisionAsset prepared)
|
|
{
|
|
_flatGfxObj.TryAdd(gfxObjId, prepared);
|
|
if (prepared.VisualBounds is { } bounds)
|
|
{
|
|
_visualBounds.TryAdd(gfxObjId, new GfxObjVisualBounds
|
|
{
|
|
Min = bounds.Min,
|
|
Max = bounds.Max,
|
|
Center = bounds.Center,
|
|
Radius = bounds.Radius,
|
|
HalfExtents = bounds.HalfExtents,
|
|
});
|
|
}
|
|
|
|
if (prepared.PhysicsBsp.RootIndex < 0)
|
|
return;
|
|
|
|
FlatCollisionSphere root =
|
|
prepared.PhysicsBsp.Nodes[prepared.PhysicsBsp.RootIndex]
|
|
.BoundingSphere;
|
|
_gfxObj.TryAdd(gfxObjId, new GfxObjPhysics
|
|
{
|
|
SourceId = gfxObjId,
|
|
BoundingSphere = new Sphere
|
|
{
|
|
Origin = root.Origin,
|
|
Radius = root.Radius,
|
|
},
|
|
FlatPhysicsBsp = prepared.PhysicsBsp,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the cached visual AABB for a GfxObj, or null if not cached.
|
|
/// </summary>
|
|
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
|
|
_visualBounds.TryGetValue(gfxObjId, out var vb)
|
|
? vb
|
|
: _readFallback?.GetVisualBounds(gfxObjId);
|
|
|
|
/// <summary>
|
|
/// Compute a tight axis-aligned bounding box over all vertices in the mesh.
|
|
/// Used as a fallback collision shape for entities whose Setup has no
|
|
/// physics data — we approximate collision using the visual extent.
|
|
/// </summary>
|
|
internal static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray)
|
|
{
|
|
if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0)
|
|
{
|
|
return new GfxObjVisualBounds
|
|
{
|
|
Min = Vector3.Zero,
|
|
Max = Vector3.Zero,
|
|
Center = Vector3.Zero,
|
|
Radius = 0f,
|
|
HalfExtents = Vector3.Zero,
|
|
};
|
|
}
|
|
|
|
var min = new Vector3(float.MaxValue);
|
|
var max = new Vector3(float.MinValue);
|
|
foreach (var kv in vertexArray.Vertices)
|
|
{
|
|
var p = kv.Value.Origin;
|
|
if (p.X < min.X) min.X = p.X;
|
|
if (p.Y < min.Y) min.Y = p.Y;
|
|
if (p.Z < min.Z) min.Z = p.Z;
|
|
if (p.X > max.X) max.X = p.X;
|
|
if (p.Y > max.Y) max.Y = p.Y;
|
|
if (p.Z > max.Z) max.Z = p.Z;
|
|
}
|
|
|
|
var center = (min + max) * 0.5f;
|
|
var halfExt = (max - min) * 0.5f;
|
|
float radius = halfExt.Length();
|
|
|
|
return new GfxObjVisualBounds
|
|
{
|
|
Min = min,
|
|
Max = max,
|
|
Center = center,
|
|
Radius = radius,
|
|
HalfExtents = halfExt,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract and cache the collision shape data from a Setup.
|
|
/// No-ops if the id is already cached.
|
|
/// </summary>
|
|
public void CacheSetup(
|
|
uint setupId,
|
|
Setup setup,
|
|
FlatSetupCollision? prepared = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(setup);
|
|
|
|
if (_requirePreparedCollision
|
|
&& prepared is null
|
|
&& _flatSetup.TryGetValue(setupId, out var retainedPrepared))
|
|
{
|
|
prepared = retainedPrepared;
|
|
}
|
|
|
|
if (_requirePreparedCollision &&
|
|
prepared is null &&
|
|
!_flatSetup.ContainsKey(setupId))
|
|
throw MissingPreparedCollision("Setup", setupId);
|
|
|
|
if (_requirePreparedCollision)
|
|
{
|
|
CachePreparedSetup(
|
|
setupId,
|
|
prepared
|
|
?? throw MissingPreparedCollision("Setup", setupId));
|
|
return;
|
|
}
|
|
|
|
if (prepared is not null)
|
|
_flatSetup.TryAdd(setupId, prepared);
|
|
|
|
if (_setup.TryGetValue(setupId, out SetupPhysics? existing))
|
|
{
|
|
if (prepared is not null)
|
|
existing.FlatCollision ??= prepared;
|
|
return;
|
|
}
|
|
_setup[setupId] = new SetupPhysics
|
|
{
|
|
SourceId = setupId,
|
|
CylSpheres = setup.CylSpheres ?? new(),
|
|
Spheres = setup.Spheres ?? new(),
|
|
Height = setup.Height,
|
|
Radius = setup.Radius,
|
|
StepUpHeight = setup.StepUpHeight,
|
|
StepDownHeight = setup.StepDownHeight,
|
|
FlatCollision = prepared,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes one package-prepared Setup without retaining parsed DBObj
|
|
/// cylinder/sphere lists.
|
|
/// </summary>
|
|
public void CacheSetup(uint setupId, FlatSetupCollision prepared)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(prepared);
|
|
CachePreparedSetup(setupId, prepared);
|
|
}
|
|
|
|
private void CachePreparedSetup(uint setupId, FlatSetupCollision prepared)
|
|
{
|
|
_flatSetup.TryAdd(setupId, prepared);
|
|
_setup.TryAdd(setupId, new SetupPhysics
|
|
{
|
|
SourceId = setupId,
|
|
Height = prepared.Height,
|
|
Radius = prepared.Radius,
|
|
StepUpHeight = prepared.StepUpHeight,
|
|
StepDownHeight = prepared.StepDownHeight,
|
|
FlatCollision = prepared,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract and cache an authored CellStruct payload (indoor room geometry).
|
|
/// A missing physics root is valid (the cell can still own containment and
|
|
/// portals); a missing containment root is not a loadable CEnvCell and is
|
|
/// rejected before either the graph or collision record is published.
|
|
/// </summary>
|
|
public void CacheCellStruct(
|
|
uint envCellId,
|
|
DatReaderWriter.DBObjs.EnvCell envCell,
|
|
CellStruct cellStruct,
|
|
Matrix4x4 worldTransform,
|
|
FlatCellStructureCollisionAsset? preparedStructure = null,
|
|
FlatEnvCellTopology? preparedTopology = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(envCell);
|
|
ArgumentNullException.ThrowIfNull(cellStruct);
|
|
|
|
if (_requirePreparedCollision)
|
|
{
|
|
if (preparedStructure is null)
|
|
_flatCellStruct.TryGetValue(envCellId, out preparedStructure);
|
|
if (preparedTopology is null)
|
|
_flatEnvCell.TryGetValue(envCellId, out preparedTopology);
|
|
}
|
|
|
|
if (_requirePreparedCollision &&
|
|
preparedStructure is null &&
|
|
!_flatCellStruct.ContainsKey(envCellId))
|
|
throw MissingPreparedCollision("CellStruct", envCellId);
|
|
if (_requirePreparedCollision &&
|
|
preparedTopology is null &&
|
|
!_flatEnvCell.ContainsKey(envCellId))
|
|
throw MissingPreparedCollision("EnvCell topology", envCellId);
|
|
|
|
if (_requirePreparedCollision)
|
|
{
|
|
CachePreparedCellStruct(
|
|
envCellId,
|
|
envCell,
|
|
worldTransform,
|
|
preparedStructure
|
|
?? throw MissingPreparedCollision("CellStruct", envCellId),
|
|
preparedTopology
|
|
?? throw MissingPreparedCollision("EnvCell topology", envCellId));
|
|
return;
|
|
}
|
|
|
|
// CCellStruct::point_in_cell dereferences cell_bsp->root_node before
|
|
// entering BSPNODE::point_inside_cell_bsp. A null ROOT is therefore
|
|
// not the recursive missing-positive-child "inside" sentinel. The
|
|
// installed 2013 catalog contains zero such payloads; quarantine one
|
|
// rather than publishing a cell that claims the whole world.
|
|
if (cellStruct.CellBSP?.Root is null)
|
|
return;
|
|
|
|
// A malformed optional prepared shadow must not attach to an otherwise
|
|
// valid raw cell. Production takes the prepared-only overload below.
|
|
if (preparedStructure?.ContainmentBsp.RootIndex < 0)
|
|
{
|
|
preparedStructure = null;
|
|
preparedTopology = null;
|
|
}
|
|
if (preparedStructure is not null)
|
|
_flatCellStruct.TryAdd(envCellId, preparedStructure);
|
|
if (preparedTopology is not null)
|
|
_flatEnvCell.TryAdd(envCellId, preparedTopology);
|
|
|
|
// UCG Stage 1: register only a loadable authored cell.
|
|
if (!CellGraph.Contains(envCellId))
|
|
{
|
|
CellGraph.Add(UcgEnvCell.FromDat(
|
|
envCellId,
|
|
envCell,
|
|
cellStruct,
|
|
worldTransform,
|
|
preparedStructure?.ContainmentBsp));
|
|
}
|
|
|
|
if (_cellStruct.ContainsKey(envCellId)) return;
|
|
|
|
Matrix4x4.Invert(worldTransform, out var inverseTransform);
|
|
|
|
var resolved = cellStruct.PhysicsPolygons is null
|
|
? new Dictionary<ushort, ResolvedPolygon>()
|
|
: ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
|
|
|
|
// Visible polygons — portals reference these (NOT PhysicsPolygons).
|
|
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
|
|
|
|
// Portal list from envCell.CellPortals.
|
|
var portals = new System.Collections.Generic.List<PortalInfo>(envCell.CellPortals.Count);
|
|
foreach (var p in envCell.CellPortals)
|
|
{
|
|
portals.Add(new PortalInfo(
|
|
otherCellId: p.OtherCellId,
|
|
polygonId: p.PolygonId,
|
|
flags: (ushort)p.Flags));
|
|
}
|
|
|
|
// VisibleCells set — populated for future use; not consulted this phase.
|
|
// envCell.VisibleCells is List<UInt16> per the DatReaderWriter shape — iterate directly, no .Keys.
|
|
var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
|
|
if (envCell.VisibleCells is not null)
|
|
{
|
|
uint lbPrefix = envCellId & 0xFFFF0000u;
|
|
foreach (var lowId in envCell.VisibleCells)
|
|
visibleCellIds.Add(lbPrefix | lowId);
|
|
}
|
|
|
|
var cellPhysics = new CellPhysics
|
|
{
|
|
SourceId = envCellId,
|
|
BSP = cellStruct.PhysicsBSP,
|
|
PhysicsPolygons = cellStruct.PhysicsPolygons,
|
|
Vertices = cellStruct.VertexArray,
|
|
WorldTransform = worldTransform,
|
|
InverseWorldTransform = inverseTransform,
|
|
Resolved = resolved,
|
|
FlatPhysicsBsp = preparedStructure?.PhysicsBsp,
|
|
FlatContainmentBsp = preparedStructure?.ContainmentBsp,
|
|
FlatPortalPolygons = preparedStructure?.PortalPolygons,
|
|
FlatTopology = preparedTopology,
|
|
// ── Phase 2 portal fields ──
|
|
CellBSP = cellStruct.CellBSP,
|
|
Portals = portals,
|
|
PortalPolygons = portalPolygons,
|
|
VisibleCellIds = visibleCellIds,
|
|
// #107: retail CEnvCell.seen_outside — consumed by AdjustPosition's
|
|
// indoor not-found fallback (acclient :280037).
|
|
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
|
|
// AP-71 (Campaign P Slice P4, 2026-07-30): retail CObjCell::
|
|
// restriction_obj — a DAT-baked uint32 gated by EnvCellFlags.
|
|
// HasRestrictionObj (0x8), confirmed via ACE.DatLoader.FileTypes.
|
|
// EnvCell.cs:66-67 and Chorizite.DatReaderWriter's own EnvCell
|
|
// field of the same name (reflection-confirmed 2026-07-30). Zero
|
|
// for every ordinary (non-house-barrier) cell.
|
|
RestrictionObj = envCell.RestrictionObj,
|
|
};
|
|
_cellStruct[envCellId] = cellPhysics;
|
|
|
|
if (PhysicsDiagnostics.ProbeDumpCellsEnabled
|
|
&& PhysicsDiagnostics.ProbeDumpCellIds.Contains(envCellId))
|
|
{
|
|
try
|
|
{
|
|
var dump = CellDumpSerializer.Capture(envCellId, cellPhysics);
|
|
var path = System.IO.Path.Combine(
|
|
PhysicsDiagnostics.ProbeDumpCellsPath,
|
|
System.FormattableString.Invariant($"0x{envCellId:X8}.json"));
|
|
CellDumpSerializer.Write(dump, path);
|
|
Console.WriteLine(System.FormattableString.Invariant(
|
|
$"[cell-dump] wrote 0x{envCellId:X8} polys={dump.ResolvedPolygons.Count} portals={dump.Portals.Count} → {path}"));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine(System.FormattableString.Invariant(
|
|
$"[cell-dump] FAILED to dump 0x{envCellId:X8}: {ex.GetType().Name}: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
if (PhysicsDiagnostics.ProbeCellCacheEnabled)
|
|
{
|
|
var root = cellStruct.PhysicsBSP?.Root;
|
|
int bspRootPolyCount = root?.Polygons?.Count ?? 0;
|
|
bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null;
|
|
|
|
int bspTotalLeafPolys = 0;
|
|
int bspUnmatchedIds = 0;
|
|
if (root is not null)
|
|
{
|
|
var stack = new System.Collections.Generic.Stack<DatReaderWriter.Types.PhysicsBSPNode>();
|
|
stack.Push(root);
|
|
while (stack.Count > 0)
|
|
{
|
|
var n = stack.Pop();
|
|
if (n.Polygons is not null)
|
|
{
|
|
foreach (var pid in n.Polygons)
|
|
{
|
|
bspTotalLeafPolys++;
|
|
if (!resolved.ContainsKey(pid)) bspUnmatchedIds++;
|
|
}
|
|
}
|
|
if (n.PosNode is not null) stack.Push(n.PosNode);
|
|
if (n.NegNode is not null) stack.Push(n.NegNode);
|
|
}
|
|
}
|
|
|
|
var bs = root?.BoundingSphere;
|
|
string bsStr = bs is null
|
|
? "bsphere=n/a"
|
|
: System.FormattableString.Invariant(
|
|
$"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");
|
|
|
|
var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);
|
|
|
|
// A6.P3 slice 4 (2026-05-22): also dump portal targets so we
|
|
// can see which cells the player should be able to transition
|
|
// to (issue #98 investigation: cellar-up stuck at top of ramp).
|
|
string portalTargets;
|
|
if (portals.Count == 0)
|
|
{
|
|
portalTargets = "portalTargets=[]";
|
|
}
|
|
else
|
|
{
|
|
var sb = new System.Text.StringBuilder("portalTargets=[");
|
|
for (int i = 0; i < portals.Count; i++)
|
|
{
|
|
if (i > 0) sb.Append(',');
|
|
sb.Append(System.FormattableString.Invariant(
|
|
$"(cell=0x{portals[i].OtherCellId:X4},poly=0x{portals[i].PolygonId:X4},flags=0x{portals[i].Flags:X4})"));
|
|
}
|
|
sb.Append(']');
|
|
portalTargets = sb.ToString();
|
|
}
|
|
|
|
Console.WriteLine(System.FormattableString.Invariant(
|
|
$"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} portalCount={portals.Count} visibleCells={visibleCellIds.Count} cellBspRoot={(cellStruct.CellBSP?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2}) {portalTargets}"));
|
|
}
|
|
|
|
if (PhysicsDiagnostics.ProbeWalkMissEnabled)
|
|
{
|
|
int walkableCount = 0;
|
|
foreach (var entry in WalkMissDiagnostic.EnumerateWalkable(
|
|
resolved, PhysicsGlobals.FloorZ))
|
|
walkableCount++;
|
|
|
|
Console.Write(System.FormattableString.Invariant(
|
|
$"[floor-polys] cellId=0x{envCellId:X8} walkableCount={walkableCount}"));
|
|
foreach (var entry in WalkMissDiagnostic.EnumerateWalkable(
|
|
resolved, PhysicsGlobals.FloorZ))
|
|
{
|
|
Console.Write(System.FormattableString.Invariant(
|
|
$" [id=0x{entry.PolyId:X4} nz={entry.NormalZ:F3} bbox=({entry.BboxMin.X:F2},{entry.BboxMin.Y:F2})..({entry.BboxMax.X:F2},{entry.BboxMax.Y:F2}) planeZ@center={entry.PlaneZAtBboxCenter:F3}]"));
|
|
}
|
|
Console.WriteLine();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes one package-prepared EnvCell without retaining its parsed DAT
|
|
/// CellStruct, BSP trees, polygon dictionaries, or vertex arrays.
|
|
/// </summary>
|
|
public void CacheCellStruct(
|
|
uint envCellId,
|
|
DatReaderWriter.DBObjs.EnvCell envCell,
|
|
Matrix4x4 worldTransform,
|
|
FlatCellStructureCollisionAsset preparedStructure,
|
|
FlatEnvCellTopology preparedTopology)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(envCell);
|
|
ArgumentNullException.ThrowIfNull(preparedStructure);
|
|
ArgumentNullException.ThrowIfNull(preparedTopology);
|
|
CachePreparedCellStruct(
|
|
envCellId,
|
|
envCell,
|
|
worldTransform,
|
|
preparedStructure,
|
|
preparedTopology);
|
|
}
|
|
|
|
private void CachePreparedCellStruct(
|
|
uint envCellId,
|
|
DatReaderWriter.DBObjs.EnvCell envCell,
|
|
Matrix4x4 worldTransform,
|
|
FlatCellStructureCollisionAsset preparedStructure,
|
|
FlatEnvCellTopology preparedTopology)
|
|
{
|
|
// Same invariant as the raw loader. RootIndex -1 is the flattened
|
|
// encoding of a missing ROOT, not a recursive positive-child sentinel.
|
|
// Reject it atomically so graph, collision, and prepared caches agree
|
|
// that this cell is unavailable and a later valid hydration may retry.
|
|
if (preparedStructure.ContainmentBsp.RootIndex < 0)
|
|
return;
|
|
|
|
_flatCellStruct.TryAdd(envCellId, preparedStructure);
|
|
_flatEnvCell.TryAdd(envCellId, preparedTopology);
|
|
|
|
if (!CellGraph.Contains(envCellId))
|
|
{
|
|
CellGraph.Add(UcgEnvCell.FromPrepared(
|
|
envCellId,
|
|
worldTransform,
|
|
preparedStructure,
|
|
preparedTopology));
|
|
}
|
|
|
|
// Physics may be rootless even though the cell's containment and
|
|
// topology are valid; preserve that loaded, non-colliding cell.
|
|
if (_cellStruct.ContainsKey(envCellId))
|
|
return;
|
|
|
|
Matrix4x4.Invert(worldTransform, out Matrix4x4 inverseTransform);
|
|
var portals = new List<PortalInfo>(preparedTopology.Portals.Length);
|
|
for (int i = 0; i < preparedTopology.Portals.Length; i++)
|
|
{
|
|
FlatEnvCellPortal portal = preparedTopology.Portals[i];
|
|
portals.Add(new PortalInfo(
|
|
portal.OtherCellId,
|
|
portal.PolygonId,
|
|
portal.Flags));
|
|
}
|
|
|
|
_cellStruct.TryAdd(envCellId, new CellPhysics
|
|
{
|
|
SourceId = envCellId,
|
|
WorldTransform = worldTransform,
|
|
InverseWorldTransform = inverseTransform,
|
|
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
|
FlatPhysicsBsp = preparedStructure.PhysicsBsp,
|
|
FlatContainmentBsp = preparedStructure.ContainmentBsp,
|
|
FlatPortalPolygons = preparedStructure.PortalPolygons,
|
|
FlatTopology = preparedTopology,
|
|
Portals = portals,
|
|
VisibleCellIds = new HashSet<uint>(
|
|
preparedTopology.VisibleCellIds),
|
|
SeenOutside = preparedTopology.SeenOutside,
|
|
// AP-71 (Campaign P Slice P4, 2026-07-30): read straight from the
|
|
// locally-parsed live envCell, same as SeenOutside historically
|
|
// was before the bake pipeline existed — RestrictionObj is NOT
|
|
// (and does not need to be) part of the baked FlatEnvCellTopology
|
|
// payload; the caller (LandblockPhysicsPublisher.PublishCell)
|
|
// already has a real parsed DatReaderWriter.DBObjs.EnvCell in
|
|
// hand for Position/EnvironmentId, so this is free.
|
|
RestrictionObj = envCell.RestrictionObj,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pre-resolve all physics polygons: lookup vertex positions from VertexArray
|
|
/// and compute the face plane. Matches ACE's Polygon constructor which calls
|
|
/// make_plane() and resolves Vertices from VertexIDs at load time.
|
|
/// </summary>
|
|
internal static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
|
|
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
|
|
VertexArray vertexArray)
|
|
{
|
|
var resolved = new Dictionary<ushort, ResolvedPolygon>(polys.Count);
|
|
foreach (var (id, poly) in polys)
|
|
{
|
|
int numVerts = poly.VertexIds.Count;
|
|
if (numVerts < 3) continue;
|
|
|
|
var verts = new Vector3[numVerts];
|
|
bool valid = true;
|
|
for (int i = 0; i < numVerts; i++)
|
|
{
|
|
ushort vid = (ushort)poly.VertexIds[i];
|
|
if (!vertexArray.Vertices.TryGetValue(vid, out var sv))
|
|
{ valid = false; break; }
|
|
verts[i] = sv.Origin;
|
|
}
|
|
if (!valid) continue;
|
|
|
|
// Compute plane normal using ACE's make_plane algorithm:
|
|
// fan cross-product accumulation + normalization.
|
|
var normal = Vector3.Zero;
|
|
for (int i = 1; i < numVerts - 1; i++)
|
|
{
|
|
var v1 = verts[i] - verts[0];
|
|
var v2 = verts[i + 1] - verts[0];
|
|
normal += Vector3.Cross(v1, v2);
|
|
}
|
|
float len = normal.Length();
|
|
if (len < 1e-8f) continue;
|
|
normal /= len;
|
|
|
|
// D = -(average dot(normal, vertex))
|
|
float dotSum = 0f;
|
|
for (int i = 0; i < numVerts; i++)
|
|
dotSum += Vector3.Dot(normal, verts[i]);
|
|
float d = -(dotSum / numVerts);
|
|
|
|
resolved[id] = new ResolvedPolygon
|
|
{
|
|
Vertices = verts,
|
|
Plane = new Plane(normal, d),
|
|
NumPoints = numVerts,
|
|
SidesType = poly.SidesType,
|
|
Id = id,
|
|
};
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
private static InvalidOperationException MissingPreparedCollision(
|
|
string kind,
|
|
uint sourceId) =>
|
|
new(
|
|
$"Production {kind} 0x{sourceId:X8} has no prepared collision asset. " +
|
|
"Gameplay must not extract or fall back to a parsed DAT graph.");
|
|
|
|
public GfxObjPhysics? GetGfxObj(uint id) =>
|
|
_gfxObj.TryGetValue(id, out var p)
|
|
? p
|
|
: _readFallback?.GetGfxObj(id);
|
|
|
|
public SetupPhysics? GetSetup(uint id) =>
|
|
_setup.TryGetValue(id, out var p)
|
|
? p
|
|
: _readFallback?.GetSetup(id);
|
|
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
|
|
public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) =>
|
|
_flatGfxObj.TryGetValue(id, out var value)
|
|
? value
|
|
: _readFallback?.GetFlatGfxObj(id);
|
|
public FlatSetupCollision? GetFlatSetup(uint id) =>
|
|
_flatSetup.TryGetValue(id, out var value)
|
|
? value
|
|
: _readFallback?.GetFlatSetup(id);
|
|
public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) =>
|
|
_flatCellStruct.TryGetValue(id, out var value) ? value : null;
|
|
public FlatEnvCellTopology? GetFlatEnvCell(uint id) =>
|
|
_flatEnvCell.TryGetValue(id, out var value) ? value : null;
|
|
public int GfxObjCount => _gfxObj.Count;
|
|
public int SetupCount => _setup.Count;
|
|
public int CellStructCount => _cellStruct.Count;
|
|
public int FlatGfxObjCount => _flatGfxObj.Count;
|
|
public int FlatSetupCount => _flatSetup.Count;
|
|
public int FlatCellStructCount => _flatCellStruct.Count;
|
|
public int FlatEnvCellCount => _flatEnvCell.Count;
|
|
public int GraphGfxObjCount => CountRetainedGfxObjGraphs();
|
|
public int GraphSetupCount => CountRetainedSetupGraphs();
|
|
public int GraphCellStructCount => CountRetainedCellGraphs();
|
|
|
|
private int CountRetainedGfxObjGraphs()
|
|
{
|
|
int count = 0;
|
|
foreach (GfxObjPhysics value in _gfxObj.Values)
|
|
{
|
|
if (value.BSP is not null
|
|
|| value.PhysicsPolygons is not null
|
|
|| value.Vertices is not null
|
|
|| value.Resolved.Count != 0)
|
|
{
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private int CountRetainedSetupGraphs()
|
|
{
|
|
int count = 0;
|
|
foreach (SetupPhysics value in _setup.Values)
|
|
{
|
|
if (value.CylSpheres.Count != 0 || value.Spheres.Count != 0)
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private int CountRetainedCellGraphs()
|
|
{
|
|
int count = 0;
|
|
foreach (CellPhysics value in _cellStruct.Values)
|
|
{
|
|
if (value.BSP is not null
|
|
|| value.CellBSP is not null
|
|
|| value.PhysicsPolygons is not null
|
|
|| value.Vertices is not null
|
|
|| value.Resolved.Count != 0
|
|
|| value.PortalPolygons is not null)
|
|
{
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
|
|
/// EnvCell ids — used by <see cref="AcDream.Core.Selection.WorldPicker"/>
|
|
/// to enumerate occluder candidates without exposing the underlying
|
|
/// dictionary. Returns the live key-set; callers should snapshot the
|
|
/// collection if they need stability across frames.
|
|
/// </summary>
|
|
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
|
|
|
|
/// <summary>
|
|
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
|
|
/// Intended for unit-test fixtures that construct synthetic BSP trees
|
|
/// without needing real DAT content.
|
|
/// </summary>
|
|
public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
|
|
=> _gfxObj[gfxObjId] = physics;
|
|
|
|
/// <summary>
|
|
/// Register a pre-built <see cref="CellPhysics"/> directly. Intended for
|
|
/// unit-test fixtures that construct synthetic cells without going through
|
|
/// dat-driven <see cref="CacheCellStruct"/>.
|
|
/// </summary>
|
|
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
|
|
=> _cellStruct[envCellId] = physics;
|
|
|
|
/// <summary>
|
|
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
|
|
/// for an outdoor landcell that contains a building stab. Used by
|
|
/// <see cref="CellTransit.CheckBuildingTransit"/>.
|
|
/// </summary>
|
|
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform,
|
|
uint modelId = 0u)
|
|
{
|
|
if (_buildings.ContainsKey(landcellId)) return;
|
|
Matrix4x4.Invert(worldTransform, out var inverse);
|
|
_buildings[landcellId] = new BuildingPhysics
|
|
{
|
|
WorldTransform = worldTransform,
|
|
InverseWorldTransform = inverse,
|
|
Portals = portals,
|
|
// BR-7: first-wins per cell mirrors retail CSortCell::add_building
|
|
// (0x00534030) — and one building per origin landcell mirrors
|
|
// CLandBlock::init_buildings (0x0052fd80).
|
|
ModelId = modelId,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// #146 (2026-06-24): drop every cached building belonging to a landblock so
|
|
/// the next <see cref="CacheBuilding"/> pass re-bases their STREAMING-RELATIVE
|
|
/// <c>WorldTransform</c> against the CURRENT <c>_liveCenter</c>. Without this,
|
|
/// CacheBuilding's per-cell first-wins guard LOCKS the transform at whatever
|
|
/// frame it was first cached with; a teleport recenter then leaves the shell
|
|
/// BSP at a stale world offset (~the source↔dest landblock distance), so the
|
|
/// foot-sphere walks through where the wall visually is and collision is lost
|
|
/// (login at Arwic → portal to Holtburg → house walls clip; bldOrigin probed
|
|
/// ~5.5 km off). Terrain avoids this because <c>AddLandblock</c> overwrites its
|
|
/// WorldOffset every apply; buildings get the equivalent per-apply re-base via
|
|
/// the caller clearing-then-repopulating (which keeps first-wins within each
|
|
/// fresh pass — retail <c>CSortCell::add_building</c> semantics preserved).
|
|
/// </summary>
|
|
public void RemoveBuildingsForLandblock(uint landblockId)
|
|
{
|
|
uint prefix = landblockId & 0xFFFF0000u;
|
|
foreach (var key in _buildings.Keys)
|
|
if ((key & 0xFFFF0000u) == prefix)
|
|
_buildings.TryRemove(key, out _);
|
|
}
|
|
|
|
/// <summary>
|
|
/// D8 (2026-06-24): drop every cached cell struct belonging to a landblock so
|
|
/// the next <see cref="CacheCellStruct"/> re-bases its STREAMING-RELATIVE
|
|
/// <c>WorldTransform</c> against the current <c>_liveCenter</c> — symmetric with
|
|
/// <see cref="RemoveBuildingsForLandblock"/> (#146). Without this, the
|
|
/// <c>_cellStruct</c> first-wins guard LOCKS a dungeon cell's transform at its
|
|
/// first streaming frame; a teleport recenter then leaves the cell BSP at a stale
|
|
/// world offset (~the source↔dest landblock distance), so foot-sphere collision
|
|
/// queries walk through where the wall geometry is.
|
|
/// </summary>
|
|
public void RemoveCellsForLandblock(uint landblockId)
|
|
{
|
|
uint prefix = landblockId & 0xFFFF0000u;
|
|
foreach (var key in _cellStruct.Keys)
|
|
if ((key & 0xFFFF0000u) == prefix)
|
|
_cellStruct.TryRemove(key, out _);
|
|
foreach (var key in _flatCellStruct.Keys)
|
|
if ((key & 0xFFFF0000u) == prefix)
|
|
_flatCellStruct.TryRemove(key, out _);
|
|
foreach (var key in _flatEnvCell.Keys)
|
|
if ((key & 0xFFFF0000u) == prefix)
|
|
_flatEnvCell.TryRemove(key, out _);
|
|
}
|
|
|
|
public BuildingPhysics? GetBuilding(uint landcellId)
|
|
=> _buildings.TryGetValue(landcellId, out var b) ? b : null;
|
|
|
|
public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
|
|
|
|
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
|
|
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
|
|
|
|
internal sealed class LandblockReplacementBuilder : IDisposable
|
|
{
|
|
private readonly PhysicsDataCache _active;
|
|
private readonly PhysicsDataCache _staging;
|
|
private readonly uint _prefix;
|
|
private readonly uint[] _gfxIds;
|
|
private readonly uint[] _setupIds;
|
|
private readonly List<KeyValuePair<uint, GfxObjPhysics>> _gfx = new();
|
|
private readonly List<KeyValuePair<uint, GfxObjVisualBounds>> _bounds = new();
|
|
private readonly List<KeyValuePair<uint, FlatGfxObjCollisionAsset>> _flatGfx = new();
|
|
private readonly List<KeyValuePair<uint, SetupPhysics>> _setups = new();
|
|
private readonly List<KeyValuePair<uint, FlatSetupCollision>> _flatSetups = new();
|
|
private readonly List<KeyValuePair<uint, CellPhysics>> _cells = new();
|
|
private readonly List<KeyValuePair<uint, FlatCellStructureCollisionAsset>> _flatCells = new();
|
|
private readonly List<KeyValuePair<uint, FlatEnvCellTopology>> _flatEnvCells = new();
|
|
private readonly List<KeyValuePair<uint, BuildingPhysics>> _buildings = new();
|
|
private readonly HashSet<uint> _cellIds = new();
|
|
private readonly HashSet<uint> _flatCellIds = new();
|
|
private readonly HashSet<uint> _flatEnvCellIds = new();
|
|
private readonly HashSet<uint> _buildingIds = new();
|
|
private readonly List<uint> _removeCells = new();
|
|
private readonly List<uint> _removeFlatCells = new();
|
|
private readonly List<uint> _removeFlatEnvCells = new();
|
|
private readonly List<uint> _removeBuildings = new();
|
|
private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph;
|
|
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cellEnumerator;
|
|
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>? _flatCellEnumerator;
|
|
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvEnumerator;
|
|
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _buildingEnumerator;
|
|
private int _phase;
|
|
private int _cursor;
|
|
|
|
internal LandblockReplacementBuilder(
|
|
PhysicsDataCache active,
|
|
PhysicsDataCache staging,
|
|
uint landblockId,
|
|
uint[] gfxIds,
|
|
uint[] setupIds)
|
|
{
|
|
_active = active;
|
|
_staging = staging;
|
|
_prefix = landblockId & 0xFFFF0000u;
|
|
_gfxIds = gfxIds;
|
|
_setupIds = setupIds;
|
|
_cellGraph = active.CellGraph.CreateLandblockReplacementBuilder(
|
|
staging.CellGraph,
|
|
_prefix);
|
|
}
|
|
|
|
internal int WorkUnits { get; private set; }
|
|
internal PreparedPhysicsDataCacheLandblock? Prepared { get; private set; }
|
|
|
|
internal bool Advance()
|
|
{
|
|
switch (_phase)
|
|
{
|
|
case 0:
|
|
if (_cursor < _gfxIds.Length)
|
|
{
|
|
uint id = _gfxIds[_cursor++];
|
|
Preinstall(_staging._gfxObj, _active._gfxObj, id);
|
|
Preinstall(_staging._visualBounds, _active._visualBounds, id);
|
|
Preinstall(_staging._flatGfxObj, _active._flatGfxObj, id);
|
|
Capture(_staging._gfxObj, id, _gfx);
|
|
Capture(_staging._visualBounds, id, _bounds);
|
|
Capture(_staging._flatGfxObj, id, _flatGfx);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_cursor = 0;
|
|
_phase++;
|
|
return false;
|
|
case 1:
|
|
if (_cursor < _setupIds.Length)
|
|
{
|
|
uint id = _setupIds[_cursor++];
|
|
Preinstall(_staging._setup, _active._setup, id);
|
|
Preinstall(_staging._flatSetup, _active._flatSetup, id);
|
|
Capture(_staging._setup, id, _setups);
|
|
Capture(_staging._flatSetup, id, _flatSetups);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_phase++;
|
|
return false;
|
|
case 2:
|
|
_cellEnumerator ??= _staging._cellStruct.GetEnumerator();
|
|
if (CapturePrefixOne(_cellEnumerator, _prefix, _cells, _cellIds))
|
|
{
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_cellEnumerator.Dispose();
|
|
_cellEnumerator = null;
|
|
_phase++;
|
|
return false;
|
|
case 3:
|
|
_cellEnumerator ??= _active._cellStruct.GetEnumerator();
|
|
if (CaptureRemovalOne(_cellEnumerator, _prefix, _cellIds, _removeCells))
|
|
{
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_cellEnumerator.Dispose();
|
|
_cellEnumerator = null;
|
|
_phase++;
|
|
return false;
|
|
case 4:
|
|
_flatCellEnumerator ??= _staging._flatCellStruct.GetEnumerator();
|
|
if (CapturePrefixOne(_flatCellEnumerator, _prefix, _flatCells, _flatCellIds))
|
|
{
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_flatCellEnumerator.Dispose();
|
|
_flatCellEnumerator = null;
|
|
_phase++;
|
|
return false;
|
|
case 5:
|
|
_flatCellEnumerator ??= _active._flatCellStruct.GetEnumerator();
|
|
if (CaptureRemovalOne(_flatCellEnumerator, _prefix, _flatCellIds, _removeFlatCells))
|
|
{
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_flatCellEnumerator.Dispose();
|
|
_flatCellEnumerator = null;
|
|
_phase++;
|
|
return false;
|
|
case 6:
|
|
_flatEnvEnumerator ??= _staging._flatEnvCell.GetEnumerator();
|
|
if (CapturePrefixOne(_flatEnvEnumerator, _prefix, _flatEnvCells, _flatEnvCellIds))
|
|
{
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_flatEnvEnumerator.Dispose();
|
|
_flatEnvEnumerator = null;
|
|
_phase++;
|
|
return false;
|
|
case 7:
|
|
_flatEnvEnumerator ??= _active._flatEnvCell.GetEnumerator();
|
|
if (CaptureRemovalOne(_flatEnvEnumerator, _prefix, _flatEnvCellIds, _removeFlatEnvCells))
|
|
{
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_flatEnvEnumerator.Dispose();
|
|
_flatEnvEnumerator = null;
|
|
_phase++;
|
|
return false;
|
|
case 8:
|
|
_buildingEnumerator ??= _staging._buildings.GetEnumerator();
|
|
if (CapturePrefixOne(_buildingEnumerator, _prefix, _buildings, _buildingIds))
|
|
{
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_buildingEnumerator.Dispose();
|
|
_buildingEnumerator = null;
|
|
_phase++;
|
|
return false;
|
|
case 9:
|
|
_buildingEnumerator ??= _active._buildings.GetEnumerator();
|
|
if (CaptureRemovalOne(_buildingEnumerator, _prefix, _buildingIds, _removeBuildings))
|
|
{
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_buildingEnumerator.Dispose();
|
|
_buildingEnumerator = null;
|
|
_phase++;
|
|
return false;
|
|
case 10:
|
|
WorkUnits++;
|
|
if (!_cellGraph.Advance())
|
|
return false;
|
|
Prepared = new PreparedPhysicsDataCacheLandblock(
|
|
_prefix,
|
|
_gfx,
|
|
_bounds,
|
|
_flatGfx,
|
|
_setups,
|
|
_flatSetups,
|
|
_removeCells,
|
|
_cells,
|
|
_removeFlatCells,
|
|
_flatCells,
|
|
_removeFlatEnvCells,
|
|
_flatEnvCells,
|
|
_removeBuildings,
|
|
_buildings,
|
|
_cellGraph.Prepared!);
|
|
_phase++;
|
|
return true;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private static void Capture<T>(
|
|
ConcurrentDictionary<uint, T> source,
|
|
uint id,
|
|
List<KeyValuePair<uint, T>> destination)
|
|
{
|
|
if (source.TryGetValue(id, out T? value))
|
|
destination.Add(new KeyValuePair<uint, T>(id, value));
|
|
}
|
|
|
|
private static void Preinstall<T>(
|
|
ConcurrentDictionary<uint, T> source,
|
|
ConcurrentDictionary<uint, T> destination,
|
|
uint id)
|
|
{
|
|
if (source.TryGetValue(id, out T? value))
|
|
destination.TryAdd(id, value);
|
|
}
|
|
|
|
private static bool CapturePrefixOne<T>(
|
|
IEnumerator<KeyValuePair<uint, T>> enumerator,
|
|
uint prefix,
|
|
List<KeyValuePair<uint, T>> destination,
|
|
HashSet<uint> ids)
|
|
{
|
|
if (!enumerator.MoveNext())
|
|
return false;
|
|
KeyValuePair<uint, T> pair = enumerator.Current;
|
|
if ((pair.Key & 0xFFFF0000u) == prefix)
|
|
{
|
|
destination.Add(pair);
|
|
ids.Add(pair.Key);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool CaptureRemovalOne<T>(
|
|
IEnumerator<KeyValuePair<uint, T>> enumerator,
|
|
uint prefix,
|
|
HashSet<uint> retained,
|
|
List<uint> destination)
|
|
{
|
|
if (!enumerator.MoveNext())
|
|
return false;
|
|
uint id = enumerator.Current.Key;
|
|
if ((id & 0xFFFF0000u) == prefix && !retained.Contains(id))
|
|
destination.Add(id);
|
|
return true;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_cellEnumerator?.Dispose();
|
|
_flatCellEnumerator?.Dispose();
|
|
_flatEnvEnumerator?.Dispose();
|
|
_buildingEnumerator?.Dispose();
|
|
_cellGraph.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed record PreparedPhysicsDataCacheLandblock(
|
|
uint LandblockPrefix,
|
|
IReadOnlyList<KeyValuePair<uint, GfxObjPhysics>> GfxObjects,
|
|
IReadOnlyList<KeyValuePair<uint, GfxObjVisualBounds>> VisualBounds,
|
|
IReadOnlyList<KeyValuePair<uint, FlatGfxObjCollisionAsset>> FlatGfxObjects,
|
|
IReadOnlyList<KeyValuePair<uint, SetupPhysics>> Setups,
|
|
IReadOnlyList<KeyValuePair<uint, FlatSetupCollision>> FlatSetups,
|
|
IReadOnlyList<uint> CellIdsToRemove,
|
|
IReadOnlyList<KeyValuePair<uint, CellPhysics>> Cells,
|
|
IReadOnlyList<uint> FlatCellIdsToRemove,
|
|
IReadOnlyList<KeyValuePair<uint, FlatCellStructureCollisionAsset>> FlatCells,
|
|
IReadOnlyList<uint> FlatEnvCellIdsToRemove,
|
|
IReadOnlyList<KeyValuePair<uint, FlatEnvCellTopology>> FlatEnvCells,
|
|
IReadOnlyList<uint> BuildingIdsToRemove,
|
|
IReadOnlyList<KeyValuePair<uint, BuildingPhysics>> Buildings,
|
|
PreparedCellGraphLandblock CellGraph);
|
|
|
|
/// <summary>
|
|
/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
|
|
/// of whether it has physics data. Used as a collision fallback shape for
|
|
/// entities whose Setup has no CylSpheres/Spheres/Radius (pure decorative
|
|
/// meshes). Provides an approximate cylinder matching the visible mesh extent.
|
|
/// </summary>
|
|
public sealed class GfxObjVisualBounds
|
|
{
|
|
/// <summary>Local-space minimum corner of the mesh AABB.</summary>
|
|
public required Vector3 Min { get; init; }
|
|
/// <summary>Local-space maximum corner of the mesh AABB.</summary>
|
|
public required Vector3 Max { get; init; }
|
|
/// <summary>Center of the local-space AABB.</summary>
|
|
public required Vector3 Center { get; init; }
|
|
/// <summary>Local-space radius (diagonal half-length) — loose bound.</summary>
|
|
public required float Radius { get; init; }
|
|
/// <summary>Local-space half-extents ((Max - Min) * 0.5).</summary>
|
|
public required Vector3 HalfExtents { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A physics polygon with pre-resolved vertex positions and pre-computed plane.
|
|
/// ACE pre-computes these in its Polygon constructor; we do it at cache time
|
|
/// to avoid per-collision-test vertex lookups.
|
|
/// </summary>
|
|
public sealed class ResolvedPolygon
|
|
{
|
|
public required Vector3[] Vertices { get; init; }
|
|
public required Plane Plane { get; init; }
|
|
public required int NumPoints { get; init; }
|
|
public required CullMode SidesType { get; init; }
|
|
/// <summary>
|
|
/// Polygon index within its parent (cell or GfxObj). Used by the
|
|
/// `ACDREAM_PROBE_POLY_DUMP` probe (A6.P3 slice 4 investigation,
|
|
/// 2026-05-22) to identify which dat polygon a push-back hit so we
|
|
/// can compare our extracted vertices/plane against WorldBuilder's
|
|
/// straight-from-dat read. Defaults to 0 for test fixtures that
|
|
/// don't care about polygon identity.
|
|
/// </summary>
|
|
public ushort Id { get; init; }
|
|
}
|
|
|
|
/// <summary>Cached physics data for a single GfxObj part.</summary>
|
|
public sealed class GfxObjPhysics
|
|
{
|
|
public uint SourceId { get; init; }
|
|
public PhysicsBSPTree? BSP { get; init; }
|
|
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
|
|
public Sphere? BoundingSphere { get; init; }
|
|
public VertexArray? Vertices { get; init; }
|
|
|
|
/// <summary>
|
|
/// Pre-resolved polygon data with vertex positions and computed planes.
|
|
/// Populated once at cache time so BSP queries don't pay per-test lookup cost.
|
|
/// </summary>
|
|
public Dictionary<ushort, ResolvedPolygon> Resolved { get; init; } = new();
|
|
|
|
/// <summary>
|
|
/// Prepared integer-indexed representation. Slice I5 guarantees this for
|
|
/// production-published collision objects; graph-only test fixtures may
|
|
/// omit it.
|
|
/// </summary>
|
|
public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; }
|
|
}
|
|
|
|
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
|
|
public sealed class SetupPhysics
|
|
{
|
|
public uint SourceId { get; init; }
|
|
public List<CylSphere> CylSpheres { get; init; } = new();
|
|
public List<Sphere> Spheres { get; init; } = new();
|
|
public float Height { get; init; }
|
|
public float Radius { get; init; }
|
|
public float StepUpHeight { get; init; }
|
|
public float StepDownHeight { get; init; }
|
|
public FlatSetupCollision? FlatCollision { get; internal set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cached physics data for an indoor cell's room geometry (CellStruct).
|
|
/// Used for wall/floor/ceiling collision in EnvCells.
|
|
/// ACE: EnvCell.find_env_collisions queries CellStructure.PhysicsBSP.
|
|
/// </summary>
|
|
public sealed class CellPhysics
|
|
{
|
|
public uint SourceId { get; init; }
|
|
/// <summary>
|
|
/// The physics BSP tree for this cell. Nullable so that test fixtures
|
|
/// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/>
|
|
/// alone without needing a real DAT BSP object. Production code must
|
|
/// null-check before traversal: <c>cell.BSP?.Root is not null</c>.
|
|
/// </summary>
|
|
public PhysicsBSPTree? BSP { get; init; }
|
|
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
|
|
public VertexArray? Vertices { get; init; }
|
|
public Matrix4x4 WorldTransform { get; init; }
|
|
public Matrix4x4 InverseWorldTransform { get; init; }
|
|
|
|
/// <summary>
|
|
/// Pre-resolved polygon data with vertex positions and computed planes.
|
|
/// </summary>
|
|
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
|
|
|
|
/// <summary>
|
|
/// Prepared integer-indexed physics BSP. Slice I5 guarantees this for
|
|
/// production-published collision cells; graph-only test fixtures may
|
|
/// omit it.
|
|
/// </summary>
|
|
public FlatPhysicsBsp? FlatPhysicsBsp { get; init; }
|
|
|
|
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
|
|
|
|
/// <summary>
|
|
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
|
|
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
|
|
/// (collision) and from the renderer's drawing-BSP.
|
|
/// Source: <c>cellStruct.CellBSP</c> at cache time.
|
|
/// Root presence is required for a published cell. Missing positive
|
|
/// children inside a valid tree are the retail inside base case; a missing
|
|
/// root is rejected by <see cref="PhysicsDataCache.CacheCellStruct"/>.
|
|
/// </summary>
|
|
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
|
|
|
|
/// <summary>
|
|
/// Prepared integer-indexed cell-containment BSP. Slice I5 guarantees this
|
|
/// for production-published cells; graph-only test fixtures may omit it.
|
|
/// </summary>
|
|
public FlatCellContainmentBsp? FlatContainmentBsp { get; init; }
|
|
|
|
/// <summary>
|
|
/// Prepared visible-polygon table addressed by
|
|
/// <see cref="FlatEnvCellTopology.Portals"/>.
|
|
/// </summary>
|
|
public FlatPolygonTable? FlatPortalPolygons { get; init; }
|
|
|
|
/// <summary>
|
|
/// Prepared per-EnvCell portal/visibility state. Placement remains on this
|
|
/// runtime record's world transforms.
|
|
/// </summary>
|
|
public FlatEnvCellTopology? FlatTopology { get; init; }
|
|
|
|
/// <summary>
|
|
/// Portal connections to neighbouring cells, in cell-local space.
|
|
/// Default: empty list. Source: <c>envCell.CellPortals</c>.
|
|
/// </summary>
|
|
public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();
|
|
|
|
/// <summary>
|
|
/// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
|
|
/// keyed by polygon id. Distinct from <see cref="Resolved"/> which
|
|
/// holds <c>PhysicsPolygons</c>. Portal lookup via
|
|
/// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
|
|
/// Nullable when the cell has no visible polys (rare).
|
|
/// </summary>
|
|
public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }
|
|
|
|
/// <summary>
|
|
/// The full cell ids visible from this cell (with landblock prefix).
|
|
/// Populated from <c>envCell.VisibleCells</c> at cache time. Unused
|
|
/// this phase; reserved for the optional <c>find_cell_list</c>
|
|
/// visibility filter.
|
|
/// </summary>
|
|
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
|
|
|
|
/// <summary>
|
|
/// #107: retail <c>CEnvCell.seen_outside</c> (dat <c>EnvCellFlags.SeenOutside</c>).
|
|
/// True for interiors with outdoor-visible portals (ground-floor cottage rooms).
|
|
/// <c>PhysicsEngine.AdjustPosition</c>'s indoor branch falls back to the outdoor
|
|
/// landcell under the point when the claimed cell's visible graph does not
|
|
/// contain it AND this flag is set (retail acclient :280037-280046).
|
|
/// </summary>
|
|
public bool SeenOutside { get; init; }
|
|
|
|
/// <summary>
|
|
/// AP-71 (Campaign P Slice P4, 2026-07-30): retail <c>CObjCell::
|
|
/// restriction_obj</c> — the DAT-baked object IID (gated by
|
|
/// <c>EnvCellFlags.HasRestrictionObj</c>) that <c>CObjCell::
|
|
/// check_entry_restrictions</c> (0x0052b6d0) resolves and consults
|
|
/// before letting a player into this cell. Zero (the default) for
|
|
/// every ordinary cell — only house-barrier EnvCells set it. Consumed
|
|
/// by <see cref="ObjectInfo.CheckEntryRestrictions"/> via
|
|
/// <see cref="Transition.FindEnvCollisions"/>. Resolving the value into
|
|
/// a live restriction weenie (for the owner/guest-list
|
|
/// <c>CanMoveInto</c> check) is NOT modeled — see the AP-71 register
|
|
/// history for the narrower unfed-input row.
|
|
/// </summary>
|
|
public uint RestrictionObj { get; init; }
|
|
}
|