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

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:
Erik 2026-08-07 01:49:13 +02:00
parent 5629e2cb12
commit 55b07f6a62
12 changed files with 255 additions and 35 deletions

View file

@ -14,6 +14,7 @@ using AcDream.Core.Spells;
using AcDream.Core.Vfx;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using DatReaderWriter;
using Silk.NET.Input;

View file

@ -12,6 +12,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
@ -834,7 +835,10 @@ internal sealed class DatLiveEntityProjectionMaterializer
setup,
collisionPartGfxObjIds,
spawn,
expectedRecord,
expectedRecord.ServerGuid,
expectedRecord.Generation,
expectedRecord.WorldEntity!,
expectedRecord.FinalPhysicsState,
worldOrigin) is { } collision)
{
LiveEntityCollisionBuilder.Register(_shadows, collision);

View file

@ -267,7 +267,7 @@ public sealed class GameWindow :
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
private AcDream.Core.Physics.IAnimationLoader? _animLoader;
private AcDream.App.Physics.LiveEntityCollisionBuilder? _liveEntityCollisionBuilder;
private AcDream.Runtime.Physics.LiveEntityCollisionBuilder? _liveEntityCollisionBuilder;
// Phase E.1: central fan-out for animation hooks. Audio (E.2),
// particles (E.3), combat (E.4), and renderer state mutators all
@ -818,7 +818,7 @@ public sealed class GameWindow :
PublishCompositionOwner(ref _animLoader, value, "animation loader");
void IGameWindowContentEffectsAudioPublication.PublishLiveEntityCollisionBuilder(
AcDream.App.Physics.LiveEntityCollisionBuilder value) =>
AcDream.Runtime.Physics.LiveEntityCollisionBuilder value) =>
PublishCompositionOwner(
ref _liveEntityCollisionBuilder,
value,

View file

@ -1,9 +1,9 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime.Physics;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
@ -95,7 +95,10 @@ internal static class LiveEntityAppearanceBinding
setup,
effectivePartGfxObjIds,
spawn,
record,
record.ServerGuid,
record.Generation,
record.WorldEntity!,
record.FinalPhysicsState,
worldOrigin,
retainEmptyPayload: true));
}

View file

@ -1,5 +1,4 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Physics;
@ -7,7 +6,7 @@ using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Physics;
namespace AcDream.Runtime.Physics;
/// <summary>
/// Immutable input to the canonical shadow registry for one live object.
@ -45,6 +44,23 @@ internal sealed record LiveEntityCollisionRegistration(
/// 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;
@ -110,24 +126,47 @@ internal sealed class LiveEntityCollisionBuilder
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,
LiveEntityRecord exactRecord,
uint expectedServerGuid,
ulong expectedGeneration,
WorldEntity expectedEntity,
PhysicsStateFlags expectedFinalPhysicsState,
Vector3 worldOrigin,
bool retainEmptyPayload = false)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds);
ArgumentNullException.ThrowIfNull(exactRecord);
ArgumentNullException.ThrowIfNull(expectedEntity);
if (spawn.Position is not { } position)
return null;
if (spawn.Guid != exactRecord.ServerGuid
|| spawn.InstanceSequence != exactRecord.Generation
|| !ReferenceEquals(exactRecord.WorldEntity, entity))
if (spawn.Guid != expectedServerGuid
|| spawn.InstanceSequence != expectedGeneration
|| !ReferenceEquals(expectedEntity, entity))
{
throw new InvalidOperationException(
"Live collision construction requires the exact materialized record.");
@ -164,7 +203,7 @@ internal sealed class LiveEntityCollisionBuilder
entity.Position,
entity.Rotation,
shapes,
(uint)exactRecord.FinalPhysicsState,
(uint)expectedFinalPhysicsState,
flags,
worldOrigin.X,
worldOrigin.Y,

View file

@ -3,7 +3,7 @@ using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Physics;
namespace AcDream.Runtime.Physics;
/// <summary>
/// Resolves the motion table's authored default-state part pose used by a
@ -13,6 +13,12 @@ namespace AcDream.App.Physics;
/// 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;