refactor(physics): hoist the live-entity collision builder to Runtime (#330 groundwork)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
LiveEntityCollisionBuilder and LiveEntityDefaultPoseResolver move from AcDream.App.Physics to AcDream.Runtime.Physics with no behaviour change — diff-verified byte-identical shape math by both review lenses. The Build signature's App-record parameter is replaced by presentation-free primitives with identical guard semantics, INCLUDING the FinalPhysicsState read the contract had missed and the implementer surfaced rather than dropped. Visibility stays internal: Runtime's existing InternalsVisibleTo grants already cover every consumer, so the implementation's public widening is reverted per the architecture review's finding 11. The registration WIRING is deliberately WITHHELD. Both Opus lenses failed it, converging: a shadow registered at spawn freezes there (RuntimeRemotePhysicsUpdater is Runtime-homed but App-driven — nothing headless ticks it), so a walking NPC becomes a phantom obstacle at its spawn point while the real NPC still passes through the bot; three of five shadow-lifetime edges leaked (pickup leaves a permanent invisible collider, supersession orphans a duplicate, generation reset never unregisters and the K-ledger convergence oracle only checks retained shadows AFTER disposal clears them); and headless cannot resolve BSP collision assets at all, so doors and chests would still be walk-through. The frozen-shadow root was the SESSION LEAD's contract error (fact 3), not the implementer's. #330 stays OPEN, rewritten as the seven-point scope map the reviews produced — the honest overnight deliverable is that map, not a half-mechanism carrying new divergences. Suite 11,235 passed / 4 skipped / 0 failed (the withheld seam's two tests account for the delta from the implementation run's 11,237). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5629e2cb12
commit
55b07f6a62
12 changed files with 255 additions and 35 deletions
281
src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs
Normal file
281
src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Runtime.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>CPhysicsObj::FindObjCollisions</c> (0x0050f050), which DISPATCHES
|
||||
/// rather than unions: every physics-BSP part, ELSE the Setup's CylSpheres,
|
||||
/// ELSE its Spheres, else nothing (AP-152). The gate itself lives in
|
||||
/// <see cref="ShadowShapeBuilder.FromSetup"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A Setup that yields no shape produces no registration. Retail synthesizes
|
||||
/// nothing for a shapeless object: <c>CPhysicsObj::FindObjCollisions</c>
|
||||
/// (0x0050f050) walks CylSpheres or Spheres or the physics BSP, and when
|
||||
/// <c>CPartArray::GetNumSphere</c> returns zero it branches straight to the
|
||||
/// epilogue (<c>0x0050f22f je 0x50f31b</c>) returning the seeded
|
||||
/// <c>OK_TS</c>. <c>CPartArray::GetRadius</c> (0x005180a0) and
|
||||
/// <c>GetHeight</c> (0x005180b0) are absent from that function's entire call
|
||||
/// set — <c>Setup.Radius</c>/<c>Height</c> serve attack cones,
|
||||
/// <c>cylinder_distance</c>, and MoveTo, never collision geometry.
|
||||
/// </remarks>
|
||||
/// <remarks>
|
||||
/// #330 hoist (2026-08-07): moved from <c>AcDream.App.Physics</c> to
|
||||
/// <c>AcDream.Runtime.Physics</c> so a no-window host can build the same
|
||||
/// collision shapes a graphical host does
|
||||
/// (<c>src/AcDream.Headless/Hosting/HeadlessLiveEntityCollisionRegistrar.cs</c>).
|
||||
/// <see cref="Build"/>'s identity guard used to take the App-only
|
||||
/// <c>LiveEntityRecord exactRecord</c> parameter; it now takes the four
|
||||
/// presentation-free primitives that record exposed
|
||||
/// (<paramref name="Build"/>'s <c>expectedServerGuid</c>/
|
||||
/// <c>expectedGeneration</c>/<c>expectedEntity</c>/
|
||||
/// <c>expectedFinalPhysicsState</c> below) with IDENTICAL guard and
|
||||
/// registration-state semantics — no behaviour change. Both graphical call
|
||||
/// sites (<c>DatLiveEntityProjectionMaterializer</c>,
|
||||
/// <c>LiveEntityAppearanceBinding.PrepareCollision</c>) pass
|
||||
/// <c>record.ServerGuid, record.Generation, record.WorldEntity!,
|
||||
/// record.FinalPhysicsState</c> for these four parameters.
|
||||
/// </remarks>
|
||||
internal sealed class LiveEntityCollisionBuilder
|
||||
{
|
||||
private readonly Func<uint, ShadowPartGeometry?> _physicsBspBounds;
|
||||
/// <summary>
|
||||
/// The dispatch gate, derived from <see cref="_physicsBspBounds"/> so the
|
||||
/// two can never disagree (AP-156), and cached once so <see cref="Build"/>
|
||||
/// allocates no closure per call — Slice I1's 0 B/resolve budget.
|
||||
/// </summary>
|
||||
private readonly Func<uint, bool> _hasPhysicsBsp;
|
||||
private readonly LiveEntityDefaultPoseResolver _defaultPose;
|
||||
|
||||
public LiveEntityCollisionBuilder(
|
||||
PhysicsDataCache physicsData,
|
||||
LiveEntityDefaultPoseResolver defaultPose)
|
||||
: this(
|
||||
id =>
|
||||
{
|
||||
FlatGfxObjCollisionAsset? asset = physicsData.GetFlatGfxObj(id);
|
||||
FlatPhysicsBsp? flat = asset?.PhysicsBsp;
|
||||
return flat is { RootIndex: >= 0 }
|
||||
? ShadowPartGeometry.Create(
|
||||
flat.Nodes[flat.RootIndex].BoundingSphere,
|
||||
asset!.VisualBounds)
|
||||
: (ShadowPartGeometry?)null;
|
||||
},
|
||||
defaultPose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(physicsData);
|
||||
}
|
||||
|
||||
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP root
|
||||
/// bounding sphere AND its authored vertex-array box, or null when it has
|
||||
/// no physics BSP. ONE resolver answers every question the builder asks —
|
||||
/// "does this part dispatch as BSP?", "where and how big is its flood
|
||||
/// sphere?", and "what is its outdoor extent?" — so the dispatch gate and
|
||||
/// the emitted geometry cannot disagree, the sphere's radius cannot be
|
||||
/// carried while its origin is dropped (AP-156), and the outdoor extent
|
||||
/// walk cannot be left without a box (#334).</param>
|
||||
internal LiveEntityCollisionBuilder(
|
||||
Func<uint, ShadowPartGeometry?> physicsBspBounds,
|
||||
LiveEntityDefaultPoseResolver defaultPose)
|
||||
{
|
||||
_physicsBspBounds = physicsBspBounds
|
||||
?? throw new ArgumentNullException(nameof(physicsBspBounds));
|
||||
_hasPhysicsBsp = id => _physicsBspBounds(id) is not null;
|
||||
_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;
|
||||
}
|
||||
|
||||
/// <param name="expectedServerGuid">Identity guard term 1: must equal
|
||||
/// <paramref name="spawn"/>'s wire guid. Presentation-free replacement for
|
||||
/// the App-only <c>LiveEntityRecord.ServerGuid</c> this parameter used to
|
||||
/// read (#330 hoist).</param>
|
||||
/// <param name="expectedGeneration">Identity guard term 2: must equal
|
||||
/// <paramref name="spawn"/>'s wire instance sequence. Replaces
|
||||
/// <c>LiveEntityRecord.Generation</c> (<see langword="ushort"/>) —
|
||||
/// widened to <see langword="ulong"/> so a Runtime canonical caller can
|
||||
/// pass its own generation counter without a narrowing cast; the
|
||||
/// comparison below is exact either way.</param>
|
||||
/// <param name="expectedEntity">Identity guard term 3: must be reference-
|
||||
/// equal to <paramref name="entity"/>. Replaces
|
||||
/// <c>LiveEntityRecord.WorldEntity</c>.</param>
|
||||
/// <param name="expectedFinalPhysicsState">Replaces
|
||||
/// <c>LiveEntityRecord.FinalPhysicsState</c>, which the original
|
||||
/// <c>exactRecord</c> parameter also supplied for the emitted
|
||||
/// registration's <see cref="LiveEntityCollisionRegistration.State"/> —
|
||||
/// this is NOT part of the identity guard, only of the registration
|
||||
/// payload, but it was only reachable through the same record and so
|
||||
/// travels alongside the guard's three primitives.</param>
|
||||
public LiveEntityCollisionRegistration? Build(
|
||||
WorldEntity entity,
|
||||
Setup setup,
|
||||
IReadOnlyList<uint> effectivePartGfxObjIds,
|
||||
WorldSession.EntitySpawn spawn,
|
||||
uint expectedServerGuid,
|
||||
ulong expectedGeneration,
|
||||
WorldEntity expectedEntity,
|
||||
PhysicsStateFlags expectedFinalPhysicsState,
|
||||
Vector3 worldOrigin,
|
||||
bool retainEmptyPayload = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds);
|
||||
ArgumentNullException.ThrowIfNull(expectedEntity);
|
||||
if (spawn.Position is not { } position)
|
||||
return null;
|
||||
if (spawn.Guid != expectedServerGuid
|
||||
|| spawn.InstanceSequence != expectedGeneration
|
||||
|| !ReferenceEquals(expectedEntity, 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);
|
||||
// One resolver drives both the dispatch gate and the emitted BSP
|
||||
// geometry, and FromSetup applies the entity scale to radius and
|
||||
// center alike — there is no downstream substitution that could take
|
||||
// one and drop the other.
|
||||
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(
|
||||
setup,
|
||||
scale,
|
||||
_hasPhysicsBsp,
|
||||
partPoseOverride: defaultPose,
|
||||
effectivePartGfxObjIds: effectivePartGfxObjIds,
|
||||
physicsBspBounds: _physicsBspBounds);
|
||||
|
||||
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)expectedFinalPhysicsState,
|
||||
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);
|
||||
}
|
||||
}
|
||||
65
src/AcDream.Runtime/Physics/LiveEntityDefaultPoseResolver.cs
Normal file
65
src/AcDream.Runtime/Physics/LiveEntityDefaultPoseResolver.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Runtime.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the motion table's authored default-state part pose used by a
|
||||
/// live object's collision PartArray. Retail keeps the collision parts in the
|
||||
/// same current pose as the visual PartArray; an unresolved table falls back
|
||||
/// to Setup placement frames in <see cref="ShadowShapeBuilder"/>.
|
||||
/// The retained default-pose snapshot approximation is tracked as AP-84 in
|
||||
/// the retail divergence register until live per-frame collision poses land.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// #330 hoist (2026-08-07): moved from <c>AcDream.App.Physics</c> to
|
||||
/// <c>AcDream.Runtime.Physics</c> so a no-window host can build the same
|
||||
/// collision shapes a graphical host does. No behaviour change - this class
|
||||
/// was already presentation-free (DAT/physics-only dependencies).
|
||||
/// </remarks>
|
||||
internal sealed class LiveEntityDefaultPoseResolver
|
||||
{
|
||||
private readonly Func<uint, MotionTable?> _loadMotionTable;
|
||||
private readonly IAnimationLoader _animationLoader;
|
||||
private readonly bool _dumpMotion;
|
||||
|
||||
public LiveEntityDefaultPoseResolver(
|
||||
Func<uint, MotionTable?> loadMotionTable,
|
||||
IAnimationLoader animationLoader,
|
||||
bool dumpMotion)
|
||||
{
|
||||
_loadMotionTable = loadMotionTable
|
||||
?? throw new ArgumentNullException(nameof(loadMotionTable));
|
||||
_animationLoader = animationLoader
|
||||
?? throw new ArgumentNullException(nameof(animationLoader));
|
||||
_dumpMotion = dumpMotion;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Frame>? Resolve(uint motionTableId, int partCount)
|
||||
{
|
||||
if (motionTableId == 0u || partCount == 0)
|
||||
return null;
|
||||
|
||||
MotionTable? motionTable = _loadMotionTable(motionTableId);
|
||||
if (motionTable is null)
|
||||
return null;
|
||||
|
||||
IReadOnlyList<Frame>? pose = MotionTablePose.DefaultStatePartFrames(
|
||||
motionTable,
|
||||
_animationLoader.LoadAnimation);
|
||||
|
||||
if (_dumpMotion)
|
||||
{
|
||||
string description = pose is null
|
||||
? "null->placement-fallback"
|
||||
: FormattableString.Invariant(
|
||||
$"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})");
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {description}"));
|
||||
}
|
||||
|
||||
return pose;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue