diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 7886a86..bf46b46 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -66,6 +66,14 @@ public sealed class GameWindow : IDisposable
private int _liveCenterX;
private int _liveCenterY;
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
+
+ ///
+ /// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
+ /// UpdateMotion and UpdatePosition handlers can find the entity the
+ /// server is talking about. The sequential
+ /// keys the render list; this parallel dictionary keys by server guid.
+ ///
+ private readonly Dictionary _entitiesByServerGuid = new();
private int _liveSpawnReceived; // diagnostics
private int _liveSpawnHydrated;
private int _liveDropReasonNoPos;
@@ -589,6 +597,8 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"live: connecting to {endpoint} as {user}");
_liveSession = new AcDream.Core.Net.WorldSession(endpoint);
_liveSession.EntitySpawned += OnLiveEntitySpawned;
+ _liveSession.MotionUpdated += OnLiveMotionUpdated;
+ _liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.Connect(user, pass);
if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
@@ -932,6 +942,10 @@ public sealed class GameWindow : IDisposable
_entities = extended;
_liveSpawnHydrated++;
+ // Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so
+ // UpdateMotion / UpdatePosition events can reseat this entity by guid.
+ _entitiesByServerGuid[spawn.Guid] = entity;
+
// Phase 6.4: register for per-frame playback if we resolved a real
// cycle with a non-zero framerate and at least two frames in the
// cycle (single-frame poses are static and don't need ticking).
@@ -1015,6 +1029,74 @@ public sealed class GameWindow : IDisposable
return hx0 * (1 - ty) + hx1 * ty;
}
+ ///
+ /// Phase 6.6: the server says an entity's motion has changed. Look up
+ /// the AnimatedEntity for that guid, re-resolve the idle cycle with the
+ /// new (stance, forward-command) override, and if the cycle is still
+ /// animated, swap in the new animation/frame range. Entities not in
+ /// the animated map (static props, entities rejected at spawn time)
+ /// are simply ignored — there's nothing to tick for them.
+ ///
+ 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;
+
+ // 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.
+ ushort stance = update.MotionState.Stance;
+ ushort? command = update.MotionState.ForwardCommand;
+
+ var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
+ ae.Setup, _dats,
+ motionTableIdOverride: null, // same table; already burned into ae.Animation
+ stanceOverride: stance,
+ commandOverride: command);
+
+ if (newCycle is null || newCycle.Framerate == 0f
+ || newCycle.HighFrame <= newCycle.LowFrame
+ || newCycle.Animation.PartFrames.Count <= 1)
+ {
+ // New pose is a static one — stop animating and leave the
+ // entity on its last rendered frame. Removing from the map
+ // means the tick no longer updates it.
+ _animatedEntities.Remove(entity.Id);
+ return;
+ }
+
+ ae.Animation = newCycle.Animation;
+ ae.LowFrame = Math.Max(0, newCycle.LowFrame);
+ ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
+ ae.Framerate = newCycle.Framerate;
+ ae.CurrFrame = ae.LowFrame;
+ }
+
+ ///
+ /// Phase 6.7: the server says an entity moved. Translate its new
+ /// landblock-local position into acdream world space (same math as
+ /// CreateObject hydration) and update the entity's Position/Rotation
+ /// in place so the next Draw picks up the new transform.
+ ///
+ private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
+ {
+ 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;
+ var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
+
+ entity.Position = worldPos;
+ entity.Rotation = rot;
+ }
+
private void OnUpdate(double dt)
{
// Drain any pending live-session traffic. Non-blocking — returns
diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs
index 31441fd..708d825 100644
--- a/src/AcDream.Core/World/WorldEntity.cs
+++ b/src/AcDream.Core/World/WorldEntity.cs
@@ -6,8 +6,13 @@ public sealed class WorldEntity
{
public required uint Id { get; init; }
public required uint SourceGfxObjOrSetupId { get; init; }
- public required Vector3 Position { get; init; }
- public required Quaternion Rotation { get; init; }
+ ///
+ /// World-space position. Settable so Phase 6.7 position-update events
+ /// can reseat an existing entity without rebuilding its meshes.
+ ///
+ public required Vector3 Position { get; set; }
+ /// Settable for the same reason as .
+ public required Quaternion Rotation { get; set; }
///
/// Per-part mesh references with their root-relative transforms.
/// Mutable so the animation tick can replace it each frame for