refactor(world): extract live projection mechanics

Move appearance rebinding, collision construction, default-pose resolution, local shadow ownership, and exact leave-world presentation into focused owners. Preserve retail parent ordering with staged validation, committed recovery, recursive attached-subtree withdrawal, and retryable exact teardown across parent, pickup, position, unwield, and pose-loss edges.

Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 16:17:03 +02:00
parent c87bac31a0
commit 69a2ca0c6d
26 changed files with 4172 additions and 430 deletions

View file

@ -0,0 +1,231 @@
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.GetGfxObj(id)?.BSP?.Root is not null,
id => physicsData.GetGfxObj(id)?.BoundingSphere?.Radius,
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);
}
}

View file

@ -0,0 +1,59 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.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>
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;
}
}

View file

@ -0,0 +1,19 @@
using System.Numerics;
namespace AcDream.App.Physics;
/// <summary>Session-scoped cache of the local player's last published shadow pose.</summary>
internal sealed class LocalPlayerShadowState
{
public readonly record struct Snapshot(
Vector3 Position,
Quaternion Orientation,
uint CellId);
public Snapshot? Current { get; private set; }
public void Set(Vector3 position, Quaternion orientation, uint cellId) =>
Current = new Snapshot(position, orientation, cellId);
public void Clear() => Current = null;
}

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene);
private readonly AcDream.App.RuntimeOptions _options;
private readonly AnimationPresentationDiagnostics _animationDiagnostics;
private readonly string _datDir;
private readonly WorldGameState _worldGameState;
private readonly WorldEvents _worldEvents;
@ -192,6 +193,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Step 7 projectile presentation. The controller owns no identity map;
// each runtime component is stored on the canonical LiveEntityRecord.
private AcDream.App.Physics.ProjectileController? _projectileController;
private AcDream.App.World.LiveEntityProjectionWithdrawalController?
_liveEntityProjectionWithdrawal;
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
@ -300,11 +303,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// </summary>
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
private sealed record AppearanceUpdateState(
AcDream.Core.World.WorldEntity Entity,
LiveEntityAnimationState? Animation);
private AcDream.Core.Physics.IAnimationLoader? _animLoader;
private AcDream.App.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
@ -591,9 +591,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
private MovementTruthOutbound? _lastMovementTruthOutbound;
private (System.Numerics.Vector3 Position,
System.Numerics.Quaternion Orientation,
uint CellId)? _lastLocalPlayerShadow;
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
private readonly record struct MovementTruthOutbound(
string Kind,
@ -815,6 +813,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_worldGameState = worldGameState;
_worldEvents = worldEvents;
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() => _liveEntities);
_remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _liveEntities);
@ -847,7 +846,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
() => _staticAnimationScheduler,
_effectPoses,
this,
AnimationPresentationDiagnostics.FromEnvironment(),
_animationDiagnostics,
options.HidePartIndex);
_localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController(
resolveEntity: () =>
@ -1182,6 +1181,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
SpellBook.InstallMetadata(_magicCatalog.SpellTable);
Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat");
_animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats);
_liveEntityCollisionBuilder = new AcDream.App.Physics.LiveEntityCollisionBuilder(
_physicsDataCache,
new AcDream.App.Physics.LiveEntityDefaultPoseResolver(
id => _dats.Get<DatReaderWriter.DBObjs.MotionTable>(id),
_animLoader,
_animationDiagnostics.DumpMotionEnabled));
_emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats);
// Phase E.3 particles: always-on, no driver dependency. Registered
@ -2281,6 +2286,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
{
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
};
_liveEntityProjectionWithdrawal =
new AcDream.App.World.LiveEntityProjectionWithdrawalController(
_liveEntities,
_projectileController,
_worldGameState,
_worldEvents,
_physicsEngine.ShadowObjects,
_effectPoses,
_localPlayerShadow);
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
_liveEntities,
_effectPoses,
@ -2293,7 +2307,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Objects,
_liveEntities,
_effectPoses,
TryAcceptParentForRender);
TryAcceptParentForRender,
(childRecord, positionVersion, projectionVersion) =>
_liveEntityProjectionWithdrawal!.WithdrawExact(
childRecord,
positionVersion,
projectionVersion,
_playerServerGuid));
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
id => _dats!.Get<DatReaderWriter.DBObjs.PhysicsScriptTable>(id));
@ -2672,7 +2692,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_autoRunActive = false;
_chaseModeEverEntered = false;
_lastMovementTruthOutbound = null;
_lastLocalPlayerShadow = null;
_localPlayerShadow.Clear();
_spawnClaimRangeMemo = null;
}
}
@ -2988,13 +3008,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
{
PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps);
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration)
{
_equippedChildRenderer?.OnGenerationReplaced(
spawn.Guid,
result.Snapshot.InstanceSequence);
}
AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn(
Objects,
spawn,
@ -3156,7 +3169,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private void OnLiveEntitySpawnedLocked(
AcDream.Core.Net.WorldSession.EntitySpawn spawn,
LiveProjectionPurpose purpose,
AppearanceUpdateState? appearanceUpdate = null)
LiveEntityAppearanceUpdateState? appearanceUpdate = null)
{
_liveSpawnReceived++;
@ -3461,6 +3474,28 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// changes resolve (which match against the resolved mesh's
// surfaces) and BEFORE the GfxObjMesh.Build / texture upload
// path consumes the part list.
// Collision reads slot 0 of the installed CPhysicsPart degrade array.
// Keep this independent from the optional visual close-LOD policy:
// draw settings and Setup type must never change physical geometry.
uint[] collisionPartGfxObjIds =
AcDream.App.Physics.LiveEntityCollisionBuilder.ResolveEffectivePartIdentities(
parts.Select(static part => part.GfxObjId).ToArray(),
baseId =>
{
if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj(
_dats,
baseId,
out uint slotZeroId,
out DatReaderWriter.DBObjs.GfxObj? slotZeroGfx))
{
return baseId;
}
if (slotZeroGfx is not null)
_physicsDataCache.CacheGfxObj(slotZeroId, slotZeroGfx);
return slotZeroId;
});
if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup))
{
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
@ -3708,6 +3743,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (appearanceUpdate is { } visualUpdate)
{
AcDream.Core.World.WorldEntity existing = visualUpdate.Entity;
LiveEntityAppearanceCollisionUpdate? appearanceCollision =
LiveEntityAppearanceBinding.PrepareCollision(
_liveEntities!,
_liveEntityCollisionBuilder!,
existing,
setup,
collisionPartGfxObjIds,
spawn,
origin);
_wbEntitySpawnAdapter!.OnAppearanceChanged(
existing,
meshRefs,
@ -3723,11 +3767,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
},
() =>
{
if (appearanceCollision is not null)
LiveEntityAppearanceBinding.CommitCollision(
_liveEntities,
_physicsEngine.ShadowObjects,
appearanceCollision);
existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax))
existing.SetLocalBounds(appearanceMin, appearanceMax);
if (visualUpdate.Animation is { } animation)
RebindAnimatedEntityForAppearance(
LiveEntityAppearanceBinding.RebindAnimation(
animation,
existing,
setup,
@ -3844,8 +3893,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// are deliberately skipped — retail FUN's `FindObjCollisions`
// falls through to OK_TS for any object with no collision
// geometry (acclient_2013_pseudo_c.txt:276917,276987).
RegisterLiveEntityCollision(entity, setup, spawn, origin);
if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord))
if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord)
&& _liveEntityCollisionBuilder!.Build(
entity,
setup,
collisionPartGfxObjIds,
spawn,
liveRecord,
origin) is { } collision)
{
AcDream.App.Physics.LiveEntityCollisionBuilder.Register(
_physicsEngine.ShadowObjects,
collision);
}
if (_liveEntities.TryGetRecord(spawn.Guid, out liveRecord))
_projectileController?.TryBind(
liveRecord,
setup,
@ -4124,9 +4185,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
isLocalPlayer: delete.Guid == _playerServerGuid,
beforeTeardown: () =>
{
_equippedChildRenderer?.OnGenerationDeleted(
delete.Guid,
delete.InstanceSequence);
AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete);
});
@ -4174,7 +4232,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
lock (_datLock)
{
AppearanceUpdateState? appearanceState = CaptureLiveAppearanceState(update.Guid);
LiveEntityAppearanceUpdateState? appearanceState =
LiveEntityAppearanceBinding.Capture(_liveEntities, update.Guid);
OnLiveEntitySpawnedLocked(
newSpawn,
LiveProjectionPurpose.AppearanceMutation,
@ -4188,36 +4247,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_paperdollDollDirty = true;
}
/// <summary>
/// Capture the runtime identity that retail preserves across
/// <c>DoObjDescChangesFromDefault</c>. Replacement visual data is hydrated
/// first and then assigned to this same entity instance.
/// </summary>
private AppearanceUpdateState? CaptureLiveAppearanceState(uint serverGuid)
{
if (_liveEntities?.TryGetWorldEntity(serverGuid, out var entity) != true)
return null;
_animatedEntities.TryGetValue(entity.Id, out var animation);
return new AppearanceUpdateState(entity, animation);
}
internal static void RebindAnimatedEntityForAppearance(
LiveEntityAnimationState animation,
AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup,
float scale,
IReadOnlyList<LiveAnimationPartTemplate> partTemplate,
IReadOnlyList<bool> partAvailability)
{
animation.Entity = entity;
animation.Setup = setup;
animation.Scale = scale;
animation.PartTemplate = partTemplate;
animation.PartAvailability = partAvailability;
animation.InvalidatePresentationPoses();
}
/// <summary>
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
@ -4327,175 +4356,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
doll.MeshRefs = reposed;
}
/// <summary>
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
/// the <see cref="ShadowObjectRegistry"/> as a single collision body.
/// One entry per entity (in contrast to static scenery's per-CylSphere
/// registration) so the leave-world path's single
/// <c>Deregister(entity.Id)</c> cleans it up without leaks.
///
/// <para>
/// Geometry-priority order matches retail
/// (<c>acclient_2013_pseudo_c.txt:276858-276987</c>): CylSpheres &gt;
/// Sphere fallback &gt; Setup.Radius. Phantom Setups (no shape) are
/// rejected — retail's <c>FindObjCollisions</c> falls through to
/// OK_TS in that case.
/// </para>
///
/// <para>
/// Carries <see cref="EntityCollisionFlags"/> derived from the PWD
/// bitfield (<c>acclient.h:6431-6463</c>) plus <c>IsCreature</c>
/// derived from the inbound ItemType. Commit C consumes these in
/// the PvP exemption block.
/// </para>
/// </summary>
private void RegisterLiveEntityCollision(
AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup,
AcDream.Core.Net.WorldSession.EntitySpawn spawn,
System.Numerics.Vector3 origin)
{
if (spawn.Position is null) return;
bool hasCyl = setup.CylSpheres.Count > 0;
bool hasSphere = setup.Spheres.Count > 0;
bool hasRadius = setup.Radius > 0.0001f;
// NOTE: We intentionally do NOT gate here on `!hasCyl && !hasSphere && !hasRadius`.
// That premature check was wrong: ShadowShapeBuilder.FromSetup also emits a BSP shape
// for any Part whose GfxObj has a non-null PhysicsBSP (e.g. a candle holder or floor
// candelabra with only a BSP collision mesh, no CylSphere/Sphere/Radius). Gating here
// would silently drop those BSP-only entities before the builder runs, making them
// invisible walls or fully passable. The correct final gate is at shapes.Count==0
// below (after the builder + Radius fallback have run), which correctly handles ALL
// cases: BSP-only -> builder emits BSP shape -> registered; truly shapeless -> builder
// empty, no Radius fallback -> shapes.Count==0 -> return. Retail eligibility is
// "has physics_bsp OR cylsphere OR sphere" per CPhysicsObj::FindObjCollisions
// (acclient_2013_pseudo_c.txt:276917 context: the gate fires on the MOVER, not the
// target; no equivalent target-side gate skips BSP-only objects).
float entScale = spawn.ObjScale ?? 1.0f;
// A6.P4 door fix (2026-05-24): build the multi-part shape list.
// ShadowShapeBuilder emits one entry per CylSphere, one per Sphere
// (only when no CylSpheres), and one per Part with a non-null
// PhysicsBSP. Retail-faithful per CTransition::find_obj_collisions
// → CPartArray::FindObjCollisions
// (acclient_2013_pseudo_c.txt:286236). Pre-fix doors registered
// ONE small Cylinder via setup.Radius / Sphere — too narrow to
// span the doorway gap, so the player could walk through. With
// this change the door also registers the part-0 BSP slab
// (1.9 × 0.26 × 2.5 m) that retail uses for the real block.
// #175 (2026-07-05): BSP part shapes must pose at the motion table's
// DEFAULT-STATE frame (the closed pose — what the sequencer renders
// for an idle entity and what retail's live CPhysicsPart pose is),
// not the Setup's placement frame. The Facility Hub double door
// (Setup 0x02000C9D) places its panels AJAR in the placement frame
// (yaw 150°/30°, 0.44 m behind the doorway) while rendering
// closed — the user embedded into the visual door on one side and
// hit a phantom slab on the other. Null (no motion table / no
// cycle / part-count mismatch) falls back to placement frames.
var closedPose = MotionTableDefaultPose(
spawn.MotionTableId ?? 0u, setup.Parts.Count);
var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
setup, entScale,
id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null,
partPoseOverride: closedPose);
// Substitute the real bounding-sphere radius for BSP shapes —
// the pure builder's 2.0 placeholder works for typical doors
// (BS radius ≈ 1.975 m) but is loose for larger entities and
// tight for smaller ones. Mirrors the landblock-static path's
// pattern of pulling the real radius from PhysicsDataCache.
var shapes = new List<AcDream.Core.Physics.ShadowShape>(raw.Count);
foreach (var s in raw)
{
if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.BSP)
{
var phys = _physicsDataCache.GetGfxObj(s.GfxObjId);
float bspR = (phys?.BoundingSphere?.Radius ?? 2f) * entScale;
shapes.Add(s with { Radius = bspR });
}
else
{
shapes.Add(s);
}
}
// setup.Radius fallback: the builder doesn't emit a Radius-only
// shape (it only walks CylSpheres / Spheres / Parts). For entities
// with no CylSpheres / Spheres / BSP-bearing Parts but a non-zero
// Radius (rare — simple decorative props), preserve the prior
// behavior of registering a setup.Radius cylinder so we don't
// silently regress those entities' collision.
if (shapes.Count == 0 && hasRadius)
{
shapes.Add(new AcDream.Core.Physics.ShadowShape(
GfxObjId: 0u,
LocalPosition: System.Numerics.Vector3.Zero,
LocalRotation: System.Numerics.Quaternion.Identity,
Scale: entScale,
CollisionType: AcDream.Core.Physics.ShadowCollisionType.Cylinder,
Radius: setup.Radius * entScale,
CylHeight: (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale));
}
if (shapes.Count == 0)
return;
// Decode PvP / Player / Impenetrable from PWD._bitfield.
// IsCreature comes from the spawn's ItemType (server-known type).
// Every server-spawned live entity is backed by a CWeenieObject.
// Static landblock geometry is registered through the separate path
// with HasWeenie clear. Retail OBJECTINFO::missile_ignore 0x0050CEB0
// needs this distinction for ethereal non-creatures such as doors.
var flags = AcDream.Core.Physics.EntityCollisionFlags.HasWeenie;
if (spawn.ObjectDescriptionFlags is { } odf)
flags |= AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf);
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
uint state = _liveEntities?.TryGetRecord(spawn.Guid, out LiveEntityRecord liveRecord) == true
? (uint)liveRecord.FinalPhysicsState
: spawn.PhysicsState ?? 0u;
_physicsEngine.ShadowObjects.RegisterMultiPart(
entityId: entity.Id,
entityWorldPos: entity.Position,
entityWorldRot: entity.Rotation,
shapes: shapes,
state: state,
flags: flags,
worldOffsetX: origin.X,
worldOffsetY: origin.Y,
landblockId: spawn.Position.Value.LandblockId,
// BR-7 / A6.P4 (2026-06-11): the server position's full cell id
// is the registration flood seed (retail m_position.objcell_id
// into CObjCell::find_cell_list). A door whose spheres straddle
// the doorway lands in BOTH the outdoor landcell and the
// vestibule's shadow list at registration — the architectural
// close of #99. Dynamic objects use calc_cross_cells (no
// do_not_load prune), hence isStatic: false.
seedCellId: spawn.Position.Value.LandblockId,
isStatic: false);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
// Per-shape detail appears in [resolve-bldg] when collisions fire;
// this entity-level line keeps the spawn-time identification.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
{
int nCyl = 0, nBsp = 0;
foreach (var s in shapes)
{
if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder) nCyl++;
else nBsp++;
}
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} shapes=cyl{nCyl}+bsp{nBsp} note=server-spawn-root state=0x{state:X8} flags={flags}"));
}
}
private void RouteSameGenerationCreateObject(
AcDream.App.World.SameGenerationCreateObjectEvents refresh)
{
@ -4511,10 +4371,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
OnLiveAppearanceUpdated(refresh.Appearance);
if (refresh.Parent is { } parent
&& _liveEntities!.TryApplyCreateParent(parent, out var acceptedParent))
&& _liveEntities!.TryApplyCreateParent(parent, out _))
{
WithdrawLiveEntityWorldProjection(parent.ChildGuid);
_equippedChildRenderer?.OnSpawn(acceptedParent);
_equippedChildRenderer?.OnCreateParentAccepted(parent);
}
else if (refresh.Position is { } position)
{
@ -4541,8 +4400,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (!_liveEntities!.TryApplyPickup(pickup, out _))
return;
_equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid);
bool removed = WithdrawLiveEntityWorldProjection(pickup.Guid);
AcDream.App.Rendering.ChildUnparentDisposition childDisposition =
_equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid)
?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
bool removed = childDisposition switch
{
AcDream.App.Rendering.ChildUnparentDisposition.Completed => true,
AcDream.App.Rendering.ChildUnparentDisposition.NotAttached => true,
_ => false,
};
if (removed
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
@ -4564,9 +4430,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
{
if (!_liveEntities!.TryApplyParent(update, out _)) return false;
WithdrawLiveEntityWorldProjection(update.ChildGuid);
return true;
return _liveEntities!.TryApplyParent(update, out _);
}
private void PublishLocalPhysicsTimestamps(
@ -4596,7 +4460,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
}
if (!force
&& _lastLocalPlayerShadow is { } last
&& _localPlayerShadow.Current is { } last
&& last.CellId == cellId
&& System.Numerics.Vector3.DistanceSquared(
last.Position, playerEntity.Position) <= 1e-4f
@ -4614,7 +4478,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
cellId,
_liveCenterX,
_liveCenterY);
_lastLocalPlayerShadow = (
_localPlayerShadow.Set(
playerEntity.Position,
playerEntity.Rotation,
cellId);
@ -4631,38 +4495,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AcDream.Core.World.WorldEntity playerEntity)
{
_physicsEngine.ShadowObjects.Suspend(playerEntity.Id);
_lastLocalPlayerShadow = null;
}
/// <summary>
/// #175: the motion table's default-state pose (the closed pose for
/// doors) — the derivation lives in
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose"/> (Core,
/// dat-conformance-tested; the first cut here used a bare-style cycle
/// key, always missed, and silently no-oped — the "175 is not fixed"
/// report). Returns null → placement-frame fallback.
/// </summary>
private IReadOnlyList<DatReaderWriter.Types.Frame>? MotionTableDefaultPose(
uint motionTableId, int partCount)
{
if (motionTableId == 0u || partCount == 0 || _dats is null) return null;
var mt = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(motionTableId);
if (mt is null) return null;
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
mt, id => _animLoader?.LoadAnimation(id));
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
string desc = pose is null ? "null->placement-fallback"
: System.FormattableString.Invariant(
$"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})");
Console.WriteLine(System.FormattableString.Invariant(
$"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {desc}"));
}
return pose;
_localPlayerShadow.Clear();
}
/// <summary>
@ -5067,7 +4900,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
cleanups.Add(() => incarnation.RunIfNoReplacement(
() => _remoteLastMove.Remove(serverGuid)));
cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record));
cleanups.Add(() => _liveEntityProjectionWithdrawal!.LeaveWorld(
record,
_playerServerGuid));
cleanups.Add(() => _equippedChildRenderer?.OnLogicalTeardown(record));
// Logical teardown ends the retained shadow payload too. The weaker
// pickup/parent path stops after Suspend so re-entry can restore it.
cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id));
@ -5075,56 +4911,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
return new AcDream.App.World.LiveEntityTeardownPlan(cleanups);
}
/// <summary>
/// Retail <c>CPhysicsObj::leave_world</c> (0x005155A0): remove cell/shadow
/// presentation while retaining the PartArray, MovementManager, script
/// manager, animation owner, physics host, and effect owner. Pickup and
/// ParentEvent use this weaker transition; logical delete additionally
/// executes <see cref="TearDownLiveEntityRuntimeComponents"/>.
/// </summary>
private void LeaveWorldLiveEntityRuntimeComponents(LiveEntityRecord record)
{
if (record.WorldEntity is not { } entity)
return;
bool retainedProjectileShadow =
_projectileController?.LeaveWorld(record) == true;
_worldGameState.RemoveById(entity.Id);
_worldEvents.ForgetEntity(entity.Id);
if (!retainedProjectileShadow)
_physicsEngine.ShadowObjects.Deregister(entity.Id);
if (record.ServerGuid == _playerServerGuid)
{
var incarnation = new AcDream.App.World.LiveEntityIncarnationCleanup(
record,
guid => _liveEntities?.TryGetRecord(guid, out LiveEntityRecord current) == true
? current
: null);
incarnation.RunIfNoReplacement(() => _lastLocalPlayerShadow = null);
}
// Pose-attached effects keep logical ownership while cell-less. A
// missing pose suppresses stale anchors; re-entry republishes the same
// WorldEntity frames without replaying create-time resources.
_effectPoses.Remove(entity.Id);
}
/// <summary>
/// Removes the world-facing projection of a still-live object. Pickup and
/// parenting advance POSITION_TS but do not end the object incarnation, so
/// the runtime identity and create-time mesh/script resources survive.
/// </summary>
private bool WithdrawLiveEntityWorldProjection(uint serverGuid)
{
if (_liveEntities is null
|| !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is null)
return false;
LeaveWorldLiveEntityRuntimeComponents(record);
return _liveEntities.WithdrawLiveEntityProjection(serverGuid);
}
/// <summary>
/// R4-V5: shared retail <c>unpack_movement</c> type-6..9 routing
/// (<c>0x00524440</c> cases 6/7/8/9, decomp §2f) — one body for remotes
@ -6220,15 +6006,33 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
{
if (!IsCurrentPositionOwner())
return;
_equippedChildRenderer?.OnChildBecameUnparented(update.Guid);
if (!IsCurrentPositionOwner())
return;
lock (_datLock)
OnLiveEntitySpawnedLocked(
acceptedSpawn,
LiveProjectionPurpose.SpatialRecovery);
AcDream.App.Rendering.ChildUnparentDisposition unparented =
_equippedChildRenderer?.OnChildBecameUnparented(
update.Guid,
() =>
{
if (!IsCurrentPositionOwner())
return;
lock (_datLock)
OnLiveEntitySpawnedLocked(
acceptedSpawn,
LiveProjectionPurpose.SpatialRecovery);
})
?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded
or AcDream.App.Rendering.ChildUnparentDisposition.Pending)
return;
if (!IsCurrentPositionOwner())
return;
if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached)
{
lock (_datLock)
OnLiveEntitySpawnedLocked(
acceptedSpawn,
LiveProjectionPurpose.SpatialRecovery);
if (!IsCurrentPositionOwner())
return;
}
}
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;

View file

@ -0,0 +1,127 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
/// <summary>
/// Exact runtime identity retained while retail
/// <c>SmartBox::UpdateVisualDesc</c> mutates only a live object's visual
/// description.
/// </summary>
internal sealed record LiveEntityAppearanceUpdateState(
WorldEntity Entity,
LiveEntityAnimationState? Animation);
internal sealed record LiveEntityAppearanceCollisionUpdate(
LiveEntityRecord Record,
WorldEntity Entity,
LiveEntityCollisionRegistration? Registration);
/// <summary>
/// Focused appearance-lifetime operations shared by ObjDesc hydration and the
/// final animation presenter. This type owns no identity index; every capture
/// is resolved from <see cref="LiveEntityRuntime"/>.
/// </summary>
internal static class LiveEntityAppearanceBinding
{
public static LiveEntityAppearanceUpdateState? Capture(
LiveEntityRuntime runtime,
uint serverGuid)
{
ArgumentNullException.ThrowIfNull(runtime);
if (!runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return null;
}
return new LiveEntityAppearanceUpdateState(
entity,
record.AnimationRuntime as LiveEntityAnimationState);
}
public static void RebindAnimation(
LiveEntityAnimationState animation,
WorldEntity entity,
Setup setup,
float scale,
IReadOnlyList<LiveAnimationPartTemplate> partTemplate,
IReadOnlyList<bool> partAvailability)
{
ArgumentNullException.ThrowIfNull(animation);
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(partTemplate);
ArgumentNullException.ThrowIfNull(partAvailability);
animation.Entity = entity;
animation.Setup = setup;
animation.Scale = scale;
animation.PartTemplate = partTemplate;
animation.PartAvailability = partAvailability;
animation.InvalidatePresentationPoses();
}
public static LiveEntityAppearanceCollisionUpdate? PrepareCollision(
LiveEntityRuntime runtime,
LiveEntityCollisionBuilder builder,
WorldEntity entity,
Setup setup,
IReadOnlyList<uint> effectivePartGfxObjIds,
WorldSession.EntitySpawn spawn,
Vector3 worldOrigin)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds);
if (!runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record)
|| !ReferenceEquals(record.WorldEntity, entity))
{
return null;
}
return new LiveEntityAppearanceCollisionUpdate(
record,
entity,
builder.Build(
entity,
setup,
effectivePartGfxObjIds,
spawn,
record,
worldOrigin,
retainEmptyPayload: true));
}
public static bool CommitCollision(
LiveEntityRuntime runtime,
ShadowObjectRegistry registry,
LiveEntityAppearanceCollisionUpdate update)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(update);
if (!runtime.TryGetRecord(update.Record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, update.Record)
|| !ReferenceEquals(current.WorldEntity, update.Entity))
{
return false;
}
LiveEntityCollisionBuilder.ReconcileAppearance(
registry,
update.Entity.Id,
update.Registration,
suspendIfNew: !current.IsSpatiallyVisible
|| current.ProjectionKind is not LiveEntityProjectionKind.World
|| (current.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0);
return true;
}
}

View file

@ -136,12 +136,7 @@ public sealed class InboundPhysicsStateController
return false;
}
accepted = ApplyParent(
child,
update.ParentGuid,
update.ParentLocation,
update.PlacementId,
update.ChildPositionSequence);
accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence);
_snapshots[update.ChildGuid] = accepted;
return true;
}
@ -160,13 +155,34 @@ public sealed class InboundPhysicsStateController
return false;
}
accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence);
_snapshots[update.ChildGuid] = accepted;
return true;
}
public bool TryCommitParent(
uint childGuid,
uint parentGuid,
uint parentLocation,
uint placementId,
ushort positionSequence,
out WorldSession.EntitySpawn accepted)
{
if (!TryGet(childGuid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn child)
|| gate.PositionTimestamp != positionSequence
|| child.PositionSequence != positionSequence)
{
accepted = default;
return false;
}
accepted = ApplyParent(
child,
update.ParentGuid,
update.ParentLocation,
update.PlacementId,
update.ChildPositionSequence);
_snapshots[update.ChildGuid] = accepted;
parentGuid,
parentLocation,
placementId,
positionSequence);
_snapshots[childGuid] = accepted;
return true;
}
@ -595,6 +611,25 @@ public sealed class InboundPhysicsStateController
};
}
private static WorldSession.EntitySpawn ApplyPositionTimestampOnly(
WorldSession.EntitySpawn old,
ushort positionSequence)
{
PhysicsSpawnData? physics = old.Physics;
if (physics is { } desc)
{
physics = desc with
{
Timestamps = desc.Timestamps with { Position = positionSequence },
};
}
return old with
{
PositionSequence = positionSequence,
Physics = physics,
};
}
private static WorldSession.EntitySpawn ApplyParent(
WorldSession.EntitySpawn old,
uint parentGuid,

View file

@ -0,0 +1,175 @@
using AcDream.App.Physics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
namespace AcDream.App.World;
/// <summary>
/// Owns retail <c>CPhysicsObj::leave_world</c>'s App projection tail for a
/// still-live exact record. Component projection is removed before canonical
/// runtime withdrawal, matching the shipped failure/retry boundary.
/// </summary>
internal sealed class LiveEntityProjectionWithdrawalController
{
private readonly LiveEntityRuntime _runtime;
private readonly ProjectileController _projectiles;
private readonly WorldGameState _worldState;
private readonly WorldEvents _worldEvents;
private readonly ShadowObjectRegistry _shadows;
private readonly EntityEffectPoseRegistry _effectPoses;
private readonly LocalPlayerShadowState _localPlayerShadow;
public LiveEntityProjectionWithdrawalController(
LiveEntityRuntime runtime,
ProjectileController projectiles,
WorldGameState worldState,
WorldEvents worldEvents,
ShadowObjectRegistry shadows,
EntityEffectPoseRegistry effectPoses,
LocalPlayerShadowState localPlayerShadow)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_projectiles = projectiles ?? throw new ArgumentNullException(nameof(projectiles));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
_worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses));
_localPlayerShadow = localPlayerShadow
?? throw new ArgumentNullException(nameof(localPlayerShadow));
}
public bool Withdraw(uint serverGuid, uint localPlayerGuid)
{
if (!_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is null)
{
return false;
}
return Withdraw(record, localPlayerGuid);
}
/// <summary>
/// Withdraws only the captured incarnation and projection operation. A
/// leave-world observer may accept a fresh same-generation Position and
/// reproject this record; that newer projection must survive the displaced
/// outer operation just as a newer INSTANCE_TS must.
/// </summary>
public bool Withdraw(LiveEntityRecord record, uint localPlayerGuid)
{
ArgumentNullException.ThrowIfNull(record);
if (!_runtime.IsCurrentRecord(record) || record.WorldEntity is null)
return false;
return Withdraw(
record,
record.PositionAuthorityVersion,
record.ProjectionMutationVersion,
localPlayerGuid);
}
public bool Withdraw(
LiveEntityRecord record,
ulong positionAuthorityVersion,
ulong projectionMutationVersion,
uint localPlayerGuid)
{
ExactProjectionWithdrawalOutcome outcome = WithdrawExact(
record,
positionAuthorityVersion,
projectionMutationVersion,
localPlayerGuid);
if (outcome.Failure is not null)
throw outcome.Failure;
return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed;
}
public ExactProjectionWithdrawalOutcome WithdrawExact(
LiveEntityRecord record,
ulong positionAuthorityVersion,
ulong projectionMutationVersion,
uint localPlayerGuid)
{
ArgumentNullException.ThrowIfNull(record);
if (!_runtime.IsCurrentRecord(record)
|| record.WorldEntity is null
|| record.PositionAuthorityVersion != positionAuthorityVersion
|| record.ProjectionMutationVersion != projectionMutationVersion)
{
return new(
ExactProjectionWithdrawalDisposition.Superseded,
Failure: null);
}
try
{
LeaveWorld(record, localPlayerGuid);
bool completed = _runtime.WithdrawLiveEntityProjection(
record,
positionAuthorityVersion,
projectionMutationVersion);
return new(
completed
? ExactProjectionWithdrawalDisposition.Completed
: ExactProjectionWithdrawalDisposition.Superseded,
Failure: null);
}
catch (Exception error)
{
bool current = _runtime.IsCurrentRecord(record);
ExactProjectionWithdrawalDisposition disposition =
current
&& record.PositionAuthorityVersion == positionAuthorityVersion
&& record.ProjectionMutationVersion == projectionMutationVersion
&& record.IsSpatiallyProjected
? ExactProjectionWithdrawalDisposition.Pending
: current
&& record.PositionAuthorityVersion == positionAuthorityVersion
&& !record.IsSpatiallyProjected
? ExactProjectionWithdrawalDisposition.Completed
: ExactProjectionWithdrawalDisposition.Superseded;
return new(disposition, error);
}
}
/// <summary>
/// Removes only the shorter world-facing component projection. This exact
/// overload is also used by logical teardown after the active GUID table
/// may already expose a replacement incarnation.
/// </summary>
public void LeaveWorld(LiveEntityRecord record, uint localPlayerGuid)
{
ArgumentNullException.ThrowIfNull(record);
if (record.WorldEntity is not { } entity)
return;
bool retainedProjectileShadow = _projectiles.LeaveWorld(record);
if (!retainedProjectileShadow)
_shadows.Suspend(entity.Id);
_worldState.RemoveById(entity.Id);
_worldEvents.ForgetEntity(entity.Id);
if (record.ServerGuid == localPlayerGuid
&& (!_runtime.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| ReferenceEquals(current, record)))
{
_localPlayerShadow.Clear();
}
// Pose-attached effects keep logical ownership while cell-less. A
// missing pose suppresses stale anchors; re-entry republishes the same
// WorldEntity frames without replaying create-time resources.
_effectPoses.Remove(entity.Id);
}
}
public enum ExactProjectionWithdrawalDisposition
{
Completed,
Pending,
Superseded,
}
public readonly record struct ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition Disposition,
Exception? Failure);

View file

@ -931,6 +931,38 @@ public sealed class LiveEntityRuntime
return true;
}
/// <summary>
/// Exact-incarnation withdrawal used after App leave-world callbacks. A
/// callback may accept a newer INSTANCE_TS for the same GUID; that newer
/// projection must never be withdrawn by the displaced operation.
/// </summary>
public bool WithdrawLiveEntityProjection(LiveEntityRecord expectedRecord)
{
ArgumentNullException.ThrowIfNull(expectedRecord);
return WithdrawLiveEntityProjection(
expectedRecord,
expectedRecord.PositionAuthorityVersion,
expectedRecord.ProjectionMutationVersion);
}
/// <summary>
/// Exact-operation withdrawal used across arbitrary App callbacks. Both
/// the accepted Position authority and projection mutation token are
/// pinned so a same-incarnation re-entry cannot be removed by an older
/// parent/pickup/leave-world operation.
/// </summary>
internal bool WithdrawLiveEntityProjection(
LiveEntityRecord expectedRecord,
ulong expectedPositionAuthorityVersion,
ulong expectedProjectionMutationVersion)
{
ArgumentNullException.ThrowIfNull(expectedRecord);
return IsCurrentRecord(expectedRecord)
&& expectedRecord.PositionAuthorityVersion == expectedPositionAuthorityVersion
&& expectedRecord.ProjectionMutationVersion == expectedProjectionMutationVersion
&& WithdrawLiveEntityProjection(expectedRecord.ServerGuid);
}
/// <summary>
/// Withdraws a still-live projection whose authoritative rollback frame
/// is outside every world cell. Logical owners remain registered.
@ -1515,7 +1547,6 @@ public sealed class LiveEntityRuntime
RefreshRecord(update.ChildGuid, accepted);
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
ClearWorldCell(update.ChildGuid);
}
return applied;
}
@ -1528,11 +1559,43 @@ public sealed class LiveEntityRuntime
RefreshRecord(update.ChildGuid, accepted);
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
ClearWorldCell(update.ChildGuid);
}
return applied;
}
internal bool CommitStagedParent(
ParentAttachmentRelation relation,
out WorldSession.EntitySpawn accepted)
{
bool committed = _inbound.TryCommitParent(
relation.ChildGuid,
relation.ParentGuid,
relation.ParentLocation,
relation.PlacementId,
relation.ChildPositionSequence,
out accepted);
if (committed)
RefreshRecord(relation.ChildGuid, accepted);
return committed;
}
/// <summary>
/// Commits retail <c>CPhysicsObj::set_parent</c>'s cell-less edge only
/// after the parent accepted the child (PartArray + holding-location
/// validation). The POSITION_TS is consumed before this step, matching
/// retail; invalid parent relationships retain the old world projection.
/// </summary>
internal bool CommitAcceptedParentCellless(
LiveEntityRecord record,
ulong positionAuthorityVersion)
{
ArgumentNullException.ThrowIfNull(record);
if (!IsCurrentPositionAuthority(record, positionAuthorityVersion))
return false;
ClearWorldCell(record.ServerGuid);
return IsCurrentPositionAuthority(record, positionAuthorityVersion);
}
public bool TryApplyMotion(
WorldSession.EntityMotionUpdate update,
bool retainPayload,
@ -2154,7 +2217,7 @@ public sealed class LiveEntityRuntime
RefreshSpatialRuntimeIndexes(record);
}
private bool IsCurrentRecord(LiveEntityRecord record) =>
internal bool IsCurrentRecord(LiveEntityRecord record) =>
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
&& ReferenceEquals(current, record);

View file

@ -6,19 +6,20 @@ namespace AcDream.App.World;
/// <summary>
/// Update-thread state machine for parent relations that may arrive before
/// either CreateObject. Unaccepted wire events remain in arrival order;
/// rollback history contains only relations that passed the canonical retail
/// timestamp gate.
/// committed rollback history contains only relations that passed both the
/// canonical retail timestamp gate and retail <c>PartArray::add_child</c>
/// validation.
/// </summary>
public sealed class ParentAttachmentState
{
private readonly Dictionary<uint, Queue<ParentAttachmentRelation>> _unresolvedByChild = new();
private readonly Dictionary<uint, ParentAttachmentRelation> _projectionByChild = new();
private readonly Dictionary<uint, ParentAttachmentRelation> _stagedByChild = new();
private readonly Dictionary<uint, ParentAttachmentRelation> _recoveryByChild = new();
private readonly Dictionary<uint, ParentAttachmentRelation> _lastAcceptedByChild = new();
public void AcceptCreateObjectRelation(ParentAttachmentRelation relation)
{
_projectionByChild[relation.ChildGuid] = relation;
_lastAcceptedByChild[relation.ChildGuid] = relation;
_stagedByChild[relation.ChildGuid] = relation;
}
public void Enqueue(ParentEvent.Parsed update)
@ -50,6 +51,8 @@ public sealed class ParentAttachmentState
Func<uint, ushort?> resolveInstance,
Func<ParentEvent.Parsed, bool> accept)
{
if (_stagedByChild.ContainsKey(childGuid))
return;
if (!_unresolvedByChild.TryGetValue(childGuid, out Queue<ParentAttachmentRelation>? queue))
return;
int candidateCount = queue.Count;
@ -95,8 +98,11 @@ public sealed class ParentAttachmentState
if (!accept(update))
continue;
_projectionByChild[childGuid] = relation;
_lastAcceptedByChild[childGuid] = relation;
_stagedByChild[childGuid] = relation with
{
WaitOwner = ParentAttachmentWaitOwner.Unknown,
};
break;
}
if (queue.Count == 0)
@ -104,22 +110,109 @@ public sealed class ParentAttachmentState
}
public bool TryGetProjection(uint childGuid, out ParentAttachmentRelation relation) =>
_projectionByChild.TryGetValue(childGuid, out relation);
_stagedByChild.TryGetValue(childGuid, out relation)
|| _recoveryByChild.TryGetValue(childGuid, out relation);
public void MarkProjected(uint childGuid) => _projectionByChild.Remove(childGuid);
public bool TryGetStagedProjection(
uint childGuid,
out ParentAttachmentRelation relation) =>
_stagedByChild.TryGetValue(childGuid, out relation);
public bool TryGetRecoveryProjection(
uint childGuid,
out ParentAttachmentRelation relation) =>
_recoveryByChild.TryGetValue(childGuid, out relation);
public void CopyPendingProjectionChildrenTo(List<uint> destination)
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach (uint childGuid in _stagedByChild.Keys)
destination.Add(childGuid);
foreach (uint childGuid in _recoveryByChild.Keys)
{
if (!_stagedByChild.ContainsKey(childGuid))
destination.Add(childGuid);
}
}
public bool IsCommitted(ParentAttachmentRelation relation) =>
_lastAcceptedByChild.TryGetValue(
relation.ChildGuid,
out ParentAttachmentRelation committed)
&& committed == relation;
public bool IsPending(
ParentAttachmentRelation relation,
ParentProjectionCandidateKind kind) =>
(kind is ParentProjectionCandidateKind.Staged
? _stagedByChild
: _recoveryByChild).TryGetValue(
relation.ChildGuid,
out ParentAttachmentRelation pending)
&& pending == relation;
public void MarkProjected(
ParentAttachmentRelation relation,
ParentProjectionCandidateKind kind)
{
Dictionary<uint, ParentAttachmentRelation> source =
kind is ParentProjectionCandidateKind.Staged
? _stagedByChild
: _recoveryByChild;
if (source.TryGetValue(
relation.ChildGuid,
out ParentAttachmentRelation pending)
&& pending == relation)
{
source.Remove(relation.ChildGuid);
}
}
public bool CommitProjection(ParentAttachmentRelation relation)
{
if (!_stagedByChild.TryGetValue(
relation.ChildGuid,
out ParentAttachmentRelation staged)
|| staged != relation)
{
return false;
}
_lastAcceptedByChild[relation.ChildGuid] = relation;
_stagedByChild.Remove(relation.ChildGuid);
_recoveryByChild[relation.ChildGuid] = relation;
return true;
}
public void RejectProjection(ParentAttachmentRelation relation)
{
if (_stagedByChild.TryGetValue(
relation.ChildGuid,
out ParentAttachmentRelation staged)
&& staged == relation)
{
_stagedByChild.Remove(relation.ChildGuid);
}
}
public bool RestoreLastAccepted(uint childGuid)
{
if (!_lastAcceptedByChild.TryGetValue(childGuid, out ParentAttachmentRelation relation))
return false;
_projectionByChild[childGuid] = relation;
_recoveryByChild[childGuid] = relation;
return true;
}
public IReadOnlyList<uint> ChildrenWaitingForParent(uint parentGuid)
{
var result = new HashSet<uint>();
foreach ((uint childGuid, ParentAttachmentRelation relation) in _projectionByChild)
foreach ((uint childGuid, ParentAttachmentRelation relation) in _stagedByChild)
{
if (relation.ParentGuid == parentGuid)
result.Add(childGuid);
}
foreach ((uint childGuid, ParentAttachmentRelation relation) in _recoveryByChild)
{
if (relation.ParentGuid == parentGuid)
result.Add(childGuid);
@ -136,11 +229,13 @@ public sealed class ParentAttachmentState
public void RemoveObject(uint guid)
{
_projectionByChild.Remove(guid);
_stagedByChild.Remove(guid);
_recoveryByChild.Remove(guid);
_lastAcceptedByChild.Remove(guid);
_unresolvedByChild.Remove(guid);
RemoveParentReferences(_projectionByChild, guid);
RemoveParentReferences(_stagedByChild, guid);
RemoveParentReferences(_recoveryByChild, guid);
RemoveParentReferences(_lastAcceptedByChild, guid);
uint[] children = _unresolvedByChild.Keys.ToArray();
@ -165,9 +260,11 @@ public sealed class ParentAttachmentState
FilterChildCandidates(
guid,
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
_projectionByChild.Remove(guid);
_stagedByChild.Remove(guid);
_recoveryByChild.Remove(guid);
_lastAcceptedByChild.Remove(guid);
RemoveParentReferences(_projectionByChild, guid);
RemoveParentReferences(_stagedByChild, guid);
RemoveParentReferences(_recoveryByChild, guid);
RemoveParentReferences(_lastAcceptedByChild, guid);
FilterParentCandidates(
guid,
@ -187,9 +284,11 @@ public sealed class ParentAttachmentState
FilterChildCandidates(
guid,
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
_projectionByChild.Remove(guid);
_stagedByChild.Remove(guid);
_recoveryByChild.Remove(guid);
_lastAcceptedByChild.Remove(guid);
RemoveParentReferences(_projectionByChild, guid);
RemoveParentReferences(_stagedByChild, guid);
RemoveParentReferences(_recoveryByChild, guid);
RemoveParentReferences(_lastAcceptedByChild, guid);
FilterParentCandidates(
guid,
@ -204,13 +303,15 @@ public sealed class ParentAttachmentState
/// </summary>
public void EndChildProjection(uint childGuid)
{
_projectionByChild.Remove(childGuid);
_stagedByChild.Remove(childGuid);
_recoveryByChild.Remove(childGuid);
_lastAcceptedByChild.Remove(childGuid);
}
public void RemoveChild(uint childGuid)
{
_projectionByChild.Remove(childGuid);
_stagedByChild.Remove(childGuid);
_recoveryByChild.Remove(childGuid);
_lastAcceptedByChild.Remove(childGuid);
_unresolvedByChild.Remove(childGuid);
}
@ -218,7 +319,8 @@ public sealed class ParentAttachmentState
public void Clear()
{
_unresolvedByChild.Clear();
_projectionByChild.Clear();
_stagedByChild.Clear();
_recoveryByChild.Clear();
_lastAcceptedByChild.Clear();
}
@ -286,3 +388,9 @@ public enum ParentAttachmentWaitOwner
Parent,
Child,
}
public enum ParentProjectionCandidateKind
{
Staged,
Recovery,
}

View file

@ -216,6 +216,92 @@ public sealed class ShadowObjectRegistry
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
}
/// <summary>
/// Replaces an existing live PartArray collision payload in its current
/// shadow-cell membership. Retail <c>CPartArray::SetPart</c> changes the
/// part read by later collision tests; it does not recalculate cross-cells.
/// A suspended owner updates only its retained payload. A newly shaped
/// owner may perform its first flood, then immediately suspend when its
/// canonical projection is Hidden, attached, or cell-less.
/// </summary>
public void ReplaceMultiPartPayload(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes,
uint state,
EntityCollisionFlags flags,
float worldOffsetX,
float worldOffsetY,
uint landblockId,
uint seedCellId = 0u,
bool isStatic = false,
bool suspendIfNew = false)
{
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior)
|| !prior.IsMultiPart)
{
if (shapes.Count == 0)
return;
RegisterMultiPart(
entityId,
entityWorldPos,
entityWorldRot,
shapes,
state,
flags,
worldOffsetX,
worldOffsetY,
landblockId,
seedCellId,
isStatic);
if (suspendIfNew)
Suspend(entityId);
return;
}
bool suspended = _suspendedEntities.Contains(entityId);
_entityShapes[entityId] = shapes;
_entityReg[entityId] = prior with
{
EntityWorldPos = entityWorldPos,
EntityWorldRot = entityWorldRot,
State = state,
Flags = flags,
};
if (suspended || !_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return;
foreach (uint cellId in cells)
{
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? entries))
entries.RemoveAll(entry => entry.EntityId == entityId);
}
foreach (ShadowShape shape in shapes)
{
Vector3 partWorldPos = entityWorldPos
+ Vector3.Transform(shape.LocalPosition, entityWorldRot);
Quaternion partWorldRot = entityWorldRot * shape.LocalRotation;
var entry = new ShadowEntry(
EntityId: entityId,
GfxObjId: shape.GfxObjId,
Position: partWorldPos,
Rotation: partWorldRot,
Radius: shape.Radius,
CollisionType: shape.CollisionType,
CylHeight: shape.CylHeight,
Scale: shape.Scale,
State: state,
Flags: flags,
LocalPosition: shape.LocalPosition,
LocalRotation: shape.LocalRotation);
foreach (uint cellId in cells)
AddEntryToCell(entry, cellId);
}
}
/// <summary>
/// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0):
/// when the object has cylinder shapes, each contributes one sphere at

View file

@ -50,11 +50,16 @@ public static class ShadowShapeBuilder
/// placement frame per part (entities with no motion table, and the
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
/// the setup too).</param>
/// <param name="effectivePartGfxObjIds">Current part identities after
/// retail <c>AnimPartChanged</c> processing. Collision keeps each Setup
/// index and pose, but reads PhysicsBSP from the installed replacement.
/// Null or short lists fall back to the Setup identity.</param>
public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup,
float entScale,
Func<uint, bool> hasPhysicsBsp,
IReadOnlyList<Frame>? partPoseOverride = null)
IReadOnlyList<Frame>? partPoseOverride = null,
IReadOnlyList<uint>? effectivePartGfxObjIds = null)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
@ -102,7 +107,14 @@ public static class ShadowShapeBuilder
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
for (int i = 0; i < setup.Parts.Count; i++)
{
uint gfxId = (uint)setup.Parts[i];
// Retail CPhysicsPart::SetPart installs AnimPartChanged's current
// degrade array before CPartArray::FindObjCollisions reads it.
// Keep the stable Setup part index/pose, but source collision
// identity from that effective part when one was supplied.
uint gfxId = effectivePartGfxObjIds is not null
&& i < effectivePartGfxObjIds.Count
? effectivePartGfxObjIds[i]
: (uint)setup.Parts[i];
if (!hasPhysicsBsp(gfxId)) continue;
Frame partFrame;

View file

@ -3505,7 +3505,7 @@ public sealed class Transition
/// PathClipped movers + airborne head-sphere hits. Non-PerfectClip records the
/// center-to-center collision normal and hard-stops (the M1.5 load-bearing
/// path — players never set PerfectClip). PerfectClip gets the exact
/// time-of-impact reposition (missiles only — AP-84, dead in M1.5, ported per
/// time-of-impact reposition (missiles only — AP-91, dead in M1.5, ported per
/// ACE Sphere.cs:175-210; re-verify vs Ghidra before missiles ship).
/// </summary>
private TransitionState SphereCollideWithPoint(ShadowEntry obj, SpherePath sp,
@ -3521,7 +3521,7 @@ public sealed class Transition
return TransitionState.Collided;
}
// PerfectClip exact time-of-impact (AP-84 — dead in M1.5). Block offset = 0.
// PerfectClip exact time-of-impact (AP-91 — dead in M1.5). Block offset = 0.
Vector3 checkOffset = checkSphere.Origin - gCenter;
double toi = FindSphereTimeOfCollision(checkOffset, globalOffset, radsum + PhysicsGlobals.EPSILON);
if (toi < PhysicsGlobals.EPSILON || toi > 1.0)