feat(net): port retail physics spawn and event timestamps
Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client. Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
d53fe30ffe
commit
8a5d77f7f4
50 changed files with 3809 additions and 649 deletions
|
|
@ -395,6 +395,10 @@ public sealed class PlayerMovementController
|
|||
/// strict subset of the funnel's Contact+OnWalkable gate).</summary>
|
||||
internal bool BodyInContact => _body.InContact;
|
||||
|
||||
/// <summary>Retail <c>SendPositionEvent</c> admission gate: both Contact
|
||||
/// and OnWalkable must be present on the local physics body.</summary>
|
||||
internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable;
|
||||
|
||||
/// <summary>R4-V5: body orientation for the <see cref="MoveTo"/>
|
||||
/// manager's position seam (re-derived from <see cref="Yaw"/> every
|
||||
/// Update — heading reads/writes go through Yaw, not this).</summary>
|
||||
|
|
@ -508,7 +512,7 @@ public sealed class PlayerMovementController
|
|||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
UpdateCellId(cellId, "teleport");
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
|
||||
|
||||
// Treat as grounded after a server-side position snap.
|
||||
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
|
|
@ -547,6 +551,20 @@ public sealed class PlayerMovementController
|
|||
_physicsAccum = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SmartBox::BlipPlayer</c> (0x00453940): apply a server
|
||||
/// FORCE_POSITION correction through <c>CPhysicsObj::SetPositionSimple</c>
|
||||
/// without the teleport hook. Active motion, velocity, contact state, and
|
||||
/// PositionManager stick relationships deliberately survive the blip.
|
||||
/// </summary>
|
||||
public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
||||
{
|
||||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
|
||||
}
|
||||
|
||||
private Vector3 ComputeRenderPosition()
|
||||
{
|
||||
float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f);
|
||||
|
|
@ -1196,9 +1214,7 @@ public sealed class PlayerMovementController
|
|||
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
|
||||
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
|
||||
// set simultaneously, NOT a bitwise-OR mask test.
|
||||
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
|
||||
|
||||
HeartbeatDue = groundedOnWalkable && sendThisFrame;
|
||||
HeartbeatDue = CanSendPositionEvent && sendThisFrame;
|
||||
|
||||
// R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the
|
||||
// run pacing now comes from the ported machinery itself
|
||||
|
|
|
|||
|
|
@ -27,12 +27,11 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
private readonly GpuWorldState _worldState;
|
||||
private readonly Func<uint, WorldEntity?> _resolveEntity;
|
||||
private readonly Func<uint, WorldSession.EntitySpawn?> _resolveSpawn;
|
||||
private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
|
||||
private readonly Func<uint> _nextEntityId;
|
||||
|
||||
private readonly Dictionary<uint, PendingAttachment> _pendingByChild = new();
|
||||
private readonly Dictionary<uint, PendingAttachment> _lastRelationByChild = new();
|
||||
private readonly ParentAttachmentState _relations = new();
|
||||
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
|
||||
private readonly Dictionary<uint, ushort> _positionSequenceByChild = new();
|
||||
|
||||
public IEnumerable<uint> AttachedEntityIds
|
||||
{
|
||||
|
|
@ -50,6 +49,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
GpuWorldState worldState,
|
||||
Func<uint, WorldEntity?> resolveEntity,
|
||||
Func<uint, WorldSession.EntitySpawn?> resolveSpawn,
|
||||
Func<ParentEvent.Parsed, bool> acceptParent,
|
||||
Func<uint> nextEntityId)
|
||||
{
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
|
|
@ -58,11 +58,12 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
|
||||
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
|
||||
_resolveSpawn = resolveSpawn ?? throw new ArgumentNullException(nameof(resolveSpawn));
|
||||
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
|
||||
_nextEntityId = nextEntityId ?? throw new ArgumentNullException(nameof(nextEntityId));
|
||||
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.MoveRolledBack += OnMoveRolledBack;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectRemovalClassified += OnObjectRemovalClassified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -72,100 +73,106 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
/// </summary>
|
||||
public void OnSpawn(WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
_positionSequenceByChild[spawn.Guid] = spawn.PositionSequence;
|
||||
|
||||
if (spawn.ParentGuid is { } parentGuid and not 0
|
||||
&& spawn.ParentLocation is { } parentLocation
|
||||
&& spawn.PlacementId is { } placementId)
|
||||
{
|
||||
var relation = new PendingAttachment(
|
||||
parentGuid,
|
||||
spawn.Guid,
|
||||
parentLocation,
|
||||
placementId,
|
||||
spawn.InstanceSequence,
|
||||
spawn.PositionSequence,
|
||||
FromCreateObject: true);
|
||||
_pendingByChild[spawn.Guid] = relation;
|
||||
_lastRelationByChild[spawn.Guid] = relation;
|
||||
}
|
||||
|
||||
lock (_datLock)
|
||||
{
|
||||
if (spawn.ParentGuid is { } parentGuid and not 0
|
||||
&& spawn.ParentLocation is { } parentLocation
|
||||
&& spawn.PlacementId is { } placementId)
|
||||
{
|
||||
_relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
parentGuid,
|
||||
spawn.Guid,
|
||||
parentLocation,
|
||||
placementId,
|
||||
ParentInstanceSequence: 0,
|
||||
spawn.PositionSequence));
|
||||
}
|
||||
|
||||
ResolveRelations(spawn.Guid);
|
||||
TryRealize(spawn.Guid);
|
||||
|
||||
// ParentEvent can precede the parent's CreateObject. Revisit every
|
||||
// child waiting specifically on the object that just arrived.
|
||||
uint[] waiting = _pendingByChild.Values
|
||||
.Where(p => p.ParentGuid == spawn.Guid)
|
||||
.Select(p => p.ChildGuid)
|
||||
.ToArray();
|
||||
for (int i = 0; i < waiting.Length; i++)
|
||||
IReadOnlyList<uint> waiting = _relations.ChildrenWaitingForParent(spawn.Guid);
|
||||
for (int i = 0; i < waiting.Count; i++)
|
||||
{
|
||||
ResolveRelations(waiting[i]);
|
||||
TryRealize(waiting[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply/queue a live ParentEvent using retail's two sequence gates:
|
||||
/// parent instance must match, child position must advance strictly.
|
||||
/// Completes attachment projection after a root world entity has been
|
||||
/// registered. Relation acceptance intentionally happens before root
|
||||
/// projection selection; this notification only satisfies render-pose
|
||||
/// dependencies for children waiting on that root.
|
||||
/// </summary>
|
||||
public void OnWorldEntityRegistered(uint guid)
|
||||
{
|
||||
lock (_datLock)
|
||||
{
|
||||
IReadOnlyList<uint> waiting = _relations.ChildrenWaitingForParent(guid);
|
||||
for (int i = 0; i < waiting.Count; i++)
|
||||
{
|
||||
ResolveRelations(waiting[i]);
|
||||
TryRealize(waiting[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply/queue a live ParentEvent after the live-entity owner has exact-
|
||||
/// gated the parent's INSTANCE_TS and advanced the child's shared
|
||||
/// POSITION_TS. This controller owns only the render relationship.
|
||||
/// </summary>
|
||||
public void OnParentEvent(ParentEvent.Parsed update)
|
||||
{
|
||||
WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(update.ParentGuid);
|
||||
if (parentSpawn is { } knownParent
|
||||
&& knownParent.InstanceSequence != update.ParentInstanceSequence)
|
||||
{
|
||||
// Known parent newer than the event: stale. Event newer than the
|
||||
// known parent: retain until its CreateObject arrives.
|
||||
if (MotionSequenceGate.IsNewer(
|
||||
update.ParentInstanceSequence,
|
||||
knownParent.InstanceSequence))
|
||||
return;
|
||||
}
|
||||
|
||||
if (_positionSequenceByChild.TryGetValue(update.ChildGuid, out ushort current)
|
||||
&& !MotionSequenceGate.IsNewer(current, update.ChildPositionSequence))
|
||||
return;
|
||||
|
||||
var relation = new PendingAttachment(
|
||||
update.ParentGuid,
|
||||
update.ChildGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
update.ParentInstanceSequence,
|
||||
update.ChildPositionSequence,
|
||||
FromCreateObject: false);
|
||||
_pendingByChild[update.ChildGuid] = relation;
|
||||
_lastRelationByChild[update.ChildGuid] = relation;
|
||||
|
||||
lock (_datLock)
|
||||
{
|
||||
_relations.Enqueue(update);
|
||||
ResolveRelations(update.ChildGuid);
|
||||
TryRealize(update.ChildGuid);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnObjectDeleted(uint guid)
|
||||
public void OnGenerationReplaced(uint guid, ushort replacementGeneration)
|
||||
{
|
||||
Remove(guid);
|
||||
TearDownObjectProjections(guid);
|
||||
_relations.EndGeneration(guid, replacementGeneration);
|
||||
}
|
||||
|
||||
uint[] children = _attachedByChild.Values
|
||||
.Where(c => c.ParentGuid == guid)
|
||||
.Select(c => c.ChildGuid)
|
||||
.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
Remove(children[i]);
|
||||
public void OnGenerationDeleted(uint guid, ushort deletedGeneration)
|
||||
{
|
||||
TearDownObjectProjections(guid);
|
||||
_relations.DeleteGeneration(guid, deletedGeneration);
|
||||
}
|
||||
|
||||
uint[] pendingChildren = _lastRelationByChild.Values
|
||||
.Where(c => c.ParentGuid == guid)
|
||||
.Select(c => c.ChildGuid)
|
||||
.ToArray();
|
||||
for (int i = 0; i < pendingChildren.Length; i++)
|
||||
{
|
||||
_pendingByChild.Remove(pendingChildren[i]);
|
||||
_lastRelationByChild.Remove(pendingChildren[i]);
|
||||
}
|
||||
private void OnObjectRemovalClassified(ClientObjectRemoval removal)
|
||||
{
|
||||
if (removal.Reason is not ClientObjectRemovalReason.Ordinary)
|
||||
return;
|
||||
|
||||
_pendingByChild.Remove(guid);
|
||||
_lastRelationByChild.Remove(guid);
|
||||
_positionSequenceByChild.Remove(guid);
|
||||
uint guid = removal.Object.ObjectId;
|
||||
TearDownObjectProjections(guid);
|
||||
// InventoryRemoveObject removes the item from the UI view, not the
|
||||
// live physics generation. Preserve fresher pending ParentEvents so a
|
||||
// same-generation world/parent update can still replay them. Logical
|
||||
// delete/replacement are driven directly by the accepted inbound
|
||||
// lifecycle and never depend on this table.
|
||||
_relations.EndChildProjection(guid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fresh Position or Pickup unparented this child. Remove the accepted
|
||||
/// attachment/rollback projection while retaining fresher unresolved
|
||||
/// ParentEvents; unlike logical deletion, do not disturb child-addressed
|
||||
/// pending state or children that may themselves reference it.
|
||||
/// </summary>
|
||||
public void OnChildBecameUnparented(uint childGuid)
|
||||
{
|
||||
Remove(childGuid);
|
||||
_relations.EndChildProjection(childGuid);
|
||||
}
|
||||
|
||||
/// <summary>Recompose every child after the parent's animation tick.</summary>
|
||||
|
|
@ -197,37 +204,15 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
|
||||
private void TryRealize(uint childGuid)
|
||||
{
|
||||
if (!_pendingByChild.TryGetValue(childGuid, out PendingAttachment pending))
|
||||
if (!_relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
|
||||
return;
|
||||
|
||||
// A ParentEvent queued before the child existed is replayed only if
|
||||
// its position stamp still advances the child's CreateObject seed.
|
||||
// Retail reaches the same check inside DoParentEvent after its blob
|
||||
// queue resolves both objects.
|
||||
if (!pending.FromCreateObject
|
||||
&& _positionSequenceByChild.TryGetValue(childGuid, out ushort childPosition)
|
||||
&& !MotionSequenceGate.IsNewer(childPosition, pending.ChildPositionSequence))
|
||||
{
|
||||
_pendingByChild.Remove(childGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
WorldEntity? parentEntity = _resolveEntity(pending.ParentGuid);
|
||||
WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(pending.ParentGuid);
|
||||
WorldSession.EntitySpawn? childSpawn = _resolveSpawn(childGuid);
|
||||
if (parentEntity is null || parentSpawn is null || childSpawn is null)
|
||||
return;
|
||||
|
||||
if (!pending.FromCreateObject
|
||||
&& parentSpawn.Value.InstanceSequence != pending.ParentInstanceSequence)
|
||||
{
|
||||
if (MotionSequenceGate.IsNewer(
|
||||
pending.ParentInstanceSequence,
|
||||
parentSpawn.Value.InstanceSequence))
|
||||
_pendingByChild.Remove(childGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentSpawn.Value.SetupTableId is not { } parentSetupId
|
||||
|| childSpawn.Value.SetupTableId is not { } childSetupId
|
||||
|| parentEntity.ParentCellId is not { } parentCellId)
|
||||
|
|
@ -283,8 +268,16 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
Console.WriteLine(
|
||||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||
$"location={parentLocation} placement={placement}");
|
||||
_positionSequenceByChild[childGuid] = pending.ChildPositionSequence;
|
||||
_pendingByChild.Remove(childGuid);
|
||||
_relations.MarkProjected(childGuid);
|
||||
}
|
||||
|
||||
private void ResolveRelations(uint childGuid)
|
||||
{
|
||||
_relations.Resolve(
|
||||
childGuid,
|
||||
guid => _resolveSpawn(guid) is not null,
|
||||
guid => _resolveSpawn(guid)?.InstanceSequence,
|
||||
_acceptParent);
|
||||
}
|
||||
|
||||
private IReadOnlyList<MeshRef> BuildPartTemplate(
|
||||
|
|
@ -377,16 +370,13 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
// A rejected unwield restores the equipped location without a fresh
|
||||
// wire ParentEvent; reinstall the last accepted relationship only for
|
||||
// this explicit rollback signal.
|
||||
if (_lastRelationByChild.TryGetValue(item.ObjectId, out PendingAttachment relation))
|
||||
if (_relations.RestoreLastAccepted(item.ObjectId))
|
||||
{
|
||||
_pendingByChild[item.ObjectId] = relation;
|
||||
lock (_datLock)
|
||||
TryRealize(item.ObjectId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnObjectRemoved(ClientObject item) => OnObjectDeleted(item.ObjectId);
|
||||
|
||||
private void Remove(uint childGuid)
|
||||
{
|
||||
if (!_attachedByChild.Remove(childGuid))
|
||||
|
|
@ -394,21 +384,33 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
_worldState.RemoveEntityByServerGuid(childGuid);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
private void TearDownObjectProjections(uint guid)
|
||||
{
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.MoveRolledBack -= OnMoveRolledBack;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
Remove(guid);
|
||||
|
||||
uint[] children = _attachedByChild.Values
|
||||
.Where(child => child.ParentGuid == guid)
|
||||
.Select(child => child.ChildGuid)
|
||||
.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
Remove(children[i]);
|
||||
}
|
||||
|
||||
private readonly record struct PendingAttachment(
|
||||
uint ParentGuid,
|
||||
uint ChildGuid,
|
||||
uint ParentLocation,
|
||||
uint PlacementId,
|
||||
ushort ParentInstanceSequence,
|
||||
ushort ChildPositionSequence,
|
||||
bool FromCreateObject);
|
||||
public void Clear()
|
||||
{
|
||||
uint[] attached = _attachedByChild.Keys.ToArray();
|
||||
for (int i = 0; i < attached.Length; i++)
|
||||
Remove(attached[i]);
|
||||
_relations.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Clear();
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.MoveRolledBack -= OnMoveRolledBack;
|
||||
_objects.ObjectRemovalClassified -= OnObjectRemovalClassified;
|
||||
}
|
||||
|
||||
private sealed record AttachedChild(
|
||||
uint ParentGuid,
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// top of <see cref="OnLiveMotionUpdated"/>, dropped with the entity in
|
||||
/// <see cref="OnLiveEntityDeleted"/>.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, AcDream.Core.Physics.MotionSequenceGate> _motionSequenceGates = new();
|
||||
private readonly AcDream.App.World.InboundPhysicsStateController _inboundPhysics = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote-entity physics + motion stack — verbatim application of
|
||||
|
|
@ -1028,7 +1028,8 @@ public sealed class GameWindow : IDisposable
|
|||
/// <see cref="OnLiveAppearanceUpdated"/> reuses the cached position/setup/motion
|
||||
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
|
||||
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
|
||||
_inboundPhysics.Snapshots;
|
||||
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
|
||||
// once the local auto-walk overlay reports arrival (body has finished
|
||||
// rotating to face the target). Only set for close-range Use/PickUp;
|
||||
|
|
@ -1241,7 +1242,7 @@ public sealed class GameWindow : IDisposable
|
|||
// cellReady is the faithful indoor equivalent (#106/#107, AD-2).
|
||||
// (Before #135 this only passed by accident: the 25×25 window
|
||||
// happened to stream the neighbour terrain.)
|
||||
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||||
if (LastSpawns.TryGetValue(_playerServerGuid, out var sp)
|
||||
&& sp.Position is { } spawnClaim
|
||||
&& spawnClaim.LandblockId != 0
|
||||
&& (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
|
||||
|
|
@ -1523,7 +1524,7 @@ public sealed class GameWindow : IDisposable
|
|||
uint rawItemType = (uint)LiveItemType(guid);
|
||||
uint pwdBits = 0;
|
||||
uint? useability = null;
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
{
|
||||
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
|
||||
useability = spawn.Useability;
|
||||
|
|
@ -2098,7 +2099,7 @@ public sealed class GameWindow : IDisposable
|
|||
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
|
||||
Objects,
|
||||
_entitiesByServerGuid,
|
||||
_lastSpawnByGuid,
|
||||
LastSpawns,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
playerYawRadians: () => _playerController?.Yaw ?? 0f,
|
||||
playerCellId: () => _playerController?.CellId ?? 0u,
|
||||
|
|
@ -2313,9 +2314,10 @@ public sealed class GameWindow : IDisposable
|
|||
Objects,
|
||||
_worldState,
|
||||
guid => _entitiesByServerGuid.TryGetValue(guid, out var entity) ? entity : null,
|
||||
guid => _lastSpawnByGuid.TryGetValue(guid, out var spawn)
|
||||
guid => LastSpawns.TryGetValue(guid, out var spawn)
|
||||
? spawn
|
||||
: (AcDream.Core.Net.WorldSession.EntitySpawn?)null,
|
||||
TryAcceptParentForRender,
|
||||
() => _liveEntityIdCounter++);
|
||||
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
|
|
@ -2457,6 +2459,8 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void TryStartLiveSession()
|
||||
{
|
||||
ClearInboundEntityState();
|
||||
|
||||
// Step 2 (2026-05-16): delegate pre-Connect setup to LiveSessionController.
|
||||
// The controller owns DNS resolution + WorldSession instantiation + the
|
||||
// wireEvents callback; this method keeps the Connect → CharacterList →
|
||||
|
|
@ -2487,6 +2491,7 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSessionController.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
ClearInboundEntityState();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2521,9 +2526,20 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSessionController?.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
ClearInboundEntityState();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearInboundEntityState()
|
||||
{
|
||||
// Attachment projections own GL-backed world registrations, so tear
|
||||
// them down before dropping the canonical GUID/timestamp snapshots.
|
||||
_equippedChildRenderer?.Clear();
|
||||
Objects.Clear();
|
||||
_selection.Reset();
|
||||
_inboundPhysics.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
|
||||
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
|
||||
|
|
@ -2534,19 +2550,20 @@ public sealed class GameWindow : IDisposable
|
|||
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
_liveSession = session;
|
||||
// D.5.4: ingest CreateObject into the object table (upsert) and wire Delete +
|
||||
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
|
||||
// the table is populated before the render handler runs.
|
||||
// D.5.4: wire non-lifecycle object quality/property updates. The live
|
||||
// handlers below apply Create/Delete to both projections only after
|
||||
// their canonical timestamp decision.
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(
|
||||
session, Objects, () => _playerServerGuid, LocalPlayer);
|
||||
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
|
||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||
_liveSession.EntityPickedUp += OnLiveEntityPickedUp;
|
||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
||||
_liveSession.StateUpdated += OnLiveStateUpdated;
|
||||
_liveSession.ParentUpdated += update => _equippedChildRenderer?.OnParentEvent(update);
|
||||
_liveSession.ParentUpdated += OnLiveParentUpdated;
|
||||
_liveSession.TeleportStarted += OnTeleportStarted;
|
||||
_liveSession.AppearanceUpdated += OnLiveAppearanceUpdated;
|
||||
|
||||
|
|
@ -2558,7 +2575,7 @@ public sealed class GameWindow : IDisposable
|
|||
// retail uses for spell casts, combat flinches, emote
|
||||
// gestures, AND — per Agent #5 research — lightning
|
||||
// flashes during stormy weather.
|
||||
_liveSession.PlayScriptReceived += OnPlayScriptReceived;
|
||||
_liveSession.PlayPhysicsScriptReceived += OnPlayScriptReceived;
|
||||
|
||||
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
|
||||
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
|
||||
|
|
@ -2916,7 +2933,32 @@ public sealed class GameWindow : IDisposable
|
|||
// with BuildLandblockForStreaming on the worker thread.
|
||||
lock (_datLock)
|
||||
{
|
||||
OnLiveEntitySpawnedLocked(spawn);
|
||||
AcDream.App.World.InboundCreateResult result = _inboundPhysics.AcceptCreate(spawn);
|
||||
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration)
|
||||
return;
|
||||
|
||||
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,
|
||||
replaceGeneration: result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration);
|
||||
|
||||
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration)
|
||||
{
|
||||
if (result.SameGenerationEvents is { } refresh)
|
||||
RouteSameGenerationCreateObject(refresh);
|
||||
return;
|
||||
}
|
||||
|
||||
OnLiveEntitySpawnedLocked(result.Snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2928,7 +2970,7 @@ public sealed class GameWindow : IDisposable
|
|||
///
|
||||
/// <para>
|
||||
/// The dungeon collapse (and Near→Far demote) drops a landblock's render
|
||||
/// entities for FPS but keeps the parsed spawns in <see cref="_lastSpawnByGuid"/>
|
||||
/// entities for FPS but keeps the parsed spawns in <see cref="LastSpawns"/>
|
||||
/// (our <c>weenie_object_table</c> for world objects). ACE never
|
||||
/// re-broadcasts objects it believes we still know — its per-player
|
||||
/// <c>KnownObjects</c> set is not cleared on a normal teleport (verified
|
||||
|
|
@ -2950,7 +2992,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId)
|
||||
{
|
||||
if (_lastSpawnByGuid.Count == 0) return;
|
||||
if (LastSpawns.Count == 0) return;
|
||||
|
||||
// Server guids that already have a live render entity. The gate keys on
|
||||
// GpuWorldState (the render projection), NOT _entitiesByServerGuid, which
|
||||
|
|
@ -2959,11 +3001,10 @@ public sealed class GameWindow : IDisposable
|
|||
foreach (var e in _worldState.Entities)
|
||||
if (e.ServerGuid != 0) present.Add(e.ServerGuid);
|
||||
|
||||
// Snapshot the retained spawns — the replay mutates _lastSpawnByGuid
|
||||
// (remove then re-add), so we must not iterate it live.
|
||||
// Snapshot retained spawns before render projection changes.
|
||||
var retained = new List<AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn>(
|
||||
_lastSpawnByGuid.Count);
|
||||
foreach (var kv in _lastSpawnByGuid)
|
||||
LastSpawns.Count);
|
||||
foreach (var kv in LastSpawns)
|
||||
{
|
||||
var sp = kv.Value;
|
||||
bool hasWorldMesh = sp.Position is not null && sp.SetupTableId is not null;
|
||||
|
|
@ -2982,7 +3023,7 @@ public sealed class GameWindow : IDisposable
|
|||
lock (_datLock)
|
||||
{
|
||||
foreach (var guid in guids)
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
OnLiveEntitySpawnedLocked(spawn);
|
||||
}
|
||||
|
||||
|
|
@ -3010,18 +3051,6 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_liveSpawnReceived++;
|
||||
|
||||
// L.2g S1 (DEV-6): seed the movement-event staleness gate from the
|
||||
// CreateObject PhysicsDesc timestamp block, as retail seeds
|
||||
// update_times at object creation. Seed() adopts wholesale on first
|
||||
// sight and advance-only afterward, so the #138 rehydrate replay of
|
||||
// a RETAINED spawn through this handler cannot regress live stamps.
|
||||
if (!_motionSequenceGates.TryGetValue(spawn.Guid, out var seqGate))
|
||||
{
|
||||
seqGate = new AcDream.Core.Physics.MotionSequenceGate();
|
||||
_motionSequenceGates[spawn.Guid] = seqGate;
|
||||
}
|
||||
seqGate.Seed(spawn.InstanceSequence, spawn.MovementSequence, spawn.ServerControlSequence);
|
||||
|
||||
// De-dup: the server re-sends CreateObject for the same guid in
|
||||
// several situations (visibility refresh, landblock crossing,
|
||||
// appearance update). Without cleanup the OLD copy remains in
|
||||
|
|
@ -3041,9 +3070,15 @@ public sealed class GameWindow : IDisposable
|
|||
// weapon CreateObjects are intentionally no-position and carry their
|
||||
// Parent + Placement in PhysicsData, so cache before the renderability
|
||||
// gate and offer the relationship to the focused child controller.
|
||||
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||||
_equippedChildRenderer?.OnSpawn(spawn);
|
||||
|
||||
// A ParentEvent may have arrived before this CreateObject. Resolving
|
||||
// it above can synchronously advance the canonical child POSITION_TS
|
||||
// and turn this object into an attachment. Select the projection from
|
||||
// that post-callback snapshot, never from the stale method argument.
|
||||
if (_inboundPhysics.TryGetSnapshot(spawn.Guid, out var canonicalSpawn))
|
||||
spawn = canonicalSpawn;
|
||||
|
||||
// When requested, log every spawn that arrives so we can inventory what the server
|
||||
// sends (including the ones we can't render yet). The Name field
|
||||
// is the critical one — we can grep the log for "Nullified Statue
|
||||
|
|
@ -3599,7 +3634,6 @@ public sealed class GameWindow : IDisposable
|
|||
if (visualUpdate.Animation is { } animation)
|
||||
RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs);
|
||||
_classificationCache.InvalidateEntity(existing.Id);
|
||||
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -3677,10 +3711,9 @@ public sealed class GameWindow : IDisposable
|
|||
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
|
||||
_entitiesByServerGuid[spawn.Guid] = entity;
|
||||
|
||||
// Cache the spawn so OnLiveAppearanceUpdated can replay it with new
|
||||
// appearance fields when a later 0xF625 ObjDescEvent arrives.
|
||||
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||||
_equippedChildRenderer?.OnSpawn(spawn);
|
||||
// The root now exists, so parent relations that arrived before this
|
||||
// object's render projection can compose their child meshes.
|
||||
_equippedChildRenderer?.OnWorldEntityRegistered(spawn.Guid);
|
||||
|
||||
// Commit B 2026-04-29 — live-entity collision registration.
|
||||
// The local player is the simulator (its PhysicsBody is the source of
|
||||
|
|
@ -3875,25 +3908,17 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
|
||||
{
|
||||
// L.2g S1: drop the staleness gate with the entity — a subsequent
|
||||
// re-create adopts fresh stamps from its CreateObject (retail's
|
||||
// update_times die with the CPhysicsObj).
|
||||
_motionSequenceGates.Remove(delete.Guid);
|
||||
_equippedChildRenderer?.OnObjectDeleted(delete.Guid);
|
||||
if (!_inboundPhysics.TryDelete(
|
||||
delete,
|
||||
isLocalPlayer: delete.Guid == _playerServerGuid))
|
||||
return;
|
||||
|
||||
// Snapshot before RemoveLiveEntityByServerGuid clears the render-side
|
||||
// cache. Pickup removes only the 3-D projection; the weenie persists
|
||||
// in inventory and may later become a parented weapon.
|
||||
AcDream.Core.Net.WorldSession.EntitySpawn? pickedUp =
|
||||
delete.FromPickup && _lastSpawnByGuid.TryGetValue(delete.Guid, out var cached)
|
||||
? cached with { Position = null }
|
||||
: null;
|
||||
_equippedChildRenderer?.OnGenerationDeleted(
|
||||
delete.Guid,
|
||||
delete.InstanceSequence);
|
||||
AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete);
|
||||
|
||||
bool removed = RemoveLiveEntityByServerGuid(delete.Guid);
|
||||
if (pickedUp is { } retained)
|
||||
_lastSpawnByGuid[delete.Guid] = retained;
|
||||
else if (!delete.FromPickup)
|
||||
_lastSpawnByGuid.Remove(delete.Guid);
|
||||
|
||||
if (removed
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
|
|
@ -3916,7 +3941,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update)
|
||||
{
|
||||
if (!_lastSpawnByGuid.TryGetValue(update.Guid, out var oldSpawn))
|
||||
if (!_inboundPhysics.TryApplyObjDesc(update, out var newSpawn))
|
||||
{
|
||||
// Server can broadcast ObjDescEvent before we've seen a
|
||||
// CreateObject for this guid (race on landblock entry, or
|
||||
|
|
@ -3926,14 +3951,6 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
var md = update.ModelData;
|
||||
var newSpawn = oldSpawn with
|
||||
{
|
||||
AnimPartChanges = md.AnimPartChanges,
|
||||
TextureChanges = md.TextureChanges,
|
||||
SubPalettes = md.SubPalettes,
|
||||
BasePaletteId = md.BasePaletteId,
|
||||
};
|
||||
lock (_datLock)
|
||||
{
|
||||
AppearanceUpdateState? appearanceState = CaptureLiveAppearanceState(update.Guid);
|
||||
|
|
@ -4249,6 +4266,82 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void RouteSameGenerationCreateObject(
|
||||
AcDream.App.World.SameGenerationCreateObjectEvents refresh)
|
||||
{
|
||||
OnLiveAppearanceUpdated(refresh.Appearance);
|
||||
|
||||
if (refresh.Parent is { } parent
|
||||
&& _inboundPhysics.TryApplyCreateParent(parent, out var acceptedParent))
|
||||
{
|
||||
RemoveLiveEntityByServerGuid(parent.ChildGuid);
|
||||
_equippedChildRenderer?.OnSpawn(acceptedParent);
|
||||
}
|
||||
else if (refresh.Position is { } position)
|
||||
{
|
||||
OnLivePositionUpdated(position);
|
||||
}
|
||||
else if (refresh.Pickup is { } pickup)
|
||||
{
|
||||
OnLiveEntityPickedUp(pickup);
|
||||
}
|
||||
|
||||
if (refresh.Movement is { } movement)
|
||||
OnLiveMotionUpdated(movement);
|
||||
OnLiveStateUpdated(refresh.State);
|
||||
OnLiveVectorUpdated(refresh.Vector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail PickupEvent is a POSITION_TS update, not object destruction.
|
||||
/// It leaves the world projection while retaining the weenie, generation,
|
||||
/// and every other timestamp for later inventory/parent events.
|
||||
/// </summary>
|
||||
private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup)
|
||||
{
|
||||
if (!_inboundPhysics.TryApplyPickup(pickup, out _))
|
||||
return;
|
||||
|
||||
_equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid);
|
||||
bool removed = RemoveLiveEntityByServerGuid(pickup.Guid);
|
||||
|
||||
if (removed
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: pickup guid=0x{pickup.Guid:X8} instSeq={pickup.InstanceSequence} posSeq={pickup.PositionSequence}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ParentEvent may precede either CreateObject. The focused child renderer
|
||||
/// retains it; its realization callback performs the canonical timestamp
|
||||
/// acceptance immediately before changing the projection.
|
||||
/// </summary>
|
||||
private void OnLiveParentUpdated(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
|
||||
{
|
||||
_equippedChildRenderer?.OnParentEvent(update);
|
||||
}
|
||||
|
||||
private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
|
||||
{
|
||||
if (!_inboundPhysics.TryApplyParent(update, out _)) return false;
|
||||
RemoveLiveEntityByServerGuid(update.ChildGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void PublishLocalPhysicsTimestamps(
|
||||
uint guid,
|
||||
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
if (guid != _playerServerGuid || _liveSession is null) return;
|
||||
_liveSession.PublishAcceptedLocalPhysicsTimestamps(
|
||||
timestamps.Instance,
|
||||
timestamps.ServerControlledMove,
|
||||
timestamps.Teleport,
|
||||
timestamps.ForcePosition);
|
||||
}
|
||||
|
||||
private void SyncLocalPlayerShadow(
|
||||
AcDream.Core.World.WorldEntity playerEntity,
|
||||
uint cellId,
|
||||
|
|
@ -4545,7 +4638,7 @@ public sealed class GameWindow : IDisposable
|
|||
// a server-scaled creature variant read unscaled radii and kept the
|
||||
// #171 interpenetration exactly for scaled bodies.
|
||||
float scale =
|
||||
_lastSpawnByGuid.TryGetValue(serverGuid, out var sp)
|
||||
LastSpawns.TryGetValue(serverGuid, out var sp)
|
||||
&& sp.ObjScale is { } objScale && objScale > 0f
|
||||
? objScale
|
||||
: (entity.Scale > 0f ? entity.Scale : 1f);
|
||||
|
|
@ -4629,7 +4722,6 @@ public sealed class GameWindow : IDisposable
|
|||
_remoteDeadReckon.Remove(serverGuid);
|
||||
_remoteLastMove.Remove(serverGuid);
|
||||
_entitiesByServerGuid.Remove(serverGuid);
|
||||
_lastSpawnByGuid.Remove(serverGuid);
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
|
|
@ -4785,27 +4877,18 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveMotionUpdated(AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
|
||||
{
|
||||
if (_dats is null) return;
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
|
||||
|
||||
// L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation.
|
||||
// Retail drops stale/duplicate/superseded movement events at
|
||||
// DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) +
|
||||
// CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer +
|
||||
// SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
|
||||
// straggler re-applies an old gait or un-stops a stop.
|
||||
if (!_motionSequenceGates.TryGetValue(update.Guid, out var seqGate))
|
||||
{
|
||||
// UM for an entity whose CreateObject we never parsed (rare —
|
||||
// the entity lookup above implies a spawn). Adopt-on-first-seed
|
||||
// keeps the gate correct from this event onward.
|
||||
seqGate = new AcDream.Core.Physics.MotionSequenceGate();
|
||||
_motionSequenceGates[update.Guid] = seqGate;
|
||||
seqGate.Seed(update.InstanceSequence, update.MovementSequence, update.ServerControlSequence);
|
||||
}
|
||||
else if (!seqGate.TryAcceptMovementEvent(
|
||||
update.InstanceSequence, update.MovementSequence, update.ServerControlSequence))
|
||||
bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;
|
||||
if (!_inboundPhysics.TryApplyMotion(
|
||||
update,
|
||||
retainPayload,
|
||||
out _,
|
||||
out var timestamps))
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||
|
|
@ -4817,6 +4900,8 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
PublishLocalPhysicsTimestamps(update.Guid, timestamps);
|
||||
|
||||
// R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous
|
||||
// gate (0x00509690 @0050972e, raw 271370-271431) — a movement event
|
||||
// whose wire autonomous byte is set is DROPPED ENTIRELY (no state
|
||||
|
|
@ -4834,9 +4919,13 @@ public sealed class GameWindow : IDisposable
|
|||
// retail's own feeds: PlayerDescription skills (SetCharacterSkills,
|
||||
// K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the
|
||||
// former ApplyServerRunRate echo tap is deleted, not gated.
|
||||
if (update.Guid == _playerServerGuid && update.IsAutonomous)
|
||||
if (!retainPayload)
|
||||
return;
|
||||
|
||||
if (_dats is null) return;
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
|
||||
|
||||
// Re-resolve using the new stance/command. Keep the setup and
|
||||
// motion-table we already know about — the server's motion
|
||||
// updates override state within the same table, not swap tables.
|
||||
|
|
@ -5285,6 +5374,10 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
|
||||
{
|
||||
if (!_inboundPhysics.TryApplyVector(update, out _)) return;
|
||||
|
||||
if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
|
||||
|
||||
if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
|
||||
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return;
|
||||
|
||||
|
|
@ -5351,6 +5444,10 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
|
||||
{
|
||||
if (!_inboundPhysics.TryApplyState(parsed, out _)) return;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
|
||||
|
||||
// L.2g slice 1c (2026-05-13): the server addresses entities by
|
||||
// ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but
|
||||
// ShadowObjectRegistry's cell index is keyed by local entity.Id
|
||||
|
|
@ -5358,9 +5455,7 @@ public sealed class GameWindow : IDisposable
|
|||
// mutating the registry — otherwise the lookup misses and the
|
||||
// state flip silently no-ops, leaving doors blocked even though
|
||||
// ACE flipped the ETHEREAL bit.
|
||||
uint registryKey = parsed.Guid;
|
||||
if (_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity))
|
||||
registryKey = entity.Id;
|
||||
uint registryKey = entity.Id;
|
||||
|
||||
_physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState);
|
||||
|
||||
|
|
@ -5436,8 +5531,107 @@ public sealed class GameWindow : IDisposable
|
|||
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's local FORCE_POSITION branch calls SendPositionEvent
|
||||
/// immediately after BlipPlayer. This acknowledgement uses the accepted
|
||||
/// timestamps and preserves the player's current heading.
|
||||
/// </summary>
|
||||
private void SendImmediateLocalPositionEvent()
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _playerController is null
|
||||
|| !_playerController.CanSendPositionEvent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AcDream.Core.Physics.Position canonical = _playerController.CellPosition;
|
||||
uint cellId = canonical.ObjCellId;
|
||||
System.Numerics.Vector3 position = canonical.Frame.Origin;
|
||||
System.Numerics.Quaternion rotation = YawToAcQuaternion(_playerController.Yaw);
|
||||
if (!AcDream.Core.Physics.PositionFrameValidation.IsValid(
|
||||
cellId, position, rotation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
uint sequence = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
|
||||
gameActionSequence: sequence,
|
||||
cellId: cellId,
|
||||
position: position,
|
||||
rotation: rotation,
|
||||
instanceSequence: _liveSession.InstanceSequence,
|
||||
serverControlSequence: _liveSession.ServerControlSequence,
|
||||
teleportSequence: _liveSession.TeleportSequence,
|
||||
forcePositionSequence: _liveSession.ForcePositionSequence,
|
||||
lastContact: 1);
|
||||
_liveSession.SendGameAction(body);
|
||||
_playerController.NotePositionSent(
|
||||
_playerController.Position,
|
||||
cellId,
|
||||
_playerController.ContactPlane,
|
||||
_playerController.SimTimeSeconds);
|
||||
}
|
||||
|
||||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
bool known = _inboundPhysics.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: update.Guid == _playerServerGuid,
|
||||
forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null
|
||||
? YawToAcQuaternion(_playerController.Yaw)
|
||||
: null,
|
||||
currentLocalVelocity: update.Guid == _playerServerGuid && _playerController is not null
|
||||
? _playerController.BodyVelocity
|
||||
: null,
|
||||
out var timestampDisposition,
|
||||
out var acceptedSpawn,
|
||||
out var timestamps);
|
||||
if (!known)
|
||||
{
|
||||
// Preserve the local streaming observer hint, but do not consume
|
||||
// a per-object timestamp until a live object can receive the
|
||||
// packet. Retail queues the full blob in this case (AD-32).
|
||||
if (update.Guid == _playerServerGuid)
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
return;
|
||||
}
|
||||
|
||||
PublishLocalPhysicsTimestamps(update.Guid, timestamps);
|
||||
|
||||
if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Rejected)
|
||||
return;
|
||||
|
||||
var p = update.Position;
|
||||
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - _liveCenterX) * 192f,
|
||||
(lbY - _liveCenterY) * 192f,
|
||||
0f);
|
||||
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
|
||||
|
||||
bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
||||
&& update.Guid == _playerServerGuid
|
||||
&& _playerController is not null;
|
||||
if (forceLocal)
|
||||
{
|
||||
var cellLocal = new System.Numerics.Vector3(
|
||||
p.PositionX, p.PositionY, p.PositionZ);
|
||||
_playerController!.BlipPosition(worldPos, p.LandblockId, cellLocal);
|
||||
SendImmediateLocalPositionEvent();
|
||||
}
|
||||
|
||||
if (!_entitiesByServerGuid.ContainsKey(update.Guid))
|
||||
{
|
||||
_equippedChildRenderer?.OnChildBecameUnparented(update.Guid);
|
||||
lock (_datLock)
|
||||
OnLiveEntitySpawnedLocked(acceptedSpawn);
|
||||
}
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
|
||||
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
|
||||
// streaming controller can follow the player in the fly-camera / pre-player-mode
|
||||
// (login hold) views. Filtered to our OWN character guid — resolving the original
|
||||
|
|
@ -5451,17 +5645,6 @@ public sealed class GameWindow : IDisposable
|
|||
if (update.Guid == _playerServerGuid)
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
|
||||
var p = update.Position;
|
||||
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - _liveCenterX) * 192f,
|
||||
(lbY - _liveCenterY) * 192f,
|
||||
0f);
|
||||
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
|
||||
|
||||
// B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for
|
||||
// the local player. Combined with [autowalk-mt] this answers
|
||||
// whether ACE's broadcast frequency during a server-initiated
|
||||
|
|
@ -5477,16 +5660,11 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}"));
|
||||
}
|
||||
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||
var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
||||
? entity.Rotation
|
||||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||
DumpMovementTruthServerEcho(update, worldPos);
|
||||
|
||||
// Keep the cached spawn's Position in sync with server truth so a
|
||||
// later ObjDescEvent (which only carries new appearance, not new
|
||||
// position) re-applies at the entity's CURRENT location instead of
|
||||
// popping back to its login spot. See OnLiveAppearanceUpdated.
|
||||
if (_lastSpawnByGuid.TryGetValue(update.Guid, out var cached))
|
||||
_lastSpawnByGuid[update.Guid] = cached with { Position = update.Position };
|
||||
|
||||
// Capture the pre-update render position for the soft-snap residual
|
||||
// calculation below. Assign entity.Position to the server truth up
|
||||
// front; if we then compute a snap residual, we restore the rendered
|
||||
|
|
@ -6224,6 +6402,9 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnTeleportStarted(uint sequence)
|
||||
{
|
||||
if (!_inboundPhysics.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
|
||||
return;
|
||||
|
||||
if (_playerController is not null)
|
||||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
||||
_teleportInProgress = true;
|
||||
|
|
@ -6251,7 +6432,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// sufficient.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void OnPlayScriptReceived(uint guid, uint scriptId)
|
||||
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
|
||||
{
|
||||
if (_scriptRunner is null) return;
|
||||
|
||||
|
|
@ -6262,7 +6443,7 @@ public sealed class GameWindow : IDisposable
|
|||
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
|
||||
}
|
||||
|
||||
_scriptRunner.Play(scriptId, guid, camWorldPos);
|
||||
_scriptRunner.Play(message.ScriptDid, message.Guid, camWorldPos);
|
||||
}
|
||||
|
||||
private void UpdateSkyPes(
|
||||
|
|
@ -12094,7 +12275,7 @@ public sealed class GameWindow : IDisposable
|
|||
float? pickUseRadius = null;
|
||||
float pickScale = 1f;
|
||||
uint? pickSetupId = null;
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
{
|
||||
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
|
||||
pickUseability = spawn.Useability;
|
||||
|
|
@ -12394,7 +12575,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
useRadius = 3.0f;
|
||||
}
|
||||
else if (_lastSpawnByGuid.TryGetValue(targetGuid, out var spawn)
|
||||
else if (LastSpawns.TryGetValue(targetGuid, out var spawn)
|
||||
&& spawn.ObjectDescriptionFlags is { } odf)
|
||||
{
|
||||
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||
|
|
@ -12423,7 +12604,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
useRadius = 3.0f;
|
||||
}
|
||||
else if (_lastSpawnByGuid.TryGetValue(targetGuid, out var spawn)
|
||||
else if (LastSpawns.TryGetValue(targetGuid, out var spawn)
|
||||
&& spawn.ObjectDescriptionFlags is { } odf)
|
||||
{
|
||||
// BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE
|
||||
|
|
@ -12611,7 +12792,7 @@ public sealed class GameWindow : IDisposable
|
|||
worldRadius = 0f;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
|
||||
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn)) return false;
|
||||
if (!LastSpawns.TryGetValue(guid, out var spawn)) return false;
|
||||
if (spawn.SetupTableId is not uint setupId) return false;
|
||||
if (_dats is null) return false;
|
||||
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)) return false;
|
||||
|
|
@ -12670,7 +12851,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private bool IsUseableTarget(uint guid)
|
||||
{
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
{
|
||||
// Authoritative path: server published Useability.
|
||||
// 2026-05-16 — retail-faithful gate per ItemUses::IsUseable
|
||||
|
|
@ -12800,7 +12981,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private bool IsPickupableTarget(uint guid)
|
||||
{
|
||||
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (!LastSpawns.TryGetValue(guid, out var spawn))
|
||||
return false;
|
||||
|
||||
// 2026-05-16 — primary discriminator is the BF_STUCK
|
||||
|
|
@ -13232,10 +13413,10 @@ public sealed class GameWindow : IDisposable
|
|||
//
|
||||
// Fall back to the old sentinel when no spawn record is cached
|
||||
// (defensive — should never fire in live play because the
|
||||
// _lastSpawnByGuid entry was written by OnLiveEntitySpawnedLocked
|
||||
// The inbound-state snapshot was accepted before render hydration.
|
||||
// before EnterPlayerModeNow could possibly be reached).
|
||||
uint pinitCellId;
|
||||
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var playerSpawn)
|
||||
if (LastSpawns.TryGetValue(_playerServerGuid, out var playerSpawn)
|
||||
&& playerSpawn.Position is { } spawnPos
|
||||
&& spawnPos.LandblockId != 0)
|
||||
{
|
||||
|
|
@ -13566,7 +13747,7 @@ public sealed class GameWindow : IDisposable
|
|||
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
|
||||
$"forceReload={_streamingController?.ForceReloadCount ?? 0}" +
|
||||
$"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " +
|
||||
$"esg={_entitiesByServerGuid.Count} spawn={_lastSpawnByGuid.Count} " +
|
||||
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
|
||||
$"resident={_worldState.Entities.Count} walked={walked}");
|
||||
_frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker
|
||||
|
||||
|
|
@ -13648,6 +13829,7 @@ public sealed class GameWindow : IDisposable
|
|||
_equippedChildRenderer?.Dispose();
|
||||
_liveSessionController?.Dispose();
|
||||
_liveSession = null;
|
||||
_inboundPhysics.Clear();
|
||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||
_wbDrawDispatcher?.Dispose();
|
||||
_envCellRenderer?.Dispose(); // Phase A8
|
||||
|
|
|
|||
288
src/AcDream.App/Rendering/ParentAttachmentState.cs
Normal file
288
src/AcDream.App/Rendering/ParentAttachmentState.cs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <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.
|
||||
/// </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> _lastAcceptedByChild = new();
|
||||
|
||||
public void AcceptCreateObjectRelation(ParentAttachmentRelation relation)
|
||||
{
|
||||
_projectionByChild[relation.ChildGuid] = relation;
|
||||
_lastAcceptedByChild[relation.ChildGuid] = relation;
|
||||
}
|
||||
|
||||
public void Enqueue(ParentEvent.Parsed update)
|
||||
{
|
||||
if (!_unresolvedByChild.TryGetValue(update.ChildGuid, out Queue<ParentAttachmentRelation>? queue))
|
||||
{
|
||||
queue = new Queue<ParentAttachmentRelation>();
|
||||
_unresolvedByChild.Add(update.ChildGuid, queue);
|
||||
}
|
||||
|
||||
queue.Enqueue(new ParentAttachmentRelation(
|
||||
update.ParentGuid,
|
||||
update.ChildGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
update.ParentInstanceSequence,
|
||||
update.ChildPositionSequence));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves every now-addressable relation for one child in wire order.
|
||||
/// A future parent generation remains queued; an older generation or a
|
||||
/// rejected child POSITION_TS is discarded. Every accepted relation
|
||||
/// supersedes the preceding render projection.
|
||||
/// </summary>
|
||||
public void Resolve(
|
||||
uint childGuid,
|
||||
Func<uint, bool> isObjectKnown,
|
||||
Func<uint, ushort?> resolveInstance,
|
||||
Func<ParentEvent.Parsed, bool> accept)
|
||||
{
|
||||
if (!_unresolvedByChild.TryGetValue(childGuid, out Queue<ParentAttachmentRelation>? queue))
|
||||
return;
|
||||
int candidateCount = queue.Count;
|
||||
for (int i = 0; i < candidateCount; i++)
|
||||
{
|
||||
ParentAttachmentRelation relation = queue.Dequeue();
|
||||
ushort? parentInstance = resolveInstance(relation.ParentGuid);
|
||||
if (parentInstance is null)
|
||||
{
|
||||
queue.Enqueue(relation with { WaitOwner = ParentAttachmentWaitOwner.Parent });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parentInstance.Value != relation.ParentInstanceSequence)
|
||||
{
|
||||
// Current parent newer than the packet: discard the stale
|
||||
// relation. Packet newer than current: retain it until that
|
||||
// parent generation is constructed.
|
||||
if (PhysicsTimestampGate.IsNewer(
|
||||
relation.ParentInstanceSequence,
|
||||
parentInstance.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.Enqueue(relation with { WaitOwner = ParentAttachmentWaitOwner.Parent });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isObjectKnown(childGuid))
|
||||
{
|
||||
queue.Enqueue(relation with { WaitOwner = ParentAttachmentWaitOwner.Child });
|
||||
continue;
|
||||
}
|
||||
|
||||
var update = new ParentEvent.Parsed(
|
||||
relation.ParentGuid,
|
||||
relation.ChildGuid,
|
||||
relation.ParentLocation,
|
||||
relation.PlacementId,
|
||||
relation.ParentInstanceSequence,
|
||||
relation.ChildPositionSequence);
|
||||
if (!accept(update))
|
||||
continue;
|
||||
|
||||
_projectionByChild[childGuid] = relation;
|
||||
_lastAcceptedByChild[childGuid] = relation;
|
||||
}
|
||||
|
||||
if (queue.Count == 0)
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
}
|
||||
|
||||
public bool TryGetProjection(uint childGuid, out ParentAttachmentRelation relation) =>
|
||||
_projectionByChild.TryGetValue(childGuid, out relation);
|
||||
|
||||
public void MarkProjected(uint childGuid) => _projectionByChild.Remove(childGuid);
|
||||
|
||||
public bool RestoreLastAccepted(uint childGuid)
|
||||
{
|
||||
if (!_lastAcceptedByChild.TryGetValue(childGuid, out ParentAttachmentRelation relation))
|
||||
return false;
|
||||
_projectionByChild[childGuid] = relation;
|
||||
return true;
|
||||
}
|
||||
|
||||
public IReadOnlyList<uint> ChildrenWaitingForParent(uint parentGuid)
|
||||
{
|
||||
var result = new HashSet<uint>();
|
||||
foreach ((uint childGuid, ParentAttachmentRelation relation) in _projectionByChild)
|
||||
{
|
||||
if (relation.ParentGuid == parentGuid)
|
||||
result.Add(childGuid);
|
||||
}
|
||||
|
||||
foreach ((uint childGuid, Queue<ParentAttachmentRelation> queue) in _unresolvedByChild)
|
||||
{
|
||||
if (queue.Any(relation => relation.ParentGuid == parentGuid))
|
||||
result.Add(childGuid);
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
public void RemoveObject(uint guid)
|
||||
{
|
||||
_projectionByChild.Remove(guid);
|
||||
_lastAcceptedByChild.Remove(guid);
|
||||
_unresolvedByChild.Remove(guid);
|
||||
|
||||
RemoveParentReferences(_projectionByChild, guid);
|
||||
RemoveParentReferences(_lastAcceptedByChild, guid);
|
||||
|
||||
uint[] children = _unresolvedByChild.Keys.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
{
|
||||
uint childGuid = children[i];
|
||||
Queue<ParentAttachmentRelation> retained = new(
|
||||
_unresolvedByChild[childGuid].Where(relation => relation.ParentGuid != guid));
|
||||
if (retained.Count == 0)
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
else
|
||||
_unresolvedByChild[childGuid] = retained;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends one logical incarnation without discarding unresolved wire events
|
||||
/// that may explicitly address the replacement/future parent generation.
|
||||
/// </summary>
|
||||
public void EndGeneration(uint guid, ushort replacementGeneration)
|
||||
{
|
||||
FilterChildCandidates(
|
||||
guid,
|
||||
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
|
||||
_projectionByChild.Remove(guid);
|
||||
_lastAcceptedByChild.Remove(guid);
|
||||
RemoveParentReferences(_projectionByChild, guid);
|
||||
RemoveParentReferences(_lastAcceptedByChild, guid);
|
||||
FilterParentCandidates(
|
||||
guid,
|
||||
relation => relation.ParentInstanceSequence == replacementGeneration
|
||||
|| PhysicsTimestampGate.IsNewer(
|
||||
replacementGeneration,
|
||||
relation.ParentInstanceSequence));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes one exact incarnation. Child-addressed candidates die with the
|
||||
/// child; candidates addressed to a strictly newer parent incarnation
|
||||
/// survive for retail's post-Create blob replay.
|
||||
/// </summary>
|
||||
public void DeleteGeneration(uint guid, ushort deletedGeneration)
|
||||
{
|
||||
FilterChildCandidates(
|
||||
guid,
|
||||
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
|
||||
_projectionByChild.Remove(guid);
|
||||
_lastAcceptedByChild.Remove(guid);
|
||||
RemoveParentReferences(_projectionByChild, guid);
|
||||
RemoveParentReferences(_lastAcceptedByChild, guid);
|
||||
FilterParentCandidates(
|
||||
guid,
|
||||
relation => PhysicsTimestampGate.IsNewer(
|
||||
deletedGeneration,
|
||||
relation.ParentInstanceSequence));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears an accepted attachment projection after Pickup or a world
|
||||
/// Position without destroying fresher unresolved ParentEvents.
|
||||
/// </summary>
|
||||
public void EndChildProjection(uint childGuid)
|
||||
{
|
||||
_projectionByChild.Remove(childGuid);
|
||||
_lastAcceptedByChild.Remove(childGuid);
|
||||
}
|
||||
|
||||
public void RemoveChild(uint childGuid)
|
||||
{
|
||||
_projectionByChild.Remove(childGuid);
|
||||
_lastAcceptedByChild.Remove(childGuid);
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_unresolvedByChild.Clear();
|
||||
_projectionByChild.Clear();
|
||||
_lastAcceptedByChild.Clear();
|
||||
}
|
||||
|
||||
private static void RemoveParentReferences(
|
||||
Dictionary<uint, ParentAttachmentRelation> relations,
|
||||
uint parentGuid)
|
||||
{
|
||||
uint[] children = relations
|
||||
.Where(pair => pair.Value.ParentGuid == parentGuid)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
relations.Remove(children[i]);
|
||||
}
|
||||
|
||||
private void FilterParentCandidates(
|
||||
uint parentGuid,
|
||||
Func<ParentAttachmentRelation, bool> retain)
|
||||
{
|
||||
uint[] children = _unresolvedByChild.Keys.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
{
|
||||
uint childGuid = children[i];
|
||||
Queue<ParentAttachmentRelation> queue = _unresolvedByChild[childGuid];
|
||||
var retained = new Queue<ParentAttachmentRelation>(
|
||||
queue.Where(relation => relation.ParentGuid != parentGuid || retain(relation)));
|
||||
if (retained.Count == 0)
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
else
|
||||
_unresolvedByChild[childGuid] = retained;
|
||||
}
|
||||
}
|
||||
|
||||
private void FilterChildCandidates(
|
||||
uint childGuid,
|
||||
Func<ParentAttachmentRelation, bool> retain)
|
||||
{
|
||||
if (!_unresolvedByChild.TryGetValue(
|
||||
childGuid,
|
||||
out Queue<ParentAttachmentRelation>? queue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var retained = new Queue<ParentAttachmentRelation>(queue.Where(retain));
|
||||
if (retained.Count == 0)
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
else
|
||||
_unresolvedByChild[childGuid] = retained;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct ParentAttachmentRelation(
|
||||
uint ParentGuid,
|
||||
uint ChildGuid,
|
||||
uint ParentLocation,
|
||||
uint PlacementId,
|
||||
ushort ParentInstanceSequence,
|
||||
ushort ChildPositionSequence,
|
||||
ParentAttachmentWaitOwner WaitOwner = ParentAttachmentWaitOwner.Unknown);
|
||||
|
||||
public enum ParentAttachmentWaitOwner
|
||||
{
|
||||
Unknown,
|
||||
Parent,
|
||||
Child,
|
||||
}
|
||||
|
|
@ -20,9 +20,9 @@ namespace AcDream.App.Streaming;
|
|||
///
|
||||
/// <para>
|
||||
/// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's
|
||||
/// RENDER entities for FPS but retains the parsed spawns
|
||||
/// (<c>GameWindow._lastSpawnByGuid</c> — the table is only pruned by an
|
||||
/// explicit server <c>DeleteObject</c> or a spawn de-dup). On reload, ACE will
|
||||
/// RENDER entities for FPS but retains the accepted immutable spawns in
|
||||
/// <c>InboundPhysicsStateController.Snapshots</c> (pruned by an explicit server
|
||||
/// <c>DeleteObject</c> or session teardown). On reload, ACE will
|
||||
/// not re-send the objects it still thinks we have, so the render side stays
|
||||
/// empty — the #138 symptom. This selects the retained spawns whose render
|
||||
/// entity is currently absent so <c>GameWindow</c> can replay them, making
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
|
|
@ -242,6 +243,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
||||
private void OnObjectsCleared() => Populate();
|
||||
|
||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
|
|
@ -702,6 +704,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
|
||||
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -211,6 +212,11 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
|
||||
private void OnObjectsCleared()
|
||||
{
|
||||
ApplyAetheriaVisibility();
|
||||
Populate();
|
||||
}
|
||||
|
||||
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
|
||||
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
||||
|
|
@ -343,6 +349,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
if (_dollViewport is UiViewport doll)
|
||||
doll.ClickedAt = null;
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
repo.ObjectUpdated += OnRepositoryObjectChanged;
|
||||
repo.ObjectRemoved += OnRepositoryObjectChanged;
|
||||
repo.ObjectMoved += OnRepositoryObjectMoved;
|
||||
repo.Cleared += OnRepositoryCleared;
|
||||
RefreshAmmo();
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +226,12 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
Populate();
|
||||
}
|
||||
|
||||
private void OnRepositoryCleared()
|
||||
{
|
||||
RefreshAmmo();
|
||||
Populate();
|
||||
}
|
||||
|
||||
private bool IsAmmoRelated(ClientObject obj)
|
||||
{
|
||||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||||
|
|
@ -710,5 +717,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
_repo.ObjectUpdated -= OnRepositoryObjectChanged;
|
||||
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
|
||||
_repo.ObjectMoved -= OnRepositoryObjectMoved;
|
||||
_repo.Cleared -= OnRepositoryCleared;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
647
src/AcDream.App/World/InboundPhysicsStateController.cs
Normal file
647
src/AcDream.App/World/InboundPhysicsStateController.cs
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread owner of inbound retail physics timestamps and the latest
|
||||
/// accepted immutable spawn description. This is deliberately narrower than
|
||||
/// <c>LiveEntityRuntime</c>: it owns no renderer, local entity ID, physics body,
|
||||
/// animation, effect, or spatial-bucket resource.
|
||||
/// </summary>
|
||||
public sealed class InboundPhysicsStateController
|
||||
{
|
||||
private readonly Dictionary<uint, PhysicsTimestampGate> _gates = new();
|
||||
private readonly Dictionary<uint, WorldSession.EntitySpawn> _snapshots = new();
|
||||
|
||||
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _snapshots;
|
||||
|
||||
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
|
||||
_snapshots.TryGetValue(guid, out spawn);
|
||||
|
||||
public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming)
|
||||
{
|
||||
if (!_gates.TryGetValue(incoming.Guid, out PhysicsTimestampGate? gate))
|
||||
{
|
||||
gate = new PhysicsTimestampGate();
|
||||
_gates.Add(incoming.Guid, gate);
|
||||
}
|
||||
|
||||
PhysicsTimestamps timestamps = SpawnTimestamps(incoming);
|
||||
CreateObjectTimestampDisposition disposition = gate.SeedForCreateObject(
|
||||
timestamps.Position,
|
||||
timestamps.Movement,
|
||||
timestamps.State,
|
||||
timestamps.Vector,
|
||||
timestamps.Teleport,
|
||||
timestamps.ServerControlledMove,
|
||||
timestamps.ForcePosition,
|
||||
timestamps.ObjDesc,
|
||||
timestamps.Instance);
|
||||
|
||||
if (disposition is CreateObjectTimestampDisposition.StaleGeneration)
|
||||
return new InboundCreateResult(disposition, default, null, Current(gate));
|
||||
|
||||
if (disposition is not CreateObjectTimestampDisposition.ExistingGeneration
|
||||
|| !_snapshots.TryGetValue(incoming.Guid, out WorldSession.EntitySpawn retained))
|
||||
{
|
||||
_snapshots[incoming.Guid] = incoming;
|
||||
return new InboundCreateResult(disposition, incoming, null, Current(gate));
|
||||
}
|
||||
|
||||
WorldSession.EntitySpawn merged = MergeUntimestampedCreate(retained, incoming);
|
||||
_snapshots[incoming.Guid] = merged;
|
||||
return new InboundCreateResult(
|
||||
disposition,
|
||||
merged,
|
||||
incoming.Physics is null ? null : BuildSameGenerationEvents(incoming),
|
||||
Current(gate));
|
||||
}
|
||||
|
||||
public bool TryDelete(DeleteObject.Parsed delete, bool isLocalPlayer)
|
||||
{
|
||||
if (!_gates.TryGetValue(delete.Guid, out PhysicsTimestampGate? gate))
|
||||
return false;
|
||||
|
||||
if (!gate.TryAcceptDeleteEvent(delete.InstanceSequence, isLocalPlayer))
|
||||
return false;
|
||||
|
||||
_gates.Remove(delete.Guid);
|
||||
_snapshots.Remove(delete.Guid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyObjDesc(
|
||||
ObjDescEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old)
|
||||
|| !gate.TryAcceptObjDescEvent(update.InstanceSequence, update.ObjDescSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Timestamps = desc.Timestamps with { ObjDesc = update.ObjDescSequence },
|
||||
};
|
||||
|
||||
accepted = old with
|
||||
{
|
||||
AnimPartChanges = update.ModelData.AnimPartChanges,
|
||||
TextureChanges = update.ModelData.TextureChanges,
|
||||
SubPalettes = update.ModelData.SubPalettes,
|
||||
BasePaletteId = update.ModelData.BasePaletteId,
|
||||
Physics = physics,
|
||||
};
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyPickup(
|
||||
PickupEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old)
|
||||
|| !gate.TryAcceptPositionChannelEvent(
|
||||
update.InstanceSequence, update.PositionSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = ApplyUnparentedPosition(old, null, update.PositionSequence);
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the parent branch embedded in a same-generation PhysicsDesc.
|
||||
/// Unlike standalone ParentEvent it carries no parent INSTANCE_TS, so only
|
||||
/// the child's shared POSITION_TS participates in freshness.
|
||||
/// </summary>
|
||||
public bool TryApplyCreateParent(
|
||||
CreateParentUpdate update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.ChildGuid, out PhysicsTimestampGate? childGate, out WorldSession.EntitySpawn child)
|
||||
|| !childGate.TryAcceptPositionChannelEvent(
|
||||
update.ChildInstanceSequence, update.ChildPositionSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = ApplyParent(
|
||||
child,
|
||||
update.ParentGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
update.ChildPositionSequence);
|
||||
_snapshots[update.ChildGuid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyParent(
|
||||
ParentEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!_gates.TryGetValue(update.ParentGuid, out PhysicsTimestampGate? parentGate)
|
||||
|| !parentGate.IsCurrentInstance(update.ParentInstanceSequence)
|
||||
|| !TryGet(update.ChildGuid, out PhysicsTimestampGate? childGate, out WorldSession.EntitySpawn child)
|
||||
|| !childGate.TryAcceptPositionChannelEvent(
|
||||
childGate.InstanceTimestamp, update.ChildPositionSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = ApplyParent(
|
||||
child,
|
||||
update.ParentGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
update.ChildPositionSequence);
|
||||
_snapshots[update.ChildGuid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyMotion(
|
||||
WorldSession.EntityMotionUpdate update,
|
||||
bool retainPayload,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old))
|
||||
{
|
||||
accepted = default;
|
||||
timestamps = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool applyPayload = gate.TryAcceptMovementEvent(
|
||||
update.InstanceSequence,
|
||||
update.MovementSequence,
|
||||
update.ServerControlSequence);
|
||||
timestamps = Current(gate);
|
||||
WorldSession.EntitySpawn stamped = MirrorGateTimestamps(old, gate) with
|
||||
{
|
||||
MovementSequence = gate.MovementTimestamp,
|
||||
ServerControlSequence = gate.ServerControlledMoveTimestamp,
|
||||
};
|
||||
_snapshots[update.Guid] = stamped;
|
||||
|
||||
// Retail consumes MOVEMENT_TS before it discovers that the
|
||||
// SERVER_CONTROLLED_MOVE_TS is stale. Preserve that timestamp-only
|
||||
// mutation in the canonical snapshot even though no motion payload
|
||||
// is applied.
|
||||
if (!applyPayload)
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!retainPayload)
|
||||
{
|
||||
accepted = stamped;
|
||||
return true;
|
||||
}
|
||||
|
||||
PhysicsSpawnData? physics = stamped.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Movement = new PhysicsMovementData(
|
||||
ReadOnlyMemory<byte>.Empty,
|
||||
update.MotionState,
|
||||
update.IsAutonomous),
|
||||
Timestamps = desc.Timestamps with
|
||||
{
|
||||
Movement = gate.MovementTimestamp,
|
||||
ServerControlledMove = gate.ServerControlledMoveTimestamp,
|
||||
},
|
||||
};
|
||||
|
||||
accepted = stamped with
|
||||
{
|
||||
MotionState = update.MotionState,
|
||||
Physics = physics,
|
||||
};
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyVector(
|
||||
VectorUpdate.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old)
|
||||
|| !gate.TryAcceptVectorEvent(update.InstanceSequence, update.VectorSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Velocity = update.Velocity,
|
||||
AngularVelocity = update.Omega,
|
||||
Timestamps = desc.Timestamps with { Vector = update.VectorSequence },
|
||||
};
|
||||
|
||||
accepted = old with { Physics = physics };
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyState(
|
||||
SetState.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old)
|
||||
|| !gate.TryAcceptStateEvent(update.InstanceSequence, update.StateSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
RawState = update.PhysicsState,
|
||||
Timestamps = desc.Timestamps with { State = update.StateSequence },
|
||||
};
|
||||
|
||||
accepted = old with
|
||||
{
|
||||
PhysicsState = update.PhysicsState,
|
||||
Physics = physics,
|
||||
};
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the addressed live incarnation exists, even when the
|
||||
/// position payload is rejected. This lets callers publish a freshly
|
||||
/// consumed FORCE_POSITION_TS without applying a stale pose.
|
||||
/// </summary>
|
||||
public bool TryApplyPosition(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
bool isLocalPlayer,
|
||||
System.Numerics.Quaternion? forcePositionRotation,
|
||||
System.Numerics.Vector3? currentLocalVelocity,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old))
|
||||
{
|
||||
disposition = PositionTimestampDisposition.Rejected;
|
||||
accepted = default;
|
||||
timestamps = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool advancesTeleport = PhysicsTimestampGate.IsNewer(
|
||||
gate.TeleportTimestamp,
|
||||
update.TeleportSequence);
|
||||
disposition = gate.TryAcceptPositionEvent(
|
||||
update.InstanceSequence,
|
||||
update.PositionSequence,
|
||||
update.TeleportSequence,
|
||||
update.ForcePositionSequence,
|
||||
isLocalPlayer);
|
||||
timestamps = Current(gate);
|
||||
if (disposition is PositionTimestampDisposition.Rejected)
|
||||
{
|
||||
accepted = MirrorGateTimestamps(old, gate);
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
CreateObject.ServerPosition appliedPosition = update.Position;
|
||||
if (disposition is PositionTimestampDisposition.ForcePosition
|
||||
&& forcePositionRotation is { } preserved)
|
||||
{
|
||||
appliedPosition = update.Position with
|
||||
{
|
||||
RotationW = preserved.W,
|
||||
RotationX = preserved.X,
|
||||
RotationY = preserved.Y,
|
||||
RotationZ = preserved.Z,
|
||||
};
|
||||
}
|
||||
|
||||
// PositionPack::UnPack (0x00516740) initializes an absent placement
|
||||
// id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact
|
||||
// value to SetPlacementFrame on a normal accepted update.
|
||||
uint? appliedPlacement = disposition is PositionTimestampDisposition.Apply
|
||||
? update.PlacementId ?? 0u
|
||||
: old.PlacementId;
|
||||
|
||||
System.Numerics.Vector3? appliedVelocity = disposition switch
|
||||
{
|
||||
// ForcePosition returns immediately after BlipPlayer and retains
|
||||
// the local physics object's velocity.
|
||||
PositionTimestampDisposition.ForcePosition =>
|
||||
currentLocalVelocity ?? old.Physics?.Velocity,
|
||||
|
||||
// A fresh local teleport explicitly installs zero velocity. A
|
||||
// normal local correction does not consume PositionPack velocity.
|
||||
PositionTimestampDisposition.Apply when isLocalPlayer =>
|
||||
advancesTeleport
|
||||
? System.Numerics.Vector3.Zero
|
||||
: currentLocalVelocity ?? old.Physics?.Velocity,
|
||||
|
||||
// Remote MoveOrTeleport receives the unpacked vector; an absent
|
||||
// PositionPack velocity is initialized to zero by retail.
|
||||
PositionTimestampDisposition.Apply =>
|
||||
update.Velocity ?? System.Numerics.Vector3.Zero,
|
||||
|
||||
_ => old.Physics?.Velocity,
|
||||
};
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Position = appliedPosition,
|
||||
AnimationFrame = appliedPlacement,
|
||||
Velocity = appliedVelocity,
|
||||
Parent = null,
|
||||
Timestamps = desc.Timestamps with
|
||||
{
|
||||
Position = gate.PositionTimestamp,
|
||||
Teleport = gate.TeleportTimestamp,
|
||||
ForcePosition = gate.ForcePositionTimestamp,
|
||||
},
|
||||
};
|
||||
|
||||
accepted = old with
|
||||
{
|
||||
Position = appliedPosition,
|
||||
PositionSequence = gate.PositionTimestamp,
|
||||
ParentGuid = null,
|
||||
ParentLocation = null,
|
||||
PlacementId = appliedPlacement,
|
||||
Physics = physics,
|
||||
};
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F751 is a notification gate only. Retail compares it to TELEPORT_TS but
|
||||
/// advances that timestamp later, with the accepted Position packet.
|
||||
/// </summary>
|
||||
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
|
||||
_gates.TryGetValue(localPlayerGuid, out PhysicsTimestampGate? gate)
|
||||
&& gate.IsFreshTeleportStart(teleportSequence);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_gates.Clear();
|
||||
_snapshots.Clear();
|
||||
}
|
||||
|
||||
private bool TryGet(
|
||||
uint guid,
|
||||
out PhysicsTimestampGate gate,
|
||||
out WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
if (_gates.TryGetValue(guid, out gate!)
|
||||
&& _snapshots.TryGetValue(guid, out spawn))
|
||||
return true;
|
||||
|
||||
gate = null!;
|
||||
spawn = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PhysicsTimestamps SpawnTimestamps(WorldSession.EntitySpawn spawn) =>
|
||||
spawn.Physics?.Timestamps
|
||||
?? new PhysicsTimestamps(
|
||||
spawn.PositionSequence,
|
||||
spawn.MovementSequence,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
spawn.ServerControlSequence,
|
||||
0,
|
||||
0,
|
||||
spawn.InstanceSequence);
|
||||
|
||||
private static AcceptedPhysicsTimestamps Current(PhysicsTimestampGate gate) => new(
|
||||
gate.InstanceTimestamp,
|
||||
gate.ServerControlledMoveTimestamp,
|
||||
gate.TeleportTimestamp,
|
||||
gate.ForcePositionTimestamp);
|
||||
|
||||
private static WorldSession.EntitySpawn MirrorGateTimestamps(
|
||||
WorldSession.EntitySpawn spawn,
|
||||
PhysicsTimestampGate gate)
|
||||
{
|
||||
if (spawn.Physics is not { } desc)
|
||||
return spawn;
|
||||
|
||||
return spawn with
|
||||
{
|
||||
Physics = desc with
|
||||
{
|
||||
Timestamps = new PhysicsTimestamps(
|
||||
gate.PositionTimestamp,
|
||||
gate.MovementTimestamp,
|
||||
gate.StateTimestamp,
|
||||
gate.VectorTimestamp,
|
||||
gate.TeleportTimestamp,
|
||||
gate.ServerControlledMoveTimestamp,
|
||||
gate.ForcePositionTimestamp,
|
||||
gate.ObjDescTimestamp,
|
||||
gate.InstanceTimestamp),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn MergeUntimestampedCreate(
|
||||
WorldSession.EntitySpawn retained,
|
||||
WorldSession.EntitySpawn incoming) =>
|
||||
incoming with
|
||||
{
|
||||
Position = retained.Position,
|
||||
SetupTableId = retained.SetupTableId,
|
||||
AnimPartChanges = retained.AnimPartChanges,
|
||||
TextureChanges = retained.TextureChanges,
|
||||
SubPalettes = retained.SubPalettes,
|
||||
BasePaletteId = retained.BasePaletteId,
|
||||
ObjScale = retained.ObjScale,
|
||||
MotionState = retained.MotionState,
|
||||
MotionTableId = retained.MotionTableId,
|
||||
PhysicsState = retained.PhysicsState,
|
||||
Friction = retained.Friction,
|
||||
Elasticity = retained.Elasticity,
|
||||
InstanceSequence = retained.InstanceSequence,
|
||||
MovementSequence = retained.MovementSequence,
|
||||
ServerControlSequence = retained.ServerControlSequence,
|
||||
PositionSequence = retained.PositionSequence,
|
||||
ParentGuid = retained.ParentGuid,
|
||||
ParentLocation = retained.ParentLocation,
|
||||
PlacementId = retained.PlacementId,
|
||||
Physics = retained.Physics,
|
||||
};
|
||||
|
||||
private static SameGenerationCreateObjectEvents BuildSameGenerationEvents(
|
||||
WorldSession.EntitySpawn incoming)
|
||||
{
|
||||
PhysicsSpawnData physics = incoming.Physics!.Value;
|
||||
PhysicsTimestamps ts = physics.Timestamps;
|
||||
|
||||
CreateParentUpdate? parent = physics.Parent is { } attachment
|
||||
? new CreateParentUpdate(
|
||||
incoming.Guid,
|
||||
attachment.Guid,
|
||||
attachment.LocationId,
|
||||
physics.AnimationFrame ?? 0u,
|
||||
ts.Instance,
|
||||
ts.Position)
|
||||
: null;
|
||||
WorldSession.EntityPositionUpdate? position = parent is null
|
||||
&& physics.Position is { } p
|
||||
&& p.LandblockId != 0
|
||||
? new WorldSession.EntityPositionUpdate(
|
||||
incoming.Guid,
|
||||
p,
|
||||
physics.Velocity,
|
||||
physics.AnimationFrame,
|
||||
IsGrounded: true,
|
||||
ts.Instance,
|
||||
ts.Position,
|
||||
ts.Teleport,
|
||||
ts.ForcePosition)
|
||||
: null;
|
||||
PickupEvent.Parsed? pickup = parent is null && position is null
|
||||
? new PickupEvent.Parsed(incoming.Guid, ts.Instance, ts.Position)
|
||||
: null;
|
||||
WorldSession.EntityMotionUpdate? movement =
|
||||
physics.Movement is { } movementData
|
||||
&& !movementData.RawData.IsEmpty
|
||||
&& movementData.MotionState is { } motionState
|
||||
? new WorldSession.EntityMotionUpdate(
|
||||
incoming.Guid,
|
||||
motionState,
|
||||
ts.Instance,
|
||||
ts.Movement,
|
||||
ts.ServerControlledMove,
|
||||
movementData.IsAutonomous is true)
|
||||
: null;
|
||||
|
||||
return new SameGenerationCreateObjectEvents(
|
||||
new ObjDescEvent.Parsed(
|
||||
incoming.Guid,
|
||||
new CreateObject.ModelData(
|
||||
incoming.BasePaletteId,
|
||||
incoming.SubPalettes,
|
||||
incoming.TextureChanges,
|
||||
incoming.AnimPartChanges),
|
||||
ts.Instance,
|
||||
ts.ObjDesc),
|
||||
parent,
|
||||
position,
|
||||
pickup,
|
||||
movement,
|
||||
new SetState.Parsed(incoming.Guid, physics.RawState, ts.Instance, ts.State),
|
||||
new VectorUpdate.Parsed(
|
||||
incoming.Guid,
|
||||
physics.Velocity ?? System.Numerics.Vector3.Zero,
|
||||
physics.AngularVelocity ?? System.Numerics.Vector3.Zero,
|
||||
ts.Instance,
|
||||
ts.Vector));
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn ApplyUnparentedPosition(
|
||||
WorldSession.EntitySpawn old,
|
||||
CreateObject.ServerPosition? position,
|
||||
ushort positionSequence)
|
||||
{
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Position = position,
|
||||
Parent = null,
|
||||
Timestamps = desc.Timestamps with { Position = positionSequence },
|
||||
};
|
||||
|
||||
return old with
|
||||
{
|
||||
Position = position,
|
||||
ParentGuid = null,
|
||||
ParentLocation = null,
|
||||
PositionSequence = positionSequence,
|
||||
Physics = physics,
|
||||
};
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn ApplyParent(
|
||||
WorldSession.EntitySpawn old,
|
||||
uint parentGuid,
|
||||
uint parentLocation,
|
||||
uint placementId,
|
||||
ushort positionSequence)
|
||||
{
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Position = null,
|
||||
Parent = new PhysicsAttachment(parentGuid, parentLocation),
|
||||
AnimationFrame = placementId,
|
||||
Timestamps = desc.Timestamps with { Position = positionSequence },
|
||||
};
|
||||
|
||||
return old with
|
||||
{
|
||||
Position = null,
|
||||
ParentGuid = parentGuid,
|
||||
ParentLocation = parentLocation,
|
||||
PlacementId = placementId,
|
||||
PositionSequence = positionSequence,
|
||||
Physics = physics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct AcceptedPhysicsTimestamps(
|
||||
ushort Instance,
|
||||
ushort ServerControlledMove,
|
||||
ushort Teleport,
|
||||
ushort ForcePosition);
|
||||
|
||||
public readonly record struct CreateParentUpdate(
|
||||
uint ChildGuid,
|
||||
uint ParentGuid,
|
||||
uint ParentLocation,
|
||||
uint PlacementId,
|
||||
ushort ChildInstanceSequence,
|
||||
ushort ChildPositionSequence);
|
||||
|
||||
public readonly record struct SameGenerationCreateObjectEvents(
|
||||
ObjDescEvent.Parsed Appearance,
|
||||
CreateParentUpdate? Parent,
|
||||
WorldSession.EntityPositionUpdate? Position,
|
||||
PickupEvent.Parsed? Pickup,
|
||||
WorldSession.EntityMotionUpdate? Movement,
|
||||
SetState.Parsed State,
|
||||
VectorUpdate.Parsed Vector);
|
||||
|
||||
public readonly record struct InboundCreateResult(
|
||||
CreateObjectTimestampDisposition Disposition,
|
||||
WorldSession.EntitySpawn Snapshot,
|
||||
SameGenerationCreateObjectEvents? SameGenerationEvents,
|
||||
AcceptedPhysicsTimestamps Timestamps);
|
||||
Loading…
Add table
Add a link
Reference in a new issue