Each of these was temporary apparatus added to chase one bug, and each was supposed to be deleted in the commit that fixed it. Fourteen closed issues later they were still here: #337's support/wire-mesh trio, #171's sticky timeline, #119's viewer and entity dumps, #113's phantom probe, and a dozen more. 3,493 lines removed; the client now reads 144 environment variables instead of 161, and 47 temporary probes remain instead of 64. This is not only tidying. Every probe leaves a branch on its hot path when unset, several re-read the environment per call rather than caching, and the volume buries the diagnostics that are actually load-bearing. It is also a headless correctness matter: HeadlessStaticStateAudit reflects over PhysicsDiagnostics' flags to refuse a multi-session host when any is set, and cannot see probes that live outside that owner. Four files went entirely — WalkMissDiagnostic.cs, CollisionMeshWireframe.cs and two test files whose only subject was a deleted probe. TransitionTypes.SetContactPlane also sheds its CallerMemberName / CallerLineNumber parameters, which existed solely for #337's cpSrc= attribution and carried the instruction to strip them with the probe family; no call site passed them, so no behavior changes. F2's collision overlay survives and reverts to its proxy-cylinder form, which is what removing the ACDREAM_WIRE_MESH upgrade means. LaunchOptionsDocumentationTests earned its keep here: it refused the deletion until docs/launch-options.md moved the 17 rows into Retired and the frozen direct-read counts came down (PhysicsEngine.cs to zero, TransitionTypes.cs 3 to 2). The documentation could not drift during a cleanup this wide. The 14 probes that name no owning issue are deliberately NOT deleted. Nothing records when they became safe to remove, and guessing is how a future investigation loses apparatus it needed; #435 stays open for their attribution. Full hermetic suite 15,321 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1555 lines
62 KiB
C#
1555 lines
62 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);
|
|
}
|
|
GfxObjVisualBounds? parsedBounds =
|
|
_visualBounds.TryGetValue(gfxObjId, out var cachedBounds)
|
|
? cachedBounds
|
|
: null;
|
|
|
|
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,
|
|
VisualBounds = prepared?.VisualBounds ?? (parsedBounds is { } pb
|
|
? new FlatGfxObjVisualBounds(
|
|
pb.Min, pb.Max, pb.Center, pb.Radius, pb.HalfExtents)
|
|
: null),
|
|
};
|
|
_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,
|
|
VisualBounds = prepared.VisualBounds,
|
|
});
|
|
}
|
|
|
|
/// <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)
|
|
_collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure);
|
|
if (preparedTopology is not null)
|
|
_collisionWorld.Current.TryAddFlatEnvCell(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,
|
|
};
|
|
_collisionWorld.Current.SetCellStruct(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}"));
|
|
}
|
|
|
|
}
|
|
|
|
/// <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;
|
|
|
|
_collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure);
|
|
_collisionWorld.Current.TryAddFlatEnvCell(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));
|
|
}
|
|
|
|
_collisionWorld.Current.TryAddCellStruct(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)
|
|
=> _collisionWorld.Current.SetCellStruct(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);
|
|
_collisionWorld.Current.SetBuilding(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)
|
|
{
|
|
CollisionWorldState world = _collisionWorld.Current;
|
|
RemovePrefixKeys(
|
|
world.BuildingKeys,
|
|
landblockId & 0xFFFF0000u,
|
|
world.RemoveBuilding);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retires one landblock prefix's installed keys through the O1 ledger:
|
|
/// O(prefix keys), never a whole-map scan. Removal tombstones the captured
|
|
/// slot list, so index iteration over the captured reference stays exact.
|
|
/// </summary>
|
|
private static void RemovePrefixKeys(
|
|
PrefixKeyIndex ledger,
|
|
uint prefix,
|
|
Func<uint, bool> remove)
|
|
{
|
|
List<uint>? slots = ledger.SlotsForPrefix(prefix);
|
|
if (slots is null)
|
|
return;
|
|
int limit = slots.Count;
|
|
for (int index = 0; index < limit; index++)
|
|
{
|
|
uint key = slots[index];
|
|
if (key != 0u)
|
|
remove(key);
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
CollisionWorldState world = _collisionWorld.Current;
|
|
RemovePrefixKeys(world.CellStructKeys, prefix, world.RemoveCellStruct);
|
|
RemovePrefixKeys(
|
|
world.FlatCellStructKeys,
|
|
prefix,
|
|
world.RemoveFlatCellStruct);
|
|
RemovePrefixKeys(world.FlatEnvCellKeys, prefix, world.RemoveFlatEnvCell);
|
|
}
|
|
|
|
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) =>
|
|
_collisionWorld.Current.SetBuilding(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 List<uint>? _keySlots;
|
|
private int _keySlotLimit;
|
|
private bool _keySlotsCaptured;
|
|
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:
|
|
{
|
|
// O1: enumerate the staging root's installed target-prefix
|
|
// keys instead of scanning the whole staging map, one key
|
|
// per advance.
|
|
if (TryTakeNextPrefixKey(
|
|
StagingWorld.CellStructKeys,
|
|
out uint id))
|
|
{
|
|
CaptureInstall(_staging._cellStruct, id, _cells, _cellIds);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_phase++;
|
|
return false;
|
|
}
|
|
case 3:
|
|
{
|
|
// O1: enumerate the active root's installed target-prefix
|
|
// keys for removal capture. This also removes the previous
|
|
// cross-frame live enumerator over the active map.
|
|
if (TryTakeNextPrefixKey(
|
|
ActiveWorld.CellStructKeys,
|
|
out uint id))
|
|
{
|
|
CaptureRemoval(_active._cellStruct, id, _cellIds, _removeCells);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_phase++;
|
|
return false;
|
|
}
|
|
case 4:
|
|
{
|
|
if (TryTakeNextPrefixKey(
|
|
StagingWorld.FlatCellStructKeys,
|
|
out uint id))
|
|
{
|
|
CaptureInstall(
|
|
_staging._flatCellStruct,
|
|
id,
|
|
_flatCells,
|
|
_flatCellIds);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_phase++;
|
|
return false;
|
|
}
|
|
case 5:
|
|
{
|
|
if (TryTakeNextPrefixKey(
|
|
ActiveWorld.FlatCellStructKeys,
|
|
out uint id))
|
|
{
|
|
CaptureRemoval(
|
|
_active._flatCellStruct,
|
|
id,
|
|
_flatCellIds,
|
|
_removeFlatCells);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_phase++;
|
|
return false;
|
|
}
|
|
case 6:
|
|
{
|
|
if (TryTakeNextPrefixKey(
|
|
StagingWorld.FlatEnvCellKeys,
|
|
out uint id))
|
|
{
|
|
CaptureInstall(
|
|
_staging._flatEnvCell,
|
|
id,
|
|
_flatEnvCells,
|
|
_flatEnvCellIds);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_phase++;
|
|
return false;
|
|
}
|
|
case 7:
|
|
{
|
|
if (TryTakeNextPrefixKey(
|
|
ActiveWorld.FlatEnvCellKeys,
|
|
out uint id))
|
|
{
|
|
CaptureRemoval(
|
|
_active._flatEnvCell,
|
|
id,
|
|
_flatEnvCellIds,
|
|
_removeFlatEnvCells);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_phase++;
|
|
return false;
|
|
}
|
|
case 8:
|
|
{
|
|
if (TryTakeNextPrefixKey(
|
|
StagingWorld.BuildingKeys,
|
|
out uint id))
|
|
{
|
|
CaptureInstall(
|
|
_staging._buildings,
|
|
id,
|
|
_buildings,
|
|
_buildingIds);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_phase++;
|
|
return false;
|
|
}
|
|
case 9:
|
|
{
|
|
if (TryTakeNextPrefixKey(
|
|
ActiveWorld.BuildingKeys,
|
|
out uint id))
|
|
{
|
|
CaptureRemoval(
|
|
_active._buildings,
|
|
id,
|
|
_buildingIds,
|
|
_removeBuildings);
|
|
WorkUnits++;
|
|
return false;
|
|
}
|
|
_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 CollisionWorldState StagingWorld =>
|
|
_staging._collisionWorld.Current;
|
|
|
|
private CollisionWorldState ActiveWorld =>
|
|
_active._collisionWorld.Current;
|
|
|
|
/// <summary>
|
|
/// O1 metered prefix-key cursor. The first call of a phase captures
|
|
/// the ledger's live slot-list reference and count; later calls
|
|
/// iterate by index, skipping tombstones (key 0). Removal only ever
|
|
/// tombstones a slot, so a captured reference stays exact across
|
|
/// frames without holding a map enumerator.
|
|
/// </summary>
|
|
private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key)
|
|
{
|
|
if (!_keySlotsCaptured)
|
|
{
|
|
_keySlots = ledger.SlotsForPrefix(_prefix);
|
|
_keySlotLimit = _keySlots?.Count ?? 0;
|
|
_keySlotsCaptured = true;
|
|
_cursor = 0;
|
|
}
|
|
while (_cursor < _keySlotLimit)
|
|
{
|
|
uint candidate = _keySlots![_cursor++];
|
|
if (candidate != 0u)
|
|
{
|
|
key = candidate;
|
|
return true;
|
|
}
|
|
}
|
|
key = 0u;
|
|
_keySlots = null;
|
|
_keySlotsCaptured = false;
|
|
return false;
|
|
}
|
|
|
|
private static void CaptureInstall<T>(
|
|
ConcurrentDictionary<uint, T> source,
|
|
uint id,
|
|
List<KeyValuePair<uint, T>> destination,
|
|
HashSet<uint> ids)
|
|
{
|
|
if (source.TryGetValue(id, out T? value))
|
|
{
|
|
destination.Add(new KeyValuePair<uint, T>(id, value));
|
|
ids.Add(id);
|
|
}
|
|
}
|
|
|
|
private static void CaptureRemoval<T>(
|
|
ConcurrentDictionary<uint, T> source,
|
|
uint id,
|
|
HashSet<uint> retained,
|
|
List<uint> destination)
|
|
{
|
|
if (!retained.Contains(id) && source.ContainsKey(id))
|
|
destination.Add(id);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_keySlots = null;
|
|
_keySlotsCaptured = false;
|
|
_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>
|
|
/// Retail <c>CGfxObj::gfx_bound_box</c> — the AABB of this GfxObj's vertex
|
|
/// array, filled by <c>CGfxObj::init_end</c> @0x00534200 and returned by
|
|
/// <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600. Cached beside
|
|
/// <see cref="BoundingSphere"/> so the single
|
|
/// <c>ShadowShapeBuilder.FromLandblockBspParts</c> resolver answers both of
|
|
/// retail's cell-membership questions from one lookup (#334).
|
|
/// </summary>
|
|
public FlatGfxObjVisualBounds? VisualBounds { get; init; }
|
|
}
|
|
|
|
/// <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; }
|
|
}
|