acdream/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
Erik 82f8d4f82e refactor(physics): remove parsed collision graphs
Publish prepared GfxObj, Setup, CellStruct, and EnvCell collision records without retaining their parsed DAT BSP, polygon, vertex, or shape graphs. Strip temporary physics bundles at stable world commit, keep graph traversal only as an explicit test/tooling oracle, and report graph residency from actual retained fields.

Validated by 115 focused collision/streaming tests, a zero-warning Release build, and 8,413 passing Release tests with five pre-existing skips.

Co-authored-by: Codex <codex@openai.com>
2026-07-25 18:21:19 +02:00

238 lines
8.6 KiB
C#

using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Physics;
/// <summary>
/// Immutable input to the canonical shadow registry for one live object.
/// Building is DAT/physics-only and therefore testable without a window or GL
/// context; registration remains one explicit commit.
/// </summary>
internal sealed record LiveEntityCollisionRegistration(
uint EntityId,
uint SourceId,
Vector3 EntityWorldPosition,
Quaternion EntityWorldRotation,
IReadOnlyList<ShadowShape> Shapes,
uint State,
EntityCollisionFlags Flags,
float WorldOffsetX,
float WorldOffsetY,
uint LandblockId,
uint SeedCellId);
/// <summary>
/// Ports the live-object collision-shape policy used by
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, every
/// physics-BSP part, and the established Setup-radius fallback for the
/// remaining ACE prop data.
/// </summary>
internal sealed class LiveEntityCollisionBuilder
{
private readonly Func<uint, bool> _hasPhysicsBsp;
private readonly Func<uint, float?> _physicsBspRadius;
private readonly LiveEntityDefaultPoseResolver _defaultPose;
public LiveEntityCollisionBuilder(
PhysicsDataCache physicsData,
LiveEntityDefaultPoseResolver defaultPose)
: this(
id => physicsData.GetFlatGfxObj(id)?.PhysicsBsp.RootIndex >= 0,
id =>
{
FlatPhysicsBsp? flat =
physicsData.GetFlatGfxObj(id)?.PhysicsBsp;
return flat is { RootIndex: >= 0 }
? flat.Nodes[flat.RootIndex].BoundingSphere.Radius
: null;
},
defaultPose)
{
ArgumentNullException.ThrowIfNull(physicsData);
}
internal LiveEntityCollisionBuilder(
Func<uint, bool> hasPhysicsBsp,
Func<uint, float?> physicsBspRadius,
LiveEntityDefaultPoseResolver defaultPose)
{
_hasPhysicsBsp = hasPhysicsBsp
?? throw new ArgumentNullException(nameof(hasPhysicsBsp));
_physicsBspRadius = physicsBspRadius
?? throw new ArgumentNullException(nameof(physicsBspRadius));
_defaultPose = defaultPose
?? throw new ArgumentNullException(nameof(defaultPose));
}
/// <summary>
/// Resolves collision slot zero for every post-AnimPartChanged identity.
/// This intentionally has no visual-LOD option or Setup-type input.
/// </summary>
public static uint[] ResolveEffectivePartIdentities(
IReadOnlyList<uint> postAnimPartGfxObjIds,
Func<uint, uint> resolveSlotZero)
{
ArgumentNullException.ThrowIfNull(postAnimPartGfxObjIds);
ArgumentNullException.ThrowIfNull(resolveSlotZero);
var result = new uint[postAnimPartGfxObjIds.Count];
for (int i = 0; i < result.Length; i++)
result[i] = resolveSlotZero(postAnimPartGfxObjIds[i]);
return result;
}
public LiveEntityCollisionRegistration? Build(
WorldEntity entity,
Setup setup,
IReadOnlyList<uint> effectivePartGfxObjIds,
WorldSession.EntitySpawn spawn,
LiveEntityRecord exactRecord,
Vector3 worldOrigin,
bool retainEmptyPayload = false)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds);
ArgumentNullException.ThrowIfNull(exactRecord);
if (spawn.Position is not { } position)
return null;
if (spawn.Guid != exactRecord.ServerGuid
|| spawn.InstanceSequence != exactRecord.Generation
|| !ReferenceEquals(exactRecord.WorldEntity, entity))
{
throw new InvalidOperationException(
"Live collision construction requires the exact materialized record.");
}
float scale = spawn.ObjScale ?? 1f;
IReadOnlyList<Frame>? defaultPose = _defaultPose.Resolve(
spawn.MotionTableId ?? 0u,
setup.Parts.Count);
IReadOnlyList<ShadowShape> raw = ShadowShapeBuilder.FromSetup(
setup,
scale,
_hasPhysicsBsp,
partPoseOverride: defaultPose,
effectivePartGfxObjIds: effectivePartGfxObjIds);
var shapes = new List<ShadowShape>(raw.Count);
foreach (ShadowShape shape in raw)
{
if (shape.CollisionType == ShadowCollisionType.BSP)
{
float radius = (_physicsBspRadius(shape.GfxObjId) ?? 2f) * scale;
shapes.Add(shape with { Radius = radius });
}
else
{
shapes.Add(shape);
}
}
if (shapes.Count == 0 && setup.Radius > 0.0001f)
{
shapes.Add(new ShadowShape(
GfxObjId: 0u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: setup.Radius * scale,
CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale));
}
if (shapes.Count == 0 && !retainEmptyPayload)
return null;
EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie;
if (spawn.ObjectDescriptionFlags is { } descriptionFlags)
flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags);
if (spawn.ItemType == (uint)ItemType.Creature)
flags |= EntityCollisionFlags.IsCreature;
return new LiveEntityCollisionRegistration(
entity.Id,
entity.SourceGfxObjOrSetupId,
entity.Position,
entity.Rotation,
shapes,
(uint)exactRecord.FinalPhysicsState,
flags,
worldOrigin.X,
worldOrigin.Y,
position.LandblockId,
position.LandblockId);
}
public static void Register(
ShadowObjectRegistry registry,
LiveEntityCollisionRegistration registration)
{
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(registration);
registry.RegisterMultiPart(
registration.EntityId,
registration.EntityWorldPosition,
registration.EntityWorldRotation,
registration.Shapes,
registration.State,
registration.Flags,
registration.WorldOffsetX,
registration.WorldOffsetY,
registration.LandblockId,
registration.SeedCellId,
isStatic: false);
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return;
int cylinders = 0;
int bsps = 0;
foreach (ShadowShape shape in registration.Shapes)
{
if (shape.CollisionType == ShadowCollisionType.Cylinder)
cylinders++;
else
bsps++;
}
Console.WriteLine(FormattableString.Invariant(
$"[entity-source] id=0x{registration.EntityId:X8} entityId=0x{registration.EntityId:X8} src=0x{registration.SourceId:X8} gfxObj=0x{registration.SourceId:X8} lb=0x{registration.LandblockId:X8} shapes=cyl{cylinders}+bsp{bsps} note=server-spawn-root state=0x{registration.State:X8} flags={registration.Flags}"));
}
/// <summary>
/// Commits a retail ObjDesc part-array replacement. A shapeless result
/// removes the prior payload; a shaped result replaces it without making
/// a Hidden, parented, or cell-less object collide in world cells.
/// </summary>
public static void ReconcileAppearance(
ShadowObjectRegistry registry,
uint entityId,
LiveEntityCollisionRegistration? registration,
bool suspendIfNew)
{
ArgumentNullException.ThrowIfNull(registry);
if (registration is null)
return;
if (registration.EntityId != entityId)
throw new InvalidOperationException("Collision replacement belongs to a different live entity.");
registry.ReplaceMultiPartPayload(
registration.EntityId,
registration.EntityWorldPosition,
registration.EntityWorldRotation,
registration.Shapes,
registration.State,
registration.Flags,
registration.WorldOffsetX,
registration.WorldOffsetY,
registration.LandblockId,
registration.SeedCellId,
isStatic: false,
suspendIfNew);
}
}