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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 10:40:29 +02:00
parent e74efc5c06
commit ab98cda26b
6 changed files with 150 additions and 12 deletions

View file

@ -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 ## #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 **Severity:** HIGH
**Component:** retained UI / input lifecycle **Component:** retained UI / input lifecycle

View file

@ -60,6 +60,29 @@ GetDefaultCombatMode(showError):
returns the first entry whose location intersects the requested mask (and whose returns the first entry whose location intersects the requested mask (and whose
priority intersects the requested priority when that priority is non-zero). 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 ## ParentEvent wire and freshness
```text ```text

View file

@ -537,7 +537,13 @@ public static class GameEventWiring
items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType); items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType);
} }
foreach (var eq in p.Value.Equipped) 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 // D.5.1 Task 4: forward shortcut bar entries to the caller so the
// toolbar can read them without holding a parser reference. // toolbar can read them without holding a parser reference.

View file

@ -481,11 +481,14 @@ public sealed class ClientObjectTable
/// <summary> /// <summary>
/// PlayerDescription manifest: record that this guid is the player's /// PlayerDescription manifest: record that this guid is the player's
/// (in inventory or equipped at <paramref name="equip"/>), creating an /// (in inventory or equipped at <paramref name="equip"/>), creating an
/// empty entry if CreateObject hasn't arrived yet. Never touches /// empty entry if CreateObject hasn't arrived yet. Equipped entries may
/// icon/name/type/effects — that data comes from CreateObject. /// specify the player as <paramref name="containerId"/> so login and live
/// WieldObject updates share the same contained-by-wielder projection.
/// Never touches icon/name/type/effects — that data comes from CreateObject.
/// </summary> /// </summary>
public ClientObject RecordMembership(uint guid, uint containerId = 0, 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); bool existed = _objects.TryGetValue(guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis if (!existed || obj is null) // keep: satisfies nullable flow analysis
@ -495,15 +498,12 @@ public sealed class ClientObjectTable
} }
uint oldContainer = obj.ContainerId; uint oldContainer = obj.ContainerId;
if (containerId != 0) if (containerId != 0)
{
obj.ContainerId = containerId; obj.ContainerId = containerId;
obj.CurrentlyEquippedLocation = EquipMask.None; obj.CurrentlyEquippedLocation = equip;
} if (equip != EquipMask.None)
else obj.ContainerSlot = -1;
{
obj.CurrentlyEquippedLocation = equip;
}
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint; if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
if (priority is { } p) obj.Priority = p;
Reindex(obj, oldContainer); Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj; return obj;

View file

@ -452,6 +452,61 @@ public sealed class GameEventWiringTests
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); 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] [Fact]
public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState() public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState()
{ {

View file

@ -359,6 +359,28 @@ public sealed class ClientObjectTableTests
Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it 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] [Fact]
public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject
{ {