A unit tooltip's title read the bare name where 1.12.1 decorates it.
The tooltip calls the name builder `0x609370` itself (`0x52a1ab`, or `0x52a1db` when there is no second part) with the decoration flag a literal `1`, the same call `UnitPVPName` makes, so no CVar gates it. With the flag on, a ranked player reads `UNIT_PVP_NAME` of the gendered `PVP_RANK_<rank>_<team>` and the name, a city protector's `PVP_MEDAL<n>` follows on a second line (`0x6093ef`), and a civilian kill (`0x612550`) reads the `PVP_RANK_CIVILIAN` prefix (`0x609461`).
The title now runs the builder `UnitPVPName` already uses. The test covers rank, gender, medal, civilian, an unranked player and a creature; an on-demand `ui-tooltip-rank` capture shows a ranked player's tooltip.
Fixes#794
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
`TargetUnit("npc")`, `AssistUnit("npc")` and `SpellTargetUnit("npc")` did nothing with a merchant, gossip or quest window open: the app's unit-token resolver answered nobody for `npc`.
In 1.12.1 the resolver (`0x515970`) matches `npc` as a whole token (`0x515bec`, an unbounded compare, so `npctarget` stays unknown) and takes the interaction NPC's guid `[0xb4e2d0]`, which `0x4930d0` writes when the window opens.
`npc` now resolves to the held unit `InteractNpc` names. Tests cover the three verbs, a closed window and a streamed-out NPC, and `/script TargetUnit("npc")` typed into the stock chat box.
Fixes#707
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- capture: a name-rank scenario shoots a ranked player's overhead name line
- Overhead names carry the player's PvP title under UnitNamePlayerPVPTitle, as 1.12.1 does (#550)
- review: bundle the rank-title inputs; doc the snapshot and rank-19 limits
- nameplates: read rank strings per VM; test the composed name line
- pvp: correct the rank-title docs; convert the credit team without a cast
- Fix the titles row comment; name-rank reuses WOLF_POS, cites :900
- rank strings: the honor feed reads them off the VM; nameplates only reads the value
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
- Change `LevelUpInfo` to signed to match vanilla behavior
- Add parse test for negative values in SMSG_LEVELUP_INFO, update comments
- grammar
- level-up gains: cite the %d fire and vmangos's wrapped deltas; pin the reported line
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
- A loot right-click while moving does nothing, as in 1.12.1 (#760)
- loot gate: name the emitter's drain and write the landing deviation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
- spell text: $g/$G take the active player's gender and the chosen form is space-trimmed, so a female character reads "her" (0x508180, 0x508214)
- spell text: trim the $g docs, drop the colon-less $g pin
- spell tooltips: $g reads the view's player store; nets cover gender
- item tooltips: re-substitute $-texts when the gender byte moves
- tests: share one real-spells loader beside Spells
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
- A loot window that opens while you move closes, as in 1.12.1 (#782)
- Set auto_loot in the LootConfig initializer (clippy field_reassign_with_default)
- Close a window opened on the move in the feed's pass, so it never paints
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
With Click to Move on, benilla walked only to things you interact with. In 1.12.1 a right-click on the ground walks to the point (`0x5e0378` → `0x6102b0`, 0.5 yd short, speed-scaled, no leash, held in a deck's frame). A right-click on the sky with an empty cursor walks along the ray (`0x492dca` → `0x610100`), pitching while swimming. An attack on an enemy out of melee walks to where it stood (`0x60c22b` → `0x60fcc0`, mode 10): the stop is about 1.9 yd because the arm names the player's own guid, the radii are unscaled, and the 80 yd leash says ERR_AUTOFOLLOW_TOO_FAR. Inside melee the player turns to face it.
Deviations, each written where it lives: the melee point keeps the enemy's height, does not snap to the reference's fixed arrival heading, and rides the deck it was armed on.
Tests: the router, the steering, the gates, the leash and the deck frames.
Fixes#758
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mathih13 <15229325+Mathih13@users.noreply.github.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
In 1.12.1 the `SMSG_LOOT_RESPONSE` handler selects a looted unit before it opens the window (`0x5ebc35` → `0x48f3a0`, SetSelection at `0x48f3be`). benilla opened the window without selecting it. So a Tab pressed during a Click-to-Move walk to a corpse left the Tab target selected when the loot opened.
The handler now admits the response and arms the latch, then selects a unit through the one SetSelection path, then opens the window. A refusal, a chest or an item selects nothing. SetSelection's re-swing now uses StartAttack's target gate (alive, then CanAttack, `0x5ecc16`), so switching to a body mid-swing never swings at it.
Tests: the handler's select, dedup, refusals, teardown order and swing stop, a hostile body looted mid-swing, and the stock TargetFrame naming the looted body.
Fixes#763
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Use binding_abi::flag for SpellIsTargeting and SpellCanTargetUnit so addons
can compare their results with 1, as in 1.12.1. Keep nil for false.
Verified on Windows with Rust 1.98.1: three regression failures before
the fix, all 35 focused spell tests passing after it, formatting and
render-pass lint clean. The full UI run passed 1677 tests, with one
ignored and one traceback-name failure reproduced on untouched main.
Fixes#711
Three ways the one-shot emote player differed from 1.12.1.
- **LiftOff and Land were refused in combat.** `0x5fcd20` exempts AnimIDs 192 and 200 from its combat test only (`0x5fcd5f`-`0x5fcd92`), so a dragon's take-off and landing now play mid-fight.
- **An emote could restart a clip already armed.** The player skips an id already armed on the key bone or bone 0 (`0x5fcd56`, `0x5fdb50`). It compares the requested id, which op4 stores at `+0xf8` (`0x71252f`), not the fallback that plays.
- **Your own emote waited for the server.** `DoEmote` plays it locally while a direction key is held (`0x5fd680`, called at `0x5ef660`), so a moving `/wave` shows at once and its echo is skipped.
Tests cover the exemption, the armed test (including on a rider), each local-play gate, and the skipped echo.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two ways the emote-state base pick differed from 1.12.1.
- A blacksmith kept hammering while you used his vendor window. The resolver `0x5fd770` passes over a state whose `Emotes.dbc` flags have `0x2000` (work, mining, ready, dance) while the unit is the interact NPC (`0x5fd7e2`-`0x5fd7fd`). `SetInteractNPC` re-picks on open (`0x493198`) and on close (`0x493219`, before the name clears at `0x49334b`), never for a player. So the NPC stands, and the loop resumes at its next re-pick.
- A set state outranks the idle fallbacks: `0x5fd770` runs before `0x5fd830`, and a row with AnimID 0 claims Stand. After `/stand` (state 26), a swimmer at rest shows Stand until it moves.
Tests cover the open, the close hold, players, a pose under a one-shot, and the precedence order.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three ways `DoEmote` and the chat talk gesture differed from 1.12.1:
- `/wave Bob` ignored the name and targeted the selection. 1.12.1 resolves a name that does not start with `%` as a player, at any range (`0x49fdb1`-`0x49fdf0`, `0x493aa0`), and sends the emote untargeted on a miss. `DoEmote(token, name)` from Lua does the same.
- A polymorphed, stunned or sleeping speaker still played the talk gesture. `UNIT_FIELD_FLAGS 0x20000000` suppresses it when the sender's name is cached (`0x49d7f7`).
- `DoEmote` sent while our unit was possessed (`0x5ef57e`). A chat-only emote now reads `Emotes.dbc` row 0's flags, so it is refused while asleep or dead (`0x5ef5b1`, `0x47db8e`).
Tests cover the name, miss, `%t` and self cases, the gesture gate, and both `DoEmote` gates against the install's row 0.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One-shot emotes were routed by a hand-made class table that wrongly let `/pray` (75), the `/beg` family (79), and 126, 127, 131, 132 and 135 play upper-body. A mounted rider played every id on its upper body.
1.12.1 reads two byte tables:
- CLASS_A (`0x5fed90`): the ids that may play on the key bone.
- The mount set (`0x5fec80`): what goes to the mount model while mounted (`0x5fe7b5`..`0x5fe803`). Anything else plays nowhere.
What changed:
- Both tables now match the bytes, and a mount-set id is forwarded to the mount child.
- Unmounted `/eat` (61) always plays upper-body (`0x5fe834`).
- A model with no split bone arms nothing for an upper-body play (`0x5fdcc4`).
Tests pin both tables for every id 0..300, plus the mounted, eat and no-bone driver cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every text emote's voice played for every performer, so every `/cheer` in a city was heard. 1.12.1's composer tail (`0x623c80`) plays it only for you or a member of the four party slots (`0x623cc2`-`0x623cce`, `0x5efea0`), and drops an ignored sender before that (`0x49dc36`).
What changed:
- The voice plays at attachment `0x11` and stops the unit's previous emote voice once a new one opens (`0x623c10`).
- Race and sex come from the display override before the descriptor (`0x60c6a0`/`0x60c6c0`).
- `SMSG_EMOTE` no longer plays `EventSoundID`. The one-shot path never reads it (`0x5e66b0` → `0x5fcd20`); only the state emote's `$ESD` does.
Tests cover each relation class, an ignored performer and the per-unit voice slot.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A crate on top of benilla interacts with an object it picks through `Interact`, the object's own dispatcher with no select, and reads the streamed objects through `Objects`
- A right-click on a GameObject, a corpse or empty world no longer marks the selection changed; only the unit leg's select touches it, as before the click took the pick
- Interact's attack leg selects its unit through StartAttack, as the click does
- An outside crate test reads Objects and writes Interact
- Wrap the Interact finish's doc lines at the column limit
- The outside-crate test's comment says what it picks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
- Click to Move: with AutoInteract on, a right-click on an NPC, a body to loot or skin, or a GameObject out of reach walks there and runs the verb on arrival
- Click to Move: the arrival scales by the mover's current speed, and a Use still out of range walks again
- Click to Move: a Loot walk stops at the square root of the melee reach
- Put the meeting-stone test's doc comment back on its test
- A GameObject click out of range raises ERR_USE_TOO_FAR unless the walk starts
- Wrap the finish's doc lines at the column limit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
- Shift-clicking water or arrows at a vendor takes one bundle, as 1.12.1 does
- Drop the merchant comments that restate the code
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Several EditBox keys differed from 1.12.1:
- Up/Down did nothing in a multi-line box;
- Home/End went to the text's ends;
- `HighlightText` and Ctrl+A moved the caret;
- focus did not restart the blink;
- a non-digit kept a numeric box's selection;
- Option+Up could not recall chat history on a Mac.
In 1.12.1, Up/Down move a wrapped row, keeping the letter column (`0x77cb20`), or recall history in a single-line box with any modifier but Shift. Home/End stop at a newline (`0x77c980`, `0x77c9f0`). `HighlightText` writes only the selection (`0x77cca0`), and Shift extends by `0x77cd10`. Focus restarts the blink (`0x77dd68`), and Insert deletes the selection before its digit test (`0x77bf41`).
benilla now does each. Down onto the last row lands at the end, and history stays shell-style, both deviations.
Tests cover each key, in the engine and the stock mail and ticket boxes.
Fixes#735Fixes#736Fixes#737Fixes#738Fixes#745Fixes#747🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Letter and punctuation keys were named by their US position, so on AZERTY the key labelled Z bound as W, and Ctrl+A sat on the key labelled Q.
1.12.1 on Windows names a key from the VK its layout produced. `VK_A`–`VK_Z` and digits are named as themselves, and other keys by `MapVirtualKeyA` (`0x42da39`); a key with no character is dropped (`0x42da49`). The EditBox's Ctrl chords switch on that same name (`0x77b1fb`). The Mac client names keys by `KeyTranslate`.
Now:
- Windows takes the same path through `MapVirtualKeyExW`.
- The Mac names keys through the current layout, or the ASCII-capable layout it pairs with.
- Linux follows one documented rule.
- Bindings, Key Bindings capture and Ctrl/Cmd+A/C/X/V all follow the layout.
- A character above U+00FF names its key, as a deviation.
Tests cover AZERTY, Hebrew, Greek, Lithuanian, Turkish, Bulgarian, Hungarian and Dvorak names, layout switches and chords.
Fixes#621Fixes#746🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EditBox events fired from hoisted drains before any OnUpdate, OnTextChanged before OnCursorChanged, and only on an edit.
In 1.12.1:
- Each box's update `0x77a790` fires its own `OnUpdate` and then its flush `0x77d3e0`, and a key-down or click flushes first (`0x77b1e2`, `0x77b819`).
- The flush runs off dirty bits in `[E+0x31c]`: relayout on bit 0, then `OnCursorChanged` on bit 2, then `OnTextChanged`.
- The ctor sets bit 0, so a box fires at its first show. Every text-region re-seat raises bits 0–2 (`0x77ba7f`), so a resize, new insets, `SetMultiLine` or a font change fires it again.
benilla now keeps the same bits and flushes each box in the walk and at its keys and clicks. So typing DELETE and pressing Enter in one frame enables the popup's button, as in 1.12.1.
Tests cover the order, first show, re-seats, a key's flush and no-op moves.
Fixes#724Fixes#727🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The reference stamps each log line from GetLocalTime (0x65a871); benilla stamped UTC. Unix reads
localtime_r, Windows calls GetLocalTime. Built and tested on macOS; the unix path type-checked for
x86_64 Linux and the Windows path for x86_64-pc-windows-msvc in a scratch crate.
Fixes#586
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A crowd of arriving players was composited in one frame, on the main thread, under the archive lock: 40 geared looks took 76–115 ms.
In 1.12.1 an unforced full composite never waits on its section loads (`0x44b430`), and a unit whose first composite is pending is not drawn: ShouldRender `0x607da0` feeds the draw flag `0x710c50`, and the flag goes to the mount model when mounted. It appears dressed once the composite is done.
Now an arriving body composites on the async pool, one task per look, reading outside the lock. The body, and its mount, are built once the atlas lands. Arriving now costs the main thread 0.26–0.45 ms for 40 looks. The appear fade runs from arrival, as `FadeTo` is armed at create.
A re-dress, a rig heal and the glue and dressing-room previews stay in the frame. Baked NPC skins are unchanged.
Fixes#609🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A ghost heard no ambience. The ghost bed was looked up as a SoundEntries row named "Ghost", which does not exist, so it fell back to kit 0. The underwater bed was a hard-coded row.
At init, 1.12.1's `0x4609b0` walks SoundEntries from the last row down. It keeps the rows whose names match `"Ghost (DONOTRENAME)"` (`0x836444`) and `"Underwater (DONOTRENAME)"` (`0x836428`) exactly and case-sensitively (`0x64a480`, a `strncmp` through the NUL). In the install those are rows 4160 (`GhostState.wav`) and 4209.
The catalog now resolves both beds once, the same way, and the ambience selector takes them from it, with order and fades unchanged.
Tests cover the install's two rows, the exact-name match, the walk order, a ghost selecting its bed over water, and an unresolved bed staying silent.
Fixes#721🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`/errors` showed the selected error in a FontString, so a player could not select or copy the message and traceback they came to report.
The detail pane now hosts a multi-line EditBox the way stock `MailFrame.xml` hosts the mail body:
- `autoFocus="false"`, so the keyboard stays with the game until a click;
- Escape clears focus;
- a mouse-enabled scroll child that focuses the box on a click below its text;
- `ScrollingEdit_OnTextChanged` for the range, since the box is as tall as its text.
The box is read-only: an edit is undone on `OnTextChanged`. Focus selects all, so Ctrl+C copies the whole error, and a new error arriving keeps the selection. The text keeps its font, its 6/6 inset and its 270 wrap.
Tests cover the whole text in the box, the undone edits, select-all and copy, a click below the text, Escape, and the range over a long traceback.
Fixes#703🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A multi-line EditBox kept its authored height, so a scroll frame around a long mail body or ticket had no range. A box made in Lua with no template never placed its text, so it drew nothing.
In 1.12.1, the flush's relayout sets a multi-line box's height to its insets plus its wrapped text height, or one line when empty (`0x77d4d0` at `0x77d8ad`). Every resize, including the first layout, re-seats the text at the top-left inset (`0x77a8d0` → `0x77b8c0`). `SetTextInsets`, `GetTextInsets`, `SetMultiLine` and a font change re-seat it too.
benilla now does the same. A focused multi-line box's caret and text are clipped only by a scroll frame, as the reference clips neither.
Tests cover growth and shrink, the stock mail and ticket scroll ranges, each re-seat trigger, a Lua box's first layout, and the caret in an empty box and after a trailing newline.
Fixes#722Fixes#726🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An ambience change read and decoded the whole bed on the main thread, about 40 ms a swap. 1.12.1 opens the bed as a looping FMOD stream that reads the archive through its file callbacks (`0x460b54` → `0x7a54d0`, `FSOUND_Stream_Open` at `0x7a5525`).
Now:
- MPQ files open for in-place reads, one sector at a time.
- The bed streams from the archive, opened on the IO pool. A newer swap drops an open still in flight.
- kira moves to 0.12.4 on symphonia 0.6. 0.5 sized PCM frames from `nBlockAlign`, which 98 stereo WAVs store as 2, so a streamed bed fell silent halfway.
- A fork patch stops a stream at its last packet instead of spinning.
- A sector that inflates short is refused.
Tests cover a bed's true length, a seamless loop, an off-thread open, the stop at the last packet and the refusal.
Fixes#700Fixes#8🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The `/errors` window wears the ClassTrainer panel art, but its Clear and Close buttons sat at y −455, below the art, and its detail pane ran to −442, into the button sockets.
On that art the class trainer lays out (`Blizzard_TrainerUI.xml`, `ClassTrainer_SetToClassTrainer`):
- a 296×184 list at (−67, −96) from the top right, with 11 rows;
- a 119-tall detail pane 8 below the list, ending at −407;
- Train and Exit, 80×22, centred at (224, −420) and (305, −420).
The log now uses those frames: Clear and Close sit in the sockets, the detail ends above them, and the list shows 11 rows, not 13.
The new test lays out the stock trainer and the log in one UI and checks that each log frame equals the trainer's, with the buttons inside the window. It fails on the old layout.
Fixes#698🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The land chain compiled the player build twice: `cargo build -p benilla --no-default-features`,
then `cargo test -p benilla-formats -p benilla-app --no-default-features --lib`. The test build
already compiles every line of the no-`dev` graph but the `benilla` launcher, a 17-line shim with
no `dev` seam, and `benilla-app` has no `cfg(not(test))` code, so the first step added only the
shim and a link: about two of a chain's twelve minutes. It goes from `gates.sh` and CI, and
`player-tests` carries its comment.
`check.sh` no longer says a full chain before land makes the land free: main moves in between, so
the chain ran twice.
Test: this land's own chain runs the new `gates.sh`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Percentage costs took their share of the max pool, so Bloodrage cost 20% of max health. The reference's basis table (`0x612c50`, reached from `GetPowerCost` `0x6e31b0`) reads the caster's fields:
| power type | basis |
|---|---|
| health | base health (`UNIT_FIELD_BASE_HEALTH`, 163) |
| mana | base mana (162) |
| rage | 1000 |
| focus, energy | 100 |
| anything else | 0 |
`cost_basis` now holds that table, and `power_cost_at` reads through it for the tooltip, the press-path gate and the action bar. The spell tooltip's rebuild inputs watch base health in place of max health, for the player and the pet. Tests pin each type's basis, and Bloodrage on real data: "337 Health" at level 60, whatever the gear.
Fixes#604
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
The `Unit*` getters and `SpellCanTargetUnit` answered nil for chained tokens like `raid3target` or `party1target`, so a raid-assist addon's main-tank frame stayed empty.
Every getter and `SpellCanTargetUnit` resolves its token through `0x515970` and then reads the guid's object (`0x468460`); see `UnitHealth 0x517511`/`0x51752d` and `SpellCanTargetUnit 0x6e6d3a`.
A token with target hops now resolves to a guid in the VM and reads a per-guid snapshot. A new feed pushes one for each held unit a chain ends on. `SpellCanTargetUnit` keeps its verdicts by guid. The three target, target-of-target and NPC snapshot builders are folded into one.
Tests: `UnitName`/`UnitHealth("raid3target")` and `UnitExists("pettarget")` answer the chained unit, `SpellCanTargetUnit("party1target")` answers its verdict, and plain tokens are unchanged.
Fixes#693Fixes#694
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A roster packet set a member's online bit only for someone new. So a member with a pet who logged off out of view kept answering `UnitExists("partypet1")` and `UnitBuff("party1", i)`, and the party pet's `UNIT_PET` fired on online flips.
The reference's list handler reseats every member through `0x4e82d0`. That keeps the saved record and sets bit 0 from the row's non-zero status byte (`0x4e8361`-`0x4e837b`; raid twin `0x4ba947`-`0x4ba960`). `UNIT_PET` fires only when the stats diff sees the pet guid move (`0x5e57e9`).
Each list now sets every known member's online bit. The `UNIT_PET` edge follows the pet guid unfiltered, and the no-object `UnitIsConnected` reads the record (`0x517dd3`).
Test: a status-only offline list clears the pet and buffs with no `UNIT_PET`, and an online list restores them.
Fixes#666Fixes#665
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Spell descriptions printed `$b`, `$f`, `$F`, `$i`, `$q`, `$u` and `$v` raw. The reference's expander resolves each from Spell.dbc:
| token | column |
|---|---|
| `$b` | EffectPointsPerComboPoint |
| `$f`/`$F` | DmgMultiplier |
| `$i` | MaxAffectedTargets |
| `$q` | EffectMiscValue |
| `$u` | StackAmount |
| `$v` | MaxTargetLevel |
`$b` chops toward zero. `$f` prints one decimal, and `$F` rounds half away from zero, both after the scale and in double precision. The integer arms print a signed `%d`.
This change reads the new columns and adds the arms. `$f` and `$F` keep keying the `$l` plural, under the recorded deviation. Tests pin each arm, the schema's float columns and every shipped use of the six letters. Chain Heal now reads "Each jump is 50% as effective".
Fixes#593
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
`PickupPetAction` lifted a pet action onto the cursor while the pet was out of the client's view, because the pickup gate read an unstreamed pet's missing flags as "not possessed".
The reference's `PickupPetAction` (`0x4be180`) looks the pet up first (`0x4be1f7`). With no object it returns at once (`0x4be1fe`), and only then does it test `UNIT_FLAG_POSSESSED` (`0x4be20a`).
The gate now needs the pet's object before the possessed test, and three comments that cited a mid-instruction address now cite these.
Tests: with the pet out of view, a pickup lifts nothing and a held action stays on the cursor. An in-view pet still lifts, and a possessed one does not.
Fixes#688
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`RAID_ROSTER_UPDATE` fired only when a member's guid, rank, subgroup or online bit moved. A raid list that changed only a death, or an identical re-send, fired nothing, so the raid pullouts and addons listening on it went stale.
After each raid `SMSG_GROUP_LIST` rebuild, the reference's raid roster writer `0x4ba5f0` signals `RAID_ROSTER_UPDATE` whenever no member name is pending (`0x4babdf`-`0x4babef`). A list without the raid flag signals only when it drops a held raid (`0x4ba57b`).
`GroupState` now counts these per list, and the feed fires one event per list since its last look, as #691 does for `PARTY_MEMBERS_CHANGED`. The key compare stays as the edge for world entry, which fills our own row.
Test: with the default UI and `Blizzard_RaidUI` loaded, a death-only raid list turns the pullout row red.
Fixes#690
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`TargetUnit`, `AssistUnit`, `SpellTargetUnit`, `CheckInteractDistance` and `CanInspect` ignored target chains. `party1target`, `pettarget` and `raid3target` named nobody.
The reference's resolver `0x515970` takes a base token, then any number of `target` hops over held units' `UNIT_FIELD_TARGET` (`0x5159d3`-`0x515a2c`). Every one of these verbs resolves through it.
Changes:
- The app and the VM now share one parser, `parse_unit_token`.
- The reach map behind the two range verbs is keyed by guid, filled after the aura feed, and read through the VM's resolver.
- `ObjectStore::is_unit` replaces two private copies of the same test.
- Side effect: the reach feed's new order puts a few feeds after `feed_auras` where their order was undeclared before.
Tests: every chain form, a broken chain, junk after a hop and case folding. `TargetUnit("party1target")` works from Lua.
Fixes#669
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A warrior chasing a running mob was refused Hamstring at 6-7 yd with "Out of range.", where 1.12.1 lets it go out.
After both arms, `GetMinMaxRange 0x6e3480` adds 2.6667 yd (`[0x811914]`) to the max when all of these hold (`0x6e3648`-`0x6e36a2`):
- the caster and the target unit both move, with a bit of `0x200d` set;
- both run faster than twice their walk speed (`0x5fc350`);
- the row is melee, or the target is a player.
On the melee row, the auto-attack target stands in when no unit is passed.
`min_max_range` now takes each unit's motion and adds the bonus. Every caller passes what its reference call site passes. The ambiguity ceiling rises by the 11 pairs this adds, with the reason beside it.
Test: Hamstring goes out at 7.5 yd with both units running and is refused standing still.
Fixes#678
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The party feed fired `PARTY_MEMBERS_CHANGED` only when the list of member guids changed. So an `SMSG_GROUP_LIST` that moved only a member's status, flags or subgroup, the leader or the loot method fired nothing. In particular, when a member out of view logged off, the stock party frame never repainted: their bars stayed coloured and their pet frame stayed up.
**The reference.** `SMSG_GROUP_LIST`'s handler `0x5e6a40` fires `PARTY_MEMBERS_CHANGED` (`0x5e6c61 mov ecx,0xda`, `0x5e6c66 call 0x703e50`) after its member loop on every path, party and raid alike. An empty list jumps straight there (`0x5e6b3c`). Nothing compares against the previous roster, and there is no `ret` before it.
**Changes:**
- `GroupState` counts every applied list, including the all-zero leave list and an identical re-send.
- The feed fires `PARTY_MEMBERS_CHANGED` once per list since its last look, in the same place after the unit and pet snapshots.
- A fresh VM gets one catch-up event if any list landed this session.
- The `/partytest` sandbox counts the lists its intents would be answered with.
- `PARTY_LEADER_CHANGED` and `PARTY_LOOT_METHOD_CHANGED` keep their compares, as `0x4e8250` and `0x4e86f0` do.
**Tests** (in `feed.rs`, each firing the event):
- a status-only list;
- a leader-only list and a loot-only list;
- an identical list;
- three lists in one drain, firing three;
- an empty list;
- a fresh VM's catch-up;
- the sandbox's intents.
`the_stock_party_frame_greys_a_member_who_logs_off_out_of_view` loads the default UI. After a status-only offline list, `PartyMemberFrame1ManaBar` reads `(0.5, 0.5, 0.5)` and the pet frame hides.
Fixes#667🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No pet-bar verb asked whether the pet's actions were usable. So a stunned, feared or confused pet, a pet out of view, a pet that is not yours, or a charmed player still did all of this:
- pressed, latched Stay, Follow and the reactions, and swept the GCD;
- sent `CMSG_PET_ACTION` and flipped autocast;
- showed `GetPetActionsUsable()` as 1, which kept the stock bar in colour.
`0x4bcf70` is the reference's one predicate. It returns 0 in any of these cases:
- the active player does not resolve (`0x4bcf92`);
- the player is charmed (`0x4bcf98`-`0x4bcfa4`);
- the player's far-sight guid (`PLAYER_FARSIGHT`) is set and is not the pet (`0x4bcfbf`-`0x4bd012`);
- the pet guid does not resolve to a unit (`0x4bd034`-`0x4bd03d`);
- the pet's charmed-by guid, else its summoned-by guid, is not the player (`0x4bd03f`-`0x4bd067`);
- the pet is stunned, confused or fleeing (`0x4bd06f`-`0x4bd08b`);
- bar-state bit 27 is set (`0x4bd08d`).
These callers ask it:
- `GetPetActionsUsable` (`0x4be0b3`);
- the dispatcher behind `CastPetAction`, the orders and Dismiss (`0x4bd1f2`, before its type switch);
- `TogglePetAutocast` (`0x4bcbcf`);
- the pet book's toggle (`0x4bccce`);
- the bar write used by the drag's drop and the pickup's blank (`0x4bc9d0`).
Changes:
- `ui_pet/usable.rs` builds `0x4bcf70` once. The bar's feed pushes it as `GetPetActionsUsable`. `PetPress::press` asks it first, before the aura cancel.
- Both autocast drains, Dismiss and the drag's two writes ask it too. A pickup still lifts the action onto the cursor, as the reference does.
- The spell arm's later player and pet lookups, and the Attack arm's null branch, repeat the predicate's, so their dead handling goes.
- `arm_pet_gcd`'s doc now names the three places the pet's GCD starts: the press (`0x4bd36e`), a pet-addressed `SMSG_SPELL_COOLDOWN` (`0x6e9598`) and the generic cast send (`0x6e58fb`).
Tests:
- `ui_pet/usable_tests.rs` tries each of 11 exits alone, and 5 usable lookalikes, against `GetPetActionsUsable`, a spell press, the orders, both autocast toggles, Dismiss and the drag.
- A stock-UI test loads the default UI. A stunned pet's icon desaturates, and a click sends nothing until the stun ends.
- The three press tests that pinned ungated sends are flipped.
Fixes#672Fixes#673Fixes#674🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`member_unit_state` built a `partyN` or `raidN` view, then replaced `is_connected` with the roster status byte's online bit on both legs, the held leg included. So a member who went link-dead while still in view read disconnected at once, and their frame's power bar went grey.
`UnitIsConnected 0x517d50` looks the unit up in the object manager (`0x517da6`). A held unit answers 1 (`0x517daf`). Only with no object does it read the record's `+0x08 & 1` (`0x517dd3`), which `SMSG_GROUP_LIST` sets from the member's status (`0x4e836e`-`0x4e837b`).
The held leg keeps `snapshot`'s value, and only the no-object leg reads the roster's online bit.
Tests (through `feed_party` and the Lua getter):
- a held member reads connected whatever the status byte says, for `party1` and `raid1`;
- a held member going offline reads connected until the object leaves, then nil;
- an unheld member with the bit clear reads nil, and with it set reads 1.
Fixes#647🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`Cooldowns::add` appended a record for every tracked insert and never dropped one. Only `prune` removed fully elapsed records, and it ran on the player's list alone. So the pet's list kept a record for every pet GCD and every pet `SMSG_SPELL_GO` recovery for the whole session, and each pet-bar and pet-book read walked all of them.
`add` now prunes fully elapsed, not-on-hold records before it pushes. The list is bounded by its live records plus the one being added. This holds for the pet's list and the player's alike, without a new system or any schedule change.
The prune keeps its contract:
- it moves `expiry_epoch`, never `generation`, so the pet list's flush fires once for the insert and nothing for the prune;
- reads skip elapsed records already, so no reader sees a difference;
- the player's per-frame prune in `ui_action/state.rs` stays, for an expiry that arrives with no insert.
Tests:
- `an_insert_bounds_the_list_by_its_live_records_plus_itself` checks the bound after each of 30 inserts on a synthetic clock.
- `an_insert_keeps_a_running_and_a_parked_record_and_changes_no_read`.
- In the pet rig, after 40 elapsed pet presses, one more press leaves only the live records. That insert fires its one flush, and the bar's reads of the records that stay are unchanged.
Fixes#656🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`UnitCreatureType` read only a creature's template type, and fell back to "Humanoid" for a player. The form stage was not built. So a druid in cat, bear, travel or aquatic form and a shaman in Ghost Wolf all answered "Humanoid", where 1.12.1 answers "Beast".
`UnitCreatureType 0x51a280` names the type the resolver `0x605570` returns, and 0 names nil (`0x51a2c3`). The resolver tries three stages in order:
1. **Form:** the form byte (`UNIT_FIELD_BYTES_1` byte 2) indexes `SpellShapeshiftForm.dbc` column 12, which applies only above 0 (`0x60559a`).
2. **Template:** else the cached creature record (`0x6055a8`).
3. **Race:** else the race's `ChrRaces.dbc` type (`0x6055ca`).
A token naming no unit answers nil (`0x51a2b8`). The unit tooltip's level line takes the type only for a non-player (`0x52a4a6`).
Changes:
- `ui_unit::snapshot` sets `creature_type_name` for every unit through the shared `CreatureTypeSources::of`: players, creatures, pets and group tokens alike. `enrich_unit` no longer sets it.
- `UnitCreatureType` reads that field, with no fallback.
- The feeds at Bevy's parameter limit carry the sources in a `SnapshotTables` param.
- A player's tooltip level line is unchanged, since it reads race and class.
Behaviour that now matches the reference beyond the issue:
- a held pet, `partypetN` and `raidpetN` answer their template's type rather than nil;
- a party member in a form reads the form's type;
- a roster member with no object, and the pre-arrival player seat, answer nil.
Tests:
- `the_shipped_forms_name_a_beast_or_fall_through_to_the_race` reads the real `SpellShapeshiftForm.dbc`.
- `unit_creature_type_names_the_form_before_the_race_and_a_creature_its_template` runs `feed_units` and asks the Lua binding.
- Mouseover, party, raid and pet feeders each have a test.
- `a_players_level_line_ignores_the_snapshots_creature_type`.
Fixes#641🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A pet-bar spell press sent `CMSG_PET_ACTION` even when the spell's `Spell.dbc` record or the pet object did not resolve. So with the pet out of the client's view, which leaves the bar standing on its guid from `SMSG_PET_SPELLS`, a press still made the server cast. In the reference the press does nothing there.
**The reference.** The pet bar's spell arm (types 1-5 through the jump table at `0x4bd4d0`, the arm at `0x4bd240`) first handles the aura cancel (`0x4bd242`-`0x4bd2ad`). It then leaves for the epilogue `0x4bd4c6`, with no GCD and no send, in any of these cases:
- the id is past `[0xc0d78c]` (`0x4bd2e7`-`0x4bd2ed`);
- the id has no record (`0x4bd2f3`-`0x4bd2fe`);
- the active player does not resolve (`0x4bd304`-`0x4bd324`);
- the pet guid `[0xb714a0]` does not resolve to a unit (`0x4bd346`-`0x4bd34f`).
The reaction arm (`0x4bd391`) and the command arm (`0x4bd3a3`) take none of these exits.
**Changes.**
- `PetPress::press` returns, with no send, no latch and no GCD, from a spell-slot press whose record, player object or pet object is unresolved. The exit comes after the aura cancel, as in the reference.
- `arm_pet_gcd` is now called only for a resolved press, so the two guards are one.
**Tests** (`ui_pet/press_tests.rs`):
- a press with no pet object, no catalog row or no player object sends nothing;
- the aura cancel needs no player object;
- Follow, Stay and a reaction still send with the pet unheld;
- Attack still sends at the selection with the pet unheld.
Fixes#638🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The world pick dropped only the active player. A unit you possess stayed a candidate, so under a Mind Control or an Eye of Kilrogg it highlighted under the cursor and a click selected it.
`0x480610`, the pick's unit and player filter, applies two exclusions whenever the pick flags lack `0x20`:
- the active player (`0x480638` → `0x480676`);
- a unit with `UNIT_FIELD_FLAGS & 0x01000000` (`UNIT_FLAG_POSSESSED`, `0x48064f`) whose `UNIT_FIELD_CHARMEDBY`, else `UNIT_FIELD_CREATEDBY`, is the active player (`0x480658`-`0x480674`).
Outside targeting the flags always lack `0x20`. `0x481050` ORs it only for a word that can bind yourself.
Changes:
- `TargetHoverState::excludes` holds both exclusions under the `PicksSelf` gate. `is_candidate` asks it on the posed pass and on the box path, so the two separate self skips are gone.
- The possession test moves to `target::relations::is_possessed_by`. The pet bar's `possessing` now wraps it.
Tests:
- `a_unit_you_possess_lets_the_pick_through_to_the_one_behind`: Mind Control, an Eye of Kilrogg, and a unit charmed by you but created by another, outside targeting and under an enemy word. The rear unit wins, and under a `0x20` word the possessed unit is the pick again.
- `a_lone_unit_you_possess_is_no_pick_at_all`.
- `a_unit_you_do_not_possess_keeps_the_pick`, with five controls.
- `possession_is_the_flag_and_the_charmer_else_the_creator`.
Fixes#649🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ground-targeting cursor's range verdict compared the caster-to-point distance against the `SpellRange` row's raw min and max. A range talent therefore never reached it: with Arctic Reach, Blizzard read out of range past its base 30 yd.
**In the reference**, `CheckGroundPointInRange 0x6e6810` takes its bounds from `GetMinMaxRange` (`0x6e68b0`, null target at `0x6e6879`). That applies spell-mod op 5 to the max (`0x6e3744`) and, on a melee row, sums the caster's reach with the auto-attack target's. The compare (`0x6e68b5`-`0x6e68d3`) is inclusive at both bounds and fails on an unordered result. The GameObject leg of `SpellCanTargetUnit 0x6e6460` runs the same compare (`0x6e67d6`-`0x6e6802`) with the object as target, which reads as none.
**Changes:**
- `ground_point_in_range` takes its `(min, max)` from `SpellModifiers::min_max_range`, with no explicit target and with the caster's and the auto-attack target's reach. It compares `min² <= d² <= max²`.
- The terrain arm and the GameObject leg share one `RangeCall`, built once per frame.
- `BindChecks` gains the caster's reach and the auto-attack target's reach.
- Some ground spells do use the melee row (Earthshaker, Kadrak's Flag and a few quest spells), so the reaches are passed, not defaulted.
**Tests** (in `cursor.rs`):
- `a_range_talent_moves_the_ground_verdict`: Arctic Reach's cells on Blizzard's family bits put 33 yd in and 37 yd out, with the boundaries at 35.9 and 36.1. A family mismatch does not apply.
- `the_melee_row_reads_the_casters_reaches`.
- `the_terrain_verdict_reads_the_range_talent_and_the_casters_reaches` and `the_object_leg_reads_the_range_talent`, both through `drive_targeting_cursor`.
- The mirror test gains inclusive-bound and NaN cases.
Fixes#653🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pet's cooldown list fired the wrong UI events:
- The `SMSG_SPELL_GO` pet leg bumped `PetBar::bar_signals`, so the pet bar feed fired `PET_BAR_UPDATE` instead of `PET_BAR_UPDATE_COOLDOWN`.
- No change to the pet's list ever fired `ACTIONBAR_UPDATE_COOLDOWN` or `SPELL_UPDATE_COOLDOWN`. Those fired only off the player list's generation.
The stock spellbook's buttons refresh on `SPELL_UPDATE_COOLDOWN`, so a pet-bar press's GCD never swept on the spellbook's pet tab.
**In the reference**, every mutation of the pet's list is followed by `0x4b31b0`, which fires `ACTIONBAR_UPDATE_COOLDOWN` (`0xd8`) and then `SPELL_UPDATE_COOLDOWN` (`0x106`), and then by `0x4bce90`, which fires `PET_BAR_UPDATE_COOLDOWN` (`0x162`). The mutations are:
| Mutation | Insert or clear | Flush |
|---|---|---|
| `SMSG_SPELL_GO` pet leg | `0x6e85f7` | `0x6e85fc`, `0x6e8601` |
| `StartGlobalCooldown` on the pet's list | — | `0x6e2e77`, `0x6e2e8e` |
| A pet-addressed `SMSG_SPELL_COOLDOWN` | — | `0x6e95b0`, `0x6e95b9` |
| The cooldown event and clear packets | — | `0x6e3071`-`0x6e3080` |
| `SMSG_COOLDOWN_CHEAT` | — | `0x6e9712`, `0x6e971c` |
`SMSG_PET_SPELLS` seeds the list with no flush (`0x6e2c60`). Its `SetPet` fires `PET_BAR_UPDATE` on every packet (`0x4bc90c`).
**Changes:**
- **The flush.** `fire_pet_cooldown_events` watches the pet list's generation and fires the three events once per frame it moved, after the bar, the book and the player's action state have pushed. It owns the pet list's events.
- **The feed.** `feed_pet_bar` no longer fires the cooldown event itself, so a natural expiry fires nothing, as on the player's list.
- **The `SMSG_SPELL_GO` pet leg** drops its `bar_signals` bump.
- **`SMSG_PET_SPELLS`** bumps `bar_signals` once per packet, as `SetPet` does, and seeds without moving the generation.
- **Teardown** clears the list without resetting its counters, so a new bar never masks the next flush.
- **Docs.** The `Cooldowns` doc names both lists and their owners.
**Tests:**
- `ui_pet/flush_tests.rs` drives the real handlers:
- the `SMSG_SPELL_GO` trio with no `PET_BAR_UPDATE`;
- a stranger's pet;
- the pet-addressed `SMSG_SPELL_COOLDOWN`;
- the event, clear and cheat packets;
- one flush per frame;
- `SMSG_PET_SPELLS` signalling the bar but never flushing;
- teardown;
- natural expiry;
- a fresh VM;
- the flush's schedule order.
- `a_pet_gcd_sweeps_the_spellbooks_pet_tab_button` loads the default UI, opens the spellbook's pet tab, presses a pet slot and checks `SpellButton1Cooldown` sweeps 1.5 s.
- The press test now asserts the three events.
Fixes#637🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The binder cleared the targeting word's party bit `0x8` (implicit target 35) and raid bit `0x4` (targets 57 and 61) only for the caster, so no group member ever bound these spells. Affected spells:
- Power Word: Shield;
- the Blessings of Protection, Salvation and Sacrifice;
- Amplify Magic, Dampen Magic, Divine Intervention;
- every Greater Blessing;
- Seal of Protection and Seal of Sacrifice.
With a party member selected, they fell back to you or raised a cursor whose click bound nothing.
**The reference.** `BindTarget 0x6e5b40`'s party arm (`6e5cad`-`6e5cec`) asks `0x606c20(caster, unit)` and then `CanAssist 0x6066f0`. Its raid arm (`6e5cf1`-`6e5d2f`) asks `0x606d20` and `CanAssist`. The two predicates differ only in their membership call:
- The unit itself answers true.
- Otherwise both units need `UNIT_FIELD_FLAGS & 0x8`.
- Each side's controlling player is `0x606170`: charmer, else creator, else itself, and it must be a player.
- Either controller being you makes the answer the other's membership, tried both ways round.
- Membership is `0x4e7f70` for `0x606c20`: you or one of the four party slots. It is `0x4918e0` for `0x606d20`: that, or a raid roster row (`0x4baee0`).
**Changes:**
- `spell/group_relation.rs` builds both predicates as `TargetRelations::in_party` and `in_group`, over one controller helper.
- `GroupRoster` holds the party slots and the raid roster, and is published from `GroupState` before the input pass. No cast or hover system reads `GroupState` itself, so there are no new schedule orders.
- The binder's party and raid arms clear their bits for an assistable group member.
- The world pick's party leg (`4806b8`) shares `in_party`.
**Tests:**
- `group_relation.rs` covers the roster and each predicate leg: both player-controlled, pets through their owner, and the mirror.
- `the_shipped_party_and_raid_spells_bind_a_party_member` runs 11 real `Spell.dbc` rows.
- `the_press_the_click_and_the_hover_bind_the_same_group_members` runs 8 candidates through the party and raid words on the press, `SpellTargetUnit` and `SpellCanTargetUnit`, fed through the real `GroupState` publish. The candidates are a party member, a raid-only member, a stranger, pets, a friendly creature, the other faction and yourself.
Fixes#648🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`min_max_range` took one `target_reach` that served two roles: the melee arm's fallback to the auto-attack target and the ranged arm's explicit target. The ranged arm padded whenever it was `Some`. The tooltip passed the auto-attack target's reach, so while you swung, every ranged spell's tooltip read its row plus both reaches: Fireball read "38 yd range" where 1.12.1 reads 35.
`GetMinMaxRange 0x6e3480` counts its target (`[ebx+0x10]`) only when it is a unit (`0x6e34e1`-`0x6e34f9`).
- **Melee arm** (`0x6e3552`-`0x6e3594`): with no unit target, it falls back to the caster's auto-attack target (`0x6e356a`), then to the caster's own reach.
- **Ranged arm:** pads only for a non-null target (`0x6e35d8`, `0x6e35ec`). The max always takes the pad, and the min only when nonzero.
What each caller passes:
| Caller | Call site | Target |
|---|---|---|
| Tooltip | `0x52e9c2` | none |
| `ActionHasRange` | `0x4e5852` | none |
| The cast check and `IsActionInRange` | through `CanTargetUnit 0x6e4440` → `0x6e47b0` | the bound or selected target |
Changes:
- `min_max_range` takes `RangeTargets { target, attack_target }`.
- The melee arm reads the target, else the attack target, else the caster. The ranged arm pads for the target alone.
- Each caller passes what its reference call site passes. The action button's two verbs are now two calls: `has_range` with no target and `in_range` with the selected one.
- `min_max_range_reads_reach` narrows to melee rows. The tooltip safety net then counts 813 reach-dependent views instead of 10,298, so starting to swing no longer rebuilds every ranged tooltip.
Tests:
- `ranges.rs`: `the_ranged_arm_pads_only_for_the_explicit_target`, `the_melee_arm_falls_back_from_the_target_to_the_attack_target_to_the_caster`, and the narrowed exactness test.
- `range_cell_on_real_data` reads Fireball as "35 yd range" and Charge as "8-25 yd range" with an auto-attack target present.
- `cast_range_refusal_follows_the_two_compares` covers a bound target's pad.
- `a_ranged_button_pads_its_range_by_the_selected_target_alone`.
Fixes#643🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While a spell was targeting, the world pick admitted every unit and player. A unit the spell could not take, standing in front of one it could, took the hover and the click:
- a hostile or dead unit in front of a friend under a heal;
- a living player standing on a corpse under Resurrection;
- any unit in front of a chest under a lock word.
In the reference, both world picks build their flags through `0x481050`. While targeting (`4810f1`-`481187`):
- a word with unit bits (`0x6e6180`, `& 0x878e`) adds units and players (`0x18`);
- three relation flags follow: party `0x10000` (`0x6e61e0`, `& 0x40c`), assist `0x20000` (`0x6e6200`, `& 0x500`) and attack `0x40000` (`0x6e6210`, `& 0x480`);
- a dead-target word (`& 0x8600`) adds `0x200040`, and any other adds `0x100000`;
- `0x6e6240` (`0x8000`) adds assist, and `0x6e6250` (`0x200`) adds attack.
`0x480610` then drops a unit or player candidate when:
- none of the relations the flags ask for holds (`480686`): `0x606c20`, `CanAssist 0x6066f0` or `CanAttack 0x606980`, toward the active player;
- its liveness is not the one asked for (`4806fd`, signed `UNIT_FIELD_HEALTH` above 0);
- it has `UNIT_FIELD_FLAGS & 0x100` (`480737`).
A word with no unit bits puts no unit in the pick at all (`0x480780`'s unit and player arms need `0x8` and `0x10`).
- `spell/targeting/pick.rs`: `PickFlags::of(word)` builds the flags as `0x481050` does, and `PickFlags::admits` applies the three legs.
- `0x606c20` is built here, from the published party slots.
- `TargetHoverState::is_candidate` routes units and players through it on both the posed pass and the box fallback. The press latches the same pick.
- `CanAssist` and `CanAttack` toward a candidate become `TargetRelations::assistable` and `attackable`, shared with the binder's arms. The binder's behaviour is unchanged.
- The alive test is `bind_gates::unit_alive`.
Tests:
- `pick.rs` covers the flag table, each relation, Resurrection, the enemy corpse word, `0x100`, the caster, and `0x606c20` for self, a party member, pets and a stranger.
- `hover.rs` stands two units on the cursor's ray. The rear unit wins when the front one is refused. A lone refused unit gives no pick, and a word with no unit bits picks no unit. The press latches the rear unit.
- Outside targeting the front unit still wins.
Fixes#464🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>