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

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