Compare commits

...

29 Commits

Author SHA1 Message Date
sindoring ad4749ac26 Sync upstream UI fixes through c0630f3d
Integrate six upstream fixes while retaining MoonWell and ruRU changes.

Verified on Windows: fmt, clippy, workspace tests with real 1.12.1 data and
without an install, doc links and render-pass lint. The 31 optional addon
corpus cases are absent. Player tests and engine boot checks are running
before this branch lands.
2026-10-11 18:47:47 +04:00
Sam Sung c0630f3d9d check.sh and gates.sh report every failing test, not the first crate's (#869)
`cargo test` stops at the first test binary that fails, so a red `scripts/check.sh` or gate run showed one crate's failures and hid the rest: the next run found more. Both scripts now pass `--no-fail-fast`, so one run reports every failing test across the workspace. The exit status is unchanged: any failure still fails the step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:53:35 +07:00
Sam Sung 16affa8d45 OnUpdate walks strata, level and link order, as 1.12.1 does (#868)
OnUpdate ran in frame-creation order, from a list built before the walk, so a frame hidden by an earlier handler still updated, and strata and level played no part.

The reference's update walk `0x765650` goes through the nine strata from the bottom, each stratum's levels from 0, and each level's list oldest link first. It saves the next frame before each call, so a hide skips a frame before its turn. A frame linked into the level being walked waits a tick; one linked into a level still ahead runs there.

The walk now follows that order, using the link stamps shows and level changes already keep. It checks each frame at its turn and places frames linked or given an `OnUpdate` mid-walk. An EditBox still flushes right after its own `OnUpdate`.

Tests cover the order, mid-walk hides and shows, and a box's flush.

Fixes #742

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:46:15 +07:00
Sam Sung c9ddd80342 Script errors reach the error handler bare, as 1.12.1 hands them (#865)
Script errors reached the Lua error handler as mlua's display text: a `runtime error: ` word and a `stack traceback:` block, which overflow the stock `ScriptErrors` dialog. And a raise in a `CreateFrame` template's OnLoad was logged as a warning and never reached the handler at all.

In the reference, handlers run under `lua_pcall` with the error function `0x703b40`, which calls the `seterrorhandler` function with the bare message. `CreateFrame` builds its template through the XML instantiator `0x6ee280`, whose OnLoad runs under that same function (`0x704d50`).

Now every caught error records two texts: the decorated one for benilla's log and the bare message for the handler. A load report pairs the two per row, and `CreateFrame` records its template's errors as script errors.

Tests: the handler hears bare messages from handlers, and a template OnLoad raise reaches it.

Fixes #697
Fixes #859

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:12:21 +07:00
Sam Sung f356e90fe6 Addon files resolve case-insensitively, as on 1.12.1's Windows client (#860)
An addon's Lua and XML files were read with the exact case its `.toc` or `<Script file>` spelt. On a case-sensitive filesystem, Linux's, a file named in another case never loaded: Questie 3.7.1 lists `Astrolabe\Astrolabe.lua` from a folder shipped as `astrolabe`, so `Astrolabe` stayed nil and its OnUpdate raised every frame.

The reference is a Windows client, and every path it opens matches without regard to case. Addon textures and sounds already resolved that way through `loose_addon_file`; addon scripts, XML and `Bindings.xml` now go through the same resolver, and the second, case-sensitive one is gone. The shared resolver also refuses a component the host reads as more than a plain name (a Windows `C:`), so it still cannot leave the AddOns root.

Test: a file asked for in three spellings resolves to the one on disk; it fails without the change on a case-sensitive volume.

Fixes #851

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:06 +07:00
Sam Sung ff49eb5b8f UNIT_FACTION fires on the faction template's own edge (#858)
A target whose faction template the server swaps live (35 to 14, neither naming a faction group) kept its friendly plate colour: `UNIT_FACTION` fired only when the pvp, FFA, faction-group or tapped state moved.

The reference's per-field watch fires `UNIT_FACTION` on any change of `UNIT_FIELD_FACTIONTEMPLATE`, as it fires `UNIT_FLAGS` for that field. `fire_transitions` now also fires it on the faction template's edge.

Test: `a_live_faction_template_change_recolours_the_target_plate`.

From #855 by @kalistoking, landed here because the fork refuses maintainer pushes.

Co-authored-by: kalistoking <148718830+kalistoking@users.noreply.github.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:32:43 +07:00
elipp 45da0a0850 Optional feature to enable bevy/dynamic_linking in crates/benilla-app (#853)
- Add bevy/dynamic_linking feature forward for benilla-app for faster dev builds
- bevy_dynamic_linking says the binary starts only through cargo run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-11 17:05:21 +07:00
Sam Sung 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 Sung 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 Sung 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 Sung 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 Sung 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 Sung 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 Sung 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 Sung 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 Sung 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 Sung 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
goran2711 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 Sung 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 Sung 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 Sung 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
elipp 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
goran2711 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
Mathih13 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
goran2711 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
elipp 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
Mathih13 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
goran2711 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
Mathih13 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
43 changed files with 861 additions and 228 deletions
Generated
+10
View File
@@ -778,6 +778,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e"
dependencies = [
"bevy_dylib",
"bevy_internal",
"tracing",
]
@@ -1097,6 +1098,15 @@ dependencies = [
"sysinfo",
]
[[package]]
name = "bevy_dylib"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f142f72bbbc1a61338bf6da5dc7cf6dcf223683b9d53bcd635ebff4bfeb5308f"
dependencies = [
"bevy_internal",
]
[[package]]
name = "bevy_ecs"
version = "0.18.1"
+3
View File
@@ -16,6 +16,9 @@ dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:be
tracy = ["bevy/trace_tracy"]
# Writes every system's per-run span to `trace-<ts>.json` in the cwd.
trace_chrome = ["bevy/trace_chrome"]
# Links Bevy as a shared library so an incremental rebuild relinks faster. The binary then starts
# only through `cargo run`, which puts the toolchain's libstd on the loader path.
bevy_dynamic_linking = ["bevy/dynamic_linking"]
[dependencies]
anyhow.workspace = true
+118 -31
View File
@@ -158,11 +158,11 @@ impl Addon {
// order, as `AddOn_Load 0x51f240` hands every listed file to `0x6edb90`.
match script.run_chunk_named(&bytes, &self.chunk_name(file, &path)) {
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
Err(err) => {
let e = format!("{}/{file}: {err}", self.name);
error!("ui_script: {e}");
// A script error: it reaches the player through the Lua error handler.
script.report_script_error(&e);
script.report_script_error(&e, &benilla_ui::script::lua_message(&err));
failures.push(e);
}
}
@@ -210,11 +210,11 @@ impl Addon {
script.report_load_failure(&e);
failures.push(e);
}
for e in &report.errors {
for (e, heard) in report.raised() {
error!("ui_script({}/{file}): {e}", self.name);
// A script error, as in the Lua arm; `_ERRORMESSAGE`'s IsVisible guard shows only
// a burst's first, as in the reference.
script.report_script_error(&format!("{}/{file}: {e}", self.name));
script.report_script_error(&format!("{}/{file}: {e}", self.name), heard);
failures.push(format!("{}/{file}: {e}", self.name));
}
info!(
@@ -237,39 +237,18 @@ fn is_lua(entry: &str) -> bool {
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("lua"))
}
/// `root/rel`, refusing to escape `root`. `rel` is already resolved by
/// [`benilla_ui::loader::join_ref`], so an escape shows as a leading `..`, refused with anything
/// absolute before any filesystem call; a symlinked addon folder still works.
fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
let rel = Path::new(rel);
if rel
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
return None;
}
std::fs::read(root.join(rel)).ok()
}
/// The one file namespace, install-relative, as in the reference: `0x647e60` answers a name from
/// the install tree's hash index, then the archive chain, after `0x6ede10` collapses `..`. So an
/// addon's `..\Blizzard_AuctionUI\…` reaches the archive behind a `.pub`-only folder. Only a path
/// under `Interface/AddOns/` touches the filesystem, and never above the AddOns root.
/// under `Interface/AddOns/` touches the filesystem, never above the AddOns root, and each of its
/// components matches case-insensitively, as on the reference's Windows filesystem: the resolver
/// addon art and sound use ([`benilla_assets::loose_addon_file`]).
pub(crate) fn read_addon_file(root: &Path, req: &str) -> Option<Vec<u8>> {
under_addons(req)
.and_then(|rel| read_under(root, rel))
benilla_assets::loose_addon_file(root, &benilla_assets::normalize_path(req))
.and_then(|file| std::fs::read(file).ok())
.or_else(|| super::reference_ui::read(&req.replace('/', "\\")))
}
/// `req` with the `Interface/AddOns/` prefix stripped, or `None` without it; matched
/// case-insensitively, as the reference is a Windows client (`Interface\Addons\` is the same path).
fn under_addons(req: &str) -> Option<&str> {
let rest = req.get(ADDONS_PREFIX.len()..)?;
req.get(..ADDONS_PREFIX.len())
.filter(|p| p.eq_ignore_ascii_case(ADDONS_PREFIX))
.map(|_| rest)
}
/// A `Dir` addon's base before its folder name ([`Addon::prefix`]), `/`-separated as `join_ref` is.
const ADDONS_PREFIX: &str = "Interface/AddOns/";
@@ -1244,6 +1223,44 @@ mod tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// A manifest entry names its file in whatever case its author typed on Windows: Questie's
/// `.toc` lists `Astrolabe\Astrolabe.lua` from a folder shipped as `astrolabe`. Every
/// component matches case-insensitively, the folder and the file alike, on any filesystem.
#[test]
fn an_addon_file_is_found_whatever_the_case_of_its_path() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-case-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("AddOns");
std::fs::create_dir_all(root.join("!Probe/astrolabe")).unwrap();
std::fs::write(
root.join("!Probe/astrolabe/astrolabe.lua"),
"Astrolabe = {}",
)
.unwrap();
let addon = Addon {
name: "!Probe".into(),
toc: Toc::default(),
source: Source::Dir(root),
};
for req in [
"Interface/AddOns/!Probe/Astrolabe/Astrolabe.lua",
"Interface/AddOns/!PROBE/ASTROLABE/ASTROLABE.LUA",
"interface/addons/!probe/astrolabe/astrolabe.lua",
] {
assert_eq!(
addon.read(req).as_deref(),
Some(&b"Astrolabe = {}"[..]),
"{req} names the file on disk as the reference's filesystem would"
);
}
assert!(addon
.read("Interface/AddOns/!Probe/astrolabe/missing.lua")
.is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
/// A `.toc`-listed `Bindings.xml` is walked as FrameXML, each `<Binding>` an unknown frame
/// type, which the reference logs and carries on from (`0x6ee356`).
#[test]
@@ -2874,4 +2891,74 @@ mod tests {
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// The error handler hears what 1.12's `lua_pcall` error function hands it (`0x703b40`, from
/// `FS_DoBuffer 0x704ae0` for a file's top level and its syntax error, `0x704f10` for a
/// handler): the bare Lua message, with no frame, `OnLoad: `, addon or file word and no
/// traceback. The decorated text stays on the failures list and the host log.
#[test]
fn the_error_handler_hears_the_bare_message_of_every_load_failure() {
let tmp = std::env::temp_dir().join(format!("benilla-addon-bare-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let addon = dir_addon(
&tmp,
"Bare",
"## Interface: 11200\ntop.lua\nsyntax.lua\nframe.xml\n",
);
std::fs::write(tmp.join("Bare/top.lua"), "local t = nil\nt.x = 1\n").unwrap();
std::fs::write(tmp.join("Bare/syntax.lua"), "x = = 1\n").unwrap();
std::fs::write(
tmp.join("Bare/frame.xml"),
r#"<Ui><Frame name="BareFrame"><Scripts><OnLoad>this:NoSuchMethod()</OnLoad></Scripts></Frame>
<Script>local u = nil; u.y = 2</Script></Ui>"#,
)
.unwrap();
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
script
.run("Heard = {} seterrorhandler(function(m) table.insert(Heard, m) end)")
.unwrap();
let failures = addon.load(&script, &mut Status::default());
assert_eq!(failures.len(), 4, "{failures:#?}");
script.dispatch_script_errors_to_handler();
let heard: Vec<String> = script.eval("return Heard").unwrap();
assert_eq!(heard.len(), 4, "{heard:#?}");
for m in &heard {
assert!(
!m.contains("runtime error")
&& !m.contains("syntax error")
&& !m.contains("stack traceback")
&& !m.contains("OnLoad: ")
&& !m.contains("Bare/")
&& !m.contains('\n'),
"{m:?}"
);
}
assert!(
heard[0].ends_with("top.lua:2: attempt to index local `t' (a nil value)"),
"{:?}",
heard[0]
);
assert!(
heard[0].starts_with("Interface\\AddOns\\Bare\\top.lua"),
"{:?}",
heard[0]
);
assert!(
heard[1].contains("syntax.lua:1:") && heard[1].contains("unexpected symbol"),
"{:?}",
heard[1]
);
assert!(
heard[2].ends_with("attempt to call method `NoSuchMethod' (a nil value)"),
"{:?}",
heard[2]
);
assert!(
heard[3].ends_with("attempt to index local `u' (a nil value)"),
"{:?}",
heard[3]
);
let _ = std::fs::remove_dir_all(&tmp);
}
}
@@ -958,6 +958,57 @@ fn an_exalted_npc_target_reads_revered_and_its_plate_is_green() {
}
}
/// A target whose faction template the server changes live recolours its plate: template 35
/// (friendly) to 14 (hostile) names no side either way, so only the template's own edge fires
/// `UNIT_FACTION`. No edge, or another unit's, leaves the plate as it was.
#[test]
fn a_live_faction_template_change_recolours_the_target_plate() {
benilla_formats::wow_data_or_skip!();
use crate::net::FieldEdges;
use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE;
const MOB: u64 = 0xF130_0000_3796_0001;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let mob = |reaction: u8| UnitState {
exists: true,
has_object: true,
guid: MOB,
name: Some("Pusillin".into()),
health: 100,
max_health: 100,
level: 58,
reaction,
can_attack: reaction < 4,
..UnitState::default()
};
let is_green = |c: [f32; 4]| c[0].abs() < 1e-6 && (c[1] - 1.0).abs() < 1e-6;
let is_red = |c: [f32; 4]| (c[0] - 1.0).abs() < 1e-6 && c[1].abs() < 1e-6;
let friendly = mob(5);
s.set_unit("target", Some(friendly.clone()));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let before = plate_color(&mut s);
assert!(is_green(before), "template 35 reads green, got {before:?}");
let hostile = mob(2);
s.set_unit("target", Some(hostile.clone()));
let fire = |s: &mut UiScript, prev: &UnitState, edges: &[(u64, u16)]| {
crate::ui_unit::fire_transitions(s, "target", Some(prev), &hostile, &FieldEdges::of(edges));
};
fire(&mut s, &friendly, &[]);
let stale = plate_color(&mut s);
assert!(is_green(stale), "no edge, no repaint, got {stale:?}");
fire(&mut s, &hostile, &[(MOB + 1, FIELD_UNIT_FACTIONTEMPLATE)]);
let other = plate_color(&mut s);
assert!(is_green(other), "another unit's edge, got {other:?}");
fire(&mut s, &hostile, &[(MOB, FIELD_UNIT_FACTIONTEMPLATE)]);
let after = plate_color(&mut s);
assert!(is_red(after), "template 14 reads red, got {after:?}");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Stock `TargetFrame_CheckClassification` (`TargetFrame.lua:205-218`), asserted on the drawn
/// quads: elite, rare-elite and world boss share the Elite art (1.12 ships no rare-elite border),
/// and `UNIT_CLASSIFICATION_CHANGED` alone repaints it.
+13 -5
View File
@@ -1202,11 +1202,19 @@ pub(crate) fn fire_transitions(
}
// `UNIT_FACTION`, the PvP-icon repaint, on the fields the icon reads and on the tapped bit: the
// reference's dynamic-flags watcher (`0x600440`) fires event 29 on bit `0x4` (`0x6005a1` →
// `0x6005b0`) and has no arm for `0x8`.
if prev.is_none_or(|p| {
(p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped)
!= (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped)
}) {
// `0x6005b0`) and has no arm for `0x8`. Also on any change of `UNIT_FIELD_FACTIONTEMPLATE`, the
// watch bridge's id 29 (`0x51bbb0`, as for `UNIT_FLAGS` above): a template swap between two
// that name no faction group moves nothing in the tuple, yet changes the reaction.
let template_moved = edges.moved(
cur.guid,
benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE,
);
if template_moved
|| prev.is_none_or(|p| {
(p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped)
!= (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped)
})
{
script.fire_event("UNIT_FACTION", vec![tok()]);
}
}
@@ -187,13 +187,17 @@ pub fn read_chain_or_loose(
}
/// Map a normalized `interface\addons\…` path onto a file under the addon root, matching each
/// component case-insensitively as the Windows reference does; a dot-component is refused, so a
/// path cannot escape the root.
/// component case-insensitively as the Windows reference does; a dot-component, or one the host
/// reads as more than a plain name (a Windows `C:`), is refused, so a path cannot escape the root.
pub fn loose_addon_file(root: &Path, candidate: &str) -> Option<PathBuf> {
let rel = candidate.strip_prefix("interface\\addons\\")?;
let mut at = root.to_path_buf();
for comp in rel.split('\\') {
if comp.is_empty() || comp.starts_with('.') {
let plain = matches!(
Path::new(comp).components().collect::<Vec<_>>()[..],
[std::path::Component::Normal(_)]
);
if !plain || comp.starts_with('.') {
return None;
}
let direct = at.join(comp);
+1 -1
View File
@@ -15,7 +15,7 @@ impl Loader<'_> {
let table = match self.build_backdrop_table(bd) {
Ok(t) => t,
Err(e) => {
self.report.errors.push(format!("{dbg}: <Backdrop>: {e}"));
self.report.fault(format!("{dbg}: <Backdrop>: {e}"));
return;
}
};
+1 -3
View File
@@ -146,9 +146,7 @@ impl Loader<'_> {
let region: Table = match wrapper.call_method("CreateTitleRegion", ()) {
Ok(r) => r,
Err(e) => {
self.report
.errors
.push(format!("{dbg}: CreateTitleRegion: {e}"));
self.report.fault(format!("{dbg}: CreateTitleRegion: {e}"));
return;
}
};
+48 -23
View File
@@ -22,6 +22,33 @@ mod regions;
mod scripts;
mod widgets;
impl LoadReport {
/// A load fault that is no script raise: the handler hears the row's own text.
pub(super) fn fault(&mut self, text: String) {
self.handler_errors.push(text.clone());
self.errors.push(text);
}
/// A script raise: the row is `text`, the handler hears the bare Lua message of `e`.
pub(super) fn raise(&mut self, text: String, e: &mlua::Error) {
self.handler_errors.push(crate::script::lua_message(e));
self.errors.push(text);
}
/// Each of [`Self::errors`] with what the Lua error handler is called with for it.
pub fn raised(&self) -> impl Iterator<Item = (&str, &str)> {
debug_assert_eq!(
self.errors.len(),
self.handler_errors.len(),
"every error row is written through `fault` or `raise`"
);
self.errors
.iter()
.map(String::as_str)
.zip(self.handler_errors.iter().map(String::as_str))
}
}
/// The outcome of a [`load`]; nothing aborts a load, as the reference logs and continues.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LoadReport {
@@ -31,6 +58,11 @@ pub struct LoadReport {
/// Failures the reference raises on: a handler that fails to compile, a method call that
/// errors, a malformed included document.
pub errors: Vec<String>,
/// What the Lua error handler is called with for each of [`Self::errors`], row for row
/// ([`Self::raised`]): the bare Lua message of a raise, as `lua_pcall` leaves it for the
/// reference's error function (`0x703b40`), and the row's own text for a fault that is no
/// script raise.
handler_errors: Vec<String>,
/// `<Include file=>` and `<Script file=>` paths the provider lacks. The reference logs
/// `"Couldn't open %s"` (`0x6edaa0`, `0x846ff4`) or `"Error loading %s"` (`0x872e50`) and goes
/// on (`0x6ee00d`); kept apart from `warnings` so a load that resolved nothing is not clean.
@@ -101,15 +133,17 @@ pub fn load_into_under(
loader.report
}
/// Apply a template to an existing frame, `CreateFrame`'s fourth argument. Returns the warnings,
/// then the errors. The frame always survives: an unknown name leaves it bare, as in the
/// reference, and only virtual elements are registered. The frame's own name is the `$parent`
/// Apply a template to an existing frame, `CreateFrame`'s fourth argument. Returns the report:
/// its errors are script errors for the error handler, as the reference's `CreateFrame` builds the
/// template through the XML instantiator `0x6ee280`, whose OnLoad runs under the error function
/// (`0x704d50`). The frame always survives an error, and only virtual elements are registered; an
/// unknown name never gets here, since the binding raises first. The frame's own name is the `$parent`
/// base (`MyButton` makes `$parentText` `MyButtonText`). Runs inside the `CreateFrame` binding, so
/// it enters at [`Loader::decorate`]: [`Loader::materialize`] would recurse.
pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &str) -> Vec<String> {
pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &str) -> LoadReport {
let template = template.trim();
if template.is_empty() {
return Vec::new();
return LoadReport::default();
}
// A template never reads a file: `<Include>` and `<Script file=>` are top-level only.
let no_files = |_: &str| -> Option<Vec<u8>> { None };
@@ -179,9 +213,7 @@ pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &s
loader.decorate(&expanded, wrapper, &self_name, &parent_name, &dbg);
let mut out = loader.report.warnings;
out.extend(loader.report.errors);
out
loader.report
}
/// Join a FrameXML reference to its file's directory, lexically: `\` and `/` both separate, `..`
@@ -352,8 +384,7 @@ impl Loader<'_> {
Some(bytes) => {
if let Err(e) = self.run(crate::source::chunk(&bytes), &joined) {
self.report
.errors
.push(format!("<Script file=\"{path}\">: {e}"));
.raise(format!("<Script file=\"{path}\">: {e}"), &e);
}
}
None => {
@@ -377,7 +408,7 @@ impl Loader<'_> {
// `[string "Interface\FrameXML\Foo.xml:<Scripts>"]`, not a file path.
let name = format!("{}:<Scripts>", self.path.replace('/', "\\"));
if let Err(e) = self.run_named(&chunk, &name) {
self.report.errors.push(format!("inline <Script>: {e}"));
self.report.raise(format!("inline <Script>: {e}"), &e);
}
}
TopLevel::Font(el) => self.do_font(el),
@@ -418,8 +449,7 @@ impl Loader<'_> {
if status::runs_as_lua(&joined) {
if let Err(e) = self.run(crate::source::chunk(&bytes), &joined) {
self.report
.errors
.push(format!("<Include file=\"{path}\">: {e}"));
.raise(format!("<Include file=\"{path}\">: {e}"), &e);
}
return;
}
@@ -441,9 +471,7 @@ impl Loader<'_> {
Err(e) => {
let line = status::unparsed(&self.status_path(&joined));
self.report.status.report(status::FAILURE, line);
self.report
.errors
.push(format!("<Include file=\"{path}\">: {e}"))
self.report.fault(format!("<Include file=\"{path}\">: {e}"))
}
}
}
@@ -600,8 +628,7 @@ impl Loader<'_> {
Ok(f) => f,
Err(e) => {
self.report
.errors
.push(format!("CreateFrame global missing: {e}"));
.fault(format!("CreateFrame global missing: {e}"));
return None;
}
};
@@ -609,7 +636,7 @@ impl Loader<'_> {
match create.call((el.tag.clone(), resolved_name.clone(), parent.cloned())) {
Ok(w) => w,
Err(e) => {
self.report.errors.push(format!(
self.report.fault(format!(
"CreateFrame(<{}>{}): {e}",
el.tag,
resolved_name
@@ -699,7 +726,7 @@ impl Loader<'_> {
// and gives the ScrollFrame its scroll range. An empty one errors in the reference.
for sc in children_named(el, "ScrollChild") {
let Some(child) = sc.children.first() else {
self.report.errors.push(format!(
self.report.fault(format!(
"{dbg_name}: <ScrollChild> is empty — the reference errors here, and a \
ScrollFrame with no child has no scroll range at all"
));
@@ -710,9 +737,7 @@ impl Loader<'_> {
continue; // materialize already reported why
};
if let Err(e) = wrapper.call_method::<()>("SetScrollChild", child_wrapper) {
self.report
.errors
.push(format!("{dbg_name}: <ScrollChild>: {e}"));
self.report.fault(format!("{dbg_name}: <ScrollChild>: {e}"));
}
}
+7 -15
View File
@@ -53,13 +53,13 @@ impl Loader<'_> {
match wrapper.call_method(method, (rname, Some(level.clone()))) {
Ok(w) => w,
Err(e) => {
self.report.errors.push(format!("{dbg}: {method}: {e}"));
self.report.fault(format!("{dbg}: {method}: {e}"));
continue;
}
};
if region.attr_bool("hidden") {
if let Err(e) = region_wrapper.call_method::<()>("Hide", ()) {
self.report.errors.push(format!("{dbg}: region Hide: {e}"));
self.report.fault(format!("{dbg}: region Hide: {e}"));
}
}
if let Some(a) = region
@@ -85,9 +85,7 @@ impl Loader<'_> {
if let Err(e) =
crate::script::implicit_creation_anchor_lua(self.lua, &region_wrapper)
{
self.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
}
}
@@ -141,8 +139,7 @@ impl Loader<'_> {
Ok(w) => w,
Err(e) => {
self.report
.errors
.push(format!("{dbg}: CreateFontString (special): {e}"));
.fault(format!("{dbg}: CreateFontString (special): {e}"));
continue;
}
},
@@ -152,9 +149,7 @@ impl Loader<'_> {
self.apply_region_visual(region, &region_wrapper, false, dbg);
// The implicit creation anchor, as in `apply_layers`; an EditBox's adopt replaces it.
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region_wrapper) {
self.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
// It is the EditBox's text region by slot, never found by a search, which could take a
// `<Layers>` string; the box's insets anchor it.
@@ -163,8 +158,7 @@ impl Loader<'_> {
crate::script::adopt_text_region(self.lua(), wrapper, &region_wrapper)
{
self.report
.errors
.push(format!("{dbg}: adopt special FontString: {e}"));
.fault(format!("{dbg}: adopt special FontString: {e}"));
}
}
}
@@ -383,9 +377,7 @@ impl Loader<'_> {
height,
outline,
) {
self.report
.errors
.push(format!("{dbg}: region font attrs: {e}"));
self.report.fault(format!("{dbg}: region font attrs: {e}"));
}
}
None => {}
+4 -7
View File
@@ -102,8 +102,7 @@ impl Loader<'_> {
Ok(f) => Some(f),
Err(e) => {
self.report
.errors
.push(format!("{dbg}: compiling <{name}>: {e}"));
.raise(format!("{dbg}: compiling <{name}>: {e}"), &e);
None
}
}
@@ -113,7 +112,7 @@ impl Loader<'_> {
/// (`0x704d50`); an error is recorded, never propagated.
pub(super) fn fire_onload(&mut self, wrapper: &Table, func: &Function, dbg: &str) {
if let Err(e) = self.invoke_handler(wrapper, func) {
self.report.errors.push(format!("{dbg}: OnLoad: {e}"));
self.report.raise(format!("{dbg}: OnLoad: {e}"), &e);
}
}
@@ -126,7 +125,7 @@ impl Loader<'_> {
dbg: &str,
) {
if let Err(e) = wrapper.call_method::<()>(method, args) {
self.report.errors.push(format!("{dbg}: {method}: {e}"));
self.report.fault(format!("{dbg}: {method}: {e}"));
}
}
@@ -139,9 +138,7 @@ impl Loader<'_> {
dbg: &str,
) {
if let Err(e) = region.call_method::<()>(method, args) {
self.report
.errors
.push(format!("{dbg}: region {method}: {e}"));
self.report.fault(format!("{dbg}: region {method}: {e}"));
}
}
}
+3 -7
View File
@@ -165,9 +165,7 @@ impl Loader<'_> {
self.call_region(&region, "ClearAllPoints", (), dbg);
self.apply_region_layout(bt, &region, self_name, dbg, font_attrs);
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region) {
self.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
// Published both ways, like a named state texture.
if let Some(rname) = bt_name {
@@ -232,8 +230,7 @@ impl Loader<'_> {
self.lua, wrapper, which, jh,
) {
self.report
.errors
.push(format!("{dbg}: <{}> justifyH: {e}", f.tag));
.fault(format!("{dbg}: <{}> justifyH: {e}", f.tag));
}
}
// Reported, not modelled: the reference clears the axis on a cross-axis
@@ -561,8 +558,7 @@ impl Loader<'_> {
outline,
) {
self.report
.errors
.push(format!("{dbg}: <SimpleHTML> {name} font attrs: {e}"));
.fault(format!("{dbg}: <SimpleHTML> {name} font attrs: {e}"));
}
}
None => {}
+21 -12
View File
@@ -525,7 +525,10 @@ fn load_bindings(lua: &Lua, name: &str, read: &Reader) {
match crate::bindings_xml::parse(&crate::source::decode(&bytes)) {
Ok(bindings) => super::keybind::register_addon_bindings(lua, name, &bindings),
// Reported, not swallowed: otherwise a broken binding file leaves keys dead silently.
Err(e) => log_error(lua, &format!("{name}/Bindings.xml: {e}")),
Err(e) => {
let msg = format!("{name}/Bindings.xml: {e}");
log_error(lua, &msg, &msg);
}
}
}
@@ -555,7 +558,8 @@ fn load_saved_variables(lua: &Lua, i: usize) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, // the first-run case
Err(e) => {
// Present but unreadable is held, like a file that will not run.
log_error(lua, &format!("{}: {e}", path.display()));
let msg = format!("{}: {e}", path.display());
log_error(lua, &msg, &msg);
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.held_saved_files.contains(&path) {
model.held_saved_files.push(path);
@@ -570,7 +574,11 @@ fn load_saved_variables(lua: &Lua, i: usize) {
) {
// Deviation: the reference fails silently; we report it, as a player needs to know,
// and hold the file so the shutdown write keeps it.
log_error(lua, &format!("{}: {e}", path.display()));
log_error(
lua,
&format!("{}: {e}", path.display()),
&super::lua_message(&e),
);
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.held_saved_files.contains(&path) {
model.held_saved_files.push(path);
@@ -616,7 +624,7 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String], toc: &mut S
};
if lua_file {
if let Err(e) = run_chunk(lua, &bytes, &crate::script::addon_chunk_name(name, file)) {
log_error(lua, &format!("{name}/{file}: {e}"));
log_error(lua, &format!("{name}/{file}: {e}"), &super::lua_message(&e));
}
continue;
}
@@ -630,15 +638,15 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String], toc: &mut S
continue;
}
};
let report = crate::loader::load_into_under(lua, &doc, &path, &provider, ADDONS_ROOT);
let mut report = crate::loader::load_into_under(lua, &doc, &path, &provider, ADDONS_ROOT);
let banner = crate::status::file_banner(&install);
report.status.close_into(toc, framexml_debug(lua), banner);
std::mem::take(&mut report.status).close_into(toc, framexml_debug(lua), banner);
// Warnings take the `<Addon>/<file>` prefix; a bare loader warning names no document.
for w in report.warnings {
for w in &report.warnings {
crate::script::diagnostics::record_warning(lua, &format!("{name}/{file}: {w}"));
}
for e in report.errors {
log_error(lua, &format!("{name}/{file}: {e}"));
for (e, heard) in report.raised() {
log_error(lua, &format!("{name}/{file}: {e}"), heard);
}
}
}
@@ -683,12 +691,13 @@ fn run_chunk(lua: &Lua, bytes: &[u8], name: &str) -> mlua::Result<()> {
}
/// Record a demand-load error as the startup walk does ([`super::UiScript::report_script_error`]):
/// a `Load` row, dispatched to the player's error handler and put on the host drain.
fn log_error(lua: &Lua, msg: &str) {
/// a `Load` row, dispatched to the player's error handler and put on the host drain. `handler` is
/// the bare Lua message of a raise; a fault that is no raise passes its own text.
fn log_error(lua: &Lua, msg: &str, handler: &str) {
let msg = format!("LoadAddOn: {msg}");
super::diagnostics::record_load_failure(lua, &msg);
let mut model = lua.app_data_mut::<Model>().expect("model");
model.pending_error_dispatch.push(msg.clone());
model.pending_error_dispatch.push(handler.to_string());
model.errors.push(msg);
}
+1 -1
View File
@@ -998,7 +998,7 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
if let Err(e) = event::fire_widget_handler(lua, id, "OnClick", vec![btn]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
// A nameplate click selects its unit, from the pointer or `Click()` alike (the plate's
// override `0x7cb910` chains the base), after the handler so an erroring hook cannot eat it. A
+2 -1
View File
@@ -132,9 +132,10 @@ pub(super) fn install(lua: &Lua, locale: &str) -> mlua::Result<()> {
.set_mode(mlua::ChunkMode::Text)
.exec()
{
let bare = super::stdlib::lua_message(&e);
lua.app_data_mut::<crate::script::Model>()
.expect("model")
.record_script_error(super::stdlib::lua_message(&e));
.record_script_error(bare.clone(), bare);
}
Ok(())
})?,
+1 -1
View File
@@ -268,7 +268,7 @@ fn fire_color_select(lua: &Lua, this: &Table, r: f64, g: f64, b: f64) -> mlua::R
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
+2 -2
View File
@@ -294,7 +294,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
if let Err(e) = on_char {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
if fire_space {
for _ in 0..out.spaces {
@@ -519,7 +519,7 @@ fn fire_script(lua: &Lua, id: u32, name: &str) {
if let Err(e) = event::fire_widget_handler(lua, id, name, Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
@@ -124,7 +124,7 @@ fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
true
}
@@ -93,6 +93,19 @@ fn the_flush_follows_the_boxs_own_on_update_cursor_then_text() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The box's place in the walk is its stratum's, not its creation's: a BACKGROUND box updates and
/// flushes before the MEDIUM frames made around it (`0x7656c8`).
#[test]
fn a_box_flushes_after_its_on_update_in_its_strata_place() {
let mut s = script();
logged_box(&mut s);
s.run(r#"E:SetFrameStrata("BACKGROUND") E:SetText("abc")"#)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "update cursor@21 text:abc early late");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// An edit a box makes in its own `OnUpdate` is flushed by the same update, so its events fire in
/// the same tick.
#[test]
+2 -2
View File
@@ -38,7 +38,7 @@ pub(super) fn fire_all_event_listeners(lua: &Lua, event: &str, args: &[ScriptVal
if let Err(e) = fire_event_handler(lua, id, event, args) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
at = next;
@@ -102,7 +102,7 @@ pub(super) fn fire_size_changes(lua: &Lua) {
) {
lua.app_data_mut::<Model>()
.expect("model")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
}
+2 -2
View File
@@ -124,14 +124,14 @@ fn fire(lua: &Lua, id: u32, script: &str, arg: &str) {
Err(e) => {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
return;
}
});
if let Err(e) = event::fire_widget_handler(lua, id, script, vec![val]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
+10 -5
View File
@@ -121,6 +121,8 @@ mod spellbook;
mod stable;
mod statusbar;
mod stdlib;
/// An mlua error's bare Lua message, the one value the error handler is called with.
pub use stdlib::lua_message;
mod summon;
mod talent;
mod taxi;
@@ -131,6 +133,7 @@ mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
mod ui_scale;
mod update_walk;
pub use tooltip_unit::TooltipTint;
mod trade;
mod tradeskill;
@@ -1199,14 +1202,16 @@ impl UiScript {
/// Report a script error the host caught outside the VM's dispatch (an addon file that failed
/// to compile or raised at file scope): it joins the handler-dispatch queue and the retained
/// log, not `errors`, since the caller has already logged it.
pub fn report_script_error(&self, msg: &str) {
/// log, not `errors`, since the caller has already logged it. `log` is the decorated text the
/// log keeps; `handler` is the one value the error handler is called with, the bare Lua
/// message as `lua_pcall` leaves it (`0x703b40`).
pub fn report_script_error(&self, log: &str, handler: &str) {
// A Load row, not an Error one: to the player a file scope that raised and a missing file
// are the same fact, the addon is not running.
diagnostics::record_load_failure(&self.lua, msg);
diagnostics::record_load_failure(&self.lua, log);
self.model_mut()
.pending_error_dispatch
.push(msg.to_string());
.push(handler.to_string());
}
/// Hand each queued script error to the Lua error handler, as the reference's catch closure
@@ -1263,7 +1268,7 @@ impl UiScript {
}
fn push_error(&self, e: mlua::Error) {
self.model_mut().record_script_error(e.to_string());
self.model_mut().record_lua_error(&e);
}
}
+19 -7
View File
@@ -176,8 +176,11 @@ pub(crate) struct Model {
/// Which handlers each frame has; the closures themselves live in Lua's `REG_SCRIPTS` table.
pub(crate) scripts: HashMap<FrameHandle, HashSet<&'static str>>,
/// Frames with an `OnUpdate` script, kept by `SetScript`; the tick sorts them by frame id.
/// Frames with an `OnUpdate` script, kept by `SetScript`, from which the update walk starts.
pub(crate) on_update_frames: Vec<FrameHandle>,
/// Bumped each time a frame joins [`Self::on_update_frames`], so the walk notices a frame that
/// gains an `OnUpdate` before its turn.
pub(crate) on_update_joins: u64,
/// Frames with an `OnSizeChanged` script, kept by `SetScript`, for the resolve's size snapshot.
pub(crate) on_size_changed_frames: Vec<FrameHandle>,
/// Frames with an `OnUpdateModel` script, fired as a visible model pane paints (`0x76d1a0`).
@@ -985,13 +988,21 @@ impl Model {
}
}
/// Records a caught script error for the host, the retained log and the Lua error handler; an
/// error raised by the handler itself goes to `errors` directly, which stops the recursion.
pub(crate) fn record_script_error(&mut self, msg: String) {
/// Records a caught script error for the host, the retained log and the Lua error handler: `log`
/// is the decorated text the host and the retained log keep, `handler` the one value the error
/// handler is called with. An error raised by the handler itself goes to `errors` directly,
/// which stops the recursion.
pub(crate) fn record_script_error(&mut self, log: String, handler: String) {
self.diagnostics
.record(super::diagnostics::DiagnosticKind::Error, &msg);
self.pending_error_dispatch.push(msg.clone());
self.errors.push(msg);
.record(super::diagnostics::DiagnosticKind::Error, &log);
self.pending_error_dispatch.push(handler);
self.errors.push(log);
}
/// [`Self::record_script_error`] for an error mlua caught: the log keeps mlua's `Display` (its
/// word and traceback), the handler gets the bare Lua message `lua_pcall` leaves (`0x703b40`).
pub(crate) fn record_lua_error(&mut self, e: &mlua::Error) {
self.record_script_error(e.to_string(), super::stdlib::lua_message(e));
}
/// Records a non-fatal warning for the host's terminal and the retained diagnostic log.
@@ -1090,6 +1101,7 @@ impl Model {
region_names: HashMap::new(),
scripts: HashMap::new(),
on_update_frames: Vec::new(),
on_update_joins: 0,
on_size_changed_frames: Vec::new(),
on_update_model_frames: Vec::new(),
model_facts: HashMap::new(),
+7 -6
View File
@@ -600,12 +600,13 @@ pub(super) fn create_frame(
// this call, so the handler can reach the frame by name.
let wrapper = frame_wrapper(lua, id)?;
if let Some(template) = template {
let messages = crate::loader::apply_template(lua, &wrapper, &kind, &template);
if !messages.is_empty() {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
for m in messages {
model.record_warning(m);
}
let report = crate::loader::apply_template(lua, &wrapper, &kind, &template);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
for (log, heard) in report.raised() {
model.record_script_error(log.to_string(), heard.to_string());
}
for w in report.warnings {
model.record_warning(w);
}
}
Ok(wrapper)
@@ -240,7 +240,10 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
// The first handler of a kind joins that kind's list; nothing else writes `scripts`.
if model.scripts.entry(h).or_default().insert(kind) {
match kind {
"OnUpdate" => model.on_update_frames.push(h),
"OnUpdate" => {
model.on_update_frames.push(h);
model.on_update_joins += 1;
}
"OnSizeChanged" => model.on_size_changed_frames.push(h),
"OnUpdateModel" => model.on_update_model_frames.push(h),
_ => {}
+2 -2
View File
@@ -304,7 +304,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
})?,
@@ -327,7 +327,7 @@ fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> mlua::Resul
{
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
+2 -1
View File
@@ -15,7 +15,8 @@ impl super::UiScript {
// The rest of the line after the alias and one space, spaces intact: the reference's
// `strsub(text, strlen(cmdString) + 2)`.
if let Err(e) = handler.call::<()>(args.to_string()) {
self.model_mut().record_script_error(format!("/{cmd}: {e}"));
self.model_mut()
.record_script_error(format!("/{cmd}: {e}"), super::stdlib::lua_message(&e));
}
true
}
+1 -1
View File
@@ -228,7 +228,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
+2 -2
View File
@@ -253,7 +253,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
@@ -274,7 +274,7 @@ pub(super) fn fire_engine_value_changed(lua: &Lua, bar: crate::widget::FrameHand
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
+12 -4
View File
@@ -11,12 +11,20 @@
use mlua::{Lua, Value, Variadic};
/// An mlua error's bare Lua message, as `loadstring` returns it: without the `syntax error: ` or
/// `runtime error: ` word mlua's `Display` adds and the reference never does.
pub(super) fn lua_message(e: &mlua::Error) -> String {
/// An mlua error's bare Lua message: what `lua_pcall` leaves for a 1.12 error function (`0x703b40`)
/// and what `loadstring` returns. It drops the `syntax error: ` and `runtime error: ` words mlua's
/// `Display` adds, the `stack traceback:` block mlua's message handler appends to a raise, and the
/// wrappers a Rust callback's failure arrives in.
pub fn lua_message(e: &mlua::Error) -> String {
match e {
mlua::Error::SyntaxError { message, .. } => message.clone(),
mlua::Error::RuntimeError(m) => m.clone(),
mlua::Error::RuntimeError(m) => m
.rfind("\nstack traceback:\n")
.map_or(m.as_str(), |at| &m[..at])
.to_string(),
mlua::Error::CallbackError { cause, .. } | mlua::Error::WithContext { cause, .. } => {
lua_message(cause)
}
other => other.to_string(),
}
}
@@ -464,3 +464,41 @@ fn a_runtime_template_fires_its_onshow_after_its_onload_when_visible() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A template's OnLoad runs under the error function as a file's does (`CreateFrame` builds through
/// `0x6ee280`, OnLoad through `0x704d50`): a raise reaches the chosen error handler as the bare
/// message, and `CreateFrame` still returns the frame.
#[test]
fn a_raising_template_onload_reaches_the_error_handler() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
register(
&s,
r#"<Ui>
<Frame name="RaisingTemplate" virtual="true">
<Scripts><OnLoad>this:NoSuchMethod()</OnLoad></Scripts>
</Frame>
</Ui>"#,
);
s.run(
"caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \
made = CreateFrame('Frame', 'Raised', UIParent, 'RaisingTemplate')",
)
.unwrap();
s.dispatch_script_errors_to_handler();
let caught: Vec<String> = s.eval("return caught").unwrap();
assert_eq!(caught.len(), 1, "{caught:?}");
assert!(
caught[0].ends_with("attempt to call method `NoSuchMethod' (a nil value)")
&& !caught[0].contains('\n'),
"{:?}",
caught[0]
);
let made: bool = s
.eval("return made ~= nil and made:GetName() == 'Raised'")
.unwrap();
assert!(
made,
"CreateFrame returns the frame after its OnLoad raised"
);
}
+46
View File
@@ -52,6 +52,7 @@ mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod toplevel;
mod update_walk;
mod visibility;
mod widget_surface;
mod worldframe;
@@ -221,6 +222,51 @@ fn a_widget_handler_error_reaches_the_chosen_error_handler() {
assert!(caught.contains("slide boom"), "OnValueChanged: {caught}");
}
/// 1.12 calls the error handler with the one value the failed call raised (`0x703b40`: the
/// handler pushed, inserted below the message, `lua_call` 1 argument): the bare Lua message, no
/// `runtime error: ` word and no traceback, which only benilla's own log carries.
#[test]
fn the_error_handler_receives_the_bare_lua_message() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
"caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \
local f = CreateFrame('Frame') \
f:RegisterEvent('ERROR_PROBE') \
f:SetScript('OnEvent', function() this:NoSuchMethod() end) \
local b = CreateFrame('Button') \
b:SetScript('OnClick', function() local t = nil; t.x = 1 end) \
b:Click() \
local c = CreateFrame('Button') \
c:SetScript('OnClick', function() SetPortraitTexture() end) \
c:Click()",
)
.unwrap();
s.fire_event("ERROR_PROBE", vec![]);
s.dispatch_script_errors_to_handler();
let caught: Vec<String> = s.eval("return caught").unwrap();
assert!(caught.len() >= 3, "{caught:?}");
for m in &caught {
assert!(
!m.contains("runtime error") && !m.contains("stack traceback") && !m.contains('\n'),
"bare message, one line: {m:?}"
);
}
for want in [
"attempt to call method `NoSuchMethod' (a nil value)",
":1: attempt to index local `t' (a nil value)",
] {
assert!(
caught
.iter()
.any(|m| m.starts_with("[string \"") && m.ends_with(want)),
"{want}: {caught:?}"
);
}
// The host log keeps the decorated text.
assert!(s.take_errors().iter().any(|e| e.contains("runtime error")));
}
/// `fire_global` carries `UPDATE_FACTION` from the faction-header verbs.
#[test]
fn a_self_unregistering_listener_does_not_rob_its_successor_on_the_internal_dispatch() {
@@ -0,0 +1,216 @@
//! The update walk (`0x765650`): strata from the bottom up (`0x7656c8`–`0x765793`), each
//! stratum's levels from 0 up (`0x765774`), and each level's list of linked frames oldest link
//! first, the list being head-inserted (`0x7648c9`) and walked from its tail (`0x7656e5`–`0x765707`).
//! The walk saves the next frame before each call (`0x765707`), a hide repairs that cursor
//! (`0x76498f`), and a frame linked into the level being walked is staged until the level ends
//! (`0x76489b`, `0x76573e`).
use crate::script::UiScript;
use super::common::script;
/// `LOG` joined by spaces, then emptied.
fn take_log(s: &UiScript) -> String {
s.eval::<String>("local l = table.concat(LOG, ' ') LOG = {} return l")
.unwrap()
}
/// Lua that makes a frame `name`, logging `tag` from its `OnUpdate`.
const LOGGED: &str = r#"
LOG = {}
function Logged(name, tag)
local f = CreateFrame("Frame", name)
f:SetScript("OnUpdate", function() table.insert(LOG, tag) end)
return f
end
"#;
#[test]
fn the_walk_runs_strata_then_level_not_creation_order() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("WalkTip", "tooltip"):SetFrameStrata("TOOLTIP")
Logged("WalkBack", "background"):SetFrameStrata("BACKGROUND")
Logged("WalkHigh", "level5"):SetFrameLevel(5)
Logged("WalkLow", "level1"):SetFrameLevel(1)
"#,
)
.unwrap();
take_log(&s);
s.tick(0.016);
assert_eq!(take_log(&s), "background level1 level5 tooltip");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Within a level the oldest link goes first, and a show links at the end: a frame hidden and
/// shown again walks after one created later.
#[test]
fn a_reshown_frame_walks_after_its_levels_older_links() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("LinkA", "a")
Logged("LinkB", "b")
LinkA:Hide()
LinkA:Show()
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "b a");
}
#[test]
fn a_frame_hidden_by_an_earlier_on_update_is_not_updated() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("HideA", "a"):SetScript("OnUpdate", function()
table.insert(LOG, "a")
HideB:Hide()
end)
Logged("HideB", "b")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "a");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A frame hidden and shown again before its turn relinks into the level being walked, which
/// stages it: it waits for the next tick. (A blink every tick would stage it every tick.)
#[test]
fn a_frame_reshown_before_its_turn_in_the_walked_level_waits_a_tick() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("BlinkA", "a"):SetScript("OnUpdate", function()
table.insert(LOG, "a")
if not BLINKED then
BLINKED = 1
BlinkB:Hide()
BlinkB:Show()
end
end)
Logged("BlinkB", "b")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "a");
s.tick(0.016);
assert_eq!(take_log(&s), "a b");
}
/// A frame shown into a level the walk has not reached yet is updated that tick; one shown into
/// the level being walked or one already walked waits for the next.
#[test]
fn a_frame_shown_mid_walk_is_updated_only_if_its_level_is_still_ahead() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("ShowA", "a"):SetScript("OnUpdate", function()
table.insert(LOG, "a")
ShowTip:Show()
ShowSame:Show()
ShowBack:Show()
end)
Logged("ShowTip", "tip"):SetFrameStrata("TOOLTIP")
Logged("ShowSame", "same")
Logged("ShowBack", "back"):SetFrameStrata("BACKGROUND")
ShowTip:Hide()
ShowSame:Hide()
ShowBack:Hide()
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "a tip");
s.tick(0.016);
assert_eq!(take_log(&s), "back a same tip");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A level change relinks a shown frame at the head of its new level (`0x76a54c`), so a frame that
/// moves itself to a level still ahead is walked again there.
#[test]
fn a_frame_that_moves_itself_to_a_level_ahead_is_walked_again() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("Mover", "m"):SetScript("OnUpdate", function()
table.insert(LOG, "m" .. this:GetFrameLevel())
if this:GetFrameLevel() == 0 then this:SetFrameLevel(3) end
end)
Logged("Mid", "mid"):SetFrameLevel(2)
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "m0 mid m3");
s.tick(0.016);
assert_eq!(take_log(&s), "mid m3");
}
/// The update reads the frame's handler when its turn comes (`0x76b2c3`), so an `OnUpdate` set by
/// an earlier frame's runs that tick.
#[test]
fn an_on_update_set_before_the_frames_turn_runs_that_tick() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("Setter", "setter"):SetScript("OnUpdate", function()
table.insert(LOG, "setter")
if not SET then
SET = true
Earlier:SetScript("OnUpdate", function() table.insert(LOG, "earlier") end)
Later:SetScript("OnUpdate", function() table.insert(LOG, "later") end)
end
end)
CreateFrame("Frame", "Earlier"):SetFrameStrata("BACKGROUND")
CreateFrame("Frame", "Later"):SetFrameStrata("HIGH")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "setter later");
s.tick(0.016);
assert_eq!(take_log(&s), "earlier setter later");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// "Ahead" is by stratum first: a frame shown into a later stratum is updated that tick even at a
/// lower level than the one being walked, as the reference walks every stratum after this one.
#[test]
fn a_frame_shown_into_a_later_stratum_at_a_lower_level_runs_that_tick() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
local late = Logged("LateHigh", "high")
late:SetFrameStrata("HIGH")
late:SetFrameLevel(1)
late:Hide()
local shower = Logged("ShowerMed", "medium")
shower:SetFrameLevel(5)
shower:SetScript("OnUpdate", function()
table.insert(LOG, "medium")
LateHigh:Show()
end)
"#,
)
.unwrap();
take_log(&s);
s.tick(0.016);
assert_eq!(take_log(&s), "medium high");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+8 -75
View File
@@ -5,7 +5,7 @@ use mlua::Lua;
use crate::widget::FrameHandle;
use super::{editbox, event, tooltip, Model, ScriptValue, UiScript};
use super::{event, tooltip, Model, ScriptValue, UiScript};
impl UiScript {
/// Queue an event for the start of the next tick, behind everything queued earlier and before
@@ -61,7 +61,7 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) {
let id = model.frame_id(h);
drop(model);
if let Err(e) = event::fire_event_handler(lua, id, event, &args) {
model_mut().record_script_error(e.to_string());
model_mut().record_lua_error(&e);
}
at = next;
}
@@ -70,10 +70,10 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) {
}
impl super::UiScript {
/// Advance a frame: the `GetTime()` clock and queued events, then the walk (`0x765650`),
/// each shown frame's update in turn: its `OnUpdate(self, elapsed)` (`0x704f10`), and for an
/// EditBox its flush and caret blink after it; then the engine's own fades, model panes and
/// hover.
/// Advance a frame: the `GetTime()` clock and queued events, then the update walk
/// (`0x765650`), each linked frame's update in strata, level and link order: its
/// `OnUpdate(self, elapsed)` (`0x704f10`), and for an EditBox its flush and caret blink after
/// it; then the engine's own fades, model panes and hover.
pub fn tick(&mut self, elapsed: f32) {
self.model_mut().now += f64::from(elapsed);
// Events queued since the last tick fire before this tick's OnUpdate.
@@ -81,74 +81,7 @@ impl super::UiScript {
for (event, args) in pending {
self.fire_event(&event, args);
}
// Each entry: the frame's id, whether it has an `OnUpdate`, and the box if an EditBox.
let walk: Vec<(u32, bool, Option<FrameHandle>)> = {
let mut model = self.model_mut();
// `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here.
let shown = |h: FrameHandle| model.arena.frame(h).is_some_and(|f| f.effective_visible);
let mut frames: Vec<(FrameHandle, bool)> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| shown(h))
.map(|h| (h, false))
.collect();
// The walk reaches every shown frame, not only those with a script, and an EditBox's
// update (`0x77a790`, vtable `+0x38`) flushes it whether or not it has an `OnUpdate`.
frames.extend(
model
.arena
.editbox_kinds()
.iter()
.copied()
.filter(|&h| shown(h))
.map(|h| (h, true)),
);
if model
.on_update_frames
.iter()
.any(|&h| model.arena.frame(h).is_none())
{
let arena = &model.arena;
let live: Vec<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| arena.frame(h).is_some())
.collect();
model.on_update_frames = live;
}
let mut walk: Vec<(u32, bool, Option<FrameHandle>)> = frames
.into_iter()
.map(|(h, editbox)| (model.frame_id(h), !editbox, editbox.then_some(h)))
.collect();
// Creation order, by frame id: getters settle on demand, so a handler sees what an
// earlier one did this sweep, and the order must be stable. The reference walks each
// strata level's list of shown frames (`0x765650`). A box with an `OnUpdate` is
// listed twice, and the two entries merge.
walk.sort_unstable_by_key(|&(id, ..)| id);
walk.dedup_by(|later, earlier| {
let same = later.0 == earlier.0;
if same {
earlier.1 |= later.1;
earlier.2 = earlier.2.or(later.2);
}
same
});
walk
};
for (id, on_update, editbox) in walk {
if on_update {
if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) {
self.push_error(e);
}
}
// The box's own update runs its flush after its `OnUpdate` (`0x77a79a`, then
// `0x77a7a1`), so the events an edit raised fire in the box's place in the walk.
if let Some(h) = editbox {
editbox::update(&self.lua, h, elapsed);
}
}
self.update_walk(elapsed);
self.tick_model_panes(elapsed);
// The engine's line fades, apart from any OnUpdate script: ScrollingMessageFrame's
// (`0x788460`) and MessageFrame's (`0x786200`), which also keeps only the lines that fit
@@ -255,7 +188,7 @@ impl UiScript {
})
.collect();
let mut ids: Vec<u32> = frames.into_iter().map(|h| model.frame_id(h)).collect();
ids.sort_unstable(); // creation order, as in the OnUpdate sweep
ids.sort_unstable(); // creation order
ids
};
for id in update_ids {
+1 -1
View File
@@ -313,7 +313,7 @@ pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) {
if let Err(e) = event::fire_widget_handler(lua, id, "OnTooltipCleared", Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
@@ -114,7 +114,7 @@ fn fire_add_money(lua: &Lua, h: crate::widget::FrameHandle, copper: u64) {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
+1 -1
View File
@@ -226,7 +226,7 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! The update walk (`0x765650`), each linked frame's per-frame update in the reference's order: the
//! nine strata from the bottom up (`0x7656c8`–`0x765793`), each stratum's levels from 0 up
//! (the bound re-read after each level, `0x765774`), and each level's list of linked frames from its
//! tail (`0x7656e5`–`0x765707`). Every link is a head insert (`0x7648c9`), so a level walks oldest
//! link first: the order of [`Frame::insertion_seq`](crate::widget::Frame::insertion_seq).
//!
//! The walk is live. It saves the next frame before each call (`0x765707`), and a hide that
//! unlinks that frame moves the cursor past it (`0x76498f`), so a frame hidden before its turn is
//! not updated. A frame linked while its own level is being walked is staged until that level
//! ends (`0x76489b`, spliced at `0x76573e`) and so waits a tick, as does one linked into a level
//! already walked; one linked into a level still ahead is updated there, after that level's
//! older links.
use std::collections::{BTreeMap, HashMap, HashSet};
use crate::widget::{FrameHandle, FrameKind};
use super::{editbox, event, UiScript};
/// A place in the walk: stratum, level, then within the level the frames linked before the walk
/// began (0, by link stamp) ahead of those linked during it (1, by the order they linked).
type Place = (u8, u16, u8, u64);
impl UiScript {
/// Run the walk: each frame's `OnUpdate(self, elapsed)` (`0x76b2c0`, reading the handler when
/// its turn comes), and for an EditBox its flush after it (`0x77a79a`, then `0x77a7a1`), so the
/// events an edit raised fire in the box's place.
pub(super) fn update_walk(&mut self, elapsed: f32) {
let mut ahead: BTreeMap<Place, FrameHandle> = BTreeMap::new();
let (renumbers, mut joins, mut seen) = {
let mut model = self.model_mut();
// `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here.
if model
.on_update_frames
.iter()
.any(|&h| model.arena.frame(h).is_none())
{
let arena = &model.arena;
let live: Vec<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| arena.frame(h).is_some())
.collect();
model.on_update_frames = live;
}
// Only frames with an `OnUpdate` or an EditBox's flush do anything when walked.
let mut seen = HashSet::new();
for &h in model
.on_update_frames
.iter()
.chain(model.arena.editbox_kinds())
{
if let Some(f) = model.arena.frame(h).filter(|f| f.effective_visible) {
let place = (f.strata as u8, f.level, 0, u64::from(f.insertion_seq));
ahead.insert(place, h);
seen.insert(h);
}
}
model.arena.open_link_log();
(model.arena.renumbers(), model.on_update_joins, seen)
};
// Each frame linked during the walk, by the number of its latest link.
let mut relinked: HashMap<FrameHandle, u64> = HashMap::new();
let mut links = 0u64;
while let Some((place, h)) = ahead.pop_first() {
let (on_update, editbox) = {
let mut model = self.model_mut();
let Some(f) = model.arena.frame(h) else {
continue;
};
// Still at this place: linked, and not relinked since the place was taken.
let here = f.effective_visible
&& match place.2 {
0 => !relinked.contains_key(&h),
_ => relinked.get(&h) == Some(&place.3),
};
let editbox = f.kind == FrameKind::EditBox;
let on_update = here
&& model
.scripts
.get(&h)
.is_some_and(|s| s.contains("OnUpdate"));
(on_update.then(|| model.frame_id(h)), here && editbox)
};
if let Some(id) = on_update {
if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) {
self.push_error(e);
}
}
if editbox {
editbox::update(&self.lua, h, elapsed);
}
let mut model = self.model_mut();
for linked in model.arena.take_linked() {
links += 1;
relinked.insert(linked, links);
let Some(f) = model.arena.frame(linked) else {
continue;
};
if (f.strata as u8, f.level) > (place.0, place.1) {
ahead.insert((f.strata as u8, f.level, 1, links), linked);
}
}
// A frame that gained an `OnUpdate` keeps its place if the walk has not passed it. Its
// stamp compares with the walk's only while no renumber came between; else it waits.
if model.on_update_joins != joins {
joins = model.on_update_joins;
if model.arena.renumbers() == renumbers {
let joined: Vec<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|h| !seen.contains(h) && !relinked.contains_key(h))
.collect();
for j in joined {
seen.insert(j);
let Some(f) = model.arena.frame(j).filter(|f| f.effective_visible) else {
continue;
};
let at = (f.strata as u8, f.level, 0, u64::from(f.insertion_seq));
if at > place {
ahead.insert(at, j);
}
}
}
}
}
self.model_mut().arena.close_link_log();
}
}
+1 -1
View File
@@ -152,6 +152,6 @@ fn model_mut(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> {
/// A handler error is recorded in [`Model::errors`], never propagated to the verb.
fn record(lua: &Lua, fired: mlua::Result<()>) {
if let Err(e) = fired {
model_mut(lua).record_script_error(e.to_string());
model_mut(lua).record_lua_error(&e);
}
}
+42
View File
@@ -250,6 +250,12 @@ pub struct WidgetArena {
regions: Arena<Region>,
names: HashMap<String, FrameHandle>,
next_insertion: u32,
/// How many times the link stamps were renumbered at the cap, so a reader holding stamps can
/// tell they are still comparable with the live ones.
renumbers: u32,
/// While the update walk runs, every frame linked into a bucket since it opened, in link order
/// ([`Self::open_link_log`]).
link_log: Option<Vec<FrameHandle>>,
/// The draw list's fingerprint cache ([`crate::order::traversal`]).
pub(crate) order_cache: crate::order::OrderCache,
/// Minimap frames ever created, so the state feed notices a new one without a walk.
@@ -301,6 +307,8 @@ impl WidgetArena {
regions: Arena::new(),
names: HashMap::new(),
next_insertion: 0,
renumbers: 0,
link_log: None,
minimap_created: 0,
order_cache: Default::default(),
ticked_kinds: Vec::new(),
@@ -381,14 +389,42 @@ impl WidgetArena {
}
}
self.next_insertion = order.len() as u32;
self.renumbers += 1;
}
let seq = self.next_insertion;
if let Some(f) = self.frame_mut(h) {
f.insertion_seq = seq;
self.next_insertion += 1;
if let Some(log) = &mut self.link_log {
log.push(h);
}
}
}
/// Start recording every link into a bucket, for the update walk (`0x765650`), which must see
/// the frames a handler links while it runs.
pub(crate) fn open_link_log(&mut self) {
self.link_log = Some(Vec::new());
}
/// The frames linked since the last call, in link order, the log staying open.
pub(crate) fn take_linked(&mut self) -> Vec<FrameHandle> {
self.link_log
.as_mut()
.map(std::mem::take)
.unwrap_or_default()
}
/// Stop recording links.
pub(crate) fn close_link_log(&mut self) {
self.link_log = None;
}
/// How many times the link stamps have been renumbered; stamps read under one count compare.
pub(crate) fn renumbers(&self) -> u32 {
self.renumbers
}
/// The first frame created with `name`; a later duplicate never takes it over (`0x701bd0`).
pub fn lookup(&self, name: &str) -> Option<FrameHandle> {
self.names.get(name).copied()
@@ -502,8 +538,14 @@ impl WidgetArena {
| FrameKind::DressUpModel
| FrameKind::TabardModel
);
let visible = frame.effective_visible;
let (index, generation) = self.frames.insert(frame);
let handle = FrameHandle { index, generation };
if visible {
if let Some(log) = &mut self.link_log {
log.push(handle);
}
}
if ticked {
self.ticked_kinds.push(handle);
}
+2
View File
@@ -15,6 +15,8 @@ dev = ["benilla-app/dev"]
# Profiling switches, forwarded so `cargo run -p benilla --features tracy` works.
tracy = ["benilla-app/tracy"]
trace_chrome = ["benilla-app/trace_chrome"]
# Forwards to benilla-app's `bevy_dynamic_linking`, which enables bevy/dynamic_linking.
bevy_dynamic_linking = ["benilla-app/bevy_dynamic_linking"]
[dependencies]
# `default-features = false`, or `--no-default-features` here still builds benilla-app with `dev`.
+2 -1
View File
@@ -110,7 +110,8 @@ run() {
run fmt cargo fmt --all -- --check
run clippy cargo clippy $pflags --all-targets -- -D warnings
run test env BENILLA_SKIP_LOG="$skips" cargo test $pflags
# --no-fail-fast: one red test binary does not hide the failures in the ones after it.
run test env BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast $pflags
# Data-gated tests skip without the install or the addon corpus and libtest swallows their line,
# so each skip is also logged to $BENILLA_SKIP_LOG and counted here.
if [ -s "$skips" ]; then
+2 -2
View File
@@ -120,12 +120,12 @@ else
"corpus=$([ -d "$root/wow-addons-vanilla" ] && echo yes || echo no)):" \
"data-gated tests skip silently on this machine"
fi
run test env ${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" cargo test --workspace
run test env ${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast --workspace
report_skips test
# test-no-install: refuses a test that reads the install without `wow_data_or_skip!`. `WOW_DATA=`
# (set, empty) is the resolver's "no install"; no build reads it, so the test binaries are reused.
run test-no-install env -u BENILLA_REQUIRE_DATA WOW_DATA= cargo test --workspace
run test-no-install env -u BENILLA_REQUIRE_DATA WOW_DATA= cargo test --no-fail-fast --workspace
# doc-links: refuses a doc link to a `crate::`, `super::`, `self::` or `Self::` path whose leaf is
# declared nowhere in the workspace. Narrow on purpose: every other unresolved link is a convention.