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

@ -30,6 +30,7 @@ internal sealed record ContentEffectsAudioResult(
MagicCatalog MagicCatalog,
IAnimationLoader AnimationLoader,
LiveEntityCollisionBuilder CollisionBuilder,
LiveCollisionAssetPublisher CollisionAssets,
EmitterDescRegistry EmitterRegistry,
ParticleSystem ParticleSystem,
ParticleHookSink ParticleSink,
@ -95,6 +96,9 @@ internal interface IContentEffectsAudioCompositionFactory
IDatReaderWriter dats,
IAnimationLoader animationLoader,
bool dumpMotionEnabled);
LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher(
PhysicsDataCache physicsData,
IPreparedAssetSource preparedAssets);
EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats);
ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters);
ParticleHookSink CreateParticleSink(
@ -165,6 +169,15 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
animationLoader,
dumpMotionEnabled));
public LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher(
PhysicsDataCache physicsData,
IPreparedAssetSource preparedAssets) =>
new(
physicsData,
preparedAssets as IPreparedCollisionSource
?? throw new NotSupportedException(
"Production prepared assets must expose collision data."));
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) =>
new(dats);
@ -332,6 +345,10 @@ internal sealed class ContentEffectsAudioCompositionPhase :
_dependencies.DumpMotionEnabled);
_publication.PublishLiveEntityCollisionBuilder(collision);
Fault(ContentEffectsAudioCompositionPoint.CollisionBuilderPublished);
LiveCollisionAssetPublisher collisionAssets =
_factory.CreateLiveCollisionAssetPublisher(
_dependencies.PhysicsDataCache,
preparedAssets);
EmitterDescRegistry emitters = _factory.CreateEmitterRegistry(dats);
_publication.PublishEmitterRegistry(emitters);
@ -403,6 +420,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
magic,
animations,
collision,
collisionAssets,
emitters,
particles,
particleSink,

View file

@ -441,6 +441,9 @@ internal sealed class FrameRootCompositionPhase
d.FrameProfiler,
content.Dats,
foundation.Residency,
d.PhysicsEngine.DataCache
?? throw new InvalidOperationException(
"Lifecycle automation requires the canonical physics cache."),
currentRenderSceneOracle,
renderSceneShadowComparison,
renderFrameProduct);

View file

@ -12,6 +12,7 @@ using AcDream.App.Settings;
using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Chat;
@ -252,11 +253,16 @@ internal sealed class SessionPlayerCompositionPhase
$"(window={2 * farRadius + 1}x{2 * farRadius + 1})");
Fault(SessionPlayerCompositionPoint.StreamingRadiiResolved);
IPreparedCollisionSource preparedCollisions =
content.PreparedAssets as IPreparedCollisionSource ??
throw new NotSupportedException(
"Production prepared assets must expose the matching " +
"prepared-collision catalog.");
var landblockBuildFactory = new LandblockBuildFactory(
content.Dats,
preparedCollisions,
d.DatLock,
world.TerrainBuild.HeightTable,
d.PhysicsDataCache,
d.Options.DumpSceneryZ);
var streamerLease = scope.Acquire(
"landblock streamer",
@ -399,7 +405,7 @@ internal sealed class SessionPlayerCompositionPhase
d.Options,
content.Dats,
live.LiveEntities,
d.PhysicsDataCache,
content.CollisionAssets,
content.AnimationLoader,
live.EntitySpawnAdapter,
world.Foundation.TextureCache,
@ -689,7 +695,7 @@ internal sealed class SessionPlayerCompositionPhase
d.MotionBindings,
content.Dats,
d.DatLock,
d.PhysicsDataCache,
content.CollisionAssets,
d.AnimatedEntities,
localPlayerAnimation,
localPlayerShadow,

View file

@ -4,6 +4,7 @@ using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
using AcDream.Core.Physics;
namespace AcDream.App.Diagnostics;
@ -81,6 +82,14 @@ internal sealed record WorldLifecycleResourceSnapshot(
long DatObjectCacheHits,
long DatObjectCacheMisses,
long DatObjectCacheEvictions,
int PhysicsGraphGfxObjs,
int PhysicsGraphSetups,
int PhysicsGraphCells,
int PhysicsFlatGfxObjs,
int PhysicsFlatSetups,
int PhysicsFlatCells,
int PhysicsFlatEnvCells,
CollisionShadowStats CollisionShadow,
StreamingWorkDiagnostics StreamingWork,
ResidencySnapshot Residency,
double Fps,

View file

@ -6,6 +6,7 @@ using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter.Lib.IO;
@ -39,6 +40,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly FrameProfiler _frameProfiler;
private readonly IDatReaderWriter _dats;
private readonly ResidencyManager _residency;
private readonly PhysicsDataCache _physics;
private readonly ICurrentRenderSceneOracleSnapshotSource?
_renderSceneOracle;
private readonly IRenderSceneShadowSnapshotSource?
@ -63,6 +65,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
FrameProfiler frameProfiler,
IDatReaderWriter dats,
ResidencyManager residency,
PhysicsDataCache physics,
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null,
IRenderSceneShadowSnapshotSource? renderSceneShadow = null,
IRenderFrameProductSnapshotSource? renderFrameProduct = null)
@ -92,6 +95,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_residency = residency
?? throw new ArgumentNullException(nameof(residency));
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_renderSceneOracle = renderSceneOracle;
_renderSceneShadow = renderSceneShadow;
_renderFrameProduct = renderFrameProduct;
@ -184,6 +188,14 @@ internal sealed class WorldLifecycleResourceSnapshotSource
DatObjectCacheHits: datObjectCacheStats.Hits,
DatObjectCacheMisses: datObjectCacheStats.Misses,
DatObjectCacheEvictions: datObjectCacheStats.Evictions,
PhysicsGraphGfxObjs: _physics.GfxObjCount,
PhysicsGraphSetups: _physics.SetupCount,
PhysicsGraphCells: _physics.CellStructCount,
PhysicsFlatGfxObjs: _physics.FlatGfxObjCount,
PhysicsFlatSetups: _physics.FlatSetupCount,
PhysicsFlatCells: _physics.FlatCellStructCount,
PhysicsFlatEnvCells: _physics.FlatEnvCellCount,
CollisionShadow: _physics.CollisionShadowStats,
StreamingWork: _streaming.WorkDiagnostics,
Residency: residency,
Fps: render.Fps,

View file

@ -33,7 +33,7 @@ internal sealed class PlayerModeController :
private readonly ILiveEntityMotionRuntimeBindings _motionBindings;
private readonly IDatReaderWriter _dats;
private readonly object _datLock;
private readonly PhysicsDataCache _physicsDataCache;
private readonly LiveCollisionAssetPublisher _collisionAssets;
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
private readonly LocalPlayerAnimationController _animation;
private readonly LocalPlayerShadowSynchronizer _shadow;
@ -59,7 +59,7 @@ internal sealed class PlayerModeController :
ILiveEntityMotionRuntimeBindings motionBindings,
IDatReaderWriter dats,
object datLock,
PhysicsDataCache physicsDataCache,
LiveCollisionAssetPublisher collisionAssets,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
LocalPlayerAnimationController animation,
LocalPlayerShadowSynchronizer shadow,
@ -82,7 +82,8 @@ internal sealed class PlayerModeController :
_motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
_collisionAssets = collisionAssets ??
throw new ArgumentNullException(nameof(collisionAssets));
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
_animation = animation ?? throw new ArgumentNullException(nameof(animation));
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
@ -536,7 +537,9 @@ internal sealed class PlayerModeController :
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(
playerEntity.SourceGfxObjOrSetupId);
if (setup is not null)
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, setup);
_collisionAssets.CacheSetup(
playerEntity.SourceGfxObjOrSetupId,
setup);
controller.StepUpHeight = setup is { StepUpHeight: > 0f }
? setup.StepUpHeight
: 0.4f;

View file

@ -0,0 +1,68 @@
using AcDream.Content;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Physics;
/// <summary>
/// Publishes the graph and prepared collision views for DAT objects discovered
/// through the live-object path. Streamed statics carry their prepared closure
/// on <c>LandblockBuild</c>; live CreateObject/appearance data arrives outside
/// that transaction and therefore uses this matching strict publisher.
/// </summary>
internal sealed class LiveCollisionAssetPublisher
{
private readonly PhysicsDataCache _cache;
private readonly IPreparedCollisionSource _source;
public LiveCollisionAssetPublisher(
PhysicsDataCache cache,
IPreparedCollisionSource source)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_source = source ?? throw new ArgumentNullException(nameof(source));
}
public void CacheGfxObj(uint id, GfxObj gfxObj)
{
ArgumentNullException.ThrowIfNull(gfxObj);
FlatGfxObjCollisionAsset? prepared =
_cache.GetFlatGfxObj(id) is not null
? null
: Require(
_source.ReadGfxObjCollision(id),
"GfxObj collision",
id);
_cache.CacheGfxObj(id, gfxObj, prepared);
}
public void CacheSetup(uint id, Setup setup)
{
ArgumentNullException.ThrowIfNull(setup);
FlatSetupCollision? prepared =
_cache.GetFlatSetup(id) is not null
? null
: Require(
_source.ReadSetupCollision(id),
"Setup collision",
id);
_cache.CacheSetup(id, setup, prepared);
}
private static T Require<T>(
PreparedCollisionReadResult<T> result,
string kind,
uint id)
where T : class
{
if (result.Status == PreparedAssetReadStatus.Loaded &&
result.Data is not null)
{
return result.Data;
}
throw new InvalidDataException(
$"{kind} 0x{id:X8} is {result.Status}. " +
"Live collision cannot publish only one representation.");
}
}

View file

@ -30,7 +30,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
private readonly RuntimeOptions _options;
private readonly IDatReaderWriter _dats;
private readonly LiveEntityRuntime _runtime;
private readonly PhysicsDataCache _physicsData;
private readonly LiveCollisionAssetPublisher _collisionAssets;
private readonly IAnimationLoader _animationLoader;
private readonly EntitySpawnAdapter _spawnAdapter;
private readonly TextureCache _textures;
@ -62,7 +62,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
RuntimeOptions options,
IDatReaderWriter dats,
LiveEntityRuntime runtime,
PhysicsDataCache physicsData,
LiveCollisionAssetPublisher collisionAssets,
IAnimationLoader animationLoader,
EntitySpawnAdapter spawnAdapter,
TextureCache textures,
@ -82,7 +82,8 @@ internal sealed class DatLiveEntityProjectionMaterializer
_options = options ?? throw new ArgumentNullException(nameof(options));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_physicsData = physicsData ?? throw new ArgumentNullException(nameof(physicsData));
_collisionAssets = collisionAssets ??
throw new ArgumentNullException(nameof(collisionAssets));
_animationLoader = animationLoader ?? throw new ArgumentNullException(nameof(animationLoader));
_spawnAdapter = spawnAdapter ?? throw new ArgumentNullException(nameof(spawnAdapter));
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
@ -172,7 +173,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
Setup? setup = _dats.Get<Setup>(canonicalSpawn.SetupTableId.Value);
if (setup is not null)
_physicsData.CacheSetup(canonicalSpawn.SetupTableId.Value, setup);
_collisionAssets.CacheSetup(canonicalSpawn.SetupTableId.Value, setup);
if (setup is null)
{
_missingSetup++;
@ -282,7 +283,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
continue;
}
_physicsData.CacheGfxObj(part.GfxObjId, gfx);
_collisionAssets.CacheGfxObj(part.GfxObjId, gfx);
if (dumpClothing)
{
var subMeshes = GfxObjMesh.Build(gfx, _dats);
@ -430,7 +431,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
}
if (slotZeroGfx is not null)
_physicsData.CacheGfxObj(slotZeroId, slotZeroGfx);
_collisionAssets.CacheGfxObj(slotZeroId, slotZeroGfx);
return slotZeroId;
}
@ -512,7 +513,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
}
continue;
}
_physicsData.CacheGfxObj(parts[partIndex].GfxObjId, gfx);
_collisionAssets.CacheGfxObj(parts[partIndex].GfxObjId, gfx);
Dictionary<uint, uint>? resolved = null;
foreach (var surfaceQid in gfx.Surfaces)

View file

@ -11,7 +11,8 @@ namespace AcDream.App.Streaming;
public sealed record LandblockBuild(
LoadedLandblock Landblock,
EnvCellLandblockBuild? EnvCells = null,
LandblockBuildOrigin Origin = default)
LandblockBuildOrigin Origin = default,
LandblockCollisionBuild? Collisions = null)
{
public uint LandblockId => Landblock.LandblockId;
}

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using DatReaderWriter;
using AcDream.Content;
@ -17,19 +18,21 @@ namespace AcDream.App.Streaming;
public sealed class LandblockBuildFactory
{
private readonly IDatReaderWriter _dats;
private readonly IPreparedCollisionSource _preparedCollisions;
private readonly object _datLock;
private readonly float[] _heightTable;
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache;
private readonly bool _dumpSceneryZ;
public LandblockBuildFactory(
IDatReaderWriter dats,
IPreparedCollisionSource preparedCollisions,
object datLock,
float[] heightTable,
AcDream.Core.Physics.PhysicsDataCache physicsDataCache,
bool dumpSceneryZ = false)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_preparedCollisions = preparedCollisions ??
throw new ArgumentNullException(nameof(preparedCollisions));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
ArgumentNullException.ThrowIfNull(heightTable);
if (heightTable.Length < 256)
@ -37,7 +40,6 @@ public sealed class LandblockBuildFactory
"The retail terrain height table must contain at least 256 entries.",
nameof(heightTable));
_heightTable = (float[])heightTable.Clone();
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
_dumpSceneryZ = dumpSceneryZ;
}
@ -73,6 +75,7 @@ public sealed class LandblockBuildFactory
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
// cost). Identical work in both branches; the probe branch only adds the
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
LandblockBuild? build;
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
@ -80,17 +83,28 @@ public sealed class LandblockBuildFactory
{
long waitedMs = sw.ElapsedMilliseconds;
sw.Restart();
var built = BuildLocked(request);
build = BuildLocked(request);
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"BUILD", request.LandblockId,
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}");
return built;
}
}
lock (_datLock)
else
{
return BuildLocked(request);
lock (_datLock)
build = BuildLocked(request);
}
if (build is null ||
request.Kind == LandblockStreamJobKind.LoadFar)
{
return build;
}
return build with
{
Collisions = BuildPreparedCollisionClosure(build.Landblock),
};
}
private AcDream.App.Streaming.LandblockBuild? BuildLocked(
@ -143,7 +157,6 @@ public sealed class LandblockBuildFactory
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(
@ -156,13 +169,11 @@ public sealed class LandblockBuildFactory
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
_physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value);
meshRefs.Add(mr);
@ -293,6 +304,103 @@ public sealed class LandblockBuildFactory
return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
}
/// <summary>
/// Reads the exact prepared collision closure after the serialized DAT
/// transaction completes. The immutable package is independently
/// thread-safe, so these reads never extend the shared DAT-reader lock.
/// Missing or corrupt prepared data fails the complete near-tier build;
/// production must not publish a graph-only partial generation.
/// </summary>
private AcDream.Core.World.LandblockCollisionBuild
BuildPreparedCollisionClosure(
AcDream.Core.World.LoadedLandblock landblock)
{
AcDream.Core.World.PhysicsDatBundle dats =
landblock.PhysicsDats ??
AcDream.Core.World.PhysicsDatBundle.Empty;
ImmutableArray<uint> gfxObjIds = [.. dats.GfxObjs.Keys.Order()];
ImmutableArray<uint> setupIds = [.. dats.Setups.Keys.Order()];
ImmutableArray<uint> envCellIds = [.. dats.EnvCells.Keys.Order()];
var gfxObjs =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatGfxObjCollisionAsset>();
var setups =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatSetupCollision>();
var cellStructures =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatCellStructureCollisionAsset>();
var envCells =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatEnvCellTopology>();
foreach (uint id in gfxObjIds)
{
gfxObjs.Add(
id,
RequirePrepared(
_preparedCollisions.ReadGfxObjCollision(id),
"GfxObj collision",
id));
}
foreach (uint id in setupIds)
{
setups.Add(
id,
RequirePrepared(
_preparedCollisions.ReadSetupCollision(id),
"Setup collision",
id));
}
foreach (uint id in envCellIds)
{
cellStructures.Add(
id,
RequirePrepared(
_preparedCollisions.ReadCellStructureCollision(id),
"CellStruct collision",
id));
envCells.Add(
id,
RequirePrepared(
_preparedCollisions.ReadEnvCellTopology(id),
"EnvCell topology",
id));
}
return new AcDream.Core.World.LandblockCollisionBuild(
gfxObjs.ToImmutable(),
setups.ToImmutable(),
cellStructures.ToImmutable(),
envCells.ToImmutable(),
gfxObjIds,
setupIds,
envCellIds);
}
private static T RequirePrepared<T>(
PreparedCollisionReadResult<T> result,
string kind,
uint id)
where T : class
{
if (result.Status == PreparedAssetReadStatus.Loaded &&
result.Data is not null)
{
return result.Data;
}
throw new InvalidDataException(
$"{kind} 0x{id:X8} is {result.Status}. " +
"The complete near-tier generation cannot be published.");
}
/// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls.
@ -363,7 +471,6 @@ public sealed class LandblockBuildFactory
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
@ -374,13 +481,11 @@ public sealed class LandblockBuildFactory
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
if (setup is not null)
{
_physicsDataCache.CacheSetup(spawn.ObjectId, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
// Compose: part's own transform, then the spawn's scale.
var partXf = mr.PartTransform * scaleMat;
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
@ -687,7 +792,6 @@ public sealed class LandblockBuildFactory
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
@ -702,7 +806,6 @@ public sealed class LandblockBuildFactory
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
if (setup is not null)
{
_physicsDataCache.CacheSetup(stab.Id, setup);
stabLightCount = setup.Lights.Count;
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
if (dumpStab)
@ -727,7 +830,6 @@ public sealed class LandblockBuildFactory
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped");
continue;
}
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
meshRefs.Add(mr);

View file

@ -46,6 +46,8 @@ public sealed class LandblockPhysicsPublication
internal int BuildingCursor { get; set; }
internal bool BaseCommitted { get; set; }
internal int GfxCursor { get; set; }
internal uint[] SetupObjectIds { get; set; } = Array.Empty<uint>();
internal int SetupCursor { get; set; }
internal int PriorStaticCursor { get; set; }
internal int StaticCursor { get; set; }
internal int BspOwnerCount { get; set; }
@ -199,7 +201,7 @@ public sealed class LandblockPhysicsPublisher
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray()
?? Array.Empty<BuildingInfo>();
return new LandblockPhysicsPublication(
var publication = new LandblockPhysicsPublication(
_receiptOwner,
build,
origin,
@ -207,6 +209,10 @@ public sealed class LandblockPhysicsPublisher
buildings,
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
build.Landblock.LandblockId));
publication.SetupObjectIds = build.Collisions is { } collisions
? [.. collisions.SetupIds]
: datBundle.Setups.Keys.Order().ToArray();
return publication;
}
/// <summary>
@ -223,6 +229,14 @@ public sealed class LandblockPhysicsPublisher
IReadOnlyList<WorldEntity> entities =
publication.Build.Landblock.Entities;
if (publication.Build.Collisions is { } collisions)
{
publication.GfxObjectIds = [.. collisions.GfxObjIds];
publication.PreparationCursor = entities.Count;
publication.PreparationCommitted = true;
return true;
}
if (publication.PreparationCursor < entities.Count)
{
WorldEntity entity = entities[publication.PreparationCursor];
@ -389,10 +403,33 @@ public sealed class LandblockPhysicsPublisher
long cacheStarted = Stopwatch.GetTimestamp();
uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor];
if (datBundle.GfxObjs.TryGetValue(gfxObjectId, out var gfx))
_physicsDataCache.CacheGfxObj(gfxObjectId, gfx);
{
FlatGfxObjCollisionAsset? prepared = null;
publication.Build.Collisions?.GfxObjs.TryGetValue(
gfxObjectId,
out prepared);
_physicsDataCache.CacheGfxObj(
gfxObjectId,
gfx,
prepared);
}
publication.GfxCursor++;
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
}
else if (publication.SetupCursor < publication.SetupObjectIds.Length)
{
uint setupId =
publication.SetupObjectIds[publication.SetupCursor];
if (datBundle.Setups.TryGetValue(setupId, out var setup))
{
FlatSetupCollision? prepared = null;
publication.Build.Collisions?.Setups.TryGetValue(
setupId,
out prepared);
_physicsDataCache.CacheSetup(setupId, setup, prepared);
}
publication.SetupCursor++;
}
else if (publication.PriorStaticCursor
< publication.PriorStaticOwnerIds.Length)
{
@ -486,11 +523,21 @@ public sealed class LandblockPhysicsPublisher
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld);
FlatCellStructureCollisionAsset? preparedStructure = null;
FlatEnvCellTopology? preparedTopology = null;
publication.Build.Collisions?.CellStructures.TryGetValue(
envCellId,
out preparedStructure);
publication.Build.Collisions?.EnvCells.TryGetValue(
envCellId,
out preparedTopology);
_physicsDataCache.CacheCellStruct(
envCellId,
envCell,
cellStruct,
physicsCellTransform);
physicsCellTransform,
preparedStructure,
preparedTopology);
var worldVertices = new Dictionary<ushort, Vector3>(
cellStruct.VertexArray.Vertices.Count);

View file

@ -0,0 +1,555 @@
using System.Globalization;
using System.Numerics;
using System.Text.Json;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
public readonly record struct CollisionShadowStats(
long Queries,
long Samples,
long Matches,
long Mismatches,
long Faults);
/// <summary>
/// Slice I5 sampled graph/flat referee. The graph result always remains
/// authoritative. One retained clone receives the flat query first; the
/// canonical transition is then evaluated by the graph path and compared
/// field-for-field with exact float bits.
/// </summary>
internal sealed class CollisionShadowVerifier
{
private readonly int _sampleEvery;
private readonly string _artifactDirectory;
private readonly Transition _shadowTransition = new();
private long _queries;
private long _samples;
private long _matches;
private long _mismatches;
private long _faults;
private bool _flatPass;
public CollisionShadowVerifier(
int sampleEvery,
string artifactDirectory)
{
if (sampleEvery <= 0)
throw new ArgumentOutOfRangeException(nameof(sampleEvery));
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDirectory);
_sampleEvery = sampleEvery;
_artifactDirectory = Path.GetFullPath(artifactDirectory);
}
internal bool IsFlatPass => _flatPass;
internal CollisionShadowStats Stats => new(
_queries,
_samples,
_matches,
_mismatches,
_faults);
internal bool TrySample(out long sample)
{
if (_flatPass)
{
sample = 0;
return false;
}
long query = ++_queries;
if (query % _sampleEvery != 0)
{
sample = 0;
return false;
}
sample = ++_samples;
return true;
}
internal Transition PrepareShadow(Transition source)
{
_shadowTransition.CopyFrom(source);
return _shadowTransition;
}
internal void BeginFlatPass()
{
if (_flatPass)
throw new InvalidOperationException(
"Collision shadow flat passes cannot overlap.");
_flatPass = true;
}
internal void EndFlatPass()
{
if (!_flatPass)
throw new InvalidOperationException(
"No collision shadow flat pass is active.");
_flatPass = false;
}
internal void RecordBoolean(
long sample,
string kind,
uint sourceId,
bool graph,
bool flat,
string input)
{
if (graph == flat)
{
_matches++;
return;
}
RecordMismatch(
sample,
kind,
sourceId,
"Result",
graph.ToString(),
flat.ToString(),
input);
}
internal void RecordSphere(
long sample,
string kind,
uint sourceId,
FlatCollisionSphere graph,
FlatCollisionSphere flat)
{
if (Exact(graph.Origin, flat.Origin) &&
Exact(graph.Radius, flat.Radius))
{
_matches++;
return;
}
RecordMismatch(
sample,
kind,
sourceId,
"RootBoundingSphere",
Format(graph),
Format(flat),
string.Empty);
}
internal void RecordTransition(
long sample,
string kind,
uint sourceId,
TransitionState graphState,
TransitionState flatState,
Transition graph,
Transition flat,
string input)
{
if (graphState != flatState)
{
RecordMismatch(
sample,
kind,
sourceId,
"TransitionState",
graphState.ToString(),
flatState.ToString(),
input);
return;
}
if (TransitionExactComparer.TryFindDifference(
graph,
flat,
out string path,
out string graphValue,
out string flatValue))
{
RecordMismatch(
sample,
kind,
sourceId,
path,
graphValue,
flatValue,
input);
return;
}
_matches++;
}
internal void RecordFault(
long sample,
string kind,
uint sourceId,
Exception fault,
string input)
{
_faults++;
WriteArtifact(new CollisionShadowMismatchArtifact(
1,
sample,
kind,
$"0x{sourceId:X8}",
"FlatFault",
"graph-authoritative",
$"{fault.GetType().FullName}: {fault.Message}",
input));
}
private void RecordMismatch(
long sample,
string kind,
uint sourceId,
string difference,
string graph,
string flat,
string input)
{
_mismatches++;
WriteArtifact(new CollisionShadowMismatchArtifact(
1,
sample,
kind,
$"0x{sourceId:X8}",
difference,
graph,
flat,
input));
}
private void WriteArtifact(CollisionShadowMismatchArtifact artifact)
{
Directory.CreateDirectory(_artifactDirectory);
string path = Path.Combine(
_artifactDirectory,
FormattableString.Invariant(
$"collision-shadow-{artifact.Sample:D8}.json"));
string json = JsonSerializer.Serialize(
artifact,
CollisionShadowJsonContext.Default
.CollisionShadowMismatchArtifact);
File.WriteAllText(path, json);
}
internal static string FormatInput(
Vector3 center,
float radius) =>
$"center={Format(center)};radius={Bits(radius)}";
internal static string FormatInput(
Vector3 center,
float radius,
bool hasSphere1,
Vector3 sphere1Center,
float sphere1Radius,
Vector3 currentCenter,
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld,
Vector3 worldOrigin) =>
$"sphere0={Format(center)}/{Bits(radius)};" +
$"sphere1={(hasSphere1 ? Format(sphere1Center) : "none")}/" +
$"{Bits(sphere1Radius)};current={Format(currentCenter)};" +
$"localZ={Format(localSpaceZ)};scale={Bits(scale)};" +
$"rotation={Format(localToWorld)};origin={Format(worldOrigin)}";
private static string Format(FlatCollisionSphere sphere) =>
$"{Format(sphere.Origin)}/{Bits(sphere.Radius)}";
private static string Format(Vector3 value) =>
$"{Bits(value.X)},{Bits(value.Y)},{Bits(value.Z)}";
private static string Format(Quaternion value) =>
$"{Bits(value.X)},{Bits(value.Y)},{Bits(value.Z)},{Bits(value.W)}";
private static string Bits(float value) =>
$"0x{BitConverter.SingleToInt32Bits(value):X8}";
private static bool Exact(float left, float right) =>
BitConverter.SingleToInt32Bits(left) ==
BitConverter.SingleToInt32Bits(right);
private static bool Exact(Vector3 left, Vector3 right) =>
Exact(left.X, right.X) &&
Exact(left.Y, right.Y) &&
Exact(left.Z, right.Z);
}
internal sealed record CollisionShadowMismatchArtifact(
int SchemaVersion,
long Sample,
string Kind,
string SourceId,
string Difference,
string Graph,
string Flat,
string Input);
[System.Text.Json.Serialization.JsonSerializable(
typeof(CollisionShadowMismatchArtifact))]
internal sealed partial class CollisionShadowJsonContext :
System.Text.Json.Serialization.JsonSerializerContext;
internal static class TransitionExactComparer
{
internal static bool TryFindDifference(
Transition graph,
Transition flat,
out string path,
out string graphValue,
out string flatValue)
{
if (CompareObjectInfo(
graph.ObjectInfo,
flat.ObjectInfo,
out path,
out graphValue,
out flatValue))
{
return true;
}
if (CompareSpherePath(
graph.SpherePath,
flat.SpherePath,
out path,
out graphValue,
out flatValue))
{
return true;
}
return CompareCollisionInfo(
graph.CollisionInfo,
flat.CollisionInfo,
out path,
out graphValue,
out flatValue);
}
private static bool CompareObjectInfo(
ObjectInfo a,
ObjectInfo b,
out string path,
out string av,
out string bv)
{
if (Different("ObjectInfo.State", a.State, b.State, out path, out av, out bv)) return true;
if (Different("ObjectInfo.StepUpHeight", a.StepUpHeight, b.StepUpHeight, out path, out av, out bv)) return true;
if (Different("ObjectInfo.StepDownHeight", a.StepDownHeight, b.StepDownHeight, out path, out av, out bv)) return true;
if (Different("ObjectInfo.Ethereal", a.Ethereal, b.Ethereal, out path, out av, out bv)) return true;
if (Different("ObjectInfo.StepDown", a.StepDown, b.StepDown, out path, out av, out bv)) return true;
if (Different("ObjectInfo.Scale", a.Scale, b.Scale, out path, out av, out bv)) return true;
if (Different("ObjectInfo.MoverPhysicsState", a.MoverPhysicsState, b.MoverPhysicsState, out path, out av, out bv)) return true;
if (Different("ObjectInfo.TargetId", a.TargetId, b.TargetId, out path, out av, out bv)) return true;
if (Different("ObjectInfo.SelfEntityId", a.SelfEntityId, b.SelfEntityId, out path, out av, out bv)) return true;
if (Different("ObjectInfo.MoverHasGravity", a.MoverHasGravity, b.MoverHasGravity, out path, out av, out bv)) return true;
if (Different("ObjectInfo.VelocityKilled", a.VelocityKilled, b.VelocityKilled, out path, out av, out bv)) return true;
return Equal(out path, out av, out bv);
}
private static bool CompareCollisionInfo(
CollisionInfo a,
CollisionInfo b,
out string path,
out string av,
out string bv)
{
if (Different("CollisionInfo.ContactPlaneValid", a.ContactPlaneValid, b.ContactPlaneValid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.ContactPlane", a.ContactPlane, b.ContactPlane, out path, out av, out bv)) return true;
if (Different("CollisionInfo.ContactPlaneCellId", a.ContactPlaneCellId, b.ContactPlaneCellId, out path, out av, out bv)) return true;
if (Different("CollisionInfo.ContactPlaneIsWater", a.ContactPlaneIsWater, b.ContactPlaneIsWater, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastKnownContactPlaneValid", a.LastKnownContactPlaneValid, b.LastKnownContactPlaneValid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastKnownContactPlane", a.LastKnownContactPlane, b.LastKnownContactPlane, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastKnownContactPlaneCellId", a.LastKnownContactPlaneCellId, b.LastKnownContactPlaneCellId, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastKnownContactPlaneIsWater", a.LastKnownContactPlaneIsWater, b.LastKnownContactPlaneIsWater, out path, out av, out bv)) return true;
if (Different("CollisionInfo.SlidingNormalValid", a.SlidingNormalValid, b.SlidingNormalValid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.SlidingNormal", a.SlidingNormal, b.SlidingNormal, out path, out av, out bv)) return true;
if (Different("CollisionInfo.CollisionNormalValid", a.CollisionNormalValid, b.CollisionNormalValid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.CollisionNormal", a.CollisionNormal, b.CollisionNormal, out path, out av, out bv)) return true;
if (Different("CollisionInfo.CollidedWithEnvironment", a.CollidedWithEnvironment, b.CollidedWithEnvironment, out path, out av, out bv)) return true;
if (Different("CollisionInfo.FramesStationaryFall", a.FramesStationaryFall, b.FramesStationaryFall, out path, out av, out bv)) return true;
if (Different("CollisionInfo.AdjustOffset", a.AdjustOffset, b.AdjustOffset, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastCollidedObjectGuid", a.LastCollidedObjectGuid, b.LastCollidedObjectGuid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.ContactPlaneWriteCount", a.ContactPlaneWriteCount, b.ContactPlaneWriteCount, out path, out av, out bv)) return true;
if (DifferentList("CollisionInfo.CollideObjectGuids", a.CollideObjectGuids, b.CollideObjectGuids, out path, out av, out bv)) return true;
return Equal(out path, out av, out bv);
}
private static bool CompareSpherePath(
SpherePath a,
SpherePath b,
out string path,
out string av,
out string bv)
{
if (Different("SpherePath.NumSphere", a.NumSphere, b.NumSphere, out path, out av, out bv)) return true;
if (DifferentSpheres("SpherePath.LocalSphere", a.LocalSphere, b.LocalSphere, out path, out av, out bv)) return true;
if (DifferentSpheres("SpherePath.GlobalSphere", a.GlobalSphere, b.GlobalSphere, out path, out av, out bv)) return true;
if (DifferentSpheres("SpherePath.GlobalCurrCenter", a.GlobalCurrCenter, b.GlobalCurrCenter, out path, out av, out bv)) return true;
if (Different("SpherePath.BeginPos", a.BeginPos, b.BeginPos, out path, out av, out bv)) return true;
if (Different("SpherePath.EndPos", a.EndPos, b.EndPos, out path, out av, out bv)) return true;
if (Different("SpherePath.CurPos", a.CurPos, b.CurPos, out path, out av, out bv)) return true;
if (Different("SpherePath.CheckPos", a.CheckPos, b.CheckPos, out path, out av, out bv)) return true;
if (Different("SpherePath.BeginOrientation", a.BeginOrientation, b.BeginOrientation, out path, out av, out bv)) return true;
if (Different("SpherePath.EndOrientation", a.EndOrientation, b.EndOrientation, out path, out av, out bv)) return true;
if (Different("SpherePath.CurOrientation", a.CurOrientation, b.CurOrientation, out path, out av, out bv)) return true;
if (Different("SpherePath.CheckOrientation", a.CheckOrientation, b.CheckOrientation, out path, out av, out bv)) return true;
if (Different("SpherePath.CurCellId", a.CurCellId, b.CurCellId, out path, out av, out bv)) return true;
if (Different("SpherePath.CheckCellId", a.CheckCellId, b.CheckCellId, out path, out av, out bv)) return true;
if (Different("SpherePath.CarriedBlockOrigin", a.CarriedBlockOrigin, b.CarriedBlockOrigin, out path, out av, out bv)) return true;
if (Different("SpherePath.GlobalOffset", a.GlobalOffset, b.GlobalOffset, out path, out av, out bv)) return true;
if (Different("SpherePath.StepUp", a.StepUp, b.StepUp, out path, out av, out bv)) return true;
if (Different("SpherePath.StepUpNormal", a.StepUpNormal, b.StepUpNormal, out path, out av, out bv)) return true;
if (Different("SpherePath.Collide", a.Collide, b.Collide, out path, out av, out bv)) return true;
if (Different("SpherePath.StepDown", a.StepDown, b.StepDown, out path, out av, out bv)) return true;
if (Different("SpherePath.StepDownAmt", a.StepDownAmt, b.StepDownAmt, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkInterp", a.WalkInterp, b.WalkInterp, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkableValid", a.WalkableValid, b.WalkableValid, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkablePlane", a.WalkablePlane, b.WalkablePlane, out path, out av, out bv)) return true;
if (DifferentVectors("SpherePath.WalkableVertices", a.WalkableVertices, b.WalkableVertices, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkableUp", a.WalkableUp, b.WalkableUp, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkableAllowance", a.WalkableAllowance, b.WalkableAllowance, out path, out av, out bv)) return true;
if (Different("SpherePath.LastWalkableValid", a.LastWalkableValid, b.LastWalkableValid, out path, out av, out bv)) return true;
if (Different("SpherePath.LastWalkablePlane", a.LastWalkablePlane, b.LastWalkablePlane, out path, out av, out bv)) return true;
if (DifferentVectors("SpherePath.LastWalkableVertices", a.LastWalkableVertices, b.LastWalkableVertices, out path, out av, out bv)) return true;
if (Different("SpherePath.LastWalkableUp", a.LastWalkableUp, b.LastWalkableUp, out path, out av, out bv)) return true;
if (Different("SpherePath.BackupCheckPos", a.BackupCheckPos, b.BackupCheckPos, out path, out av, out bv)) return true;
if (Different("SpherePath.BackupCheckCellId", a.BackupCheckCellId, b.BackupCheckCellId, out path, out av, out bv)) return true;
if (Different("SpherePath.NegPolyHit", a.NegPolyHit, b.NegPolyHit, out path, out av, out bv)) return true;
if (Different("SpherePath.NegStepUp", a.NegStepUp, b.NegStepUp, out path, out av, out bv)) return true;
if (Different("SpherePath.NegCollisionNormal", a.NegCollisionNormal, b.NegCollisionNormal, out path, out av, out bv)) return true;
if (Different("SpherePath.CheckWalkable", a.CheckWalkable, b.CheckWalkable, out path, out av, out bv)) return true;
if (Different("SpherePath.InsertType", a.InsertType, b.InsertType, out path, out av, out bv)) return true;
if (Different("SpherePath.PlacementAllowsSliding", a.PlacementAllowsSliding, b.PlacementAllowsSliding, out path, out av, out bv)) return true;
if (Different("SpherePath.ObstructionEthereal", a.ObstructionEthereal, b.ObstructionEthereal, out path, out av, out bv)) return true;
if (Different("SpherePath.BldgCheck", a.BldgCheck, b.BldgCheck, out path, out av, out bv)) return true;
if (Different("SpherePath.HitsInteriorCell", a.HitsInteriorCell, b.HitsInteriorCell, out path, out av, out bv)) return true;
if (DifferentList("SpherePath.CellCandidates", a.CellCandidates.OrderedIds, b.CellCandidates.OrderedIds, out path, out av, out bv)) return true;
return Equal(out path, out av, out bv);
}
private static bool DifferentSpheres(
string pathPrefix,
Sphere[] a,
Sphere[] b,
out string path,
out string av,
out string bv)
{
for (int i = 0; i < a.Length; i++)
{
if (Different($"{pathPrefix}[{i}].Origin", a[i].Origin, b[i].Origin, out path, out av, out bv)) return true;
if (Different($"{pathPrefix}[{i}].Radius", a[i].Radius, b[i].Radius, out path, out av, out bv)) return true;
}
return Equal(out path, out av, out bv);
}
private static bool DifferentVectors(
string pathPrefix,
Vector3[]? a,
Vector3[]? b,
out string path,
out string av,
out string bv)
{
if (a is null || b is null)
{
if (a is null && b is null)
return Equal(out path, out av, out bv);
return Difference(pathPrefix, a is null ? "null" : "array", b is null ? "null" : "array", out path, out av, out bv);
}
if (a.Length != b.Length)
return Difference(pathPrefix + ".Length", a.Length.ToString(CultureInfo.InvariantCulture), b.Length.ToString(CultureInfo.InvariantCulture), out path, out av, out bv);
for (int i = 0; i < a.Length; i++)
if (Different($"{pathPrefix}[{i}]", a[i], b[i], out path, out av, out bv)) return true;
return Equal(out path, out av, out bv);
}
private static bool DifferentList<T>(
string pathPrefix,
IReadOnlyList<T> a,
IReadOnlyList<T> b,
out string path,
out string av,
out string bv)
{
if (a.Count != b.Count)
return Difference(pathPrefix + ".Count", a.Count.ToString(CultureInfo.InvariantCulture), b.Count.ToString(CultureInfo.InvariantCulture), out path, out av, out bv);
for (int i = 0; i < a.Count; i++)
if (!EqualityComparer<T>.Default.Equals(a[i], b[i]))
return Difference($"{pathPrefix}[{i}]", Format(a[i]), Format(b[i]), out path, out av, out bv);
return Equal(out path, out av, out bv);
}
private static bool Different<T>(
string candidatePath,
T a,
T b,
out string path,
out string av,
out string bv)
{
if (Exact(a, b))
return Equal(out path, out av, out bv);
return Difference(candidatePath, Format(a), Format(b), out path, out av, out bv);
}
private static bool Exact<T>(T a, T b)
{
if (a is float af && b is float bf)
return BitConverter.SingleToInt32Bits(af) == BitConverter.SingleToInt32Bits(bf);
if (a is Vector3 av && b is Vector3 bv)
return Exact(av.X, bv.X) && Exact(av.Y, bv.Y) && Exact(av.Z, bv.Z);
if (a is Quaternion aq && b is Quaternion bq)
return Exact(aq.X, bq.X) && Exact(aq.Y, bq.Y) &&
Exact(aq.Z, bq.Z) && Exact(aq.W, bq.W);
if (a is Plane ap && b is Plane bp)
return Exact(ap.Normal, bp.Normal) && Exact(ap.D, bp.D);
return EqualityComparer<T>.Default.Equals(a, b);
}
private static string Format<T>(T value) => value switch
{
null => "null",
float f => $"0x{BitConverter.SingleToInt32Bits(f):X8}",
Vector3 v => $"{Format(v.X)},{Format(v.Y)},{Format(v.Z)}",
Quaternion q => $"{Format(q.X)},{Format(q.Y)},{Format(q.Z)},{Format(q.W)}",
Plane p => $"{Format(p.Normal)}/{Format(p.D)}",
IFormattable formattable => formattable.ToString(
null,
CultureInfo.InvariantCulture),
_ => value.ToString() ?? string.Empty,
};
private static bool Difference(
string candidatePath,
string a,
string b,
out string path,
out string av,
out string bv)
{
path = candidatePath;
av = a;
bv = b;
return true;
}
private static bool Equal(
out string path,
out string av,
out string bv)
{
path = string.Empty;
av = string.Empty;
bv = string.Empty;
return false;
}
}

View file

@ -23,49 +23,172 @@ internal static class CollisionTraversal
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
if (UseFlat(cache))
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
return flat.RootIndex >= 0;
}
return cell.CellBSP?.Root is not null;
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return cell.CellBSP?.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = (cell.FlatContainmentBsp ??
throw MissingFlat("cell containment")).RootIndex >= 0;
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = cell.CellBSP?.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"HasCellContainment",
cell.SourceId,
graphResult,
flatResult,
string.Empty);
}
else
{
shadow.RecordFault(
sample,
"HasCellContainment",
cell.SourceId,
flatFault,
string.Empty);
}
return graphResult;
}
internal static bool HasPhysics(
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
if (UseFlat(cache))
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
return flat.RootIndex >= 0;
}
return cell.BSP?.Root is not null;
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return cell.BSP?.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = (cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics")).RootIndex >= 0;
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = cell.BSP?.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"HasCellPhysics",
cell.SourceId,
graphResult,
flatResult,
string.Empty);
}
else
{
shadow.RecordFault(
sample,
"HasCellPhysics",
cell.SourceId,
flatFault,
string.Empty);
}
return graphResult;
}
internal static bool HasPhysics(
PhysicsDataCache cache,
GfxObjPhysics gfxObject)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
if (UseFlat(cache))
{
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics");
return flat.RootIndex >= 0;
}
return gfxObject.BSP.Root is not null;
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return gfxObject.BSP.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = (gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics")).RootIndex >= 0;
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = gfxObject.BSP.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"HasGfxObjPhysics",
gfxObject.SourceId,
graphResult,
flatResult,
string.Empty);
}
else
{
shadow.RecordFault(
sample,
"HasGfxObjPhysics",
gfxObject.SourceId,
flatFault,
string.Empty);
}
return graphResult;
}
internal static FlatCollisionSphere RootBoundingSphere(
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
if (UseFlat(cache))
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
@ -73,7 +196,39 @@ internal static class CollisionTraversal
}
DatReaderWriter.Types.Sphere sphere = cell.BSP!.Root!.BoundingSphere;
return new FlatCollisionSphere(sphere.Origin, sphere.Radius);
var graphResult =
new FlatCollisionSphere(sphere.Origin, sphere.Radius);
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return graphResult;
try
{
shadow.BeginFlatPass();
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
FlatCollisionSphere flatResult =
flat.Nodes[flat.RootIndex].BoundingSphere;
shadow.EndFlatPass();
shadow.RecordSphere(
sample,
"RootBoundingSphere",
cell.SourceId,
graphResult,
flatResult);
}
catch (Exception fault)
{
if (shadow.IsFlatPass)
shadow.EndFlatPass();
shadow.RecordFault(
sample,
"RootBoundingSphere",
cell.SourceId,
fault,
string.Empty);
}
return graphResult;
}
internal static bool PointInsideCell(
@ -81,14 +236,63 @@ internal static class CollisionTraversal
CellPhysics cell,
Vector3 localPoint)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
if (UseFlat(cache))
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
return FlatBspQuery.PointInsideCellBsp(flat, localPoint);
}
return BSPQuery.PointInsideCellBsp(cell.CellBSP?.Root, localPoint);
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return BSPQuery.PointInsideCellBsp(
cell.CellBSP?.Root,
localPoint);
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = FlatBspQuery.PointInsideCellBsp(
cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"),
localPoint);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = BSPQuery.PointInsideCellBsp(
cell.CellBSP?.Root,
localPoint);
string input = CollisionShadowVerifier.FormatInput(
localPoint,
0f);
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"PointInsideCell",
cell.SourceId,
graphResult,
flatResult,
input);
}
else
{
shadow.RecordFault(
sample,
"PointInsideCell",
cell.SourceId,
flatFault,
input);
}
return graphResult;
}
internal static bool SphereIntersectsCell(
@ -97,7 +301,7 @@ internal static class CollisionTraversal
Vector3 localCenter,
float radius)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
if (UseFlat(cache))
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
@ -107,10 +311,61 @@ internal static class CollisionTraversal
radius);
}
return BSPQuery.SphereIntersectsCellBsp(
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
{
return BSPQuery.SphereIntersectsCellBsp(
cell.CellBSP?.Root,
localCenter,
radius);
}
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = FlatBspQuery.SphereIntersectsCellBsp(
cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"),
localCenter,
radius);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = BSPQuery.SphereIntersectsCellBsp(
cell.CellBSP?.Root,
localCenter,
radius);
string input = CollisionShadowVerifier.FormatInput(
localCenter,
radius);
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"SphereIntersectsCell",
cell.SourceId,
graphResult,
flatResult,
input);
}
else
{
shadow.RecordFault(
sample,
"SphereIntersectsCell",
cell.SourceId,
flatFault,
input);
}
return graphResult;
}
internal static TransitionState FindCollisions(
@ -129,7 +384,7 @@ internal static class CollisionTraversal
PhysicsEngine? engine,
Vector3 worldOrigin)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
if (UseFlat(cache))
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
@ -149,7 +404,58 @@ internal static class CollisionTraversal
worldOrigin);
}
return BSPQuery.FindCollisions(
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
{
return BSPQuery.FindCollisions(
cell.BSP?.Root,
cell.Resolved,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
Transition flatTransition = shadow.PrepareShadow(transition);
TransitionState flatState = TransitionState.Invalid;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatState = FlatBspQuery.FindCollisions(
cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics"),
flatTransition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
TransitionState graphState = BSPQuery.FindCollisions(
cell.BSP?.Root,
cell.Resolved,
transition,
@ -164,6 +470,39 @@ internal static class CollisionTraversal
localToWorld,
engine,
worldOrigin);
string input = CollisionShadowVerifier.FormatInput(
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
worldOrigin);
if (flatFault is null)
{
shadow.RecordTransition(
sample,
"FindCellCollisions",
cell.SourceId,
graphState,
flatState,
transition,
flatTransition,
input);
}
else
{
shadow.RecordFault(
sample,
"FindCellCollisions",
cell.SourceId,
flatFault,
input);
}
return graphState;
}
internal static TransitionState FindCollisions(
@ -182,7 +521,7 @@ internal static class CollisionTraversal
PhysicsEngine? engine,
Vector3 worldOrigin)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
if (UseFlat(cache))
{
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics");
@ -202,7 +541,58 @@ internal static class CollisionTraversal
worldOrigin);
}
return BSPQuery.FindCollisions(
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
{
return BSPQuery.FindCollisions(
gfxObject.BSP.Root,
gfxObject.Resolved,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
Transition flatTransition = shadow.PrepareShadow(transition);
TransitionState flatState = TransitionState.Invalid;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatState = FlatBspQuery.FindCollisions(
gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics"),
flatTransition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
TransitionState graphState = BSPQuery.FindCollisions(
gfxObject.BSP.Root,
gfxObject.Resolved,
transition,
@ -217,8 +607,45 @@ internal static class CollisionTraversal
localToWorld,
engine,
worldOrigin);
string input = CollisionShadowVerifier.FormatInput(
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
worldOrigin);
if (flatFault is null)
{
shadow.RecordTransition(
sample,
"FindGfxObjCollisions",
gfxObject.SourceId,
graphState,
flatState,
transition,
flatTransition,
input);
}
else
{
shadow.RecordFault(
sample,
"FindGfxObjCollisions",
gfxObject.SourceId,
flatFault,
input);
}
return graphState;
}
private static bool UseFlat(PhysicsDataCache cache) =>
cache.CollisionTraversalMode == CollisionTraversalMode.Flat ||
cache.CollisionShadow?.IsFlatPass == true;
private static InvalidOperationException MissingFlat(string kind) =>
new(
$"Flat collision traversal requires a prepared {kind} asset. " +

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>.

View file

@ -21,6 +21,27 @@ namespace AcDream.Core.Physics;
/// </summary>
public static class PhysicsDiagnostics
{
/// <summary>
/// Slice I5 graph/flat referee cadence. Zero disables the diagnostic;
/// N samples every Nth collision-traversal entry. The graph path remains
/// authoritative regardless of the comparison result.
/// </summary>
public static int CollisionShadowSampleEvery { get; set; } =
ParsePositiveInt(
Environment.GetEnvironmentVariable(
"ACDREAM_COLLISION_SHADOW_EVERY"));
/// <summary>
/// Directory for deterministic Slice I5 mismatch artifacts.
/// </summary>
public static string CollisionShadowArtifactDirectory { get; set; } =
Environment.GetEnvironmentVariable(
"ACDREAM_COLLISION_SHADOW_DIR")
?? Path.Combine(
Environment.CurrentDirectory,
".test-out",
"collision-shadow");
/// <summary>
/// When true, <see cref="PhysicsEngine.ResolveWithTransition"/> emits
/// one structured <c>[resolve]</c> line per call: input + target +
@ -962,4 +983,14 @@ public static class PhysicsDiagnostics
}
return "?";
}
private static int ParsePositiveInt(string? value) =>
int.TryParse(
value,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int parsed)
&& parsed > 0
? parsed
: 0;
}

View file

@ -182,6 +182,22 @@ public sealed class ObjectInfo
MoverHasGravity = false;
VelocityKilled = false;
}
internal void CopyFrom(ObjectInfo source)
{
ArgumentNullException.ThrowIfNull(source);
State = source.State;
StepUpHeight = source.StepUpHeight;
StepDownHeight = source.StepDownHeight;
Ethereal = source.Ethereal;
StepDown = source.StepDown;
Scale = source.Scale;
MoverPhysicsState = source.MoverPhysicsState;
TargetId = source.TargetId;
SelfEntityId = source.SelfEntityId;
MoverHasGravity = source.MoverHasGravity;
VelocityKilled = source.VelocityKilled;
}
}
/// <summary>
@ -398,6 +414,30 @@ public sealed class CollisionInfo
LastCollidedObjectGuid = null;
ContactPlaneWriteCount = 0;
}
internal void CopyFrom(CollisionInfo source)
{
ArgumentNullException.ThrowIfNull(source);
_contactPlaneValid = source._contactPlaneValid;
_contactPlane = source._contactPlane;
_contactPlaneCellId = source._contactPlaneCellId;
_contactPlaneIsWater = source._contactPlaneIsWater;
_lastKnownContactPlaneValid = source._lastKnownContactPlaneValid;
_lastKnownContactPlane = source._lastKnownContactPlane;
_lastKnownContactPlaneCellId = source._lastKnownContactPlaneCellId;
_lastKnownContactPlaneIsWater = source._lastKnownContactPlaneIsWater;
SlidingNormalValid = source.SlidingNormalValid;
SlidingNormal = source.SlidingNormal;
CollisionNormalValid = source.CollisionNormalValid;
CollisionNormal = source.CollisionNormal;
CollidedWithEnvironment = source.CollidedWithEnvironment;
FramesStationaryFall = source.FramesStationaryFall;
AdjustOffset = source.AdjustOffset;
CollideObjectGuids.Clear();
CollideObjectGuids.AddRange(source.CollideObjectGuids);
LastCollidedObjectGuid = source.LastCollidedObjectGuid;
ContactPlaneWriteCount = source.ContactPlaneWriteCount;
}
}
/// <summary>
@ -774,6 +814,65 @@ public sealed class SpherePath
OrderedCellScratch.ResetForReuse();
}
internal void CopyFrom(SpherePath source)
{
ArgumentNullException.ThrowIfNull(source);
NumSphere = source.NumSphere;
CopySphereArray(source.LocalSphere, LocalSphere);
CopySphereArray(source.GlobalSphere, GlobalSphere);
CopySphereArray(source.GlobalCurrCenter, GlobalCurrCenter);
BeginPos = source.BeginPos;
EndPos = source.EndPos;
CurPos = source.CurPos;
CheckPos = source.CheckPos;
BeginOrientation = source.BeginOrientation;
EndOrientation = source.EndOrientation;
CurOrientation = source.CurOrientation;
CheckOrientation = source.CheckOrientation;
CurCellId = source.CurCellId;
CheckCellId = source.CheckCellId;
CarriedBlockOrigin = source.CarriedBlockOrigin;
GlobalOffset = source.GlobalOffset;
StepUp = source.StepUp;
StepUpNormal = source.StepUpNormal;
Collide = source.Collide;
StepDown = source.StepDown;
StepDownAmt = source.StepDownAmt;
WalkInterp = source.WalkInterp;
WalkableValid = source.WalkableValid;
WalkablePlane = source.WalkablePlane;
WalkableVertices = source.WalkableVertices is null
? null
: CopyExact(
source.WalkableVertices,
ref _walkableVertexStorage);
WalkableUp = source.WalkableUp;
WalkableAllowance = source.WalkableAllowance;
LastWalkableValid = source.LastWalkableValid;
LastWalkablePlane = source.LastWalkablePlane;
LastWalkableVertices = source.LastWalkableVertices is null
? null
: CopyExact(
source.LastWalkableVertices,
ref _lastWalkableVertexStorage);
LastWalkableUp = source.LastWalkableUp;
BackupCheckPos = source.BackupCheckPos;
BackupCheckCellId = source.BackupCheckCellId;
NegPolyHit = source.NegPolyHit;
NegStepUp = source.NegStepUp;
NegCollisionNormal = source.NegCollisionNormal;
CheckWalkable = source.CheckWalkable;
InsertType = source.InsertType;
PlacementAllowsSliding = source.PlacementAllowsSliding;
ObstructionEthereal = source.ObstructionEthereal;
BldgCheck = source.BldgCheck;
HitsInteriorCell = source.HitsInteriorCell;
CellCandidates.Clear();
foreach (uint id in source.CellCandidates)
CellCandidates.Add(id);
OrderedCellScratch.ResetForReuse();
}
private static Vector3[] CopyExact(
ReadOnlySpan<Vector3> source,
ref Vector3[]? storage)
@ -800,6 +899,15 @@ public sealed class SpherePath
}
}
private static void CopySphereArray(Sphere[] source, Sphere[] destination)
{
for (int i = 0; i < destination.Length; i++)
{
destination[i].Origin = source[i].Origin;
destination[i].Radius = source[i].Radius;
}
}
/// <summary>
/// Slide fallback when step-up fails. Clears the contact-plane state that
/// caused the step-up attempt and runs the full sphere-slide computation
@ -958,6 +1066,14 @@ public sealed class Transition
CollisionInfo.ResetForReuse();
}
internal void CopyFrom(Transition source)
{
ArgumentNullException.ThrowIfNull(source);
ObjectInfo.CopyFrom(source.ObjectInfo);
SpherePath.CopyFrom(source.SpherePath);
CollisionInfo.CopyFrom(source.CollisionInfo);
}
private static bool DumpEdgeSlideEnabled =>
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";

View file

@ -0,0 +1,29 @@
using System.Collections.Immutable;
using AcDream.Core.Physics;
namespace AcDream.Core.World;
/// <summary>
/// Immutable prepared-collision closure carried by one near-tier landblock
/// build. Keys remain the original DAT source IDs. Cell-structure geometry and
/// per-EnvCell topology stay separate so package aliases retain one reusable
/// geometry identity without conflating cell placement or portal state.
/// </summary>
public sealed record LandblockCollisionBuild(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset> GfxObjs,
ImmutableDictionary<uint, FlatSetupCollision> Setups,
ImmutableDictionary<uint, FlatCellStructureCollisionAsset> CellStructures,
ImmutableDictionary<uint, FlatEnvCellTopology> EnvCells,
ImmutableArray<uint> GfxObjIds,
ImmutableArray<uint> SetupIds,
ImmutableArray<uint> EnvCellIds)
{
public static readonly LandblockCollisionBuild Empty = new(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset>.Empty,
ImmutableDictionary<uint, FlatSetupCollision>.Empty,
ImmutableDictionary<uint, FlatCellStructureCollisionAsset>.Empty,
ImmutableDictionary<uint, FlatEnvCellTopology>.Empty,
ImmutableArray<uint>.Empty,
ImmutableArray<uint>.Empty,
ImmutableArray<uint>.Empty);
}