diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 024b5dfa..7443bf43 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) +## 2. Adaptation (AD) — 38 rows (AD-11 retired 2026-07-23 — exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -73,7 +73,7 @@ accepted-divergence entries (#96, #49, #50). | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | -| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow null for the established creature/large-object fallback while preserving explicit zero/one rejection | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsUseable`) | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally leaves without useability can become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923; `ItemUses::IsUseable @ 0x004FCCC0` | +| ~~AD-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | | AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB | | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | @@ -203,7 +203,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` | | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | -| AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **Primary replacement retired from this row 2026-07-14:** inventory activation and paperdoll drops share the confirmed blocker transaction, preserve explicit slot intent, relocate an already-worn item to an explicitly selected compatible slot, and emit retail's successful move-to-backpack status. **Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; primary weapon, incompatible shield, and mismatched ammo blockers sequence through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | +| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/examine/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat plus M3 spellbook/component/effects/favorite-spell/Link/Vitae surfaces now cover the active melee/missile/magic loops; the Link page has exact ping RTT but reports the current transport's zero default until its missing reliability statistics land | Large portions of retail gameplay still have no production UI; real packet loss is displayed as 0.00% instead of retail's moving average | Named `gm*UI::PostInit` methods; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 089d390f..bb9e4a5e 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -15,11 +15,14 @@ assessment information, equipped-child picking, vendor browsing, and server-authoritative vendor transactions. Slice 1's DAT-authored arrow-only favorite-spell scrollbar, fixed 18-cell capacity, authored arrow sides/media, and persistent manual offset passed the connected gate. Slice 2 is active: the -status hand now follows canonical selection/object notices, ghosts empty and -unusable selections, and routes enabled weapons, direct-use items, and -use-on-target tools through the existing interaction transaction and cursor -owners. Its connected hand-state gate is pending; the magnifying-glass Assess -command follows in the same slice. +status hand follows canonical selection/object notices, ghosts empty and +explicitly unusable selections, and routes enabled weapons, direct-use items, +and use-on-target tools through the existing interaction transaction and +cursor owners. Its connected state gate passed. A named-retail correction now +treats reset/absent useability zero as usable, allowing Blackmoor's Favor to +send ordinary Use, while shared AutoWear reports the exact overlapping worn +item that must be removed. That correction awaits its connected gate; the +magnifying-glass Assess command follows in the same slice. --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index b1bc8d83..db7126b6 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -91,8 +91,10 @@ broaden the feature surface. Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2 is active: the status hand's selected-object availability and existing -Use/AutoWield/use-on-target command routing are code-complete, with the -connected state/cursor gate pending before the Assess half continues. +Use/AutoWield/use-on-target command routing passed their connected gate. +The follow-up retail correction for zero-valued direct-use items and named +AutoWear blockers is code-complete and awaiting its connected gate before the +Assess half continues. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index b4521b37..9216b058 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -1,7 +1,8 @@ # World interaction completion — pre-M4 program -**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand state and command -path code-complete; connected visual gate pending before Assess continues. +**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection state +passed its connected gate. The follow-up zero-useability/AutoWear correction is +code-complete and awaits its connected gate before Assess continues. **Milestone:** M4 prerequisite/preamble. **Architecture:** retained gameplay UI over shared selection, object, and interaction state. `GameWindow` remains a composition/callback shell. @@ -69,6 +70,20 @@ AP-122. The 14-test Core interaction focus, 49-test toolbar focus, Release solution build, 3,474 App tests / 3 skips, and 7,848 complete-solution tests / 5 skips pass. +The connected selection-state gate passed. Its follow-up exposed two deeper +shared-policy defects, now corrected from named retail plus the matching +binary. `ItemUses::IsUseable @ 0x004FCCC0` tests only `USEABLE_NO`; reset/absent +value zero is usable, so Blackmoor's Favor and other zero-valued direct-use +items now emit the ordinary Use request. AutoWear now applies +`CPlayerSystem::AutoWearIsLegal @ 0x0055EF40` through the same double-click and +toolbar-hand path, resolves the overlapping worn object from the +retail-ordered equipment projection, and emits the exact system line +`You must remove your to wear that`. Research: +[`../research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`](../research/2026-07-23-retail-item-use-and-autowear-pseudocode.md). +The focused 25-test Core and 85-test App sets pass, as do the warning-free +Release solution build, 3,476 App tests / 3 skips, and 7,857 complete-solution +tests / 5 skips. + ## Slice 1 — spell-bar overflow arrows ### Retail oracle @@ -148,7 +163,8 @@ Named retail references and executable pseudocode are recorded in ### Remaining Slice 2 work - Port the magnifying-glass Assess command state and activation. -- Run the connected Use-hand visual gate: no selection, spell component, - weapon, and health kit. +- Run the connected correction gate: activate Blackmoor's Favor, then try to + equip armor blocked by an overlapping worn item and verify the named system + message. - Continue directly into the Assess information slice after the combined status-control gate. diff --git a/docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md b/docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md new file mode 100644 index 00000000..e74c1f74 --- /dev/null +++ b/docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md @@ -0,0 +1,111 @@ +# Retail item useability and AutoWear rejection + +Date: 2026-07-23 + +## Retail anchors + +- `ItemUses::ItemUses @ 0x004FCCB0` +- `ItemUses::IsUseable @ 0x004FCCC0` +- `PublicWeenieDesc::Reset @ 0x005ACC70` +- `PublicWeenieDesc::UnPack @ 0x005AD470` +- `ItemHolder::DetermineUseResult @ 0x00588460` +- `ItemHolder::UseObject @ 0x00588A80` +- `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40` +- `CPlayerSystem::AutoWear @ 0x005601C0` +- `ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00` + +The named pseudo-C is the behavioral oracle. The matching v11.4186 binary was +also inspected because an older project note interpreted +`ItemUses::IsUseable` as a nonzero test. The actual instructions are: + +```text +004fccc0 mov eax,[ecx] +004fccc2 not eax +004fccc4 and eax,1 +004fccc7 ret +``` + +Therefore the method tests only `USEABLE_NO` (bit zero). A zero-valued +`ITEM_USEABLE` is usable; `USEABLE_NEVER_WALK` is a movement modifier and does +not make the object unusable. The literal AutoWear rejection at `0x007CC868` +is: + +```text +You must remove your %s to wear that +``` + +The staged ACE, holtburger, and ACViewer reference directories are currently +empty in this checkout. Their established roles were still checked: ACE owns +the authoritative use/wield outcome, while the two client references do not +replace the retail UI-side legality and notice oracle. No server behavior is +being inferred or moved client-side here. + +## Pseudocode + +```text +ItemUses.IsUseable(): + return (useableBitfield & USEABLE_NO) == 0 +``` + +`PublicWeenieDesc::Reset` zeros the useability field. `UnPack` replaces it only +when the optional useability word is present. Consequently an absent wire word +and an explicitly packed zero both have retail `USEABLE_UNDEF` value zero and +both pass `IsUseable`. + +```text +AutoWearIsLegal(itemId, out alreadyWorn, silent): + alreadyWorn = false + + if player is not ready for an inventory request: + return false + + item = GetWeenieObject(itemId) + if item is null: + return false + if (item.validLocations & 0x08007FFF) == 0: + return false + + if (item.priority & playerSystem.clothingPriorityMask) == 0: + return true + + player = GetWeenieObject(playerId) + if player contains item at a worn location: + alreadyWorn = true + + if not silent: + if alreadyWorn: + display "The is already being worn" + else: + occupied = playerSystem.inventoryMask & item.validLocations + blocker = player.GetObjectAtLocation(occupied, item.priority) + if blocker exists: + display "You must remove your to wear that" + + return false +``` + +```text +AutoWear(itemId, out alreadyWorn, silent): + if not AutoWearIsLegal(itemId, alreadyWorn, silent): + return false + + item = GetWeenieObject(itemId) + if item exists: + item.UIAttemptWield(item.validLocations) + return true +``` + +## Port boundary + +- `ItemUseability.IsUseable` is the one Core implementation of the low-bit + rule. Toolbar enablement, item activation, and world interaction consume it. +- `ItemInteractionController` remains the shared double-click and toolbar-hand + path. A zero-valued carried gem such as Blackmoor's Favor therefore emits the + ordinary Use request; ACE remains authoritative for its spell and + consumption outcome. +- `AutoWieldController` remains the shared inventory/paperdoll equip owner. It + resolves AutoWear blockers from `ClientObjectTable`'s retail-ordered + equipment projection and emits the retail literal through system chat. +- Weapon replacement is unchanged. Retail automatically moves primary weapon, + incompatible shield, and incompatible ammunition blockers, while AutoWear + rejects overlapping clothing/armor and tells the player what to remove. diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 9d2bc30a..7fe7e363 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -307,13 +307,9 @@ internal sealed class WorldSelectionQuery public bool IsUseable(uint serverGuid) { if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn)) - { - if (spawn.Useability is uint useability) - return useability is not 0u and not 1u; - if (((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u) - return true; - } - return (GetItemType(serverGuid) & ItemType.Creature) != 0; + return ItemUseability.IsUseable( + spawn.Useability ?? ItemUseability.Undef); + return false; } /// ItemHolder::DetermineUseResult @ 0x00588460 pickup gate. diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index 5c0d4751..e0cd8d66 100644 --- a/src/AcDream.App/UI/AutoWieldController.cs +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -164,6 +164,26 @@ internal sealed class AutoWieldController : IDisposable return SendWield(item, requestedMask, combatModeAfterWield); } + if (ItemEquipRules.IsAutoWearItem(item)) + { + if (!AutoWearIsLegal(item, out ClientObject? blocker)) + { + if (blocker is not null) + { + _systemMessage?.Invoke( + $"You must remove your {blocker.GetAppropriateName()} to wear that"); + } + return false; + } + + // CPlayerSystem::AutoWear @ 0x005601C0 always supplies the complete + // PublicWeenieDesc valid-locations mask to UIAttemptWield. + return SendWield( + item, + item.ValidLocations, + combatModeAfterWield: null); + } + EquipMask weaponLocation = requestedMask == EquipMask.None ? item.ValidLocations & WeaponReadyMask : requestedMask & WeaponReadyMask; @@ -424,9 +444,6 @@ internal sealed class AutoWieldController : IDisposable private EquipMask BestAvailableEquipMask(ClientObject item) { - if (ItemEquipRules.IsAutoWearItem(item)) - return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None; - foreach (EquipMask mask in AutoEquipOrder) { if ((item.ValidLocations & mask) == EquipMask.None) @@ -437,16 +454,22 @@ internal sealed class AutoWieldController : IDisposable return EquipMask.None; } - private bool AutoWearIsLegal(ClientObject item) + private bool AutoWearIsLegal( + ClientObject item, + out ClientObject? blocker) { uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId); if ((item.Priority & priorityMask) == 0) + { + blocker = null; return true; + } EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations; - return GetEquippedObjectAtLocation( - occupiedLocations, item.Priority, item.ObjectId) is null; + blocker = GetEquippedObjectAtLocation( + occupiedLocations, item.Priority, item.ObjectId); + return false; } private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid) @@ -455,11 +478,10 @@ internal sealed class AutoWieldController : IDisposable private uint EquippedAutoWearPriorityMask(uint exceptGuid) { uint mask = 0; - foreach (ClientObject item in _objects.Objects) + foreach (ClientObject item in _objects.GetEquippedBy(_playerGuid())) { if (item.ObjectId == exceptGuid - || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None - || !IsEquippedByPlayer(item)) + || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None) continue; mask |= item.Priority; } @@ -469,11 +491,10 @@ internal sealed class AutoWieldController : IDisposable private EquipMask EquippedAutoWearLocationMask(uint exceptGuid) { EquipMask mask = EquipMask.None; - foreach (ClientObject item in _objects.Objects) + foreach (ClientObject item in _objects.GetEquippedBy(_playerGuid())) { if (item.ObjectId == exceptGuid - || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None - || !IsEquippedByPlayer(item)) + || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None) continue; mask |= item.CurrentlyEquippedLocation; } @@ -488,10 +509,9 @@ internal sealed class AutoWieldController : IDisposable if (locationMask == EquipMask.None) return null; - foreach (ClientObject item in _objects.Objects) + foreach (ClientObject item in _objects.GetEquippedBy(_playerGuid())) { if (item.ObjectId == exceptGuid - || !IsEquippedByPlayer(item) || (item.CurrentlyEquippedLocation & locationMask) == EquipMask.None) continue; if ((item.Priority & priority) != 0 || priority == 0) @@ -500,13 +520,6 @@ internal sealed class AutoWieldController : IDisposable return null; } - private bool IsEquippedByPlayer(ClientObject item) - { - uint player = _playerGuid(); - return item.CurrentlyEquippedLocation != EquipMask.None - && (item.WielderId == player || item.ContainerId == player); - } - internal static bool BlocksUseOfShield(ClientObject item) { byte combatUse = item.CombatUse ?? 0; diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 7cef7b3f..3e2ff8ab 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -310,6 +310,15 @@ public static class ItemUseability public static uint TargetFlags(uint useability) => (useability & TargetMask) >> 16; + /// + /// Retail ItemUses::IsUseable @ 0x004FCCC0. The implementation + /// complements the full bitfield and returns its low bit, so only + /// USEABLE_NO disables use. In particular, the default + /// USEABLE_UNDEF value zero is usable. + /// + public static bool IsUseable(uint useability) + => (useability & No) == 0; + /// /// Retail ItemUses::GetLeastLimitedTargetUse (0x004fcd50): the most /// permissive target-use bit present, priority Remote > Viewed > @@ -330,11 +339,7 @@ public static class ItemUseability } public static bool IsDirectUseable(uint useability) - { - uint source = SourceFlags(useability); - return !IsTargeted(useability) - && (source & ~(No | NeverWalk)) != Undef; - } + => !IsTargeted(useability) && IsUseable(useability); } /// diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs index a4fdc9ee..cb44e751 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -166,7 +166,7 @@ public static class ItemInteractionPolicy uint useability) => combatUse != 0 || (type & ToolbarEquipmentTypes) != 0 - || (useability & ItemUseability.No) == 0; + || ItemUseability.IsUseable(useability); public static ItemPrimaryUseResult DetermineUseResult( in ItemPolicyObject item, @@ -207,7 +207,7 @@ public static class ItemInteractionPolicy return ItemPrimaryUseResult.BeginGame; } - if (IsUseable(item.Useability)) + if (ItemUseability.IsUseable(item.Useability)) return ItemPrimaryUseResult.ItemUse; if (item.IsPlayer && item.Id != playerId) @@ -261,7 +261,7 @@ public static class ItemInteractionPolicy return Consumed(actions); } - if (IsUseable(source.Useability)) + if (ItemUseability.IsUseable(source.Useability)) { if ((source.Flags & PublicWeenieFlags.PlayerKillerSwitch) != 0) return Consumed(new ItemPolicyAction( @@ -406,7 +406,7 @@ public static class ItemInteractionPolicy if (item.OwnedByPlayer && item.IsContainer) actions.Add(new ItemPolicyAction(ItemPolicyActionKind.OpenContainedContainer, item.Id)); if (!item.OwnedByPlayer && item.IsContainer - && IsUseable(item.Useability) + && ItemUseability.IsUseable(item.Useability) && !ItemUseability.IsTargeted(item.Useability)) actions.Add(new ItemPolicyAction(ItemPolicyActionKind.SetGroundObject, item.Id, groundObjectId)); @@ -418,10 +418,6 @@ public static class ItemInteractionPolicy || ((valid & ClothingLocations) != 0 && (current & ClothingLocations) == 0) || ((valid & HeldLocations) != 0 && (current & HeldLocations) == 0); - private static bool IsUseable(uint useability) - => (ItemUseability.SourceFlags(useability) - & ~(ItemUseability.No | ItemUseability.NeverWalk)) != 0; - private static ItemPlacementPolicyDecision PlaceOnGround( in ItemPlacementPolicyInput input) { diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs index a1cc6f13..cf12a08c 100644 --- a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -279,6 +279,29 @@ public sealed class WorldSelectionQueryTests Assert.True(h.Query.IsPickupable(looseComponent)); } + [Fact] + public void UseabilityUsesRetailLowNoBitForAbsentAndExplicitValues() + { + var h = new Harness(); + const uint absent = 0x7000_0022u; + const uint zero = 0x7000_0023u; + const uint no = 0x7000_0024u; + const uint neverWalk = 0x7000_0025u; + h.Add(absent, Vector3.UnitX, ItemType.Gem); + h.Add(zero, Vector3.UnitX * 2f, ItemType.Gem, useability: 0u); + h.Add(no, Vector3.UnitX * 3f, ItemType.Gem, useability: ItemUseability.No); + h.Add( + neverWalk, + Vector3.UnitX * 4f, + ItemType.Gem, + useability: ItemUseability.NeverWalk); + + Assert.True(h.Query.IsUseable(absent)); + Assert.True(h.Query.IsUseable(zero)); + Assert.False(h.Query.IsUseable(no)); + Assert.True(h.Query.IsUseable(neverWalk)); + } + [Fact] public void SelectionSphereAppliesSetupOffsetScaleAndRotation() { diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 0684ce5b..c9656b46 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -436,6 +436,24 @@ public sealed class ItemInteractionControllerTests Assert.Equal(new[] { 0x50000A03u }, h.Uses); } + [Fact] + public void ZeroValuedUseabilityGem_sendsUseLikeBlackmoorsFavor() + { + var h = new Harness(); + const uint favor = 0x50000A30u; + h.AddContained(favor, item => + { + item.Name = "Blackmoor's Favor"; + item.Type = ItemType.Gem; + item.Useability = ItemUseability.Undef; + }); + + Assert.True(h.Controller.ActivateItem(favor)); + + Assert.Equal(new[] { favor }, h.Uses); + Assert.Equal(1, h.Controller.BusyCount); + } + [Fact] public void ContainerItem_sendsUseToOpen() { @@ -527,6 +545,7 @@ public sealed class ItemInteractionControllerTests h.Objects.AddOrUpdate(new ClientObject { ObjectId = 0x50000AF2u, + Name = "Chainmail Hauberk", Type = ItemType.Clothing, CurrentlyEquippedLocation = EquipMask.UpperArmWear, Priority = 0x00000004u, @@ -547,6 +566,10 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Wields); Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId); + Assert.Equal( + new[] { "You must remove your Chainmail Hauberk to wear that" }, + h.SystemMessages); + Assert.Empty(h.Toasts); } [Fact] @@ -563,12 +586,15 @@ public sealed class ItemInteractionControllerTests h.AddContained(0x50000A06u, item => { item.Type = ItemType.Armor; + item.CombatUse = 4; // COMBAT_USE_SHIELD item.ValidLocations = EquipMask.Shield; + item.Useability = ItemUseability.No; }); - Assert.False(h.Controller.ActivateItem(0x50000A06u)); + bool activated = h.Controller.ActivateItem(0x50000A06u); Assert.Empty(h.Wields); + Assert.False(activated); Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId); } diff --git a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs index e7d478db..228d563f 100644 --- a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs +++ b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs @@ -78,6 +78,30 @@ public sealed class ItemInteractionPolicyTests Determine(owned with { Useability = ItemUseability.Contained })); } + [Fact] + public void DetermineUseResult_zeroValuedOwnedGem_isRetailItemUse() + { + var favor = Obj(0x6002) with + { + Type = ItemType.Gem, + ContainerId = Player, + OwnedByPlayer = true, + Flags = PublicWeenieFlags.RequiresPackSlot, + Useability = ItemUseability.Undef, + }; + + Assert.Equal(ItemPrimaryUseResult.ItemUse, Determine(favor)); + + var decision = ItemInteractionPolicy.DecideUse(Use(favor)); + Assert.Equal( + new[] + { + ItemPolicyActionKind.SendUse, + ItemPolicyActionKind.IncrementBusy, + }, + decision.Actions.Select(action => action.Kind)); + } + [Fact] public void DetermineUseResult_nonOwnedGameAndPlayerPaths_areDistinct() { @@ -259,7 +283,7 @@ public sealed class ItemInteractionPolicyTests CombatUse: 0, ItemsCapacity: 0, ContainersCapacity: 0, - Useability: 0, + Useability: ItemUseability.No, TargetType: 0, OwnedByPlayer: false, IsContainer: false, diff --git a/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs b/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs index 452d9c58..9681e087 100644 --- a/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs +++ b/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs @@ -28,6 +28,23 @@ public sealed class ItemUseabilityTests Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No)); } + [Theory] + [InlineData(ItemUseability.Undef, true)] + [InlineData(ItemUseability.No, false)] + [InlineData(ItemUseability.Contained, true)] + [InlineData(ItemUseability.NeverWalk, true)] + [InlineData(ItemUseability.No | ItemUseability.Contained, false)] + public void IsUseable_matchesRetailLowNoBit(uint useability, bool expected) + { + Assert.Equal(expected, ItemUseability.IsUseable(useability)); + } + + [Fact] + public void Undef_isAZeroValuedDirectUse() + { + Assert.True(ItemUseability.IsDirectUseable(ItemUseability.Undef)); + } + [Fact] public void LeastLimitedSourceUse_matchesRetailPriorityAndIgnoresObjSelf() {