Commit Graph
580 Commits
Author SHA1 Message Date
Sam SungandClaude Opus 5.5 710eb8f531 Dev builds drop ThinLTO; dev and play build dependencies as one unit (#857)
An incremental rebuild of `benilla-app` spent most of its time in ThinLTO: above opt-level 0 rustc runs it over a crate's own codegen units by default, and incremental compilation redoes it whole. `cargo play` already turns it off, which left its dependencies without cross-unit inlining.

`[profile.dev]` now sets `lto = "off"`, and dev and play build dependencies as one codegen unit each.

Measured on an M4 Max, one-constant edit in `benilla-app`, cpu_ms at the leg pin:

| | before | after |
|---|---|---|
| `cargo build -p benilla` | 24.4 s, 5.2 GB | 5.6 s, 2.7 GB |
| `cargo test -p benilla-app --lib --no-run` | 25.8 s, 7.0 GB | 8.3 s, 4.0 GB |
| cold dev build | 5:19 | 4:06 |
| dev cpu_ms | 11.06 | 11.71 |
| play cpu_ms (release: 10.1) | 11.74 | 10.63 |

Release and ship are unchanged.

Fixes #852.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:36:25 +07:00
Sam SungandClaude Opus 5.5 c2fc3cf304 OnShow and OnHide fire children first, and after OnLoad, as in 1.12.1 (#842)
Show and hide propagated a whole subtree first, then fired from a snapshot, parent first: a parent's `OnShow` ran before its children's, and a child hidden by an earlier handler still got its `OnShow`. A frame visible at the end of its load got no `OnShow` at all.

In 1.12.1 show `0x76ae10` and hide `0x76ad50` mark the frame, walk the live child list re-checking each child's gate, and fire the frame's own handler last (`0x76aef5`, `0x76adfd`). The post-load hook fires `OnShow` after `OnLoad` for a visible frame (`0x76a3b3`), from XML and `CreateFrame` alike.

Now handlers run inside a live post-order walk, and the post-load `OnShow` fires. Tests cover the order, a sibling hidden mid-walk, and one or two `OnShow`s after a load; they fail without the fix. Four existing tests' expected orders flip to the reference's.

Fixes #350
Fixes #59

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:44:15 +07:00
Sam SungandClaude Opus 5.5 dc63a06178 The UI scale is UIParent's own scale over a 768-unit root (#841)
The UI scale was folded into the pixel seam, with the root and `UIParent` at scale 1. Frames outside `UIParent` (dropdown menus, need/greed rolls, the world map) shrank with it, `UIParent:GetScale()` answered 1, and `GetCursorPosition()` was off by the scale.

In 1.12.1 the scale is `UIParent`'s own frame scale (`0x494550` → `0x76ac10`, floored at 0.64) over a 768-unit root. `GetScreenWidth`/`GetScreenHeight` divide by it (`0x494590`), and `GetCursorPosition` (`0x48b820`) does not.

Now the seam is `height/768`, and the scale is applied to `UIParent` at the load's end and on a resize. The nameplates' compensation is gone. Backdrops, hit insets and minimap pings now follow a frame's own scale (`0x76b580`, `0x77e8d0`); tooltips at the cursor and `OnCursorChanged` divide by it (`0x530b20`, `0x77db23`). Tests cover each, and a stock-UI comparison shows nothing inside `UIParent` moves.

Fixes #172

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:17:23 +07:00
Sam SungandClaude Opus 5.5 f0dfa86148 PLAYER_LOGIN fires at world entry, once the player exists (#840)
`PLAYER_LOGIN` fired at the end of the UI load, before the player existed: its handlers saw `UnitExists("player")` nil and level, health and XP 0, at every login and `/reload`. Ace2's `OnEnable` runs there.

In 1.12.1 the load only arms a one-shot (`0x49011d`). The world-enter cascade fires `PLAYER_LOGIN` if armed (`0x49094b`), then `PLAYER_ENTERING_WORLD` (`0x49096a`), inside a sound-suppression bracket (`0x4908d5`–`0x490a56`). It runs from the player's own create (`0x5deb60`), or on `/reload` from the load (`0x490168`) with the player still in the world. A worldport re-arms nothing.

Now the load arms it, and the unit feed fires both, silenced, once the player is seated. A `/reload` loads under the live player and no longer plays the target-select sound. Tests cover login, worldport and reload; they fail without the fix.

Fixes #189
Fixes #191

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:38:06 +07:00
Sam SungandClaude Opus 5.5 87706792cb A frame or region takes no name or id from its template (#839)
An instance with no `name=` took its template's name: `framexml::merge` laid the instance's attributes over the template's, so `name` and `id` carried through. Stock `UIOptionsFrame.xml`'s unnamed `<Button inherits="UIPanelCloseButton">` became the global `UIPanelCloseButton`, with `GetName()` answering it, and any unnamed addon frame did the same to its template's name.

In 1.12.1, `PreLoadXML` (`0x769770`) reads `name` and `id` from the instance node before `LoadXML` walks the templates (`0x76985c`). The texture and font string factories also read `name` once, outside that walk (`0x6f2751`, `0x6f27e1`). So neither is ever inherited.

The merge now drops the template's `name` and `id`. Tests cover an unnamed frame, a named frame (no template id), an unnamed texture and `CreateFrame` with a nil name. All four fail without the fix.

Fixes #394

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:18:42 +07:00
Sam SungandClaude Opus 5.5 d0d1b5def9 WORLD_MAP_UPDATE fires inside the verb that moves the map, as in 1.12.1 (#829)
Reopening the world map after zooming out showed the old map for a frame before the current zone.

In 1.12.1, every Lua verb that moves the map selection (`SetMapZoom`, `SetMapToCurrentZone`, a `ProcessMapClick` hit) goes through the setter `0x4a67a0`. The setter stores the selection, rebuilds the landmark list, then fires `WORLD_MAP_UPDATE` in place before returning (`0x4a6ce4`). So `WorldMapFrame`'s OnShow repaints the tiles before the first draw.

benilla queued the event to the next tick, so the first frame painted the previous map. The three verbs now run the setter whole. The landmark list is projected from per-row sources inside the setter, so the new tiles never show the old map's markers. The engine's own pushes still queue, as a narrowed deviation.

Tests replay the report's steps on the stock UI and check the event fires inside the verb with landmarks rebuilt first; they fail before the fix.

Fixes #811

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:24:25 +07:00
Sam SungandClaude Opus 5.5 208b1e0bb0 Your own name stays blue or green in FFA areas, as in 1.12.1 (#828)
In a free-for-all area such as the Gurubashi battle ring, your own overhead name and selection ring turned red.

1.12.1 colours a player's name and ring through `0x605960` by mutual attackability: `CanAttack` (`0x606980`) in both directions. Both true reads red, only theirs soft blue, only yours yellow. Neither splits on PvP: yourself green or blue, a party member pale, others green or blue. `UnitReaction` (`0x6061e0`) answers friendly for a unit toward itself before the FFA rung (`0x606200`), so you can never attack yourself.

benilla stood in a reaction-rank threshold for that matrix, and its reaction lacked the identity rung. The selector is now the reference's four-arm matrix, `CanAttack` runs in both directions, and a unit is friendly to itself. `UnitReaction`, `UnitCanAttack` and the cursor follow for yourself.

Tests pin each arm and yourself in FFA, on the shipped faction templates too; they fail before the fix.

Fixes #814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:00:24 +07:00
Sam SungandClaude Opus 5.5 1c5a568191 Escape on the realm list closes only the list, as in 1.12.1 (#827)
Escape on the realm list closed the list and then also quit the game from the login screen, or went back to login from character select.

1.12.1 walks the keyboard frames once per press, strata high to low, and stops at the first handler that takes it (`0x765f10`, leaving at `0x765f64`/`0x765f86`). The realm list is DIALOG strata (`RealmList.xml:246`), so its Escape never reaches the screen beneath, even though it hides itself.

benilla's list and screens read the keys in no fixed order. A screen only checked whether the list was still shown, and the list's own Escape had just hidden it. Who owns a frame's keys is now decided once, before any of them runs (`Realms::owns_input`, set in `PreUpdate`).

Tests run the real list and screen systems in both orders, and fail before the fix with the reported symptoms.

Fixes #817

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:30:51 +07:00
Sam SungandClaude Opus 5.5 ea7e0d087a Zone names stay uncoloured on PvE realms outside capitals, as in 1.12.1 (#825)
On a PvE realm, zone names were still coloured friendly, hostile or contested.

In 1.12.1, `GetZonePVPInfo` (`0x48d540`) answers a type only when its display gate is open (`0x48d5c2`/`0x48d5cc`). The gate opens on the realm's PvP flag or in a zone with Flags `0x10`, which is the six capitals. The realm flag is read from `Cfg_Configs.dbc` for the selected realm's type and stored once per world entry (`0x4015de`). A type with no row skips the store. Otherwise the type is nil, so the stock zone splash shows cream and the minimap zone text gold.

benilla now reads `Cfg_Configs.dbc` instead of a transcription that stopped at type 8, so realm type 10 lists as PvP. It latches the realm flag at world entry and applies the gate.

Tests cover the gate, the six capitals and every shipped Cfg_Configs row; each failed before the fix.

Fixes #816

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:53:16 +07:00
Sam SungandClaude Opus 5.5 63e917965d The quest log counts party members on each quest, as 1.12.1 does (#824)
The quest log never showed the `[n]` count of party members on a quest, because `IsUnitOnQuest` was a stub that always answered nil.

1.12.1 (`0x4dfe10`) checks both arguments first. It reads the row's quest id (`0x4df150`; a header or out-of-range row answers nil before the unit is resolved), resolves the token, and admits only you or one of the four party slots, by guid (`0x4e7f70`). It then needs the unit held as a player and scans the 20 `PLAYER_QUEST_LOG` ids for that quest. The answer is 1 or nil. The ids reach party members on the wire. `UNIT_QUEST_LOG_CHANGED` (`0x51bc63` → `0x51bd90`) fires once whenever any slot of a unit's window changes, so an open log recounts.

Unit snapshots now carry the window, the binding follows those steps, and the event fires. Tests cover each path and run the stock `QuestLogFrame.xml` to a `[2]` row; they fail against the stub.

Fixes #818

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:09:41 +07:00
80b0848628 fix: trainer services show their spell's description, as 1.12.1 does (#803)
- capture: trainer scenarios shoot the service description field (#793)
- fix: trainer services show their spell's description, as 1.12.1 does (#793)
- review: reuse the caster closure, cut repeated comments, pin the profession rows
- trainer: the pet's skill value is the tooltip's one helper, not a copy
- trainer: the recipe row's real-data test reads a product that has text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-08 07:16:27 +07:00
Sam SungandClaude Opus 5.5 2e82d34c1b The meeting-stone query runs at every world entry, as 1.12.1 sends it (#810)
The meeting-stone status query (`CMSG 0x296`) and its text reset ran once per UI VM, and the text drop only on leaving the world. So after a cross-map worldport benilla kept the old text and never asked again.

In 1.12.1 the world-enter cascade's meeting-stone init (`0x490a14` → `0x4c9f40`) resets the text to `UNKNOWN` and sends `0x296`, right after the battlefield init, on every pass: login, every cross-map worldport and every `/reload`. The leave-world sweep `0x490a80` drops the text (`0x490b2f` → `0x4c9f80`) on a worldport, a `/reload` and leaving the world.

The enter leg now runs on the world-enter cascade after the battlefield status. The sweep is one edge, written whenever its latch is spent, and the drop reads it. Tests cover the cascade, a worldport, a reload and the schedule order; a live trace shows `0x296` after `0x2D3` and its reply at login, on `ReloadUI()` and after `.tele`.

Fixes #99

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:29:18 +07:00
Sam SungandClaude Opus 5.5 85980939f5 A crate on top finds its own benilla-config/ through config_dir (#809)
A crate built on benilla through `run_with` has its own `benilla-config/`, but could not ask where it is: `local_state::home` is crate-private. A crate that ships an addon has to write it into that folder's `AddOns/`, where benilla discovers addons and resolves their art and fonts, so without the path it needed a fork.

- `benilla_app::config_dir()` answers `local_state::home()`, asked from `run_with` on, since a dev build resolves it from the launcher's folder set there.
- CONTRIBUTING's "Building on top" says a crate's files and addons go there, and names `run_with` and this folder as the two seams opened for crates alone.
- The example launcher logs its `AddOns/` folder through it, so the example, which clippy and the tests build, is the outside caller that keeps it public.

No 1.12.1 behaviour changes.

Fixes #807

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:12:02 +07:00
Sam SungandClaude Opus 5.5 f8493febb0 The out trace logs every world send, the login handshake's included (#806)
The `out` trace tag logged only what the long-lived writer sent, drained after each command. Everything the session sends before the reader and writer split never reached it: `CMSG_AUTH_SESSION`, `CMSG_CHAR_ENUM`, `CMSG_PLAYER_LOGIN`, character create and delete, the cinematic ack and logout. So a login trace could not show where `CMSG_PLAYER_LOGIN` sits against `SMSG_LOGIN_VERIFY_WORLD`, which wire-timing work needs.

The world socket has one write path, `send_packet`. It now reports each packet to an observer right after the write, and the app installs the trace there only when the `out` tag is on. The writer's sent-log and its drains are gone; the line format is unchanged.

A protocol test over the fake server checks that the handshake, a pre-split send and a writer send are all observed, in order. A live login trace now opens with `CMSG_AUTH_SESSION`, `CMSG_CHAR_ENUM` and `CMSG_PLAYER_LOGIN`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:45:22 +07:00
ffdc206ac2 World-enter queries wait for the player's create, as 1.12.1 sends them (#801)
At login benilla sent `CMSG_SET_ACTIVE_MOVER`, `CMSG_QUERY_TIME`, `MSG_QUERY_NEXT_MAIL_TIME` and `CMSG_BATTLEFIELD_STATUS` right after `CMSG_PLAYER_LOGIN`, before the server had seated the player. cmangos drops them; after a worldport or `/reload` the time and mail queries never went again.

In 1.12.1 all four follow the local player's own create (`0x5dea50`): `SetActiveMover` (`0x6006e0`), then the world-enter cascade (`0x5deb60 call 0x4908c0`), whose inits send `0x1CE`, `0x284` and `0x2D3` in that order. A cross-map worldport re-creates the player and takes the same route; `/reload` re-enters the cascade alone (`0x490168`).

The four now ride the self create, in that order, and `/reload` re-sends the three. Tests cover login, the create, a worldport, the schedule order and the reload; a live trace shows the order at login, after `.tele` and on `ReloadUI()`.

Fixes #789
Fixes #100

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Elias Miettinen <eliaspmiettinen@gmail.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-05 22:28:59 +07:00
bb84f1f1fb Unit tooltips title the unit with its PvP name, as 1.12.1 does (#802)
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>
2026-10-05 22:08:33 +07:00
Mathih13andClaude Opus 5.5 ab909df6d9 fix: TargetUnit("npc") selects the interaction NPC, as 1.12.1 does (#800)
`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>
2026-10-05 21:57:08 +07:00
622e1c3e7a fix: overhead names carry the player's PvP title, as 1.12.1 does (#792)
- 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>
2026-10-04 11:04:32 +07:00
aa103d5a65 fix: level-up gains read signed, so a loss prints -2, as 1.12.1 does (#791)
- 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>
2026-10-04 10:18:24 +07:00
4a389add47 fix: ignore a loot right-click while moving, as 1.12.1 does (#790)
- 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>
2026-10-04 10:01:50 +07:00
9bc7276dd8 fix: spell text $g takes the player's gender, as 1.12.1 does (#787)
- 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>
2026-10-04 08:29:04 +07:00
cf891dc375 fix: close a loot window that opens while you move, as 1.12.1 does (#786)
- 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>
2026-10-03 17:36:21 +07:00
b396bbf69a Click to Move walks to the ground, along the sky and into melee (#783)
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>
2026-10-02 21:03:36 +07:00
Mathih13andClaude Opus 5.5 9821765451 A loot window opening on a unit selects it, as in 1.12.1 (#779)
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>
2026-10-02 17:33:06 +07:00
HEOJUNFOandClaude Opus 5.5 6c25640a37 Light bands with out-of-order keys interpolate as a cycle, as in 1.12.1 (#768)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:14:41 +07:00
Andrew Barnes c627483a85 Return numeric flags from spell-targeting predicates (#764)
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
2026-10-02 13:45:16 +07:00
Sam SungandClaude Opus 5.5 4fd0c643a6 One-shot emotes: LiftOff/Land in combat, no restarts, local play (#773)
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>
2026-10-02 10:58:50 +07:00
Sam SungandClaude Opus 5.5 56d8011ab7 NPCs pause their work loop while you talk to them, as in 1.12.1 (#772)
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>
2026-10-02 10:32:20 +07:00
Sam SungandClaude Opus 5.5 2dc7d8a9e6 /wave <name> targets the named player; two DoEmote gates follow 1.12.1 (#769)
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>
2026-10-02 09:23:08 +07:00
Sam SungandClaude Opus 5.5 fbea2d9f3a Emote routing follows 1.12.1's upper-body and mount tables (#767)
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>
2026-10-02 09:02:52 +07:00
Sam SungandClaude Opus 5.5 4f36c843f1 Emote voices play only for you and your party, as in 1.12.1 (#766)
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>
2026-10-02 08:32:29 +07:00
e620a0d095 feat: let a crate on top interact with an object it picks (#753)
- 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>
2026-10-02 06:38:12 +07:00
2ce6a3e881 feat: click to move walks to an out-of-reach target, then interacts (#752)
- 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>
2026-10-02 05:57:47 +07:00
Mathih13andClaude Opus 5.5 cfd5fd2c8a fix: stop shift-click opening the stack splitter on vendor bundles (#751)
- 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>
2026-10-02 04:54:20 +07:00
Sam SungandClaude Opus 5.5 f22ec0016c EditBox caret keys, selection and focus blink follow 1.12.1 (#750)
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 #735
Fixes #736
Fixes #737
Fixes #738
Fixes #745
Fixes #747

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 10:56:03 +07:00
Sam SungandClaude Opus 5.5 be9e3d8aac Keys are named by the active layout, as 1.12.1 names them (#748)
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 #621
Fixes #746

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 09:55:26 +07:00
Sam SungandClaude Opus 5.5 e9f0e89a0b EditBox events fire from each box's flush, as 1.12.1 orders them (#734)
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 #724
Fixes #727

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:38:38 +07:00
Mathih13andClaude Opus 5.5 87f65faffd fix: stamp the chat log and FrameXML.log in local time (#723)
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>
2026-10-01 07:21:57 +07:00
Sam SungandClaude Opus 5.5 4772489aad Arriving bodies composite off the main thread and show once dressed (#732)
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>
2026-09-30 20:14:10 +07:00
Sam SungandClaude Sonnet 5.5 15e83f2483 Ghost and underwater ambience beds resolve by their reference names (#731)
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>
2026-09-30 19:43:09 +07:00
Sam SungandClaude Sonnet 5.5 7e117809b2 /errors: the selected error is copyable, in a read-only EditBox (#730)
`/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>
2026-09-30 19:18:52 +07:00
Sam SungandClaude Opus 5.5 f2e71ccebd A multi-line EditBox grows with its text; a Lua-made box draws its text (#729)
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 #722
Fixes #726

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:37:30 +07:00
Sam SungandClaude Opus 5.5 4c18c23c94 Ambience beds stream from the archive, opened off the main thread (#725)
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 #700
Fixes #8

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:22:42 +07:00
Sam SungandClaude Sonnet 5.5 cd34ab77d2 /errors: put Clear and Close in the panel art's sockets (#720)
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>
2026-09-30 15:31:35 +07:00
Sam SungandClaude Opus 5.5 2b700e6b0d gates: drop the player build that the player tests already compile (#719)
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>
2026-09-30 14:02:12 +07:00
c03ca1e4be Percentage spell costs use the reference power bases (#646)
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>
2026-09-30 13:50:40 +07:00
Sam SungandClaude Opus 5.5 010e41e600 unit getters: chained tokens like raid3target answer their unit (#716)
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 #693
Fixes #694

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:19:48 +07:00
Sam SungandClaude Opus 5.5 29c1ec6f0a party: a roster packet updates each member's online bit (#714)
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 #666
Fixes #665

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:02:47 +07:00
2b16219357 Spell descriptions resolve $b, $f, $F, $i, $q, $u and $v (#622)
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>
2026-09-30 12:36:29 +07:00
Sam SungandClaude Opus 5.5 5b3c2a98d4 pet bar: PickupPetAction needs the pet in view (#709)
`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>
2026-09-30 11:49:14 +07:00