fix(items): port retail useability and autowear rejection

Use the exact low USEABLE_NO-bit predicate so reset/zero-valued direct-use items such as Blackmoor's Favor reach the ordinary Use request. Port AutoWear's clothing-priority blocker lookup and exact named system notice through the shared activation owner.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 08:33:31 +02:00
parent 60387668d0
commit f71f947475
13 changed files with 289 additions and 57 deletions

View file

@ -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 | | # | 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-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-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-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-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-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 | | 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-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-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-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-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-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` | | ~~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` |

View file

@ -15,11 +15,14 @@ assessment information, equipped-child picking, vendor browsing, and
server-authoritative vendor transactions. Slice 1's DAT-authored arrow-only server-authoritative vendor transactions. Slice 1's DAT-authored arrow-only
favorite-spell scrollbar, fixed 18-cell capacity, authored arrow sides/media, favorite-spell scrollbar, fixed 18-cell capacity, authored arrow sides/media,
and persistent manual offset passed the connected gate. Slice 2 is active: the and persistent manual offset passed the connected gate. Slice 2 is active: the
status hand now follows canonical selection/object notices, ghosts empty and status hand follows canonical selection/object notices, ghosts empty and
unusable selections, and routes enabled weapons, direct-use items, and explicitly unusable selections, and routes enabled weapons, direct-use items,
use-on-target tools through the existing interaction transaction and cursor and use-on-target tools through the existing interaction transaction and
owners. Its connected hand-state gate is pending; the magnifying-glass Assess cursor owners. Its connected state gate passed. A named-retail correction now
command follows in the same slice. 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.
--- ---

View file

@ -91,8 +91,10 @@ broaden the feature surface.
Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2 is 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 active: the status hand's selected-object availability and existing
Use/AutoWield/use-on-target command routing are code-complete, with the Use/AutoWield/use-on-target command routing passed their connected gate.
connected state/cursor gate pending before the Assess half continues. 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: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

@ -1,7 +1,8 @@
# World interaction completion — pre-M4 program # World interaction completion — pre-M4 program
**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand state and command **Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection state
path code-complete; connected visual gate pending before Assess continues. 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. **Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and **Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell. 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 / solution build, 3,474 App tests / 3 skips, and 7,848 complete-solution tests /
5 skips pass. 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 <item> 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 ## Slice 1 — spell-bar overflow arrows
### Retail oracle ### Retail oracle
@ -148,7 +163,8 @@ Named retail references and executable pseudocode are recorded in
### Remaining Slice 2 work ### Remaining Slice 2 work
- Port the magnifying-glass Assess command state and activation. - Port the magnifying-glass Assess command state and activation.
- Run the connected Use-hand visual gate: no selection, spell component, - Run the connected correction gate: activate Blackmoor's Favor, then try to
weapon, and health kit. equip armor blocked by an overlapping worn item and verify the named system
message.
- Continue directly into the Assess information slice after the combined - Continue directly into the Assess information slice after the combined
status-control gate. status-control gate.

View file

@ -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 <item appropriate name> is already being worn"
else:
occupied = playerSystem.inventoryMask & item.validLocations
blocker = player.GetObjectAtLocation(occupied, item.priority)
if blocker exists:
display "You must remove your <blocker appropriate name> 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.

View file

@ -307,13 +307,9 @@ internal sealed class WorldSelectionQuery
public bool IsUseable(uint serverGuid) public bool IsUseable(uint serverGuid)
{ {
if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn)) if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))
{ return ItemUseability.IsUseable(
if (spawn.Useability is uint useability) spawn.Useability ?? ItemUseability.Undef);
return useability is not 0u and not 1u; return false;
if (((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u)
return true;
}
return (GetItemType(serverGuid) & ItemType.Creature) != 0;
} }
/// <summary>ItemHolder::DetermineUseResult @ 0x00588460 pickup gate.</summary> /// <summary>ItemHolder::DetermineUseResult @ 0x00588460 pickup gate.</summary>

View file

@ -164,6 +164,26 @@ internal sealed class AutoWieldController : IDisposable
return SendWield(item, requestedMask, combatModeAfterWield); 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 EquipMask weaponLocation = requestedMask == EquipMask.None
? item.ValidLocations & WeaponReadyMask ? item.ValidLocations & WeaponReadyMask
: requestedMask & WeaponReadyMask; : requestedMask & WeaponReadyMask;
@ -424,9 +444,6 @@ internal sealed class AutoWieldController : IDisposable
private EquipMask BestAvailableEquipMask(ClientObject item) private EquipMask BestAvailableEquipMask(ClientObject item)
{ {
if (ItemEquipRules.IsAutoWearItem(item))
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
foreach (EquipMask mask in AutoEquipOrder) foreach (EquipMask mask in AutoEquipOrder)
{ {
if ((item.ValidLocations & mask) == EquipMask.None) if ((item.ValidLocations & mask) == EquipMask.None)
@ -437,16 +454,22 @@ internal sealed class AutoWieldController : IDisposable
return EquipMask.None; return EquipMask.None;
} }
private bool AutoWearIsLegal(ClientObject item) private bool AutoWearIsLegal(
ClientObject item,
out ClientObject? blocker)
{ {
uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId); uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
if ((item.Priority & priorityMask) == 0) if ((item.Priority & priorityMask) == 0)
{
blocker = null;
return true; return true;
}
EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId)
& item.ValidLocations; & item.ValidLocations;
return GetEquippedObjectAtLocation( blocker = GetEquippedObjectAtLocation(
occupiedLocations, item.Priority, item.ObjectId) is null; occupiedLocations, item.Priority, item.ObjectId);
return false;
} }
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid) private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
@ -455,11 +478,10 @@ internal sealed class AutoWieldController : IDisposable
private uint EquippedAutoWearPriorityMask(uint exceptGuid) private uint EquippedAutoWearPriorityMask(uint exceptGuid)
{ {
uint mask = 0; uint mask = 0;
foreach (ClientObject item in _objects.Objects) foreach (ClientObject item in _objects.GetEquippedBy(_playerGuid()))
{ {
if (item.ObjectId == exceptGuid if (item.ObjectId == exceptGuid
|| (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|| !IsEquippedByPlayer(item))
continue; continue;
mask |= item.Priority; mask |= item.Priority;
} }
@ -469,11 +491,10 @@ internal sealed class AutoWieldController : IDisposable
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid) private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
{ {
EquipMask mask = EquipMask.None; EquipMask mask = EquipMask.None;
foreach (ClientObject item in _objects.Objects) foreach (ClientObject item in _objects.GetEquippedBy(_playerGuid()))
{ {
if (item.ObjectId == exceptGuid if (item.ObjectId == exceptGuid
|| (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|| !IsEquippedByPlayer(item))
continue; continue;
mask |= item.CurrentlyEquippedLocation; mask |= item.CurrentlyEquippedLocation;
} }
@ -488,10 +509,9 @@ internal sealed class AutoWieldController : IDisposable
if (locationMask == EquipMask.None) if (locationMask == EquipMask.None)
return null; return null;
foreach (ClientObject item in _objects.Objects) foreach (ClientObject item in _objects.GetEquippedBy(_playerGuid()))
{ {
if (item.ObjectId == exceptGuid if (item.ObjectId == exceptGuid
|| !IsEquippedByPlayer(item)
|| (item.CurrentlyEquippedLocation & locationMask) == EquipMask.None) || (item.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
continue; continue;
if ((item.Priority & priority) != 0 || priority == 0) if ((item.Priority & priority) != 0 || priority == 0)
@ -500,13 +520,6 @@ internal sealed class AutoWieldController : IDisposable
return null; 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) internal static bool BlocksUseOfShield(ClientObject item)
{ {
byte combatUse = item.CombatUse ?? 0; byte combatUse = item.CombatUse ?? 0;

View file

@ -310,6 +310,15 @@ public static class ItemUseability
public static uint TargetFlags(uint useability) public static uint TargetFlags(uint useability)
=> (useability & TargetMask) >> 16; => (useability & TargetMask) >> 16;
/// <summary>
/// Retail <c>ItemUses::IsUseable @ 0x004FCCC0</c>. The implementation
/// complements the full bitfield and returns its low bit, so only
/// <c>USEABLE_NO</c> disables use. In particular, the default
/// <c>USEABLE_UNDEF</c> value zero is usable.
/// </summary>
public static bool IsUseable(uint useability)
=> (useability & No) == 0;
/// <summary> /// <summary>
/// Retail <c>ItemUses::GetLeastLimitedTargetUse</c> (0x004fcd50): the most /// Retail <c>ItemUses::GetLeastLimitedTargetUse</c> (0x004fcd50): the most
/// permissive target-use bit present, priority Remote &gt; Viewed &gt; /// permissive target-use bit present, priority Remote &gt; Viewed &gt;
@ -330,11 +339,7 @@ public static class ItemUseability
} }
public static bool IsDirectUseable(uint useability) public static bool IsDirectUseable(uint useability)
{ => !IsTargeted(useability) && IsUseable(useability);
uint source = SourceFlags(useability);
return !IsTargeted(useability)
&& (source & ~(No | NeverWalk)) != Undef;
}
} }
/// <summary> /// <summary>

View file

@ -166,7 +166,7 @@ public static class ItemInteractionPolicy
uint useability) uint useability)
=> combatUse != 0 => combatUse != 0
|| (type & ToolbarEquipmentTypes) != 0 || (type & ToolbarEquipmentTypes) != 0
|| (useability & ItemUseability.No) == 0; || ItemUseability.IsUseable(useability);
public static ItemPrimaryUseResult DetermineUseResult( public static ItemPrimaryUseResult DetermineUseResult(
in ItemPolicyObject item, in ItemPolicyObject item,
@ -207,7 +207,7 @@ public static class ItemInteractionPolicy
return ItemPrimaryUseResult.BeginGame; return ItemPrimaryUseResult.BeginGame;
} }
if (IsUseable(item.Useability)) if (ItemUseability.IsUseable(item.Useability))
return ItemPrimaryUseResult.ItemUse; return ItemPrimaryUseResult.ItemUse;
if (item.IsPlayer && item.Id != playerId) if (item.IsPlayer && item.Id != playerId)
@ -261,7 +261,7 @@ public static class ItemInteractionPolicy
return Consumed(actions); return Consumed(actions);
} }
if (IsUseable(source.Useability)) if (ItemUseability.IsUseable(source.Useability))
{ {
if ((source.Flags & PublicWeenieFlags.PlayerKillerSwitch) != 0) if ((source.Flags & PublicWeenieFlags.PlayerKillerSwitch) != 0)
return Consumed(new ItemPolicyAction( return Consumed(new ItemPolicyAction(
@ -406,7 +406,7 @@ public static class ItemInteractionPolicy
if (item.OwnedByPlayer && item.IsContainer) if (item.OwnedByPlayer && item.IsContainer)
actions.Add(new ItemPolicyAction(ItemPolicyActionKind.OpenContainedContainer, item.Id)); actions.Add(new ItemPolicyAction(ItemPolicyActionKind.OpenContainedContainer, item.Id));
if (!item.OwnedByPlayer && item.IsContainer if (!item.OwnedByPlayer && item.IsContainer
&& IsUseable(item.Useability) && ItemUseability.IsUseable(item.Useability)
&& !ItemUseability.IsTargeted(item.Useability)) && !ItemUseability.IsTargeted(item.Useability))
actions.Add(new ItemPolicyAction(ItemPolicyActionKind.SetGroundObject, item.Id, actions.Add(new ItemPolicyAction(ItemPolicyActionKind.SetGroundObject, item.Id,
groundObjectId)); groundObjectId));
@ -418,10 +418,6 @@ public static class ItemInteractionPolicy
|| ((valid & ClothingLocations) != 0 && (current & ClothingLocations) == 0) || ((valid & ClothingLocations) != 0 && (current & ClothingLocations) == 0)
|| ((valid & HeldLocations) != 0 && (current & HeldLocations) == 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( private static ItemPlacementPolicyDecision PlaceOnGround(
in ItemPlacementPolicyInput input) in ItemPlacementPolicyInput input)
{ {

View file

@ -279,6 +279,29 @@ public sealed class WorldSelectionQueryTests
Assert.True(h.Query.IsPickupable(looseComponent)); 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] [Fact]
public void SelectionSphereAppliesSetupOffsetScaleAndRotation() public void SelectionSphereAppliesSetupOffsetScaleAndRotation()
{ {

View file

@ -436,6 +436,24 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(new[] { 0x50000A03u }, h.Uses); 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] [Fact]
public void ContainerItem_sendsUseToOpen() public void ContainerItem_sendsUseToOpen()
{ {
@ -527,6 +545,7 @@ public sealed class ItemInteractionControllerTests
h.Objects.AddOrUpdate(new ClientObject h.Objects.AddOrUpdate(new ClientObject
{ {
ObjectId = 0x50000AF2u, ObjectId = 0x50000AF2u,
Name = "Chainmail Hauberk",
Type = ItemType.Clothing, Type = ItemType.Clothing,
CurrentlyEquippedLocation = EquipMask.UpperArmWear, CurrentlyEquippedLocation = EquipMask.UpperArmWear,
Priority = 0x00000004u, Priority = 0x00000004u,
@ -547,6 +566,10 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields); Assert.Empty(h.Wields);
Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId); 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] [Fact]
@ -563,12 +586,15 @@ public sealed class ItemInteractionControllerTests
h.AddContained(0x50000A06u, item => h.AddContained(0x50000A06u, item =>
{ {
item.Type = ItemType.Armor; item.Type = ItemType.Armor;
item.CombatUse = 4; // COMBAT_USE_SHIELD
item.ValidLocations = EquipMask.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.Empty(h.Wields);
Assert.False(activated);
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId); Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
} }

View file

@ -78,6 +78,30 @@ public sealed class ItemInteractionPolicyTests
Determine(owned with { Useability = ItemUseability.Contained })); 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] [Fact]
public void DetermineUseResult_nonOwnedGameAndPlayerPaths_areDistinct() public void DetermineUseResult_nonOwnedGameAndPlayerPaths_areDistinct()
{ {
@ -259,7 +283,7 @@ public sealed class ItemInteractionPolicyTests
CombatUse: 0, CombatUse: 0,
ItemsCapacity: 0, ItemsCapacity: 0,
ContainersCapacity: 0, ContainersCapacity: 0,
Useability: 0, Useability: ItemUseability.No,
TargetType: 0, TargetType: 0,
OwnedByPlayer: false, OwnedByPlayer: false,
IsContainer: false, IsContainer: false,

View file

@ -28,6 +28,23 @@ public sealed class ItemUseabilityTests
Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No)); 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] [Fact]
public void LeastLimitedSourceUse_matchesRetailPriorityAndIgnoresObjSelf() public void LeastLimitedSourceUse_matchesRetailPriorityAndIgnoresObjSelf()
{ {