From 82f59683163affabe27e869abf6a23d39a733e3a Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 16:24:58 +0200 Subject: [PATCH] feat(D.5.4): ObjectTableWiring (CreateObject=upsert, Delete=evict, 0x02CE) off GameWindow CreateObject ingestion moves to Core.Net; GameWindow drops the EnrichItem call + inline 0x02CE handler. Fixes the Coldeve blank-icon root cause: items with no PD stub are now created, not dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 19 +--- src/AcDream.Core.Net/ObjectTableWiring.cs | 57 +++++++++++ .../ObjectTableWiringTests.cs | 97 +++++++++++++++++++ 3 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 src/AcDream.Core.Net/ObjectTableWiring.cs create mode 100644 tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs 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); + } +}