From ab98cda26b6b38bb308b0c9554d71729faa7997d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Jul 2026 10:40:29 +0200 Subject: [PATCH] fix #211: index login-equipped items under player Project PlayerDescription equipment through the same contained-by-wielder ownership and ordered contents index as live WieldObject updates. Preserve equip masks and priorities so retail GetObjectAtLocation selects Missile for an already-equipped crossbow instead of sending a server-rejected Melee request. Co-Authored-By: Codex --- docs/ISSUES.md | 34 +++++++++++- ...bat-default-and-parent-event-pseudocode.md | 23 ++++++++ src/AcDream.Core.Net/GameEventWiring.cs | 8 ++- src/AcDream.Core/Items/ClientObjectTable.cs | 20 +++---- .../GameEventWiringTests.cs | 55 +++++++++++++++++++ .../Items/ClientObjectTableTests.cs | 22 ++++++++ 6 files changed, 150 insertions(+), 12 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2c4b1e46..d498e965 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,9 +46,41 @@ Copy this block when adding a new issue: --- +## #211 — Login-equipped missile weapon incorrectly requests melee combat + +**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending user gate +**Severity:** HIGH +**Component:** inventory projection / combat mode + +**Description:** After relaunching with a bow or crossbow already equipped, +the combat button and grave key briefly selected Melee before ACE restored +NonCombat, so the character could not enter combat. + +**Root cause:** PlayerDescription recorded equipped entries with their equip +mask but no player ownership/container index. Live WieldObject updates did +index the same item under the player. `ToggleLiveCombatMode` reads the player's +ordered contents for retail `GetObjectAtLocation`, so it missed login equipment, +defaulted to Melee, and sent a mode incompatible with ACE's equipped missile +weapon. ACE correctly returned authoritative NonCombat. + +**Resolution:** PlayerDescription equipment now uses the same +contained-by-wielder projection as live WieldObject while preserving its wire +order, equip mask, and layering priority. The production combat planner now +selects Missile for the login-equipped crossbow. The connected gate completed +NonCombat → Missile → NonCombat → Missile, including a Jump press, without a +server rejection. + +**Research:** `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md` + +**Acceptance:** Relaunch with the crossbow already equipped. The combat button +and grave key enter Missile mode, the combat bar appears, returning to peace +hides it, and Jump followed by combat still works without a crash. + +--- + ## #210 — Mouse-up crashes when a UI callback changes pointer capture -**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending live gate +**Status:** DONE — 2026-07-13, user confirmed the crash no longer occurs (`e74efc5c`) **Severity:** HIGH **Component:** retained UI / input lifecycle diff --git a/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md b/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md index db6b52c8..1e7e52aa 100644 --- a/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md +++ b/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md @@ -60,6 +60,29 @@ GetDefaultCombatMode(showError): returns the first entry whose location intersects the requested mask (and whose priority intersects the requested priority when that priority is non-zero). +### Login projection invariant + +PlayerDescription carries loose inventory and equipped objects as two ordered +lists. They must feed one player-owned location lookup after parsing: + +```text +OnPlayerDescription(player, inventory, equipped): + replace player-owned loose contents with inventory, preserving wire order + for each equipped entry in wire order: + record item as owned by player + record equipped location and layering priority + append it to the same ordered player-owned lookup +``` + +This is the login equivalent of a live `WieldObject`, which also models the +item as contained by its wielder with a non-zero equipped location. Keeping +login equipment outside that lookup makes `GetDefaultCombatMode` miss an +already-equipped bow/crossbow and request Melee; ACE then correctly rejects the +mode because a missile weapon is equipped. ACE's +`Player.HandleActionChangeCombatMode_Inner` performs that rejection, while +holtburger independently retains PlayerDescription's `equipped_objects` list +for its suggested-combat-mode decision. + ## ParentEvent wire and freshness ```text diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 873b6216..137f8d1c 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -537,7 +537,13 @@ public static class GameEventWiring items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType); } foreach (var eq in p.Value.Equipped) - items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation); + { + items.RecordMembership( + eq.Guid, + containerId: ownerGuid, + equip: (EquipMask)eq.EquipLocation, + priority: eq.Priority); + } // D.5.1 Task 4: forward shortcut bar entries to the caller so the // toolbar can read them without holding a parser reference. diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 1943d7a4..f28697bd 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -481,11 +481,14 @@ public sealed class ClientObjectTable /// /// PlayerDescription manifest: record that this guid is the player's /// (in inventory or equipped at ), creating an - /// empty entry if CreateObject hasn't arrived yet. Never touches - /// icon/name/type/effects — that data comes from CreateObject. + /// empty entry if CreateObject hasn't arrived yet. Equipped entries may + /// specify the player as so login and live + /// WieldObject updates share the same contained-by-wielder projection. + /// Never touches icon/name/type/effects — that data comes from CreateObject. /// public ClientObject RecordMembership(uint guid, uint containerId = 0, - EquipMask equip = EquipMask.None, uint? containerTypeHint = null) + EquipMask equip = EquipMask.None, uint? containerTypeHint = null, + uint? priority = null) { bool existed = _objects.TryGetValue(guid, out var obj); if (!existed || obj is null) // keep: satisfies nullable flow analysis @@ -495,15 +498,12 @@ public sealed class ClientObjectTable } uint oldContainer = obj.ContainerId; if (containerId != 0) - { obj.ContainerId = containerId; - obj.CurrentlyEquippedLocation = EquipMask.None; - } - else - { - obj.CurrentlyEquippedLocation = equip; - } + obj.CurrentlyEquippedLocation = equip; + if (equip != EquipMask.None) + obj.ContainerSlot = -1; if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint; + if (priority is { } p) obj.Priority = p; Reindex(obj, oldContainer); if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); return obj; diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 2878cf43..3f88c459 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -452,6 +452,61 @@ public sealed class GameEventWiringTests Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); } + [Fact] + public void PlayerDescription_IndexesLoginEquipmentForDefaultCombatMode() + { + const uint playerGuid = 0x50000001u; + const uint packItemGuid = 0x50000A01u; + const uint crossbowGuid = 0x50000B01u; + var dispatcher = new GameEventDispatcher(); + var items = new ClientObjectTable(); + GameEventWiring.WireAll( + dispatcher, + items, + new CombatState(), + new Spellbook(), + new ChatLog(), + playerGuid: () => playerGuid); + + var sb = new MemoryStream(); + using var w = new BinaryWriter(sb); + w.Write(0u); // propertyFlags + w.Write(0x52u); // weenieType + w.Write(0x201u); // ATTRIBUTE | ENCHANTMENT + w.Write(1u); // has_health + w.Write(0u); // attribute_flags + w.Write(0u); // enchantment_mask + w.Write(0u); // option_flags + w.Write(0u); // options1 + w.Write(0u); // legacy hotbar count + w.Write(0u); // spellbook_filters + w.Write(1u); // inventory count + w.Write(packItemGuid); w.Write(0u); + w.Write(1u); // equipped count + w.Write(crossbowGuid); + w.Write((uint)EquipMask.MissileWeapon); + w.Write(7u); // layering priority + + var envelope = GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); + dispatcher.Dispatch(envelope!.Value); + + ClientObject crossbow = items.Get(crossbowGuid)!; + crossbow.Type = ItemType.MissileWeapon; + crossbow.CombatUse = 2; + var orderedPlayerContents = items.GetContents(playerGuid) + .Select(guid => items.Get(guid)!) + .ToList(); + + Assert.Equal(new[] { packItemGuid, crossbowGuid }, items.GetContents(playerGuid)); + Assert.Equal(playerGuid, crossbow.ContainerId); + Assert.Equal(EquipMask.MissileWeapon, crossbow.CurrentlyEquippedLocation); + Assert.Equal(7u, crossbow.Priority); + Assert.Equal( + CombatMode.Missile, + CombatInputPlanner.GetDefaultCombatMode(orderedPlayerContents)); + } + [Fact] public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState() { diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index dea8d7d4..0afafaf7 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -359,6 +359,28 @@ public sealed class ClientObjectTableTests Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it } + [Fact] + public void RecordMembership_OwnedEquip_UsesSameIndexShapeAsLiveWield() + { + const uint playerGuid = 0x50000001u; + const uint weaponGuid = 0x500000B9u; + var table = new ClientObjectTable(); + + table.RecordMembership( + weaponGuid, + containerId: playerGuid, + equip: EquipMask.MissileWeapon, + priority: 7u); + + var weapon = table.Get(weaponGuid); + Assert.NotNull(weapon); + Assert.Equal(playerGuid, weapon!.ContainerId); + Assert.Equal(-1, weapon.ContainerSlot); + Assert.Equal(EquipMask.MissileWeapon, weapon.CurrentlyEquippedLocation); + Assert.Equal(7u, weapon.Priority); + Assert.Equal(new[] { weaponGuid }, table.GetContents(playerGuid)); + } + [Fact] public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject {