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:
parent
c87bac31a0
commit
69a2ca0c6d
26 changed files with 4172 additions and 430 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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 >
|
||||
/// Sphere fallback > 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;
|
||||
|
|
|
|||
127
src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs
Normal file
127
src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue