diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 13e9c959..7bc28591 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -410,7 +410,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. The 15 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its edge-fired (no dwell), `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. STILL NO ACDREAM ANALOG — the spellcasting endowment icon `@0x004C63A1` / cast button `@0x004C6FE8` / favorite `@0x004C7206` / submenu `@0x004C67D8` (all `UiCatalogSlot`-based, which already has its own independent `Label`-driven `GetTooltipText` — a real gap only if that Label wiring turns out incomplete, unaudited this round), the map notes `gmMapUI::AddMapNote @0x004A1C51` (no acdream map UI), and the character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors. Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`) | +| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. The 15 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its edge-fired (no dwell), `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list; STILL NO ACDREAM ANALOG — only the map notes `gmMapUI::AddMapNote @0x004A1C51` remain (no acdream map UI; separate Map/House batch scope).** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case) and the plain-spell branch's exact wording (spell selected, no item endowed — shows the bare spell name only). That branch's three `SetTooltip` format operands (`RecvNotice_UpdateCharacterInformation` / `_EnableChatTargetSelection` / `_UserPreferenceChanged_Menu`) are genuine `gmNoticeHandler` vtable SLOTS — real function-pointer data at `0x7b5e88`-`0x7b6130`, confirmed by reading the vtable's own full declaration — unlike the endowment branch's literals, which sit in a genuinely unlabeled stretch of the narrow-char string pool (verified by decoding the surrounding bytes directly, e.g. the six short fragments recovered for the skill-formula formatter below) and decode cleanly; the plain-spell wording cannot be recovered from this dump. The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | | TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs index f1238af6..de9842e2 100644 --- a/src/AcDream.App/Net/RetailSkillFormula.cs +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -85,6 +85,114 @@ internal static class RetailSkillFormula _ => result, }; } + + /// + /// Retail SkillSystem::InqAttributeName @ 0x005c8d90 — the six + /// hardcoded attribute display names (matched exactly against + /// DatReaderWriter.Enums.AttributeId's Strength=1..Self=6 + /// numbering, the same table -style + /// switches elsewhere in this file already assume). + /// + public static string AttributeName(DatReaderWriter.Enums.AttributeId attribute) => attribute switch + { + DatReaderWriter.Enums.AttributeId.Strength => "Strength", + DatReaderWriter.Enums.AttributeId.Endurance => "Endurance", + DatReaderWriter.Enums.AttributeId.Quickness => "Quickness", + DatReaderWriter.Enums.AttributeId.Coordination => "Coordination", + DatReaderWriter.Enums.AttributeId.Focus => "Focus", + DatReaderWriter.Enums.AttributeId.Self => "Self", + _ => string.Empty, + }; + + /// + /// Retail SkillSystem::InqSkillFormula @ 0x005c89b0 — builds the + /// human-readable formula line shown in a skill's tooltip, e.g. + /// "( (Strength + Coordination) / 2 )", "( Quickness )", or + /// "( (2 x Quickness) )". Ported byte-for-byte from the retail + /// binary's string pool: the five short literal fragments below + /// (data_7e7930 = " )", data_7e7934 = "+%u", + /// data_7e7940 = " + ", data_7e7950 = "(", + /// data_7e7954 = "( ", data_797584 = ")") + /// sit between two vtable declarations in the pseudo-C dump and Binary + /// Ninja's type inference never recognized them as strings, so they show + /// up unlabeled rather than as readable literals — this port decoded + /// their raw bytes directly as narrow ASCII (the function operates + /// exclusively on AC1Legacy::PStringBase<char>, so 1 + /// byte/char, not the 2-byte/char wide encoding used elsewhere in this + /// file's neighborhood). " / %u" (divisor) and "(%u x %s)" + /// (multiplier wrap) are plain, directly-visible literals in the same + /// function and needed no such recovery. Returns null when the skill has + /// neither attribute wired (_x < 1 || _attr1 == 0 AND the + /// attr2 equivalent), matching InqSkillFormula's own false + /// return. + /// + public static string? FormatFormula(SkillFormula formula) + { + ArgumentNullException.ThrowIfNull(formula); + + bool hasAttr1 = formula.Attribute1Multiplier >= 1 + && formula.Attribute1 != 0; + bool hasAttr2 = formula.Attribute2Multiplier >= 1 + && formula.Attribute2 != 0; + if (!hasAttr1 && !hasAttr2) + return null; + + var text = new System.Text.StringBuilder("( "); + if (hasAttr1 && hasAttr2) + text.Append('('); + + if (hasAttr1) + { + string name1 = AttributeName(formula.Attribute1); + text.Append(formula.Attribute1Multiplier <= 1 + ? name1 + : $"({formula.Attribute1Multiplier} x {name1})"); + if (hasAttr2) + text.Append(" + "); + } + + if (hasAttr2) + { + string name2 = AttributeName(formula.Attribute2); + text.Append(formula.Attribute2Multiplier <= 1 + ? name2 + : $"({formula.Attribute2Multiplier} x {name2})"); + } + + if (hasAttr1 && hasAttr2) + text.Append(')'); + if (formula.Divisor != 1) + text.Append($" / {formula.Divisor}"); + if (formula.AdditiveBonus != 0) + text.Append($"+{formula.AdditiveBonus}"); + text.Append(" )"); + return text.ToString(); + } + + /// + /// Retail SkillInfoRegion::GetTooltip @ 0x004f1fe0, called once + /// from SkillInfoRegion::SkillInfoRegion @ 0x004f2140's + /// UIElement::SetTooltip at 0x004f222f. Composition is exactly + /// "\n" + formula + description — retail concatenates the + /// description directly onto the formula line with NO separator between + /// them (ported verbatim, not "fixed": append_n_chars runs + /// immediately after the formula assignment with no intervening + /// literal). SkillSystem::InqSkillDescription @ 0x005c8770 reads + /// SkillBase._description — the same DAT field + /// already + /// exposes, so no hand-transcription was needed for the ~30+ skill + /// description strings (unlike the six hardcoded attribute + /// descriptions). + /// + public static string? BuildTooltip(SkillBase skillBase) + { + ArgumentNullException.ThrowIfNull(skillBase); + + string? formula = FormatFormula(skillBase.Formula); + string description = skillBase.Description.Value ?? string.Empty; + string tooltip = (formula is null ? string.Empty : "\n" + formula) + description; + return tooltip.Length == 0 ? null : tooltip; + } } /// diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index fd7ae395..f0d0f1f3 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -228,4 +228,11 @@ public sealed record CharacterSkill( // retail SkillInfoRegion::GetVitaeModifier (0x004f0fa0). Used for the // footer-title vitae-specific parenthetical, separate from the buff delta // (CurrentLevel − VitaeModifier − BaseLevel). - int VitaeModifier = 0); + int VitaeModifier = 0, + // TS-85 (character-panel tooltips): retail SkillInfoRegion::GetTooltip + // (0x004f1fe0), composed once at row construction — formula line + skill + // description, DAT-sourced via SkillBase.Description/Formula + // (RetailSkillFormula.BuildTooltip). Null when the DAT SkillTable had no + // entry for this skill (fallback-named skills) or GetTooltip would have + // produced empty text. + string? TooltipText = null); diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 69cddd55..dc2ecb2a 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using AcDream.App.Net; using AcDream.Core.Items; using AcDream.Core.Player; using DatReaderWriter; @@ -382,6 +383,10 @@ public sealed class CharacterSheetProvider int specializedCost = skillBase?.SpecializedCost ?? 0; long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1); long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10); + // TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0) — formula line + + // DAT description, composed once here (matches retail's once-at- + // construction SetTooltip; the row never recomputes it per frame). + string? tooltipText = skillBase is null ? null : RetailSkillFormula.BuildTooltip(skillBase); // Issue #267: CurrentLevel is the EFFECTIVE (vitae + buff) level — // retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier @@ -406,7 +411,8 @@ public sealed class CharacterSheetProvider specializedCost, raiseCost, raise10Cost, - values.VitaeModifier)); + values.VitaeModifier, + tooltipText)); } return result; diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 63c250d2..bbda95dc 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -192,6 +192,43 @@ public static class CharacterStatController ("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6 }; + /// + /// TS-85 (character-panel tooltips): retail SkillSystem::InqAttributeDescription + /// @ 0x005c8e30 — six hardcoded strings, byte-decoded from the retail binary's + /// string pool (the pseudo-C dump truncates them with "…"). Ported from + /// AttributeInfoRegion::AttributeInfoRegion @ 0x004f1530's + /// UIElement::SetTooltip call at 0x004f1617, keyed by retail attribute id + /// (matches ' statId column, NOT the array index — the + /// authored row order swaps Coordination/Quickness relative to the id numbering). + /// + private static readonly IReadOnlyDictionary AttributeDescriptions = + new Dictionary + { + [1u] = "Measures your character's muscular power.", // Strength + [2u] = "Measures how healthy your character is.", // Endurance + [3u] = "Measures how fast your character is.", // Quickness + [4u] = "Measures your character's reflexes", // Coordination (no trailing period — verified byte-exact) + [5u] = "Measures your character's mind and senses.", // Focus + [6u] = "Measures your character's willpower.", // Self + }; + + /// + /// TS-85 (character-panel tooltips): retail SkillSystem::InqAttribute2ndDescription + /// @ 0x005c8f70 — three hardcoded strings shared by each Max/Current pair (1&2, + /// 3&4, 5&6), byte-decoded from the retail string pool. Ported from + /// Attribute2ndInfoRegion::Attribute2ndInfoRegion @ 0x004f1680's + /// UIElement::SetTooltip call at 0x004f1777, keyed by ' + /// maxStatId column (1/3/5 — either member of the pair resolves the same text in + /// retail). + /// + private static readonly IReadOnlyDictionary Attribute2ndDescriptions = + new Dictionary + { + [1u] = "(Endurance/2)\nIf you run out of health, you will die!", // Health + [3u] = "(Endurance)\nAffects your actions and movement.", // Stamina + [5u] = "(Self)\nAffects how much magic you can cast.", // Mana + }; + /// /// Bind the Attributes-tab header + 9-row list + footer elements, tab button states, /// and raise buttons in to . @@ -671,7 +708,7 @@ public static class CharacterStatController for (int i = 0; i < AttrRows.Length; i++) { - var (rowName, iconDid, _) = AttrRows[i]; + var (rowName, iconDid, statId) = AttrRows[i]; int rowIndex = i; var row = AddRow(list, datFont, spriteResolve, @@ -694,6 +731,7 @@ public static class CharacterStatController return v.ToString(); }, valueColorProvider: () => AttributeValueColor(data(), rowIndex)); + row.TooltipText = AttributeDescriptions.GetValueOrDefault(statId); row.OnClick = () => { @@ -706,7 +744,7 @@ public static class CharacterStatController for (int i = 0; i < VitalRows.Length; i++) { - var (rowName, iconDid, _) = VitalRows[i]; + var (rowName, iconDid, maxStatId) = VitalRows[i]; int rowIndex = i; int absIndex = AttrRows.Length + i; @@ -726,6 +764,7 @@ public static class CharacterStatController }; }, valueColorProvider: () => VitalValueColor(data(), rowIndex)); + row.TooltipText = Attribute2ndDescriptions.GetValueOrDefault(maxStatId); row.OnClick = () => { @@ -785,6 +824,9 @@ public static class CharacterStatController valueProvider: () => LiveSkill().CurrentLevel.ToString(), valueColorProvider: () => SkillValueColor(LiveSkill()), nameColor: Vector4.One); + // TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0), stamped once at + // row construction — matches retail (never recomputed per frame). + row.TooltipText = skill.TooltipText; row.OnClick = () => { HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10); diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index d30366b5..6d9dfb6a 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -563,11 +563,87 @@ public sealed class SpellcastingUiController : IRetainedPanelController private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability(); + /// + /// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30. Enabled and + /// TooltipText are retail's SAME state machine (SetState + SetTooltip + /// side by side throughout that function) — porting the tooltip text + /// without correcting Enabled to match would let the tooltip promise an + /// action the button doesn't actually allow (TS-85). + /// private void UpdateCastAvailability() - => _cast.Enabled = _endowmentSelected[_activeTab] - ? _endowmentItemId != 0u - : _selected[_activeTab] is uint spellId - && _casting.IsTargetReady(spellId); + { + if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u) + { + (bool enabled, string? tooltip) = ComputeEndowmentCastState(); + _cast.Enabled = enabled; + _cast.TooltipText = tooltip; + return; + } + + if (_selected[_activeTab] is uint spellId) + { + _cast.Enabled = _casting.IsTargetReady(spellId); + // TS-85: the plain-spell branch's exact retail wording (untargeted- + // ready / needs-target-none-selected / needs-target-present) is + // unrecovered — its three SetTooltip format-string operands + // (RecvNotice_UpdateCharacterInformation / _EnableChatTargetSelection + // / _UserPreferenceChanged_Menu) are genuine gmNoticeHandler vtable + // SLOTS (real function pointers at 0x7b5e88-0x7b6130), not the + // unlabeled-string-pool case the endowment branch below hits, so + // they can't be byte-decoded. Shows the bare spell name, which every + // one of that branch's states is confirmed (by the narrow-buffer + // prep right before each sprintf) to carry as a substring. + _cast.TooltipText = _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata) + ? metadata.Name + : null; + return; + } + + _cast.Enabled = false; + bool anyFavorites = false; + for (int tab = 0; tab < 8 && !anyFavorites; tab++) + anyFavorites = _spellbook.GetFavorites(tab).Count > 0; + // Verbatim literals: "Select a spell to cast" @ data_7b64ec, + // "You have no spells ready to cast" @ data_7b6520. + _cast.TooltipText = anyFavorites + ? "Select a spell to cast" + : "You have no spells ready to cast"; + } + + /// + /// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30's endowment-item + /// branch (m_endowmentItemID != 0). Every literal below is directly + /// visible in the decomp (not the mislabeled-vtable-slot class the + /// plain-spell branch hits above): "USE the %s" @ data_7b64c0, + /// "You must select a target for the %s" @ data_7b6478, + /// " on %s" @ data_7b6464. ItemUses::IsUseable_SelfTarget @ + /// 0x004fcd30 is exactly + /// (both test the target-mask Self bit after shifting the high word down + /// 16). NOT ported: the incompatible-target sub-state ("You must select + /// an appropriate\ntarget for the %s" @ data_7b6400), which retail + /// derives from ItemHolder::TargetCompatibleWithObject @ 0x00587520 + /// — a ~400-line function with its own chat-message side effects, out of + /// scope for a tooltip batch. A present target is optimistically treated + /// as compatible here, same text as the confirmed-compatible case. See + /// TS-85. + /// + private (bool enabled, string? tooltip) ComputeEndowmentCastState() + { + ClientObject? endowment = _objects.Get(_endowmentItemId); + if (endowment is null) + return (false, null); + + string itemName = endowment.GetAppropriateName(); + if (ItemUseability.AllowsSelfTarget(endowment.Useability ?? 0u)) + return (true, $"USE the {itemName}"); + + uint? targetId = _selection.SelectedObjectId; + if (targetId is null or 0u) + return (false, $"You must select a target for the {itemName}"); + + string targetName = _objects.Get(targetId.Value)?.GetAppropriateName() ?? itemName; + return (true, $"USE the {itemName} on {targetName}"); + } private void ConfigureSpellName() { diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index 71bf0c8b..f74b1467 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -152,6 +152,20 @@ public class UiClickablePanel : UiPanel /// Ignored when is false. public float SelectionBarHeight { get; set; } = 3f; + /// Settable tooltip, surfaced through the shared + /// hover pipeline (same pattern as + /// / ). TS-85's + /// character-panel gap: retail's AttributeInfoRegion / + /// Attribute2ndInfoRegion / SkillInfoRegion row constructors + /// (UIElement::SetTooltip at 0x004f1617 / 0x004f1777 / 0x004f222f) stamp + /// this once per row at construction — retail never updates it afterward, so a + /// plain settable string (not a live provider) matches. + public string? TooltipText { get; set; } + + /// + public override string? GetTooltipText() => + string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + public UiClickablePanel() { // Rows must receive pointer events — override the UiPanel default (ClickThrough=false, diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs index e0acedc4..9e927268 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs @@ -152,6 +152,13 @@ public sealed class SpellcastingUiControllerTests CurrentlyEquippedLocation = EquipMask.Held, SpellId = 2670u, IconId = 0x06001234u, + // TS-85: gmSpellcastingUI::UpdateCastButtonTooltip's endowment + // branch (0x004c6a30) gates immediate-use on + // ItemUses::IsUseable_SelfTarget (0x004fcd30) reading the ITEM's + // own Useability target mask -- a self-castable orb authors the + // Self target bit so clicking Cast fires without a target + // selection, matching this test's intent. + Useability = (ItemUseability.Self << 16) | ItemUseability.Wielded, }); controller.Tick();