feat(streaming): shadow-publish flat collision assets

Carry one immutable prepared collision closure with each accepted near-tier generation and install graph plus flat views through the same retained publication receipt. Apply the same strict package-only rule to live entities, add exact sampled graph-authoritative comparison artifacts and lifecycle counters, and prove cancellation, demotion, rehydrate, revisit, teardown, reconnect, and the nine-stop route with 14,064 zero-mismatch samples.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 16:38:54 +02:00
parent f7ff9f4eea
commit d9446030e6
32 changed files with 2777 additions and 92 deletions

View file

@ -11,9 +11,10 @@ namespace AcDream.Core.Physics;
/// <summary>
/// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup
/// dat objects during streaming. Populated by the streaming worker thread;
/// read by the physics engine on the game/render thread. ConcurrentDictionary
/// makes cross-thread access safe without a global lock.
/// dat objects during streaming and live-object materialization. Prepared
/// payloads are built/read off the hot path; one update-thread publisher
/// installs each graph/flat pair. ConcurrentDictionary keeps diagnostics and
/// tooling reads safe without a global lock.
/// </summary>
public sealed class PhysicsDataCache
{
@ -21,6 +22,29 @@ public sealed class PhysicsDataCache
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
private readonly ConcurrentDictionary<uint, FlatGfxObjCollisionAsset>
_flatGfxObj = new();
private readonly ConcurrentDictionary<uint, FlatSetupCollision>
_flatSetup = new();
private readonly ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
_flatCellStruct = new();
private readonly ConcurrentDictionary<uint, FlatEnvCellTopology>
_flatEnvCell = new();
public PhysicsDataCache()
{
if (PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
{
CollisionShadow = new CollisionShadowVerifier(
PhysicsDiagnostics.CollisionShadowSampleEvery,
PhysicsDiagnostics.CollisionShadowArtifactDirectory);
}
}
internal CollisionShadowVerifier? CollisionShadow { get; set; }
public CollisionShadowStats CollisionShadowStats =>
CollisionShadow?.Stats ?? default;
/// <summary>
/// Slice I graph/flat differential selector. Production remains on the
@ -55,8 +79,14 @@ public sealed class PhysicsDataCache
/// user collide with decorative meshes that don't have a CylSphere or
/// per-part BSP.
/// </summary>
public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj)
public void CacheGfxObj(
uint gfxObjId,
GfxObj gfxObj,
FlatGfxObjCollisionAsset? prepared = null)
{
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.
@ -65,18 +95,25 @@ public sealed class PhysicsDataCache
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
}
if (_gfxObj.ContainsKey(gfxObjId)) return;
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;
@ -157,17 +194,30 @@ public sealed class PhysicsDataCache
/// 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)
public void CacheSetup(
uint setupId,
Setup setup,
FlatSetupCollision? prepared = null)
{
if (_setup.ContainsKey(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,
};
}
@ -176,9 +226,19 @@ public sealed class PhysicsDataCache
/// (indoor room geometry). No-ops if the id is already cached or the
/// CellStruct has no physics BSP.
/// </summary>
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
CellStruct cellStruct, Matrix4x4 worldTransform)
public void CacheCellStruct(
uint envCellId,
DatReaderWriter.DBObjs.EnvCell envCell,
CellStruct cellStruct,
Matrix4x4 worldTransform,
FlatCellStructureCollisionAsset? preparedStructure = null,
FlatEnvCellTopology? preparedTopology = null)
{
if (preparedStructure is not null)
_flatCellStruct.TryAdd(envCellId, preparedStructure);
if (preparedTopology is not null)
_flatEnvCell.TryAdd(envCellId, preparedTopology);
// UCG Stage 1: register in the unified graph for ALL cells — before the
// idempotency + null-BSP guards below, so BSP-less cells are still included.
if (!CellGraph.Contains(envCellId))
@ -216,12 +276,17 @@ public sealed class PhysicsDataCache
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,
@ -394,9 +459,21 @@ public sealed class PhysicsDataCache
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
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 : null;
public FlatSetupCollision? GetFlatSetup(uint id) =>
_flatSetup.TryGetValue(id, out var value) ? value : null;
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;
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
@ -483,6 +560,12 @@ public sealed class PhysicsDataCache
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)
@ -539,6 +622,7 @@ public sealed class ResolvedPolygon
/// <summary>Cached physics data for a single GfxObj part.</summary>
public sealed class GfxObjPhysics
{
public uint SourceId { get; init; }
public required PhysicsBSPTree BSP { get; init; }
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
public Sphere? BoundingSphere { get; init; }
@ -551,21 +635,24 @@ public sealed class GfxObjPhysics
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Prepared integer-indexed shadow representation. Optional until Slice I5
/// dual publication is complete.
/// 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; init; }
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>
@ -575,6 +662,7 @@ public sealed class SetupPhysics
/// </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"/>
@ -593,7 +681,9 @@ public sealed class CellPhysics
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Prepared integer-indexed physics BSP shadow. Optional until Slice I5.
/// 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; }
@ -610,11 +700,23 @@ public sealed class CellPhysics
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary>
/// Prepared integer-indexed cell-containment BSP shadow. Optional until
/// Slice I5.
/// 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>.