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:
Erik 2026-07-14 00:22:17 +02:00
parent d53fe30ffe
commit 8a5d77f7f4
50 changed files with 3809 additions and 649 deletions

View file

@ -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

View file

@ -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,

View file

@ -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

View 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,
}

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View 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);

View file

@ -1,5 +1,6 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Net.Messages;
@ -10,8 +11,9 @@ namespace AcDream.Core.Net.Messages;
/// weenies like the Holtburg foundry statue) in the client's loaded area.
///
/// <para>
/// acdream's parser extracts only the fields needed to hand the spawn off
/// to <c>IGameState</c>:
/// The parser preserves the complete PhysicsDesc needed to construct a retail
/// physics object, while retaining legacy convenience fields during the
/// LiveEntityRuntime migration:
/// </para>
/// <list type="bullet">
/// <item><b>GUID</b> — always at the start of the body (after the opcode).</item>
@ -25,13 +27,9 @@ namespace AcDream.Core.Net.Messages;
/// </list>
///
/// <para>
/// Most other fields (extended weenie header, object description, motion tables,
/// palettes, texture overrides, animation frames, velocity, ...) are
/// consumed-but-ignored so the parse position ends up wherever the
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
/// the end of the body to return useful output. We read through the fixed
/// WeenieHeader prefix for Name/ItemType, then stop before optional header
/// tails.
/// Every PhysicsDesc field is captured, including presence-preserving zero
/// values and all nine timestamps. The PublicWeenieDesc parser continues to
/// retain its supported presentation/gameplay subset.
/// </para>
///
/// <para>
@ -120,7 +118,7 @@ public static class CreateObject
ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0,
// L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1)
// seeds MotionSequenceGate's MOVEMENT_TS at spawn.
// seeds PhysicsTimestampGate's MOVEMENT_TS at spawn.
ushort MovementSequence = 0,
// Parent/placement bootstrap for equipped child objects. These are
// the CreateObject equivalents of ParentEvent 0xF749.
@ -208,7 +206,11 @@ public static class CreateObject
uint? PetOwnerId = null,
// PublicWeenieDesc._ammoType, gated by WeenieHeader flag 0x100.
// AMMO_NONE is the explicit wire value zero; null means absent.
ushort? AmmoType = null);
ushort? AmmoType = null,
// Complete immutable PhysicsDesc projection. Kept alongside the
// legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2.
PhysicsSpawnData? Physics = null);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -479,8 +481,8 @@ public static class CreateObject
/// Parse a reassembled CreateObject body. <paramref name="body"/> must
/// start with the 4-byte opcode. Returns <c>null</c> if the body is
/// malformed (truncated field); returns a populated <see cref="Parsed"/>
/// on success. The parser stops at the end of PhysicsData; subsequent
/// weenie-header fields are deliberately not consumed.
/// on success after consuming the supported PhysicsDesc and public
/// WeenieHeader fields.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
@ -492,6 +494,17 @@ public static class CreateObject
float? objScale = null;
ServerMotionState? motionState = null;
uint? motionTableId = null;
PhysicsMovementData? movement = null;
uint? soundTableId = null;
uint? physicsScriptTableId = null;
ReadOnlyMemory<PhysicsAttachment>? children = null;
float? translucency = null;
Vector3? velocity = null;
Vector3? acceleration = null;
Vector3? angularVelocity = null;
uint? defaultScriptType = null;
float? defaultScriptIntensity = null;
PhysicsSpawnData? physics = null;
// Commit A 2026-04-29 — live-entity collision plumbing. PhysicsState
// (acclient.h:2815) was previously skipped at line ~337; the PWD
// _bitfield (acclient.h:6431-6463) was previously discarded as
@ -547,11 +560,16 @@ public static class CreateObject
{
if (body.Length - pos < (int)movementLen) return null;
int movementStart = pos;
motionState = TryParseMovementData(body.Slice(movementStart, (int)movementLen));
ReadOnlySpan<byte> movementBytes = body.Slice(movementStart, (int)movementLen);
motionState = TryParseMovementData(movementBytes);
pos = movementStart + (int)movementLen;
if (body.Length - pos < 4) return null;
pos += 4; // isAutonomous u32
bool isAutonomous = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)) != 0;
pos += 4;
movement = new PhysicsMovementData(movementBytes.ToArray(), motionState, isAutonomous);
}
else
movement = new PhysicsMovementData(ReadOnlyMemory<byte>.Empty, null, null);
}
else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
{
@ -585,12 +603,14 @@ public static class CreateObject
if ((physicsFlags & PhysicsDescriptionFlag.STable) != 0)
{
if (body.Length - pos < 4) return null;
soundTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.PeTable) != 0)
{
if (body.Length - pos < 4) return null;
physicsScriptTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
@ -616,19 +636,27 @@ public static class CreateObject
if (body.Length - pos < 4) return null;
int childCount = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(pos));
pos += 4;
if (childCount < 0 || childCount > 1024) return PartialResult();
if (childCount < 0 || childCount > 1024) return null;
if (body.Length - pos < childCount * 8) return null;
pos += childCount * 8; // each child = guid u32 + locationId u32
var parsedChildren = new PhysicsAttachment[childCount];
for (int i = 0; i < parsedChildren.Length; i++)
{
parsedChildren[i] = new PhysicsAttachment(
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)),
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 4)));
pos += 8;
}
children = parsedChildren;
}
if ((physicsFlags & PhysicsDescriptionFlag.ObjScale) != 0)
{
if (body.Length - pos < 4) return PartialResult();
if (body.Length - pos < 4) return null;
objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0)
{
if (body.Length - pos < 4) return PartialResult();
if (body.Length - pos < 4) return null;
friction = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
@ -641,31 +669,89 @@ public static class CreateObject
// Was previously dropped — every object got the default
// 0.05f, so server-set bouncier surfaces felt identical to
// walls.
if (body.Length - pos < 4) return PartialResult();
if (body.Length - pos < 4) return null;
elasticity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12;
if ((physicsFlags & PhysicsDescriptionFlag.Omega) != 0) pos += 12;
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScript) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0)
{
if (body.Length - pos < 4) return null;
translucency = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0)
{
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
velocity = value;
}
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0)
{
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
acceleration = value;
}
if ((physicsFlags & PhysicsDescriptionFlag.Omega) != 0)
{
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
angularVelocity = value;
}
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScript) != 0)
{
if (body.Length - pos < 4) return null;
defaultScriptType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0)
{
if (body.Length - pos < 4) return null;
defaultScriptIntensity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// 9 sequence timestamps, always present at end of PhysicsData.
// PhysicsTimeStamp enum order (acclient.h:6084; ACE
// WorldObject_Networking.cs:411-420): 0=position, 1=movement,
// 4=teleport, 5=serverControl, 6=forcePosition, 8=instance.
if (body.Length - pos < 9 * 2) return PartialResult();
if (body.Length - pos < 9 * 2) return null;
var seqSpan = body.Slice(pos, 9 * 2);
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
ushort positionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(0 * 2));
ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 2));
ushort stateSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(2 * 2));
ushort vectorSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(3 * 2));
ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2));
ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2));
ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2));
ushort objDescSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(7 * 2));
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
pos += 9 * 2;
AlignTo4(ref pos);
if (pos > body.Length) return null;
var timestamps = new PhysicsTimestamps(
positionSeq, movementSeq, stateSeq, vectorSeq, teleportSeq,
serverControlSeq, forcePositionSeq, objDescSeq, instanceSeq);
physics = new PhysicsSpawnData(
RawState: physicsState.Value,
Position: position,
Movement: movement,
AnimationFrame: placementId,
SetupTableId: setupTableId,
MotionTableId: motionTableId,
SoundTableId: soundTableId,
PhysicsScriptTableId: physicsScriptTableId,
Parent: parentGuid.HasValue && parentLocation.HasValue
? new PhysicsAttachment(parentGuid.Value, parentLocation.Value)
: null,
Children: children,
Scale: objScale,
Friction: friction,
Elasticity: elasticity,
Translucency: translucency,
Velocity: velocity,
Acceleration: acceleration,
AngularVelocity: angularVelocity,
DefaultScriptType: defaultScriptType,
DefaultScriptIntensity: defaultScriptIntensity,
Timestamps: timestamps);
// --- WeenieHeader: read the fixed prefix fields we need. ---
// ACE WorldObject_Networking.SerializeCreateObject writes:
@ -1026,17 +1112,8 @@ public static class CreateObject
CombatUse: combatUse,
PluralName: pluralName,
PetOwnerId: petOwnerId,
AmmoType: ammoType);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
Parsed PartialResult() => new(
guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId,
PhysicsState: physicsState,
ObjectDescriptionFlags: objectDescriptionFlags,
Friction: friction,
Elasticity: elasticity);
AmmoType: ammoType,
Physics: physics);
}
catch
{
@ -1391,4 +1468,20 @@ public static class CreateObject
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
return true;
}
private static bool TryReadVector3(ReadOnlySpan<byte> body, ref int pos, out Vector3 value)
{
if (body.Length - pos < 12)
{
value = default;
return false;
}
value = new Vector3(
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)),
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)),
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 8)));
pos += 12;
return true;
}
}

View file

@ -17,23 +17,14 @@ public static class DeleteObject
{
public const uint Opcode = 0xF747u;
/// <param name="FromPickup">
/// <see langword="true"/> when this delete was sourced from a
/// <c>PickupEvent (0xF74A)</c> — the object left the 3-D world view but
/// the weenie record still exists (it is moving into a container).
/// <see langword="false"/> (default) when sourced from <c>DeleteObject
/// (0xF747)</c> — the weenie was destroyed and must be evicted from
/// <see cref="AcDream.Core.Items.ClientObjectTable"/>.
/// Retail two-table model: PickupEvent removes from <c>object_table</c>;
/// DeleteObject removes from <c>weenie_object_table</c>.
/// </param>
public readonly record struct Parsed(uint Guid, ushort InstanceSequence, bool FromPickup = false);
/// <summary>A true object-destruction event for one exact incarnation.</summary>
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
/// <summary>
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
/// 4-byte opcode, matching every other parser in this namespace.
/// The returned <see cref="Parsed.FromPickup"/> is always <see langword="false"/>
/// — this parser handles true destroys only.
/// PickupEvent has a distinct parser and runtime event because it advances
/// POSITION_TS and retains the logical object.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
@ -46,6 +37,6 @@ public static class DeleteObject
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
return new Parsed(guid, instanceSequence, FromPickup: false);
return new Parsed(guid, instanceSequence);
}
}

View file

@ -26,8 +26,8 @@ namespace AcDream.Core.Net.Messages;
/// <item>u32 opcode (0xF625)</item>
/// <item>u32 guid — target object</item>
/// <item>ModelData block — see <see cref="CreateObject.ReadModelData"/></item>
/// <item>u32 instanceSequence</item>
/// <item>u32 visualDescSequence</item>
/// <item>u16 instanceSequence</item>
/// <item>u16 visualDescSequence (OBJDESC_TS)</item>
/// </list>
/// </summary>
public static class ObjDescEvent
@ -35,11 +35,14 @@ public static class ObjDescEvent
public const uint Opcode = 0xF625u;
/// <summary>
/// One ObjDescEvent: target guid + the new ModelData. Sequence
/// counters are read but not surfaced (subscribers don't need them
/// — the event always carries the full new appearance).
/// One ObjDescEvent: target guid, new ModelData, and retail's packed
/// INSTANCE_TS/OBJDESC_TS pair.
/// </summary>
public readonly record struct Parsed(uint Guid, CreateObject.ModelData ModelData);
public readonly record struct Parsed(
uint Guid,
CreateObject.ModelData ModelData,
ushort InstanceSequence,
ushort ObjDescSequence);
/// <summary>
/// Parse an ObjDescEvent body (must start with the 4-byte opcode).
@ -61,10 +64,13 @@ public static class ObjDescEvent
var modelData = CreateObject.ReadModelData(body, ref pos);
// Trailing instanceSeq + visualDescSeq are read for completeness
// but not surfaced — subscribers re-render unconditionally on
// every event since each carries the full appearance.
return new Parsed(guid, modelData);
// PhysicsTimestampPack::UnPack 0x00516F50 is exactly two u16s.
// Reject a missing or overlong tail so cursor mistakes cannot turn
// into an apparently valid, ungated appearance update.
if (body.Length - pos != 4) return null;
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
ushort objDesc = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
return new Parsed(guid, modelData, instance, objDesc);
}
catch
{

View file

@ -0,0 +1,64 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// The nine retail <c>PhysicsTimeStamp</c> channels stored at the tail of a
/// <c>PhysicsDesc</c>. Field order is verbatim from <c>acclient.h:6084</c>.
/// </summary>
public readonly record struct PhysicsTimestamps(
ushort Position,
ushort Movement,
ushort State,
ushort Vector,
ushort Teleport,
ushort ServerControlledMove,
ushort ForcePosition,
ushort ObjDesc,
ushort Instance);
/// <summary>A parent or child attachment carried by <c>PhysicsDesc</c>.</summary>
public readonly record struct PhysicsAttachment(uint Guid, uint LocationId);
/// <summary>
/// A present Movement field. The wrapper is retained even when
/// <see cref="RawData"/> is empty so callers can distinguish absent Movement
/// from an explicitly present zero-length movement block.
/// </summary>
public readonly record struct PhysicsMovementData(
ReadOnlyMemory<byte> RawData,
CreateObject.ServerMotionState? MotionState,
bool? IsAutonomous);
/// <summary>
/// Immutable, lossless projection of the retail <c>PhysicsDesc</c> embedded in
/// CreateObject (0xF745). Nullable members preserve absent versus present-zero.
/// Network acceleration is retained for protocol fidelity; retail recalculates
/// normal acceleration after applying the final physics state.
/// </summary>
public readonly record struct PhysicsSpawnData(
uint RawState,
CreateObject.ServerPosition? Position,
PhysicsMovementData? Movement,
uint? AnimationFrame,
uint? SetupTableId,
uint? MotionTableId,
uint? SoundTableId,
uint? PhysicsScriptTableId,
PhysicsAttachment? Parent,
ReadOnlyMemory<PhysicsAttachment>? Children,
float? Scale,
float? Friction,
float? Elasticity,
float? Translucency,
Vector3? Velocity,
Vector3? Acceleration,
Vector3? AngularVelocity,
uint? DefaultScriptType,
float? DefaultScriptIntensity,
PhysicsTimestamps Timestamps)
{
/// <summary>The corrected retail flag view of <see cref="RawState"/>.</summary>
public PhysicsStateFlags State => (PhysicsStateFlags)RawState;
}

View file

@ -9,9 +9,9 @@ namespace AcDream.Core.Net.Messages;
/// ACE emits this from <c>Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)</c>
/// when a player picks up a world item — distinguishes the despawn
/// from a generic <c>0xF747 DeleteObject</c> (timeout / death /
/// out-of-LOS). Downstream effect on the client view is the same
/// (remove the entity from the world), so <see cref="WorldSession"/>
/// routes both opcodes to the same <c>EntityDeleted</c> event.
/// out-of-LOS). Pickup removes only the object's world projection while
/// retaining its logical weenie and timestamp owner, so <see cref="WorldSession"/>
/// publishes it separately through <c>EntityPickedUp</c>.
/// </para>
///
/// <para>

View file

@ -0,0 +1,25 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail PlayScriptID (0xF754): play the supplied PhysicsScript DID directly
/// on the addressed object. This packet never performs a typed-table lookup.
/// Named-retail handler: <c>SmartBox::HandlePlayScriptID</c> 0x00452020.
/// </summary>
public readonly record struct PlayPhysicsScript(uint Guid, uint ScriptDid)
{
public const uint Opcode = 0xF754u;
public const int WireSize = 12;
public static PlayPhysicsScript? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length != WireSize
|| BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode)
return null;
return new PlayPhysicsScript(
BinaryPrimitives.ReadUInt32LittleEndian(body[4..]),
BinaryPrimitives.ReadUInt32LittleEndian(body[8..]));
}
}

View file

@ -0,0 +1,31 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail PlayScriptType (0xF755): resolve the raw script type and exact
/// incoming intensity through the object's current PhysicsScriptTable.
/// Unknown type values and non-finite floats are retained losslessly for the
/// resolver to reject without corrupting packet delivery.
/// Named-retail handler: <c>SmartBox::HandlePlayScriptType</c> 0x00452070.
/// </summary>
public readonly record struct PlayPhysicsScriptType(
uint Guid,
uint RawScriptType,
float Intensity)
{
public const uint Opcode = 0xF755u;
public const int WireSize = 16;
public static PlayPhysicsScriptType? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length != WireSize
|| BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode)
return null;
return new PlayPhysicsScriptType(
BinaryPrimitives.ReadUInt32LittleEndian(body[4..]),
BinaryPrimitives.ReadUInt32LittleEndian(body[8..]),
BinaryPrimitives.ReadSingleLittleEndian(body[12..]));
}
}

View file

@ -73,7 +73,7 @@ public static class UpdateMotion
///
/// <para>L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are
/// exposed for the retail gate (<see cref="AcDream.Core.Physics"/>
/// <c>MotionSequenceGate</c>) — retail compares them against
/// <c>PhysicsTimestampGate</c>) — retail compares them against
/// <c>update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS]</c>
/// and stores <c>last_move_was_autonomous</c>
/// (<c>CPhysics::SetObjectMovement</c> 0x00509690).</para>

View file

@ -27,8 +27,7 @@ namespace AcDream.Core.Net.Messages;
/// <item><b>Velocity</b> — 3xf32 if HasVelocity set</item>
/// <item><b>PlacementID</b> — u32 if HasPlacementID set</item>
/// <item><b>Four u16 sequence numbers</b> — instance, position, teleport,
/// forcePosition. We don't currently check these for freshness but
/// we must consume them to walk the buffer correctly.</item>
/// forcePosition. Runtime freshness gates consume all four.</item>
/// </list>
/// </summary>
public static class UpdatePosition
@ -66,6 +65,7 @@ public static class UpdatePosition
uint? PlacementId,
bool IsGrounded,
ushort InstanceSequence = 0,
ushort PositionSequence = 0,
ushort TeleportSequence = 0,
ushort ForcePositionSequence = 0);
@ -149,15 +149,12 @@ public static class UpdatePosition
}
// Four u16 sequence numbers: instance, position, teleport, forcePosition.
ushort instSeq = 0, teleSeq = 0, forceSeq = 0;
if (body.Length - pos >= 8)
{
instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
// pos+2 = positionSequence (not tracked by movement)
teleSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 4));
forceSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 6));
pos += 8;
}
if (body.Length - pos < 8) return null;
ushort instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
ushort posSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
ushort teleSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 4));
ushort forceSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 6));
pos += 8;
var serverPos = new CreateObject.ServerPosition(
LandblockId: cellId,
@ -166,7 +163,7 @@ public static class UpdatePosition
return new Parsed(guid, serverPos, velocity, placementId,
IsGrounded: (flags & PositionFlags.IsGrounded) != 0,
instSeq, teleSeq, forceSeq);
instSeq, posSeq, teleSeq, forceSeq);
}
catch
{

View file

@ -4,21 +4,20 @@ using AcDream.Core.Player;
namespace AcDream.Core.Net;
/// <summary>
/// Wires WorldSession GameMessage-level object events into the client object
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
/// Wires WorldSession quality/property events into the client object table:
/// PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
/// (0x02CD) = player int (burden), PrivateUpdatePropertyInt64 (0x02CF) = player
/// 64-bit qualities (experience), SetStackSize (0x0197) = stack count,
/// InventoryRemoveObject (0x0024) = inventory-view removal.
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
/// CreateObject/DeleteObject application remains exposed as pure helpers so
/// the App live-entity owner can freshness-gate the shared projections first.
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
/// </summary>
public static class ObjectTableWiring
{
/// <summary>
/// Subscribe <paramref name="table"/> to the three object-lifecycle events
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
/// to EntitySpawned so the table is populated before the render path runs.
/// Subscribe <paramref name="table"/> to quality, inventory, and property
/// updates whose freshness is independent of the physics timestamp pack.
/// </summary>
public static void Wire(
WorldSession session,
@ -29,14 +28,9 @@ public static class ObjectTableWiring
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(table);
session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
// Retail two-table model:
// PickupEvent (0xF74A) → object left the 3-D world view; the weenie persists
// (it is moving into a container, e.g. unwield-to-pack). Remove the 3-D
// render entity only — do NOT evict from ClientObjectTable.
// DeleteObject (0xF747) → weenie is DESTROYED; evict from both tables.
// FromPickup = true guards the weenie eviction so it only fires for true destroys.
session.EntityDeleted += d => { if (!d.FromPickup) table.Remove(d.Guid); };
// Create/Delete/Pickup are deliberately not subscribed here. Their
// shared live-object timestamps must be accepted before either the
// retained weenie projection or the render projection mutates.
// B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just
// UiEffects — the server is the authority on object properties. UpdateIntProperty
@ -72,6 +66,34 @@ public static class ObjectTableWiring
session.InventoryObjectRemoved += guid => table.Remove(guid);
}
/// <summary>
/// Applies one CreateObject to the retained object projection after the
/// owning runtime accepts its physics generation. Internal for a
/// socket-free conformance test of the subscription body.
/// </summary>
public static void ApplyEntitySpawn(
ClientObjectTable table,
WorldSession.EntitySpawn spawn,
bool replaceGeneration = false)
{
WeenieData data = ToWeenieData(spawn);
if (replaceGeneration)
table.ReplaceGeneration(data, spawn.InstanceSequence);
else
table.Ingest(data);
}
/// <summary>
/// Applies a true DeleteObject only after the owning runtime accepts the
/// exact object incarnation. Pickup still removes only the 3-D projection.
/// </summary>
public static void ApplyEntityDelete(
ClientObjectTable table,
Messages.DeleteObject.Parsed delete)
{
table.RemoveLogicalGeneration(delete.Guid, delete.InstanceSequence);
}
/// <summary>Shared application step kept internal for byte-to-state conformance tests.</summary>
internal static void ApplyPlayerInt64PropertyUpdate(
ClientObjectTable table,

View file

@ -113,7 +113,7 @@ public sealed class WorldSession : IDisposable
int? MaxStructure = null,
float? Workmanship = null,
// L.2g S1 (DEV-6): PhysicsDesc timestamp-block stamps that seed the
// per-entity MotionSequenceGate (retail update_times INSTANCE_TS /
// per-entity PhysicsTimestampGate (retail update_times INSTANCE_TS /
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
ushort InstanceSequence = 0,
ushort MovementSequence = 0,
@ -130,7 +130,8 @@ public sealed class WorldSession : IDisposable
byte? CombatUse = null,
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null);
ushort? AmmoType = null,
PhysicsSpawnData? Physics = null);
/// <summary>
/// Projects the wire-level CreateObject result into the stable session
@ -189,7 +190,8 @@ public sealed class WorldSession : IDisposable
CombatUse: parsed.CombatUse,
PluralName: parsed.PluralName,
PetOwnerId: parsed.PetOwnerId,
AmmoType: parsed.AmmoType);
AmmoType: parsed.AmmoType,
Physics: parsed.Physics);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
@ -204,6 +206,13 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<DeleteObject.Parsed>? EntityDeleted;
/// <summary>
/// Fires for retail PickupEvent (0xF74A). Pickup advances the object's
/// shared POSITION_TS and removes only its world projection; it is not a
/// DeleteObject and does not destroy the timestamp owner or weenie.
/// </summary>
public event Action<PickupEvent.Parsed>? EntityPickedUp;
/// <summary>
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
/// whose motion changed and its new server-side stance + forward command.
@ -235,7 +244,12 @@ public sealed class WorldSession : IDisposable
uint Guid,
CreateObject.ServerPosition Position,
System.Numerics.Vector3? Velocity,
bool IsGrounded);
uint? PlacementId,
bool IsGrounded,
ushort InstanceSequence,
ushort PositionSequence,
ushort TeleportSequence,
ushort ForcePositionSequence);
/// <summary>
/// Fires when the session parses a 0xF748 UpdatePosition game message.
@ -433,7 +447,10 @@ public sealed class WorldSession : IDisposable
/// runtime). See <c>docs/research/2026-04-23-lightning-real.md</c>.
/// </para>
/// </summary>
public event Action<uint /*guid*/, uint /*scriptId*/>? PlayScriptReceived;
public event Action<PlayPhysicsScript>? PlayPhysicsScriptReceived;
/// <summary>Fires for retail typed PhysicsScript playback (0xF755).</summary>
public event Action<PlayPhysicsScriptType>? PlayPhysicsScriptTypeReceived;
/// <summary>
/// Phase 5d — retail's <c>AdminEnvirons</c> packet (opcode
@ -453,7 +470,7 @@ public sealed class WorldSession : IDisposable
/// </description></item>
/// <item><description>
/// <c>0x76..0x7B</c> — Thunder1..Thunder6 sounds. Paired with
/// a separate <see cref="PlayScriptReceived"/> from the server
/// a separate <see cref="PlayPhysicsScriptReceived"/> from the server
/// carrying the lightning-flash PhysicsScript.
/// </description></item>
/// </list>
@ -505,6 +522,26 @@ public sealed class WorldSession : IDisposable
public ushort ServerControlSequence => _serverControlSequence;
public ushort TeleportSequence => _teleportSequence;
public ushort ForcePositionSequence => _forcePositionSequence;
/// <summary>
/// Publishes the local player's canonical, freshness-accepted physics
/// timestamps for subsequent outbound movement messages. The App runtime
/// calls this only after <c>PhysicsTimestampGate</c> commits the matching
/// CreateObject/Movement/Position event; parsing alone never changes
/// outbound authority.
/// </summary>
public void PublishAcceptedLocalPhysicsTimestamps(
ushort instance,
ushort serverControlledMove,
ushort teleport,
ushort forcePosition)
{
_instanceSequence = instance;
_serverControlSequence = serverControlledMove;
_teleportSequence = teleport;
_forcePositionSequence = forcePosition;
}
public CharacterList.Parsed? Characters { get; private set; }
private readonly NetClient _net;
@ -536,7 +573,7 @@ public sealed class WorldSession : IDisposable
// Movement sequence counters — echoed back in every MoveToState and
// AutonomousPosition so the server can detect stale/reordered packets.
// Initialized from CreateObject PhysicsData timestamps, updated by
// UpdatePosition/UpdateMotion/PlayerTeleport. Per holtburger:
// accepted UpdatePosition/UpdateMotion packets. Per holtburger:
// instance=slot 8, teleport=slot 4, serverControl=slot 5, forcePosition=slot 6.
private ushort _instanceSequence;
private ushort _serverControlSequence;
@ -878,15 +915,6 @@ public sealed class WorldSession : IDisposable
var parsed = CreateObject.TryParse(body);
if (parsed is not null)
{
// Initialize sequence counters from the player's own CreateObject.
if (parsed.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
{
_instanceSequence = parsed.Value.InstanceSequence;
_teleportSequence = parsed.Value.TeleportSequence;
_serverControlSequence = parsed.Value.ServerControlSequence;
_forcePositionSequence = parsed.Value.ForcePositionSequence;
}
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
}
}
@ -898,18 +926,11 @@ public sealed class WorldSession : IDisposable
}
else if (op == PickupEvent.Opcode)
{
// ACE sends PickupEvent (0xF74A) when an object leaves the 3-D world view
// (player picks it up, or a player unwields gear that goes back to their pack).
// The WEENIE is NOT destroyed — it is moving into a container. We adapt to
// DeleteObject.Parsed so the render/entity layer (GameWindow.OnLiveEntityDeleted)
// still removes the 3-D WorldEntity, but FromPickup = true tells ObjectTableWiring
// to skip the ClientObjectTable eviction (retail two-table model: PickupEvent
// targets object_table only; DeleteObject 0xF747 targets weenie_object_table).
// Pickup has its own POSITION_TS gate and retains the logical
// object; do not collapse it into DeleteObject.
var parsed = PickupEvent.TryParse(body);
if (parsed is not null)
EntityDeleted?.Invoke(
new DeleteObject.Parsed(
parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true));
EntityPickedUp?.Invoke(parsed.Value);
}
else if (op == ParentEvent.Opcode)
{
@ -947,19 +968,16 @@ public sealed class WorldSession : IDisposable
var posUpdate = UpdatePosition.TryParse(body);
if (posUpdate is not null)
{
// Update sequence counters from the player's own position updates.
if (posUpdate.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
{
_instanceSequence = posUpdate.Value.InstanceSequence;
_teleportSequence = posUpdate.Value.TeleportSequence;
_forcePositionSequence = posUpdate.Value.ForcePositionSequence;
}
PositionUpdated?.Invoke(new EntityPositionUpdate(
posUpdate.Value.Guid,
posUpdate.Value.Position,
posUpdate.Value.Velocity,
posUpdate.Value.IsGrounded));
posUpdate.Value.PlacementId,
posUpdate.Value.IsGrounded,
posUpdate.Value.InstanceSequence,
posUpdate.Value.PositionSequence,
posUpdate.Value.TeleportSequence,
posUpdate.Value.ForcePositionSequence));
}
}
else if (op == VectorUpdate.Opcode)
@ -1130,21 +1148,17 @@ public sealed class WorldSession : IDisposable
EnvironChanged?.Invoke(envType);
}
}
else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid
else if (op == PlayPhysicsScript.Opcode)
{
// Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]`
// per chunk_006A0000.c:12320 disassembly. Dispatch the
// event; GameWindow subscribes and feeds its
// PhysicsScriptRunner. This is the channel retail uses for
// lightning flashes, spell casts, emotes, combat FX, etc.
if (body.Length >= 12)
{
uint targetGuid = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
uint scriptId = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(8, 4));
PlayScriptReceived?.Invoke(targetGuid, scriptId);
}
var script = PlayPhysicsScript.TryParse(body);
if (script is not null)
PlayPhysicsScriptReceived?.Invoke(script.Value);
}
else if (op == PlayPhysicsScriptType.Opcode)
{
var script = PlayPhysicsScriptType.TryParse(body);
if (script is not null)
PlayPhysicsScriptTypeReceived?.Invoke(script.Value);
}
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
{
@ -1157,12 +1171,12 @@ public sealed class WorldSession : IDisposable
// movement; the LoginComplete is sent from GameWindow once
// the destination UpdatePosition is received and the player
// has been snapped to the new cell.
ushort sequence = 0;
if (body.Length >= 6)
sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
{
ushort sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
body.AsSpan(4, 2));
_teleportSequence = sequence; // track for outbound movement messages
TeleportStarted?.Invoke(sequence);
TeleportStarted?.Invoke(sequence);
}
}
else if (op == ObjDescEvent.Opcode)
{
@ -1172,7 +1186,7 @@ public sealed class WorldSession : IDisposable
// RecipeManager.cs:403, GameActionSetSingleCharacterOption.cs:27).
// Retail handler: SmartBox::HandleObjDescEvent (named-retail
// 0x453340). Body layout: u32 opcode | u32 guid | ModelData |
// u32 instanceSeq | u32 visualDescSeq.
// u16 instanceSeq | u16 visualDescSeq.
var parsed = ObjDescEvent.TryParse(body);
if (parsed is not null)
{

View file

@ -21,6 +21,18 @@ public readonly record struct MoveRequestFailure(
uint WeenieError,
bool RolledBack);
public enum ClientObjectRemovalReason
{
Ordinary,
LogicalDelete,
GenerationReplacement,
}
public readonly record struct ClientObjectRemoval(
ClientObject Object,
ClientObjectRemovalReason Reason,
ushort Generation);
/// <summary>
/// The client's table of every server object (retail <c>weenie_object_table</c> /
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
@ -103,6 +115,13 @@ public sealed class ClientObjectTable
/// <summary>Fires when an object is removed from the session.</summary>
public event Action<ClientObject>? ObjectRemoved;
/// <summary>
/// Generation-aware removal stream for runtime owners. UI consumers keep
/// using <see cref="ObjectRemoved"/>; runtime owners use the reason and
/// generation to preserve only packets addressed to future incarnations.
/// </summary>
public event Action<ClientObjectRemoval>? ObjectRemovalClassified;
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
public event Action<ClientObject>? ObjectUpdated;
@ -321,15 +340,47 @@ public sealed class ClientObjectTable
/// space, stolen, etc).
/// </summary>
public bool Remove(uint itemId)
=> RemoveCore(itemId, ClientObjectRemovalReason.Ordinary, 0, notifyObjectRemoved: true);
/// <summary>Removes an exact accepted server object incarnation.</summary>
public bool RemoveLogicalGeneration(uint itemId, ushort generation)
=> RemoveCore(
itemId,
ClientObjectRemovalReason.LogicalDelete,
generation,
notifyObjectRemoved: true);
private bool RemoveCore(
uint itemId,
ClientObjectRemovalReason reason,
ushort generation,
bool notifyObjectRemoved)
{
if (!_objects.TryRemove(itemId, out var item)) return false;
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
l.Remove(itemId);
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
ObjectRemoved?.Invoke(item);
if (notifyObjectRemoved)
ObjectRemoved?.Invoke(item);
ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation));
return true;
}
/// <summary>
/// Atomically replaces the retained qualities for a newer server object
/// incarnation while publishing a generation-specific teardown event.
/// </summary>
public ClientObject ReplaceGeneration(WeenieData data, ushort generation)
{
RemoveCore(
data.Guid,
ClientObjectRemovalReason.GenerationReplacement,
generation,
notifyObjectRemoved: true);
return Ingest(data);
}
/// <summary>
/// Apply a <see cref="PropertyBundle"/> patch (e.g. from an
/// <c>IdentifyObjectResponse</c>) to an existing object. Individual

View file

@ -1,119 +0,0 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-entity staleness gate for inbound movement events (0xF74C), ported
/// from retail (L.2g S1, closes deviation DEV-6 for the UM path).
///
/// <para>Retail keeps a <c>update_times[NUM_PHYSICS_TS]</c> array of u16
/// stamps on every <c>CPhysicsObj</c> (acclient.h:6084 —
/// <c>PhysicsTimeStamp</c>) and rejects out-of-order network events with a
/// wraparound-aware compare. Three of those stamps gate movement events:</para>
///
/// <list type="bullet">
/// <item><b>INSTANCE_TS (8)</b> — checked at dispatch
/// (<c>ACSmartBox::DispatchSmartBoxEvent</c> case 0xF74C,
/// acclient_2013_pseudo_c.txt:357214-357239): an event stamped with an
/// OLDER object incarnation than the one we know is dropped before any
/// other stamp is touched. Retail additionally QUEUES events for a NEWER
/// incarnation (<c>SmartBox::QueueBlobForObject</c>) until that object
/// version exists; acdream adopts-and-applies instead — register row
/// AD-32 records the divergence.</item>
/// <item><b>MOVEMENT_TS (1)</b> — <c>CPhysics::SetObjectMovement</c>
/// (0x00509690, acclient_2013_pseudo_c.txt:271370) applies an event only
/// when its movement sequence is STRICTLY newer than the stored stamp
/// (equal = duplicate delivery = drop), and stamps BEFORE evaluating the
/// server-control gate — a movement sequence is consumed even when the
/// event is subsequently dropped for stale server control.</item>
/// <item><b>SERVER_CONTROLLED_MOVE_TS (5)</b> — same function: the event is
/// dropped when the STORED server-control stamp is strictly newer than
/// the incoming one (a newer server-control era has already been seen);
/// equal passes and re-stamps.</item>
/// </list>
///
/// <para>The compare itself is <c>CPhysicsObj::is_newer</c> (0x00451ad0);
/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows
/// ACE's verbatim <c>PhysicsObj.is_newer</c> (PhysicsObj.cs:2853-2859),
/// cross-checked against the decomp's branch structure.</para>
/// </summary>
public sealed class MotionSequenceGate
{
private ushort _instanceTs; // update_times[INSTANCE_TS = 8]
private ushort _movementTs; // update_times[MOVEMENT_TS = 1]
private ushort _serverControlTs; // update_times[SERVER_CONTROLLED_MOVE_TS = 5]
private bool _seeded;
/// <summary>
/// <c>CPhysicsObj::is_newer</c> (0x00451ad0): true when
/// <paramref name="newStamp"/> is newer than <paramref name="oldStamp"/>
/// under u16 wraparound — when the absolute difference exceeds 0x7fff
/// the numerically SMALLER value is the newer one (the counter wrapped).
/// </summary>
public static bool IsNewer(ushort oldStamp, ushort newStamp)
{
if (Math.Abs(newStamp - oldStamp) > short.MaxValue)
return newStamp < oldStamp;
return oldStamp < newStamp;
}
/// <summary>
/// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp
/// block. Retail initializes <c>update_times</c> wholesale from the 9
/// u16s at the tail of PhysicsDesc (ACE
/// <c>WorldObject_Networking.cs:411-420</c> writes them in
/// PhysicsTimeStamp enum order); without this, an entity whose movement
/// sequence is already past 0x8000 at spawn would have every subsequent
/// movement event judged stale against a zero stamp.
///
/// <para>The first Seed adopts the stamps wholesale (fresh object);
/// subsequent Seeds only move stamps FORWARD. This makes the #138
/// rehydrate path (which replays a RETAINED CreateObject through the
/// spawn handler) a no-op instead of a stamp regression, while a genuine
/// wire re-create (server sequences only advance) still seeds correctly.
/// Retail has no equivalent replay path — its objects keep their stamps
/// for their whole lifetime — so advance-only is the faithful mapping.</para>
/// </summary>
public void Seed(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
{
if (!_seeded)
{
_instanceTs = instanceSeq;
_movementTs = movementSeq;
_serverControlTs = serverControlSeq;
_seeded = true;
return;
}
if (IsNewer(_instanceTs, instanceSeq)) _instanceTs = instanceSeq;
if (IsNewer(_movementTs, movementSeq)) _movementTs = movementSeq;
if (IsNewer(_serverControlTs, serverControlSeq)) _serverControlTs = serverControlSeq;
}
/// <summary>
/// Apply the retail three-stamp gate to an inbound movement event.
/// Returns true when the event should be applied (stamps updated),
/// false when it must be dropped as stale/duplicate/superseded.
/// </summary>
public bool TryAcceptMovementEvent(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
{
// Dispatch-level incarnation gate — runs before any other stamp is
// touched (retail drops at DispatchSmartBoxEvent, never reaching
// SetObjectMovement).
if (IsNewer(instanceSeq, _instanceTs))
return false;
_instanceTs = instanceSeq; // adopt equal-or-newer (AD-32; retail queues newer)
// Gate 1: movement sequence must be strictly newer.
if (!IsNewer(_movementTs, movementSeq))
return false;
_movementTs = movementSeq; // stamped BEFORE the server-control gate, per retail
// Gate 2: drop when a newer server-control era has already been seen.
if (IsNewer(serverControlSeq, _serverControlTs))
return false;
_serverControlTs = serverControlSeq;
return true;
}
}

View file

@ -19,18 +19,27 @@ namespace AcDream.Core.Physics;
// ────────────────────────────────────────────────────────────────────────────
/// <summary>
/// State flags stored at struct offset +0xA8 (PhysicsState).
/// Only the flags relevant to this simulation layer are included.
/// State flags stored at struct offset +0xA8 (<c>PhysicsState</c>), verbatim
/// from the retail <c>PhysicsState</c> enum in <c>acclient.h:2815</c>.
/// </summary>
[Flags]
public enum PhysicsStateFlags : uint
{
None = 0,
Static = 0x00000001, // bit 0 — never moves
Ethereal = 0x00000004, // bit 2 — no collision
ReportCollisions = 0x00000010,
Gravity = 0x00000400, // bit 10 — apply downward gravity
Hidden = 0x00001000,
None = 0x00000000,
Static = 0x00000001,
Ethereal = 0x00000004,
ReportCollisions = 0x00000008,
IgnoreCollisions = 0x00000010,
NoDraw = 0x00000020,
Missile = 0x00000040,
Pushable = 0x00000080,
AlignPath = 0x00000100,
PathClipped = 0x00000200,
Gravity = 0x00000400,
Lighting = 0x00000800,
ParticleEmitter = 0x00001000,
Hidden = 0x00004000,
ScriptedCollision = 0x00008000,
/// <summary>
/// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit
/// (acclient.h:2833). When set, the entity exposes a per-Setup
@ -43,7 +52,7 @@ public enum PhysicsStateFlags : uint
/// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP).
/// ACE name: <c>PhysicsState.HasPhysicsBSP</c>.
/// </summary>
HasPhysicsBsp = 0x00010000, // bit 16 — retail HAS_PHYSICS_BSP_PS
HasPhysicsBsp = 0x00010000,
/// <summary>
/// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
/// When set, wall-collisions zero the velocity instead of reflecting.
@ -51,8 +60,14 @@ public enum PhysicsStateFlags : uint
/// impact rather than bounce. The player NEVER has this flag set —
/// player wall-hits use the reflection path with elasticity ~0.05.
/// </summary>
Inelastic = 0x00020000, // bit 17 — retail INELASTIC_PS
Sledding = 0x00800000, // bit 23 — sledding (modified friction)
Inelastic = 0x00020000,
HasDefaultAnim = 0x00040000,
HasDefaultScript = 0x00080000,
Cloaked = 0x00100000,
ReportAsEnvironment = 0x00200000,
EdgeSlide = 0x00400000,
Sledding = 0x00800000,
Frozen = 0x01000000,
}
/// <summary>

View file

@ -0,0 +1,216 @@
using System;
namespace AcDream.Core.Physics;
public enum CreateObjectTimestampDisposition
{
StaleGeneration,
InitialGeneration,
ExistingGeneration,
NewGeneration,
}
public enum PositionTimestampDisposition
{
Rejected,
Apply,
ForcePosition,
}
/// <summary>
/// Per-object retail <c>update_times[NUM_PHYSICS_TS]</c> gate. Channel order
/// is <c>PhysicsTimeStamp</c> from <c>acclient.h:6084</c>. The comparator and
/// channel mutation order are ported from <c>CPhysicsObj::is_newer</c>
/// 0x00451AD0, <c>CPhysicsObj::newer_event</c> 0x00451B10,
/// <c>SmartBox::DoSetState</c> 0x004520D0,
/// <c>SmartBox::DoVectorUpdate</c> 0x004521C0, and
/// <c>SmartBox::HandleReceivedPosition</c> 0x00453FD0.
/// </summary>
public sealed class PhysicsTimestampGate
{
private readonly ushort[] _timestamps = new ushort[9];
private bool _seeded;
private const int Position = 0;
private const int Movement = 1;
private const int State = 2;
private const int Vector = 3;
private const int Teleport = 4;
private const int ServerControlledMove = 5;
private const int ForcePosition = 6;
private const int ObjDesc = 7;
private const int Instance = 8;
public ushort PositionTimestamp => _timestamps[Position];
public ushort MovementTimestamp => _timestamps[Movement];
public ushort StateTimestamp => _timestamps[State];
public ushort VectorTimestamp => _timestamps[Vector];
public ushort TeleportTimestamp => _timestamps[Teleport];
public ushort ServerControlledMoveTimestamp => _timestamps[ServerControlledMove];
public ushort ForcePositionTimestamp => _timestamps[ForcePosition];
public ushort ObjDescTimestamp => _timestamps[ObjDesc];
public ushort InstanceTimestamp => _timestamps[Instance];
/// <summary>
/// Retail u16 wrap comparison. At the exact half-range ambiguity
/// (0x8000), the numerically lower stamp is considered newer.
/// </summary>
public static bool IsNewer(ushort oldStamp, ushort newStamp)
{
int distance = Math.Abs((int)newStamp - oldStamp);
return distance > 0x7FFF ? newStamp < oldStamp : oldStamp < newStamp;
}
/// <summary>
/// Begins CreateObject processing. A newer INSTANCE_TS starts a fresh
/// generation and replaces every channel wholesale. A same-generation
/// CreateObject leaves all channels untouched so its Position, Parent,
/// Pickup, Movement, State, Vector, and ObjDesc branches can pass through
/// the same per-channel methods used by their standalone packets.
/// </summary>
public CreateObjectTimestampDisposition SeedForCreateObject(
ushort position,
ushort movement,
ushort state,
ushort vector,
ushort teleport,
ushort serverControlledMove,
ushort forcePosition,
ushort objDesc,
ushort instance)
{
Span<ushort> incoming =
[position, movement, state, vector, teleport, serverControlledMove, forcePosition, objDesc, instance];
if (!_seeded)
{
incoming.CopyTo(_timestamps);
_seeded = true;
return CreateObjectTimestampDisposition.InitialGeneration;
}
ushort currentInstance = _timestamps[Instance];
if (IsNewer(currentInstance, instance))
{
incoming.CopyTo(_timestamps);
return CreateObjectTimestampDisposition.NewGeneration;
}
if (IsNewer(instance, currentInstance))
return CreateObjectTimestampDisposition.StaleGeneration;
return CreateObjectTimestampDisposition.ExistingGeneration;
}
public bool TryAcceptMovementEvent(
ushort instance,
ushort movement,
ushort serverControlledMove)
{
if (!TryAcceptInstance(instance)) return false;
if (!AdvanceStrict(Movement, movement)) return false;
// Retail consumes MOVEMENT_TS before checking the server-control
// era. Equal server-control stamps pass.
if (IsNewer(serverControlledMove, _timestamps[ServerControlledMove]))
return false;
_timestamps[ServerControlledMove] = serverControlledMove;
return true;
}
public bool TryAcceptStateEvent(ushort instance, ushort state) =>
TryAcceptInstance(instance) && AdvanceStrict(State, state);
public bool TryAcceptVectorEvent(ushort instance, ushort vector) =>
TryAcceptInstance(instance) && AdvanceStrict(Vector, vector);
public bool TryAcceptObjDescEvent(ushort instance, ushort objDesc) =>
TryAcceptInstance(instance) && AdvanceStrict(ObjDesc, objDesc);
/// <summary>
/// ParentEvent and PickupEvent share POSITION_TS with UpdatePosition.
/// Retail exact-gates INSTANCE_TS, then strictly advances POSITION_TS.
/// </summary>
public bool TryAcceptPositionChannelEvent(ushort instance, ushort position) =>
TryAcceptInstance(instance) && AdvanceStrict(Position, position);
public bool IsCurrentInstance(ushort instance) => TryAcceptInstance(instance);
/// <summary>
/// Standalone PlayerTeleport (F751) is admitted when it is equal to or
/// newer than the current TELEPORT_TS. It does not advance that channel;
/// the destination Position packet does.
/// </summary>
public bool IsFreshTeleportStart(ushort teleport) =>
_seeded && !IsNewer(teleport, _timestamps[Teleport]);
/// <summary>
/// Delete/Pickup events only affect the exact live incarnation. Retail
/// drops older deletes and queues newer-incarnation deletes without
/// touching the current object.
/// </summary>
public bool TryAcceptDeleteEvent(ushort instance, bool isLocalPlayer = false) =>
!isLocalPlayer && _seeded && instance == _timestamps[Instance];
/// <summary>
/// Ports the timestamp portion of <c>HandleReceivedPosition</c>. POSITION
/// is tentatively advanced, then restored when a newer TELEPORT stamp is
/// already known. A fresh teleport advances its own channel. The local
/// player's FORCE_POSITION channel is independent; non-player objects do
/// not consume it.
/// </summary>
public PositionTimestampDisposition TryAcceptPositionEvent(
ushort instance,
ushort position,
ushort teleport,
ushort forcePosition,
bool isLocalPlayer)
{
if (!TryAcceptInstance(instance)) return PositionTimestampDisposition.Rejected;
if (isLocalPlayer && IsNewer(_timestamps[ForcePosition], forcePosition))
{
_timestamps[ForcePosition] = forcePosition;
// SmartBox::HandleReceivedPosition 0x00453FD0: a fresh local
// FORCE_POSITION whose teleport is exactly equal blips immediately,
// assigns POSITION_TS directly (even equal/older), preserves the
// current heading, sends a position event, and returns WITHOUT
// advancing TELEPORT_TS.
if (teleport == _timestamps[Teleport])
{
_timestamps[Position] = position;
return PositionTimestampDisposition.ForcePosition;
}
}
ushort previousPosition = _timestamps[Position];
if (!AdvanceStrict(Position, position)) return PositionTimestampDisposition.Rejected;
if (IsNewer(teleport, _timestamps[Teleport]))
{
_timestamps[Position] = previousPosition;
return PositionTimestampDisposition.Rejected;
}
if (IsNewer(_timestamps[Teleport], teleport))
_timestamps[Teleport] = teleport;
return PositionTimestampDisposition.Apply;
}
private bool AdvanceStrict(int channel, ushort incoming)
{
if (!IsNewer(_timestamps[channel], incoming)) return false;
_timestamps[channel] = incoming;
return true;
}
private bool TryAcceptInstance(ushort incoming)
{
// Retail applies only the current incarnation: older packets drop and
// future-incarnation packets queue. Until LiveEntityRuntime owns that
// queue, acdream drops both mismatches (AD-32) so a future packet can
// never mutate or poison the current generation's channel stamps.
return _seeded && incoming == _timestamps[Instance];
}
}

View file

@ -0,0 +1,30 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>Position::IsValid</c> 0x005A9480 composed with
/// <c>Frame::IsValid</c> 0x00534ED0. The quaternion test is performed on its
/// squared norm with retail's exact <c>0.0002 * 5</c> tolerance.
/// </summary>
public static class PositionFrameValidation
{
public static bool IsValid(uint cellId, Vector3 origin, Quaternion rotation)
{
if (!LandDefs.InboundValidCellId(cellId)
|| float.IsNaN(origin.X)
|| float.IsNaN(origin.Y)
|| float.IsNaN(origin.Z)
|| float.IsNaN(rotation.W)
|| float.IsNaN(rotation.X)
|| float.IsNaN(rotation.Y)
|| float.IsNaN(rotation.Z))
{
return false;
}
float normSquared = rotation.LengthSquared();
return !float.IsNaN(normSquared)
&& MathF.Abs(normSquared - 1f) <= 0.001f;
}
}

View file

@ -21,6 +21,7 @@ public enum SelectionChangeReason
SelectedObjectRemoved,
CombatTargetDied,
PreviousSelection,
SessionReset,
}
public readonly record struct SelectionTransition(
@ -68,6 +69,30 @@ public sealed class SelectionState : ISelectionService
=> PreviousObjectId is uint previous
&& Set(previous, source, SelectionChangeReason.PreviousSelection);
/// <summary>
/// Ends a world session. Unlike an ordinary selection clear, no previous
/// object survives for the next session: server GUIDs may be reused by a
/// different logical incarnation after reconnect.
/// </summary>
public bool Reset(SelectionChangeSource source = SelectionChangeSource.System)
{
uint? old = SelectedObjectId;
SelectedObjectId = null;
PreviousObjectId = null;
PreviousValidObjectId = null;
if (old is null)
return false;
var transition = new SelectionTransition(
old,
null,
source,
SelectionChangeReason.SessionReset);
Changed?.Invoke(transition);
DispatchPluginChanged(new SelectionChangedEvent(old, null));
return true;
}
bool ISelectionService.Select(uint objectId)
=> Select(objectId, SelectionChangeSource.Plugin);