diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index fef89e87..e5611c85 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2357,6 +2357,10 @@ 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.
+ AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects);
_liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.EntityDeleted += OnLiveEntityDeleted;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
@@ -2632,13 +2636,6 @@ public sealed class GameWindow : IDisposable
_liveSession.VitalCurrentUpdated += v =>
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
- // D.5.2: live PublicUpdatePropertyInt(0x02CE). Route UiEffects (18) to the item
- // repository so a draining/charging item re-composites its icon in real time.
- _liveSession.ObjectIntPropertyUpdated += u =>
- {
- if (u.Property == AcDream.Core.Items.ClientObjectTable.UiEffectsPropertyId)
- Objects.UpdateIntProperty(u.Guid, u.Property, u.Value);
- };
}
///
@@ -2648,14 +2645,6 @@ public sealed class GameWindow : IDisposable
///
private void OnLiveEntitySpawned(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
{
- // D.5.1: enrich a known inventory/equipped item (stubbed from PlayerDescription)
- // with the icon/name/type its CreateObject carries, so the toolbar can render it.
- // D.5.1 (2026-06-17): also pass overlay/underlay ids from the extended
- // WeenieHeader tail so IconComposer composites all icon layers.
- Objects.EnrichItem(spawn.Guid, spawn.IconId, spawn.Name ?? string.Empty,
- (AcDream.Core.Items.ItemType)(spawn.ItemType ?? 0),
- spawn.IconOverlayId, spawn.IconUnderlayId, spawn.UiEffects);
-
// Phase A.1 hotfix: live CreateObject handler reads dats extensively
// (Setup, GfxObj, Surface, SurfaceTexture) to hydrate the spawned
// entity. All of it must run under the dat lock so it doesn't race
diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs
new file mode 100644
index 00000000..463d7d9e
--- /dev/null
+++ b/src/AcDream.Core.Net/ObjectTableWiring.cs
@@ -0,0 +1,57 @@
+using AcDream.Core.Items;
+
+namespace AcDream.Core.Net;
+
+///
+/// Wires WorldSession GameMessage-level object events into the client object
+/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
+/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite.
+/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
+/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
+///
+public static class ObjectTableWiring
+{
+ ///
+ /// Subscribe to the three object-lifecycle events
+ /// on . Call this BEFORE the render handler subscribes
+ /// to EntitySpawned so the table is populated before the render path runs.
+ ///
+ public static void Wire(WorldSession session, ClientObjectTable table)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentNullException.ThrowIfNull(table);
+
+ session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
+ session.EntityDeleted += d => table.Remove(d.Guid);
+ session.ObjectIntPropertyUpdated += u =>
+ {
+ if (u.Property == ClientObjectTable.UiEffectsPropertyId)
+ table.UpdateIntProperty(u.Guid, u.Property, u.Value);
+ };
+ }
+
+ /// Translate the wire spawn into the table's merge patch.
+ public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new(
+ Guid: s.Guid,
+ Name: s.Name,
+ Type: s.ItemType is { } it ? (ItemType)it : (ItemType?)null,
+ WeenieClassId: s.WeenieClassId,
+ IconId: s.IconId,
+ IconOverlayId: s.IconOverlayId,
+ IconUnderlayId: s.IconUnderlayId,
+ Effects: s.UiEffects,
+ Value: s.Value,
+ StackSize: s.StackSize,
+ StackSizeMax: s.StackSizeMax,
+ Burden: s.Burden,
+ ContainerId: s.ContainerId,
+ WielderId: s.WielderId,
+ ValidLocations: s.ValidLocations,
+ CurrentWieldedLocation: s.CurrentWieldedLocation,
+ Priority: s.Priority,
+ ItemsCapacity: s.ItemsCapacity,
+ ContainersCapacity: s.ContainersCapacity,
+ Structure: s.Structure,
+ MaxStructure: s.MaxStructure,
+ Workmanship: s.Workmanship);
+}
diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
new file mode 100644
index 00000000..9c3d099a
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
@@ -0,0 +1,97 @@
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.Core.Net.Tests;
+
+///
+/// D.5.4 Task 7 — ObjectTableWiring.
+///
+/// Integration test is omitted: WorldSession.EntitySpawned has no internal
+/// test seam to fire it without a real Tick + packet bytes, so subscription
+/// correctness is covered by build (type-checks) + the live run. Only the
+/// pure mapping (ToWeenieData) is unit-tested here.
+///
+public sealed class ObjectTableWiringTests
+{
+ [Fact]
+ public void ToWeenieData_CopiesFieldsFromSpawn()
+ {
+ // Every EntitySpawn item field is set to a DISTINCT recognisable value so
+ // a positional transposition in ObjectTableWiring.ToWeenieData would trip
+ // at least one Assert. All 22 WeenieData fields are verified below.
+ var spawn = new WorldSession.EntitySpawn(
+ Guid: 0x00000600u,
+ Position: null,
+ SetupTableId: null,
+ AnimPartChanges: System.Array.Empty(),
+ TextureChanges: System.Array.Empty(),
+ SubPalettes: System.Array.Empty(),
+ BasePaletteId: null,
+ ObjScale: null,
+ Name: "Iron Sword",
+ ItemType: (uint)ItemType.MeleeWeapon,
+ MotionState: null,
+ MotionTableId: null)
+ with
+ {
+ WeenieClassId = 0x00001001u,
+ IconId = 0x06001111u,
+ IconOverlayId = 0x06002222u,
+ IconUnderlayId = 0x06003333u,
+ UiEffects = 0x00000004u,
+ Value = 7,
+ StackSize = 1,
+ StackSizeMax = 1,
+ Burden = 300,
+ ContainerId = 0x000000C9u,
+ WielderId = 0x000000DAu,
+ ValidLocations = 0x00000012u, // MeleeWeapon wield mask
+ CurrentWieldedLocation = 0x00000002u, // right-hand
+ Priority = 0x00000005u,
+ ItemsCapacity = 0,
+ ContainersCapacity = 0,
+ Structure = 80,
+ MaxStructure = 100,
+ Workmanship = 4.5f,
+ };
+
+ var d = ObjectTableWiring.ToWeenieData(spawn);
+
+ // --- identity ---
+ Assert.Equal(0x00000600u, d.Guid);
+ Assert.Equal("Iron Sword", d.Name);
+ Assert.Equal(ItemType.MeleeWeapon, d.Type);
+
+ // --- weenie / icon ---
+ Assert.Equal(0x00001001u, d.WeenieClassId);
+ Assert.Equal(0x06001111u, d.IconId);
+ Assert.Equal(0x06002222u, d.IconOverlayId);
+ Assert.Equal(0x06003333u, d.IconUnderlayId);
+ Assert.Equal(0x00000004u, d.Effects);
+
+ // --- quantity / economy ---
+ Assert.Equal(7, d.Value);
+ Assert.Equal(1, d.StackSize);
+ Assert.Equal(1, d.StackSizeMax);
+ Assert.Equal(300, d.Burden);
+
+ // --- container / wielder ---
+ Assert.Equal(0x000000C9u, d.ContainerId);
+ Assert.Equal(0x000000DAu, d.WielderId);
+
+ // --- equip masks ---
+ Assert.Equal(0x00000012u, d.ValidLocations);
+ Assert.Equal(0x00000002u, d.CurrentWieldedLocation);
+ Assert.Equal(0x00000005u, d.Priority);
+
+ // --- capacity ---
+ Assert.Equal(0, d.ItemsCapacity);
+ Assert.Equal(0, d.ContainersCapacity);
+
+ // --- durability ---
+ Assert.Equal(80, d.Structure);
+ Assert.Equal(100, d.MaxStructure);
+ Assert.Equal(4.5f, d.Workmanship);
+ }
+}