The VM speaks 1.12 alone: the Era globals and the uncalled hooks are gone, the engine's tooltip seams are Rust calls, strata/draw-layer/state-texture/sound/OnClick/quest-item/pickup take their 1.12 shapes, and the surface gate covers the production load (#573)

The Lua VM keeps no surface the 1.12.1 client lacks.

- **Removed:** the nine Era-only globals: `UnitAura`, `CancelUnitBuff`, `GetCursorInfo`, `GetInventoryItemID`, `GetPlayerFacing`, `GetTradePartnerName`, `IsGossipOptionCoded`, `UnitIsAFK`, `UnitIsDND`.
- **Hooks off Lua:** `GameTooltip:BenillaSetItemById` and `:BenillaGetTooltipOwner` are replaced by Rust-side calls. The uncalled `Benilla*` hooks are deleted.
- **Frame strata:** `SetFrameStrata` knows the reference's eight strata and raises its message for any other (`0x774450`).
- **`SetDrawLayer`** reads the layer name alone (`0x79a780`).
- **`Set<State>Texture`** reads a number as a path (`0x781b23`); there is no colour form.
- **`PlaySound`** names a kit and returns nothing (`0x4586d0`). `PlaySoundFile` returns one value (`0x458780`); benilla answers 1, a recorded deviation, since it plays after the call.
- **`OnClick`** sets only `arg1` (`0x779540`).
- **Quest getters:** `GetQuestItemInfo` and the quest-log reward and choice getters answer the reference's five values and its miss behaviour (`0x501f80`, `0x4e0b00`, `0x4e0cf0`).
- **`PickupContainerItem`** returns nothing (`0x4f9b30`).
- **Surface gates:** the bare VM's globals and every widget class's methods are held to `reference/1.12-globals.tsv` with one commented allowlist. A new gate on the full production load (core plus layer) allows beyond that only the layer's `Benilla`-prefixed globals.

Verified:
- A test per removal and per corrected form; a scripted undo of each fails its test.
- Each gate fails on a re-added name.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run showed the same warnings and 0 script errors before and after.
- In the 219-addon corpus, AckisRecipeList's bundled AceGUI-3.0 and Roid-Macros' Grid extension now behave as they do on 1.12.1.

Built on macOS; no platform seam touched.

Fixes #171

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 01:44:52 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0a1f69466c
commit 78a983b00e
69 changed files with 1103 additions and 1275 deletions
+7 -7
View File
@@ -99,25 +99,25 @@ impl Plugin for HoverLogPlugin {
}
}
/// The tooltip context, asked of the Lua VM, once per frame while recording.
/// The tooltip context, asked of the Lua VM, once per frame while recording; the owner, which 1.12
/// does not answer to Lua, from the engine.
fn tooltip_context(script: &UiScript) -> Option<(String, i64, String)> {
let chunk = r#"
if not GameTooltip or not GameTooltip:IsShown() then return "" end
local owner = GameTooltip:BenillaGetTooltipOwner()
local name = "(no owner)"
if owner and owner.GetName and owner:GetName() then name = owner:GetName() end
local first = ""
local first = "
if GameTooltipTextLeft1 and GameTooltipTextLeft1:GetText() then
first = GameTooltipTextLeft1:GetText()
end
return name .. "\t" .. GameTooltip:NumLines() .. "\t" .. first
return GameTooltip:NumLines() .. "\t" .. first
"#;
let s: String = script.eval(chunk).ok()?;
if s.is_empty() {
return None;
}
let mut parts = s.split('\t');
let owner = parts.next()?.to_string();
let owner = script
.tooltip_owner_name("GameTooltip")
.unwrap_or_else(|| "(no owner)".to_string());
let lines = parts.next()?.trim().parse::<f64>().ok()? as i64;
let first = parts.next().unwrap_or("").to_string();
Some((owner, lines, first))
-1
View File
@@ -48,7 +48,6 @@ fn drain_ui_sounds(
};
for req in requests {
let kit_ref = match &req {
SoundRequest::KitId(id) => KitRef::Id(*id),
SoundRequest::KitName(name) => KitRef::Name(name),
SoundRequest::File(path) => {
// `PlaySoundFile`: by path, with no kit gates or variation.
+3 -2
View File
@@ -84,7 +84,7 @@ pub(crate) struct PlayerAuraCache {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct AuraEvents;
/// Adds the aura feed and the `CancelUnitBuff` drain.
/// Adds the aura feed and the `CancelPlayerBuff`/`CancelTrackingBuff` drain.
pub(crate) struct UiAuraPlugin;
impl Plugin for UiAuraPlugin {
@@ -629,7 +629,8 @@ fn end_session_aura_state(
// No `AuraFeedMemory` reset: its edge keys die with the VM.
}
/// A `CMSG_CANCEL_AURA` per queued `CancelUnitBuff`, naming the spell, not the slot (`0x6e7040`).
/// A `CMSG_CANCEL_AURA` per queued `CancelPlayerBuff` or `CancelTrackingBuff`, naming the spell,
/// not the slot (`0x6e7040`).
fn drain_aura_cancels(script: Option<NonSendMut<UiScript>>, net: Res<NetCommands>) {
let Some(mut script) = script else {
return;
+17 -15
View File
@@ -419,27 +419,29 @@ mod tests {
.apply_stats(guid, full, info);
}
/// The spell ids `UnitBuff` (or `UnitDebuff`) enumerates for `token`, index 1 up to the nil,
/// through `UnitAura`'s tenth return, as the icon needs a spell catalog.
/// The spell ids of `token`'s pushed debuffs (`harmful`) or buffs, after checking that
/// `UnitDebuff` (or `UnitBuff`) enumerates exactly that many, index 1 up to the nil.
fn listed(app: &mut App, token: &str, harmful: bool) -> Vec<i64> {
let (verb, filter) = if harmful {
("UnitDebuff", "HARMFUL")
} else {
("UnitBuff", "HELPFUL")
};
app.world_mut()
.non_send_resource::<UiScript>()
.eval::<Vec<i64>>(&format!(
r#"local out = {{}}
let verb = if harmful { "UnitDebuff" } else { "UnitBuff" };
let s = app.world_mut().non_send_resource::<UiScript>();
let ids = s.aura_spell_ids(token, !harmful);
let enumerated = s
.eval::<usize>(&format!(
r#"local n = 0
for i = 1, 48 do
local _, count = {verb}("{token}", i)
if count == nil then break end
local _, _, _, _, _, _, _, _, _, id = UnitAura("{token}", i, "{filter}")
table.insert(out, id)
n = n + 1
end
return out"#
return n"#
))
.unwrap()
.unwrap();
assert_eq!(
enumerated,
ids.len(),
"{verb}(\"{token}\", i) enumerates the list"
);
ids.into_iter().map(i64::from).collect()
}
fn seen(app: &mut App) -> Vec<String> {
-1
View File
@@ -214,7 +214,6 @@ fn snapshot(state: &GossipState) -> Option<GossipMenu> {
.map(|o| GossipOptionView {
label: o.message.clone(),
icon_type: gossip_icon_type(o.icon).into(),
coded: o.coded,
})
.collect(),
})
+27 -13
View File
@@ -839,7 +839,7 @@ fn close_on_move_start(
}
/// Sends what the Lua asked for, as the take dispatcher `0x4c2790(slot, flag)`: a row click
/// (`BenillaTakeLootSlot`, flag 0) and the `LOOT_BIND` confirm (`LootSlot`, flag 1) stay apart,
/// (a `LootButton`'s own, flag 0) and the `LOOT_BIND` confirm (`LootSlot`, flag 1) stay apart,
/// so a second click on a bind-on-pickup row asks again.
fn drain_loot(
script: Option<NonSendMut<UiScript>>,
@@ -896,7 +896,7 @@ fn drain_loot(
// At the click, before any answer (`0x4c2926`): the item's ItemGroupSounds kit 0.
pickup.write(crate::sound::LootPickupSound { display_id });
}
None => debug!("ui_loot: BenillaTakeLootSlot({index}) out of range — ignored"),
None => debug!("ui_loot: row {index} clicked out of range — ignored"),
}
}
@@ -1069,9 +1069,12 @@ mod tests {
const SECOND_BOP: u32 = 18832;
/// `rows` open on a corpse with every template landed, then `lua` and one drain.
/// A loot window over `rows` whose display rows `clicks` are clicked by the mouse, as a
/// `LootButton` takes (`0x4c1820`), then `lua` run, then one drain.
fn drain_with(
gold: u32,
rows: Vec<LootItem>,
clicks: &[u32],
lua: &str,
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
@@ -1106,7 +1109,7 @@ mod tests {
.resource_mut::<LootState>()
.open(0x42, loot_type::CORPSE, gold, rows);
let script = UiScript::new().unwrap();
let mut script = UiScript::new().unwrap();
script
.run(
"BIND_CONFIRMS = {}\n\
@@ -1115,6 +1118,19 @@ mod tests {
f:SetScript(\"OnEvent\", function() tinsert(BIND_CONFIRMS, arg1) end)",
)
.unwrap();
script.set_screen_size(1024.0, 768.0);
for &row in clicks {
script
.run(&format!(
"local b = CreateFrame(\"LootButton\", \"DrainRow\", UIParent)\n\
b:SetPoint(\"BOTTOMLEFT\", 100, 100) b:SetWidth(50) b:SetHeight(50)\n\
b:EnableMouse(true) b:Show() b:SetSlot({row})"
))
.unwrap();
script.resolve();
script.mouse_button(125.0, 125.0, "LeftButton", true);
script.mouse_button(125.0, 125.0, "LeftButton", false);
}
script.run(lua).unwrap();
app.insert_non_send_resource(script);
app.world_mut().run_system_once(drain_loot).unwrap();
@@ -1220,7 +1236,7 @@ mod tests {
/// The click arm's deferral (`0x4c28f2`-`0x4c2920`).
#[test]
fn a_bop_row_confirms_instead_of_sending() {
let (mut app, rx) = drain_with(0, vec![item(0, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
let (mut app, rx) = drain_with(0, vec![item(0, FELSTRIKER, 1)], &[1], "");
assert!(sent(&rx).is_empty(), "the deferred take sends nothing");
assert_eq!(pending_confirm(&app), Some(1), "and stashes the row");
@@ -1234,11 +1250,7 @@ mod tests {
/// The confirm arm (`0x4c27c0`).
#[test]
fn loot_slot_completes_the_pending_confirm_exactly_once() {
let (mut app, rx) = drain_with(
0,
vec![item(7, FELSTRIKER, 1)],
"BenillaTakeLootSlot(1) LootSlot(1)",
);
let (mut app, rx) = drain_with(0, vec![item(7, FELSTRIKER, 1)], &[1], "LootSlot(1)");
assert!(
matches!(
sent(&rx)[..],
@@ -1263,6 +1275,7 @@ mod tests {
let (_app, rx) = drain_with(
0,
vec![item(0, TOUGH_JERKY, 1), item(1, FLURRY_AXE, 1)],
&[],
"LootSlot(1) LootSlot(2)",
);
assert!(
@@ -1278,7 +1291,7 @@ mod tests {
(WHITE_BOP, "quality 1 is below the floor of 2"),
(TOUGH_JERKY, "neither"),
] {
let (app, rx) = drain_with(0, vec![item(3, entry, 1)], "BenillaTakeLootSlot(1)");
let (app, rx) = drain_with(0, vec![item(3, entry, 1)], &[1], "");
assert!(
matches!(
sent(&rx)[..],
@@ -1300,7 +1313,8 @@ mod tests {
let (mut app, rx) = drain_with(
120, // gold, so display row 1 is the coin and the item is row 2
vec![item(4, FELSTRIKER, 1)],
"BenillaTakeLootSlot(2)",
&[2],
"",
);
assert_eq!(
pending_confirm(&app),
@@ -1325,7 +1339,7 @@ mod tests {
/// `0x4c27d7`: the continuation returns on an emptied record.
#[test]
fn an_accept_for_a_row_that_was_taken_away_sends_nothing() {
let (mut app, rx) = drain_with(0, vec![item(2, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
let (mut app, rx) = drain_with(0, vec![item(2, FELSTRIKER, 1)], &[1], "");
assert_eq!(pending_confirm(&app), Some(1));
app.world_mut().resource_mut::<LootState>().remove_slot(2);
@@ -1344,7 +1358,7 @@ mod tests {
/// `0x4c1df5`: the response copier resets the stash.
#[test]
fn a_pending_confirm_does_not_survive_the_window() {
let (mut app, _rx) = drain_with(0, vec![item(0, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
let (mut app, _rx) = drain_with(0, vec![item(0, FELSTRIKER, 1)], &[1], "");
assert_eq!(pending_confirm(&app), Some(1));
app.world_mut().resource_mut::<LootState>().clear();
+1 -3
View File
@@ -663,7 +663,7 @@ fn member_unit_state(
// `+0x08 & 4`) and `UnitIsGhost` (`0x517c32`, `& 8`) read it: fresher than the roster
// byte, which only `SMSG_GROUP_LIST` moves. Connected stays the roster's, though the
// reference reads the record's bit 0 (`0x517dd3`); its no-object PvP and FFA reads are
// untraced, and `UnitIsAFK`/`UnitIsDND` are not 1.12 bindings.
// untraced, and 1.12 has no AFK or DND predicate to feed.
dead: stats.is_some_and(|s| s.status.unwrap_or(0) & member_status::DEAD != 0),
ghost: stats.is_some_and(|s| s.status.unwrap_or(0) & member_status::GHOST != 0),
..Default::default()
@@ -677,8 +677,6 @@ fn member_unit_state(
s.faction_group = own_group;
// The roster status byte overlays both legs.
s.is_connected = m.status & member_status::ONLINE != 0;
s.is_afk = m.status & member_status::AFK != 0;
s.is_dnd = m.status & member_status::DND != 0;
s.is_pvp_ffa = m.status & member_status::PVP_FFA != 0;
s.pvp = s.pvp || m.status & member_status::PVP != 0;
s.ghost = s.ghost || m.status & member_status::GHOST != 0;
@@ -840,13 +840,10 @@ fn drag_drop_onto_another_button_hops_the_displaced_action() {
"Interface\\Icons\\Spell_A",
"slot 2 now shows the placed action"
);
let (kind, src) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!(
(kind.as_str(), src),
("action", 2),
"the displaced action hopped on, sourced from slot 2"
assert!(
matches!(s.cursor_payload(), Some(benilla_ui::script::CursorPayload::Action(c)) if c.src_slot == 2),
"the displaced action hopped on, sourced from slot 2: {:?}",
s.cursor_payload()
);
// Two independent sends across the one gesture: the pickup's clear, then the place's write.
@@ -1630,8 +1630,11 @@ fn the_keyring_button_opens_a_keyring_window() {
.unwrap();
assert_eq!(shown, 8, "only the level-unlocked keyring slots are drawn");
assert_eq!(
s.eval::<i64>("return BenillaGetContainerItemID(KEYRING_CONTAINER, 1)")
.unwrap(),
s.eval::<i64>(
"local _, _, id = string.find(GetContainerItemLink(KEYRING_CONTAINER, 1), 'item:(%d+)') \
return tonumber(id)"
)
.unwrap(),
7146
);
@@ -442,10 +442,11 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
let (kind, id) = s
.eval::<(String, i64)>("local k, id = GetCursorInfo() return k, id")
.unwrap();
assert_eq!((kind.as_str(), id), ("item", 1234));
assert!(
matches!(s.cursor_payload(), Some(benilla_ui::script::CursorPayload::Item(c)) if c.item_id == 1234),
"the cursor holds item 1234: {:?}",
s.cursor_payload()
);
assert!(
s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap(),
"the picked slot locks"
@@ -1278,7 +1278,7 @@ fn convert_entry(
},
color: [1.0, 1.0, 1.0, eq.alpha],
// `SetPortraitTexture` cuts the inscribed circle, as the reference stamps into
// its 64² bake's alpha; `BenillaSetBoothTexture` samples square.
// its 64² bake's alpha.
circular,
premultiplied,
clip,
@@ -2094,15 +2094,15 @@ mod extract_gate_tests {
let mut app = app_with_marker();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("BenillaSetBoothTexture(marker, 'paperdoll')")
.run("SetPortraitTexture(marker, 'target')")
.unwrap();
// The booth publishes a live bake for that slot; without an entry the region draws nothing.
// The booth publishes a live bake for that unit; without an entry the region draws nothing.
let bake = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
app.world_mut().resource_mut::<PortraitImages>().0.insert(
"paperdoll".to_string(),
"target".to_string(),
crate::portrait::PortraitSource::Live(bake.clone()),
);
app.update();
@@ -292,11 +292,6 @@ fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
matches!(&payload, Some(CursorPayload::Macro(m)) if m.index == 1),
"the macro payload, carrying its index: {payload:?}"
);
assert_eq!(
s.eval::<(String, i64)>("local k, i = GetCursorInfo() return k, i")
.unwrap(),
("macro".to_string(), 1)
);
// A bar slot packs the MACRO tag (0x40 << 24) with the macro index.
let mut s = s;
+4
View File
@@ -753,6 +753,10 @@ mod panel_template_tests;
#[cfg(test)]
mod shape_gate;
/// The surface gate: the production load's globals and widget methods against the 1.12 tables.
#[cfg(test)]
mod surface_gate;
/// The event argument-shape gate: every fire site against `reference/1.12-events.tsv`.
#[cfg(test)]
mod event_shape_gate;
@@ -68,12 +68,10 @@ fn shipped_gossip_frame_drives_end_to_end() {
GossipOptionView {
label: "Let me browse your goods.".into(),
icon_type: "vendor".into(),
coded: false,
},
GossipOptionView {
label: "I wish to sign the petition.".into(),
icon_type: "gossip".into(),
coded: true,
},
],
}));
@@ -86,8 +84,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
.unwrap(),
"Greetings, traveler. How may I help you?"
);
// The coded row is enabled too: `GossipFrameOptionsUpdate` has no coded-option handling
// (`GossipFrame.lua:111-128`). A coded option is sent without a code; that path is not built.
// Every row is enabled: `GossipFrameOptionsUpdate` greys none (`GossipFrame.lua:111-128`).
let states: (bool, bool, bool, bool) = s
.eval(
"return GossipTitleButton1:IsVisible(), GossipTitleButton1:IsEnabled() ~= 0,\n\
@@ -229,7 +226,6 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
options: vec![GossipOptionView {
label: "Let me browse your goods.".into(),
icon_type: "vendor".into(),
coded: false,
}],
}));
s.fire_event("GOSSIP_SHOW", vec![]);
@@ -338,7 +334,6 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
let long = |t: &str| GossipOptionView {
label: t.into(),
icon_type: "gossip".into(),
coded: false,
};
s.set_gossip(Some(GossipMenu {
greeting: "Make your choice!".into(),
@@ -490,7 +485,6 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
options: vec![GossipOptionView {
label: "Let me browse your goods.".into(),
icon_type: "vendor".into(),
coded: false,
}],
}));
s.fire_event("GOSSIP_SHOW", vec![]);
@@ -724,7 +718,6 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
options: vec![GossipOptionView {
label: "I would like to check my deposit box.".into(),
icon_type: "money".into(),
coded: false,
}],
}));
s.fire_event("GOSSIP_SHOW", vec![]);
@@ -793,7 +786,6 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
broken the law of the land and it is my sworn duty to enforce it, whatever the cost."
),
icon_type: "gossip".into(),
coded: false,
};
s.set_gossip(Some(GossipMenu {
greeting: "Make your choice!".into(),
@@ -443,7 +443,7 @@ fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
0,
"the grid goes down with the payload"
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
assert!(s.cursor_payload().is_none());
}
/// `LOCK_ACTIONBAR` stops both drag ends (PetActionBarFrame.lua:270,278) but not the shift-click
@@ -466,7 +466,7 @@ fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
s.take_pet_set_actions().is_empty(),
"a locked bar sends nothing — the slot was never picked up"
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
assert!(s.cursor_payload().is_none());
s.set_modifiers(true, false, false);
s.run("this = PetActionButton4; PetActionButton_OnClick(\"LeftButton\")")
@@ -203,14 +203,13 @@ fn shipped_spellbook_drives_end_to_end() {
s.mouse_button(x1, y1, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(s.take_spell_casts().is_empty(), "shift-click never casts");
let (kind, _book_id, book_type, spell_id) = s
.eval::<(String, i64, String, i64)>(
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id",
)
.unwrap();
assert_eq!(
(kind.as_str(), book_type.as_str(), spell_id),
("spell", "spell", 133)
assert!(
matches!(
s.cursor_payload(),
Some(benilla_ui::script::CursorPayload::Spell(c)) if c.book_type == "spell" && c.spell_id == 133
),
"the cursor holds spell 133 from the spell book: {:?}",
s.cursor_payload()
);
// An empty slot is hidden (`ActionButton.lua:214-215`) until a held payload opens the grid;
@@ -784,9 +783,10 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
click(&mut s, "SpellButton1", "LeftButton");
s.set_modifiers(false, false, false);
assert!(s.errors().is_empty(), "pickup errors: {:?}", s.errors());
let (kind, spell_id) = s
.eval::<(String, i64)>("local k, _, _, id = GetCursorInfo() return k, id")
.unwrap();
assert_eq!((kind.as_str(), spell_id), ("spell", 133));
assert!(
matches!(s.cursor_payload(), Some(benilla_ui::script::CursorPayload::Spell(c)) if c.spell_id == 133),
"the cursor holds spell 133: {:?}",
s.cursor_payload()
);
assert!(s.take_spell_casts().is_empty());
}
@@ -0,0 +1,143 @@
//! The 1.12 surface of the production load as a gate: every global and widget method an addon
//! reaches once the core and benilla's layer have loaded is one the 1.12.1 client has
//! (`reference/1.12-globals.tsv`), one the engine exposes, or a `Benilla`-prefixed name the layer
//! defines. The engine's own surface is gated on a bare VM in `benilla-ui`, name by name against
//! the same table (`script::tests::reference_surface`, whose commented `BEYOND_1_12` is the one
//! allowlist) and method by method against each class's registrar tables
//! (`script::tests::widget_surface`); so here the load itself may add nothing else.
use std::collections::{BTreeSet, HashSet};
use benilla_ui::script::{widget_method_census, UiScript};
use crate::local_state::test_env::ENV_LOCK;
/// `reference/1.12-globals.tsv`'s names.
fn reference_globals() -> HashSet<&'static str> {
let tsv = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-globals.tsv"
));
let names: HashSet<&str> = tsv
.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.split('\t').next())
.collect();
assert!(
names.len() > 19_000,
"the reference table looks truncated ({} names)",
names.len()
);
names
}
/// The string keys of `_G`.
fn globals_of(s: &UiScript) -> BTreeSet<String> {
s.eval::<Vec<String>>(
"local out = {} for k in pairs(getfenv(0)) do \
if type(k) == 'string' then table.insert(out, k) end end return out",
)
.expect("dump the globals")
.into_iter()
.collect()
}
/// Every `(class, method)` a VM answers, as `Class:Method`. The WorldFrame type is one-shot
/// (`0x6ee439`), spent once the stock `WorldFrame` exists, so a loaded VM cannot make a second;
/// its methods are a Frame's, which the census asks of a Frame.
fn methods_of(s: &UiScript) -> BTreeSet<String> {
widget_method_census(s)
.expect("the widget census")
.into_iter()
.filter_map(|(class, method)| {
if method == "!NOT-INSTANTIABLE" {
assert_eq!(class, "WorldFrame", "{class} could not be made");
return None;
}
Some(format!("{class}:{method}"))
})
.collect()
}
/// A name the layer may define: its prefix, or a slash alias's `SLASH_BENILLA_` form.
fn layer_prefixed(n: &str) -> bool {
n.starts_with("Benilla") || n.starts_with("BENILLA_") || n.starts_with("SLASH_BENILLA_")
}
/// What the production load adds past 1.12 and past the engine, and the layer's own names: the
/// globals (`Benilla`-prefixed ones the engine does not also define passing) and the methods.
fn production_beyond(s: &UiScript, engine: &UiScript) -> (Vec<String>, Vec<String>) {
let reference = reference_globals();
let engine_globals = globals_of(engine);
let globals = globals_of(s)
.into_iter()
.filter(|n| !reference.contains(n.as_str()) && !engine_globals.contains(n))
.filter(|n| !layer_prefixed(n))
.collect();
let engine_methods = methods_of(engine);
let methods = methods_of(s)
.into_iter()
.filter(|m| !engine_methods.contains(m))
.collect();
(globals, methods)
}
/// The production load, core and layer, adds no global past 1.12 but the layer's own
/// `Benilla`-prefixed names, and no widget method at all.
#[test]
fn the_production_load_stays_inside_the_1_12_surface() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = super::layer_tests::production_load_with("surface", false, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
let engine = UiScript::new().unwrap();
// The layer's globals are there to be checked: a load that defined none proves nothing.
assert!(
globals_of(&s)
.iter()
.any(|n| layer_prefixed(n) && !globals_of(&engine).contains(n)),
"the layer defined no global: it did not load"
);
let (globals, methods) = production_beyond(&s, &engine);
assert!(
globals.is_empty(),
"the production load exposes {} global(s) the 1.12.1 client does not:\n {}\n\n\
A layer global takes the `Benilla` prefix; anything else gets its 1.12 spelling or goes.",
globals.len(),
globals.join(" ")
);
assert!(
methods.is_empty(),
"the production load adds {} widget method(s) the engine does not register:\n {}",
methods.len(),
methods.join(" ")
);
}
/// The gate bites: a global past 1.12 put back into the loaded VM, an unprefixed layer-style one,
/// an engine-only prefixed one and a method on a class's table are each caught.
#[test]
fn the_production_gate_catches_a_name_put_back() {
let s = UiScript::new().unwrap();
let engine = UiScript::new().unwrap();
assert_eq!(
production_beyond(&s, &engine),
(Vec::new(), Vec::new()),
"two bare VMs differ"
);
s.run(
"UnitAura = function() end \
BenillaLayerThing = 1 \
getmetatable(CreateFrame('GameTooltip')).__index.BenillaSetItemById = function() end",
)
.unwrap();
let (globals, methods) = production_beyond(&s, &engine);
assert_eq!(globals, vec!["UnitAura".to_string()]);
assert!(
methods.contains(&"GameTooltip:BenillaSetItemById".to_string()),
"{methods:?}"
);
}
+1 -5
View File
@@ -525,7 +525,6 @@ fn snapshot(
trade: &TradeSession,
items: &Items,
icons: Option<&ItemDisplays>,
names: &NameCache,
commands: &NetCommands,
) -> Option<TradeState> {
if !trade.open {
@@ -534,9 +533,6 @@ fn snapshot(
Some(TradeState {
player: resolve_side(&trade.our, items, icons, commands),
target: resolve_side(&trade.their, items, icons, commands),
partner_name: trade
.partner
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
})
}
@@ -568,7 +564,7 @@ fn feed_trade(
let last_player_gold = last_player_gold.get(&script);
let last_their_gold = last_their_gold.get(&script);
let fresh = snapshot(&trade, &items, icons.as_deref(), &names, &commands);
let fresh = snapshot(&trade, &items, icons.as_deref(), &commands);
let opened = !*last_open && trade.is_open();
let closed = *last_open && !trade.is_open();
let changed = fresh != *last;
+1 -5
View File
@@ -57,11 +57,7 @@ fn state() -> TradeState {
..Default::default()
};
target.slots[0] = Some(item(4306, "Silk Cloth", 1, 2));
TradeState {
player,
target,
partner_name: Some("Thrall".into()),
}
TradeState { player, target }
}
#[test]
@@ -1,5 +1,5 @@
//! `SpellDispelType.dbc`: the dispel class name the aura tooltip (`0x52f880`) shows and
//! `UnitAura`/`UnitDebuff` return as `debuffType`, which FrameXML's `DebuffTypeColor` keys the
//! `UnitDebuff` returns as its `dispelType`, which FrameXML's `DebuffTypeColor` keys the
//! debuff border on. A row is named only when column 10 (`+0x28`) is nonzero (`0x52f906`): on 5875
//! that is ids 1-4, so Stealth and Invisibility carry names that never print. Column 11 repeats
//! the name on those four rows; the tooltip does not read it.
+10 -9
View File
@@ -83,18 +83,19 @@ impl Loader<'_> {
// form (`UIPanelButtonUpTexture`); it expands like a `<Layers>` region.
let expanded = this.expand_region(raw);
let t = &expanded;
if let Some(file) = t.attr("file") {
this.call(wrapper, method, file.to_string(), dbg);
} else if let Some(c) = children_named(t, "Color").next().map(color_of) {
this.call(wrapper, method, (c[0], c[1], c[2], c[3]), dbg);
} else {
// No art: the element alone creates the region, as the reference sends all four
// state textures through the `<Layers>` texture adder (`0x6f26f0`, from
// `0x778903`); `""` creates the slot without painting it.
this.call(wrapper, method, String::new(), dbg);
// The reference sends all four state textures through the `<Layers>` texture adder
// (`0x6f26f0`, from `0x778903`), so a `<Color>` is the region's own colour fill; the
// Lua setter has no colour form (`0x781970`). `""` creates the slot unpainted.
let color = children_named(t, "Color").next().map(color_of);
match t.attr("file") {
Some(file) => this.call(wrapper, method, file.to_string(), dbg),
None => this.call(wrapper, method, String::new(), dbg),
}
let getter = method.replacen("Set", "Get", 1);
if let Ok(region) = wrapper.call_method::<Table>(getter.as_str(), ()) {
if let (None, Some(c)) = (t.attr("file"), color) {
this.call_region(&region, "SetTexture", (c[0], c[1], c[2], c[3]), dbg);
}
if let Some(mode) = t.attr("alphaMode") {
this.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
+4 -8
View File
@@ -37,13 +37,11 @@ pub enum Strata {
Fullscreen = 6,
FullscreenDialog = 7,
Tooltip = 8,
/// A later client's stratum above `Tooltip`, not a 1.12 one.
Blizzard = 9,
}
impl Strata {
/// Every stratum in draw order.
pub const ALL: [Strata; 10] = [
pub const ALL: [Strata; 9] = [
Strata::World,
Strata::Background,
Strata::Low,
@@ -53,7 +51,6 @@ impl Strata {
Strata::Fullscreen,
Strata::FullscreenDialog,
Strata::Tooltip,
Strata::Blizzard,
];
/// The client bucket id.
@@ -114,8 +111,9 @@ impl Default for DrawLayer {
// plus 1 (`SetParent` `0x76ab10`, at `0x76ab65`), `MirrorTimer.xml:62` lowers the bar by 1 to tie
// its parent, and the tie lets the bar's ARTWORK fill draw under the parent's OVERLAY border.
//
// The sub-level is a later client's ordering within a layer, not 1.12's, which has none
// (`0x76a860` takes only region and layer and inserts at the head); stock content never sets it.
// The sub-level is the engine's own ordering within a layer, set only for the EditBox caret: 1.12
// gives Lua and XML none (`0x76a860` takes only region and layer and inserts at the head, and
// `SetDrawLayer` `0x79a780` reads the layer alone).
const STRATUM_SHIFT: u32 = 60;
const LEVEL_SHIFT: u32 = 44;
@@ -415,8 +413,6 @@ mod tests {
let lo = ZKey::frame(Strata::World, u16::MAX, 0);
let hi = ZKey::frame(Strata::Tooltip, 0, 0);
assert!(hi > lo);
// BLIZZARD is above TOOLTIP.
assert!(ZKey::frame(Strata::Blizzard, 0, 0) > ZKey::frame(Strata::Tooltip, u16::MAX, 0));
}
#[test]
+71 -271
View File
@@ -5,8 +5,8 @@
//! the list of the unit they name, and a token naming the player reads the player's list in cache
//! order, where the reference's `UnitBuff("player", i)` reads by slot.
//!
//! `UnitAura`, `UnitBuff`, `UnitDebuff` and `CancelUnitBuff` take a token and a 1-based index into
//! the sign-filtered list; the getters answer nil past the end. The 1.12 `GetPlayerBuff`
//! `UnitBuff` and `UnitDebuff` take a token and a 1-based index into the sign-filtered list and
//! answer nothing past the end. The 1.12 `GetPlayerBuff`
//! ([`player_buff`]) takes a 0-based index and returns a cache position, the same number under
//! every filter, which its siblings and `GameTooltip:SetPlayerBuff` take in place of the counter.
//!
@@ -23,7 +23,7 @@ mod player_buff;
/// [`crate::script::UiScript::set_player_auras`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AuraState {
/// `Spell.dbc` id, `UnitAura`'s `spellId`.
/// `Spell.dbc` id, what `CMSG_CANCEL_AURA` names and the app's icon lookup keys on.
pub spell_id: u32,
/// The spell's name; `None` when the catalog lacks the id.
pub name: Option<String>,
@@ -64,43 +64,16 @@ pub struct TrackingState {
pub cancelable: bool,
}
/// `UnitAura`'s `filter`: `|`-separated tokens, the sign defaulting to `HELPFUL` as in the Era API.
struct Filter {
helpful: bool,
cancelable: Option<bool>,
}
impl Filter {
fn parse(spec: Option<&str>) -> Self {
let spec = spec.unwrap_or("");
let has = |t: &str| spec.split('|').any(|s| s.trim().eq_ignore_ascii_case(t));
Self {
helpful: !has("HARMFUL"),
cancelable: match (has("CANCELABLE"), has("NOT_CANCELABLE")) {
(true, false) => Some(true),
(false, true) => Some(false),
// Both or neither: no constraint; the reference never passes both.
_ => None,
},
}
}
fn matches(&self, a: &AuraState) -> bool {
a.helpful == self.helpful && self.cancelable.is_none_or(|c| a.cancelable == c)
}
}
/// The cancel gate of `CancelUnitBuff` and `CancelPlayerBuff` (`0x4e49a0`, `0x4e49fb`-`0x4e4a14`):
/// The cancel gate of `CancelPlayerBuff` (`0x4e49a0`, `0x4e49fb`-`0x4e4a14`):
/// a helpful aura with `AFLAG_CANCELABLE` (vmangos: unless `SPELL_ATTR_NO_AURA_CANCEL`), or any
/// aura of a channeled spell, the only negative aura that cancels (breaking a channel on you).
pub(super) fn cancel_authorized(a: &AuraState) -> bool {
(a.helpful && a.cancelable) || a.channeled
}
/// The 1.12 tuple, not a prefix of the Era one: `UnitBuff` returns `(texture, applications)`
/// (`0x519500`) and `UnitDebuff` adds `dispelType` (`0x5198f0`), as stock `TargetFrame.lua:287-290`
/// reads them.
fn returns_1121(lua: &Lua, a: &AuraState, with_dispel_type: bool) -> mlua::Result<MultiValue> {
/// The 1.12 tuple: `UnitBuff` returns `(texture, applications)` (`0x519500`) and `UnitDebuff` adds
/// `dispelType` (`0x5198f0`), as stock `TargetFrame.lua:287-290` reads them.
fn returns(lua: &Lua, a: &AuraState, with_dispel_type: bool) -> mlua::Result<MultiValue> {
let icon = match &a.icon {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
@@ -115,38 +88,6 @@ fn returns_1121(lua: &Lua, a: &AuraState, with_dispel_type: bool) -> mlua::Resul
Ok(MultiValue::from_vec(out))
}
/// Which return tuple a getter pushes.
#[derive(Clone, Copy)]
enum Shape {
/// `UnitAura`'s ten values, the Era signature.
Era,
/// `UnitBuff`'s two.
Buff,
/// `UnitDebuff`'s three.
Debuff,
}
fn returns(lua: &Lua, a: &AuraState) -> mlua::Result<MultiValue> {
let s = |v: &Option<String>| -> mlua::Result<Value> {
Ok(match v {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
})
};
Ok(MultiValue::from_vec(vec![
s(&a.name)?,
s(&a.icon)?,
Value::Integer(i64::from(a.count)),
s(&a.debuff_type)?,
Value::Number(a.duration),
Value::Number(a.expiration_time),
Value::Nil, // source: the 1.12 wire carries no aura caster
Value::Nil, // isStealable: Spellsteal is TBC
Value::Nil, // nameplateShowPersonal
Value::Integer(i64::from(a.spell_id)),
]))
}
/// The list of the unit `token` names, through the resolver (`0x515970`, which `UnitBuff` calls at
/// `0x519542`): the player's cache list for the player, else that guid's; `None` for nobody or a
/// unit with no list. A token the resolver does not recognise raises `Unknown unit name`.
@@ -161,14 +102,13 @@ pub(crate) fn auras_of<'m>(model: &'m Model, token: &str) -> mlua::Result<Option
}))
}
/// The `index`-th (1-based) aura of `token` passing `filter`, in pushed order; out of range is a
/// bare nil, the loop terminator.
/// The `index`-th (1-based) buff (`helpful`) or debuff of `token`, in pushed order; out of range
/// answers nothing, the loop terminator.
fn nth_aura(
lua: &Lua,
token: &Option<String>,
index: i64,
filter: &Filter,
shape: Shape,
helpful: bool,
) -> mlua::Result<MultiValue> {
// The token resolves before the index is read (`0x519542`, then `0x51957a`), so a bad token
// raises at any index.
@@ -179,7 +119,7 @@ fn nth_aura(
.filter(|_| index >= 1)
.and_then(|list| {
list.iter()
.filter(|a| filter.matches(a))
.filter(|a| a.helpful == helpful)
.nth((index - 1) as usize)
.cloned()
}),
@@ -187,11 +127,7 @@ fn nth_aura(
}
};
match hit {
Some(a) => match shape {
Shape::Era => returns(lua, &a),
Shape::Buff => returns_1121(lua, &a, false),
Shape::Debuff => returns_1121(lua, &a, true),
},
Some(a) => returns(lua, &a, !helpful),
None => Ok(MultiValue::new()),
}
}
@@ -236,6 +172,23 @@ impl super::UiScript {
self.model_mut().tracking = tracking;
}
/// The spell ids of the buffs (`helpful`) or debuffs of the unit `token` names, in the order
/// `UnitBuff`/`UnitDebuff` enumerate them; empty for nobody and for a token the resolver
/// refuses. No 1.12 verb answers an aura's spell id, so this is the host's read.
pub fn aura_spell_ids(&self, token: &str, helpful: bool) -> Vec<u32> {
let model = self.model_ref();
auras_of(&model, token)
.ok()
.flatten()
.map(|list| {
list.iter()
.filter(|a| a.helpful == helpful)
.map(|a| a.spell_id)
.collect()
})
.unwrap_or_default()
}
/// Drain the queued cancels, one `CMSG_CANCEL_AURA` per spell id: the server cancels by spell,
/// never by slot.
pub fn take_cancel_aura_requests(&mut self) -> Vec<u32> {
@@ -247,23 +200,6 @@ impl super::UiScript {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// UnitAura(unit, index [, filter]), filter defaulting to HELPFUL, with the Era tuple: not a
// 1.12 verb.
g.set(
"UnitAura",
lua.create_function(
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
nth_aura(
lua,
&token,
index,
&Filter::parse(filter.as_deref()),
Shape::Era,
)
},
)?,
)?;
// UnitBuff(unit, index [, raidFilter]) and UnitDebuff: the verb fixes the sign, `0x519500`
// reading aura slots 0..31 and `0x5198f0` slots 32..47. A non-zero `raidFilter` keeps only
// buffs the player can cast (`0x4b3870`) or debuffs they can dispel (`0x4b3920`); it is
@@ -273,13 +209,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"UnitBuff",
lua.create_function(
|lua, (token, index, _raid_filter): (Option<String>, i64, Option<Value>)| {
nth_aura(
lua,
&token,
index,
&Filter::parse(Some("HELPFUL")),
Shape::Buff,
)
nth_aura(lua, &token, index, true)
},
)?,
)?;
@@ -287,48 +217,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"UnitDebuff",
lua.create_function(
|lua, (token, index, _raid_filter): (Option<String>, i64, Option<Value>)| {
nth_aura(
lua,
&token,
index,
&Filter::parse(Some("HARMFUL")),
Shape::Debuff,
)
},
)?,
)?;
// CancelUnitBuff(unit, index [, filter]): not a 1.12 verb, the Era name for the reference's
// `CancelPlayerBuff`. It queues the aura's spell id, what `CMSG_CANCEL_AURA` carries; an aura
// the gate refuses is a silent no-op, as in the reference.
g.set(
"CancelUnitBuff",
lua.create_function(
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
let spec = match filter {
Some(f) => format!("HELPFUL|{f}"),
None => "HELPFUL".to_string(),
};
let f = Filter::parse(Some(&spec));
let hit = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match token.as_deref() {
Some(t) => auras_of(&model, t)?
.filter(|_| index >= 1)
.and_then(|list| {
list.iter()
.filter(|a| f.matches(a))
.nth((index - 1) as usize)
.cloned()
}),
None => None,
}
};
if let Some(a) = hit.filter(cancel_authorized) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.cancel_aura_requests.push(a.spell_id);
}
Ok(())
nth_aura(lua, &token, index, false)
},
)?,
)?;
@@ -397,7 +286,7 @@ mod tests {
}
#[test]
fn unit_aura_enumerates_the_pushed_order_not_the_spell_id_order() {
fn unit_buff_enumerates_the_pushed_order_not_the_spell_id_order() {
let mut s = script();
s.set_player_auras(vec![
aura(2457, "Battle Stance", true, true),
@@ -405,9 +294,9 @@ mod tests {
aura(589, "Shadow Word: Pain", false, false),
]);
assert_eq!(
s.eval::<String>(r#"return (UnitAura("player", 1))"#)
s.eval::<String>(r#"return (UnitBuff("player", 1))"#)
.unwrap(),
"Battle Stance"
"Interface\\Icons\\Spell_2457"
);
// `UnitBuff` and `UnitDebuff` return the icon first, the 1.12 shape.
assert_eq!(
@@ -428,78 +317,17 @@ mod tests {
.eval::<bool>(r#"return UnitDebuff("player", 2) == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitAura("target", 1) == nil"#)
.eval::<bool>(r#"return UnitBuff("target", 1) == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitAura("player", 0) == nil"#)
.unwrap());
}
#[test]
fn unit_aura_defaults_to_helpful_and_honours_the_cancelable_tokens() {
let mut s = script();
s.set_player_auras(vec![
aura(2457, "Battle Stance", true, true),
aura(9999, "Sealed", true, false), // helpful, not cancelable
aura(589, "Pain", false, false),
]);
// No filter means HELPFUL.
assert_eq!(
s.eval::<i64>(
r#"local n = 0 for i=1,10 do if UnitAura("player", i) then n = n + 1 end end return n"#
)
.unwrap(),
2
);
assert_eq!(
s.eval::<String>(r#"return (UnitAura("player", 1, "HARMFUL"))"#)
.unwrap(),
"Pain"
);
assert_eq!(
s.eval::<String>(r#"return (UnitAura("player", 1, "HELPFUL|CANCELABLE"))"#)
.unwrap(),
"Battle Stance"
);
assert_eq!(
s.eval::<String>(r#"return (UnitAura("player", 1, "HELPFUL|NOT_CANCELABLE"))"#)
.unwrap(),
"Sealed"
);
// A bare CANCELABLE keeps the helpful default.
assert_eq!(
s.eval::<String>(r#"return (UnitAura("player", 1, "CANCELABLE"))"#)
.unwrap(),
"Battle Stance"
s.arity(r#"UnitBuff("player", 0)"#).unwrap(),
0,
"a miss answers nothing"
);
}
#[test]
fn unit_aura_returns_the_era_tuple_with_the_unknowable_fields_nil() {
let mut s = script();
let mut a = aura(589, "Shadow Word: Pain", false, false);
a.count = 3;
a.debuff_type = Some("Magic".into());
a.duration = 18.0;
a.expiration_time = 1042.5;
s.set_unit_auras(TARGET, Some(vec![a]));
let (name, icon, count, dtype, dur, expiry, spell) = s
.eval::<(String, String, i64, String, f64, f64, i64)>(
r#"local n, i, c, d, du, e, src, st, np, sid = UnitAura("target", 1, "HARMFUL")
assert(src == nil and st == nil and np == nil, "unknowable fields must be nil")
return n, i, c, d, du, e, sid"#,
)
.unwrap();
assert_eq!(name, "Shadow Word: Pain");
assert_eq!(icon, "Interface\\Icons\\Spell_589");
assert_eq!((count, dtype.as_str()), (3, "Magic"));
assert_eq!((dur, expiry), (18.0, 1042.5));
assert_eq!(spell, 589);
}
#[test]
fn unit_buff_and_unit_debuff_return_the_1121_tuple_not_the_era_one() {
fn unit_buff_and_unit_debuff_return_the_1121_tuple() {
let mut s = script();
let mut buff = aura(1126, "Mark of the Wild", true, true);
buff.count = 1;
@@ -545,24 +373,6 @@ mod tests {
);
}
#[test]
fn cancel_unit_buff_queues_the_spell_id_and_refuses_a_non_cancelable_aura() {
let mut s = script();
s.set_player_auras(vec![
aura(2457, "Battle Stance", true, true),
aura(9999, "Sealed", true, false),
aura(589, "Pain", false, false),
]);
assert!(s.take_cancel_aura_requests().is_empty());
// Only the cancelable buff queues, by spell id; the others are silent no-ops.
s.eval::<()>(r#"CancelUnitBuff("player", 1)"#).unwrap();
s.eval::<()>(r#"CancelUnitBuff("player", 2)"#).unwrap();
s.eval::<()>(r#"CancelUnitBuff("player", 9)"#).unwrap();
assert_eq!(s.take_cancel_aura_requests(), vec![2457]);
assert!(s.take_cancel_aura_requests().is_empty());
}
#[test]
fn tracking_bindings_read_the_pushed_state_and_cancel_by_spell_id() {
let mut s = script();
@@ -604,11 +414,11 @@ mod tests {
let mut s = script();
s.set_player_auras(vec![aura(2457, "Stance", true, true)]);
assert!(s
.eval::<bool>(r#"return UnitAura("player", 1) ~= nil"#)
.eval::<bool>(r#"return UnitBuff("player", 1) ~= nil"#)
.unwrap());
s.set_player_auras(Vec::new());
assert!(s
.eval::<bool>(r#"return UnitAura("player", 1) == nil"#)
.eval::<bool>(r#"return UnitBuff("player", 1) == nil"#)
.unwrap());
}
@@ -617,7 +427,8 @@ mod tests {
const P1_TARGET: u64 = 0xF130_0000_0000_0003;
/// We target `TARGET`, which targets party1, who targets `P1_TARGET`, which targets us; the mouse
/// is over `MOB`. Each unit's list holds a buff and a debuff named for it.
/// is over `MOB`. Each unit's list holds a buff and a debuff whose spell ids, and so icons, are
/// its own: `base + 1` and `base + 2`.
fn group_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_unit_guids(&UnitGuids {
@@ -641,23 +452,26 @@ mod tests {
aura(1126, "Mark of the Wild", true, true),
aura(11976, "Strike", false, false),
]);
for (guid, name) in [
(TARGET, "on the target"),
(MOB, "on the mouseover"),
(P1, "on party1"),
(P1_TARGET, "on party1's target"),
for (guid, base, name) in [
(TARGET, 100, "on the target"),
(MOB, 200, "on the mouseover"),
(P1, 300, "on party1"),
(P1_TARGET, 400, "on party1's target"),
] {
s.set_unit_auras(
guid,
Some(vec![aura(1, name, true, true), aura(2, name, false, false)]),
Some(vec![
aura(base + 1, name, true, true),
aura(base + 2, name, false, false),
]),
);
}
s
}
/// `UnitAura`'s name, the list's identity: the bindings' own tuples lead with the icon.
fn named(s: &UiScript, call: &str) -> Option<String> {
s.eval::<Option<String>>(&format!("local a, b, c = {call} return b and a"))
/// The icon a read answers, the list's identity.
fn icon_of(s: &UiScript, call: &str) -> Option<String> {
s.eval::<Option<String>>(&format!("return ({call})"))
.unwrap()
}
@@ -666,41 +480,28 @@ mod tests {
#[test]
fn a_token_reads_the_list_of_the_unit_it_names() {
let s = group_script();
let icon = |name: &str| {
s.eval::<Option<String>>(&format!("return ({name})"))
.unwrap()
};
assert_eq!(
icon(r#"UnitDebuff("mouseover", 1)"#).as_deref(),
Some("Interface\\Icons\\Spell_2")
);
for (token, name) in [
("mouseover", "on the mouseover"),
("party1target", "on party1's target"),
("targettarget", "on party1"),
("TargetTarget", "on party1"),
("targettargettarget", "on party1's target"),
("party1", "on party1"),
("PARTY1", "on party1"),
for (token, base) in [
("mouseover", 200),
("party1target", 400),
("targettarget", 300),
("TargetTarget", 300),
("targettargettarget", 400),
("party1", 300),
("PARTY1", 300),
] {
assert_eq!(
named(&s, &format!(r#"UnitAura("{token}", 1, "HARMFUL")"#)).as_deref(),
Some(name),
icon_of(&s, &format!(r#"UnitBuff("{token}", 1)"#)),
Some(format!("Interface\\Icons\\Spell_{}", base + 1)),
"{token}"
);
assert_eq!(
named(&s, &format!(r#"UnitAura("{token}", 1)"#)).as_deref(),
Some(name),
icon_of(&s, &format!(r#"UnitDebuff("{token}", 1)"#)),
Some(format!("Interface\\Icons\\Spell_{}", base + 2)),
"{token}"
);
}
// `UnitBuff("party1target", 1)` and `UnitDebuff("mouseover", 1)`, by their icons.
assert!(s
.eval::<bool>(
r#"return UnitBuff("party1target", 1) == "Interface\\Icons\\Spell_1"
and UnitDebuff("mouseover", 1) == "Interface\\Icons\\Spell_2"
and UnitBuff("party1target", 2) == nil"#
)
.eval::<bool>(r#"return UnitBuff("party1target", 2) == nil"#)
.unwrap());
}
@@ -717,8 +518,8 @@ mod tests {
"party1TARGETtarget",
] {
assert_eq!(
named(&s, &format!(r#"UnitAura("{token}", 2)"#)).as_deref(),
Some("Mark of the Wild"),
icon_of(&s, &format!(r#"UnitBuff("{token}", 2)"#)).as_deref(),
Some("Interface\\Icons\\Spell_1126"),
"{token}"
);
}
@@ -754,9 +555,8 @@ mod tests {
for call in [
r#"UnitBuff("bogus", 1)"#,
r#"UnitDebuff("focus", 1)"#,
r#"UnitAura("npctarget", 1)"#,
r#"UnitDebuff("npctarget", 1)"#,
r#"UnitBuff("bogus", 0)"#,
r#"CancelUnitBuff("bogus", 1)"#,
] {
let err = s.eval::<()>(call).unwrap_err().to_string();
assert!(err.contains("Unknown unit name: "), "{call}: {err}");
@@ -6,7 +6,7 @@ use mlua::{Lua, MultiValue, Value};
use super::{cancel_authorized, AuraState, Model};
/// `GetPlayerBuff`'s filter mask, not `UnitAura`'s parser: no filter argument means
/// `GetPlayerBuff`'s filter mask: no filter argument means
/// `HELPFUL|HARMFUL` (`0x4e4618`), and a filter string starts the mask at zero (`0x4e4639`), so a
/// bare `"CANCELABLE"` matches nothing.
#[derive(Clone, Copy)]
@@ -172,7 +172,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// CancelPlayerBuff(buffIndex) (`0x4e49a0`): no return values on any path. It queues the spell
// id, the one `u32` of `CMSG_CANCEL_AURA` (0x136, `Spell_C::CancelAura` `0x6e7040`), on the
// queue `CancelUnitBuff` uses; a refused or absent aura is a silent no-op.
// queue `CancelTrackingBuff` uses; a refused or absent aura is a silent no-op.
g.set(
"CancelPlayerBuff",
lua.create_function(|lua, index: Value| {
@@ -226,11 +226,6 @@ mod tests {
fn with_player_cache() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_player_auras(player_cache());
// `CancelUnitBuff("player", …)` resolves the token to the player's guid.
s.set_unit_guids(&crate::script::UnitGuids {
player: 1,
..Default::default()
});
s
}
@@ -243,7 +238,7 @@ mod tests {
s.arity("GetPlayerBuff(0)").unwrap(),
2,
"1.12 pushes exactly two values (`mov eax,0x2` at 0x4e471e) — a hit must not be a tuple \
of aura fields, which is the modern UnitAura shape"
of aura fields, which is a later client's aura shape"
);
assert_eq!(
s.arity("GetPlayerBuff(99)").unwrap(),
@@ -527,7 +522,7 @@ mod tests {
}
#[test]
fn cancel_player_buff_shares_the_gate_and_the_queue_with_cancel_unit_buff() {
fn cancel_player_buff_gates_on_the_aura_and_queues_its_spell_id() {
let mut s = with_player_cache();
assert!(s.take_cancel_aura_requests().is_empty());
@@ -541,11 +536,6 @@ mod tests {
}
assert!(s.take_cancel_aura_requests().is_empty());
// Both names reach one queue: the first helpful aura is cache position 0.
s.eval::<()>(r#"CancelUnitBuff("player", 1)"#).unwrap();
s.eval::<()>("CancelPlayerBuff(0)").unwrap();
assert_eq!(s.take_cancel_aura_requests(), vec![1126, 1126]);
// The channeled arm (`AttributesEx & 0x4`, `0x4e4a10`) cancels a negative aura.
let mut channeled = aura(689, "Drain Life", false, false);
channeled.channeled = true;
+10 -4
View File
@@ -186,8 +186,11 @@ mod tests {
"Interface\\Icons\\INV_Misc_Bag_08"
);
assert_eq!(
s.eval::<i64>("return GetInventoryItemID(\"player\", 64)")
.unwrap(),
s.eval::<i64>(
"local _, _, id = string.find(GetInventoryItemLink(\"player\", 64), \"item:(%d+)\") \
return tonumber(id)"
)
.unwrap(),
4500
);
// Slot 65 is empty and 70 is past the band; neither falls through to the doll.
@@ -255,8 +258,11 @@ mod tests {
7
);
assert_eq!(
s.eval::<i64>("return GetInventoryItemID(\"player\", 42)")
.unwrap(),
s.eval::<i64>(
"local _, _, id = string.find(GetInventoryItemLink(\"player\", 42), \"item:(%d+)\") \
return tonumber(id)"
)
.unwrap(),
4496
);
// An empty vault slot answers nil rather than falling through to `inventory_slots`.
+48 -35
View File
@@ -349,9 +349,16 @@ fn free_outgoing(lua: &Lua, outgoing: Option<RegionHandle>, incoming: RegionHand
/// `Set<State>Texture(texture | "path" | nil)`, forked on the argument's type (`0x781970`). The
/// object and nil legs never touch the slot's own region, so they run before [`ensure_slot`]
/// would create one. The `(r, g, b [, a])` form is not 1.12's, which reads a number as a path
/// (`0x781b23`).
fn set_slot_texture(lua: &Lua, this: &Table, slot: Slot, args: &MultiValue) -> mlua::Result<()> {
/// would create one. There is no colour form: a number takes the path leg through `lua_isstring`
/// (`0x781b23`) and loads as its own decimal name, so `(1, 1, 1, 0)` is the file `"1"`; anything
/// else, no argument included, raises `Usage: <name>:<usage>` (`0x87a1b4`).
fn set_slot_texture(
lua: &Lua,
this: &Table,
slot: Slot,
usage: &str,
args: &MultiValue,
) -> mlua::Result<()> {
match args.front() {
// A Texture object is itself installed into the slot (`0x781b0b` → `0x778fd0`).
Some(Value::Table(t)) => {
@@ -386,33 +393,32 @@ fn set_slot_texture(lua: &Lua, this: &Table, slot: Slot, args: &MultiValue) -> m
_ => {}
}
// `lua_tostring` (`0x781b33`): a number converts as `%.14g`, as the stack's coercion does.
let path = match args.front() {
Some(v @ (Value::String(_) | Value::Number(_) | Value::Integer(_))) => {
lua.coerce_string(v.clone())?.map(|s| s.to_string_lossy())
}
_ => None,
};
let Some(path) = path else {
let who = {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string())
};
return Err(mlua::Error::runtime(format!("Usage: {who}:{usage}")));
};
let id = ensure_slot(lua, this, slot)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rh = *model.id_to_region.get(&id).expect("slot region id");
let data = model.region_data.entry(rh).or_default();
match args.front() {
// `""` clears the texture, which `QuestLogFrame.lua:165` relies on.
Some(Value::String(s)) if s.to_str()?.is_empty() => {
data.texture = None;
data.fill = None;
}
Some(Value::String(s)) => {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
// The colour form is a solid texture, not a tint: it and the path clear each other.
Some(v @ (Value::Number(_) | Value::Integer(_))) => {
let arg = |i: usize| args.get(i).map(as_f32);
data.fill = Some([
as_f32(v),
arg(1).unwrap_or(0.0),
arg(2).unwrap_or(0.0),
arg(3).unwrap_or(1.0),
]);
data.texture = None;
}
_ => {}
}
// `""` clears the texture, which `QuestLogFrame.lua:165` relies on.
data.texture = (!path.is_empty()).then_some(path);
data.fill = None;
Ok(())
}
@@ -431,10 +437,18 @@ fn get_slot_texture(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<Value>
/// Register one `Set<X>Texture`/`Get<X>Texture` pair on `m`.
fn texture_pair(lua: &Lua, m: &Table, name: &str, slot: Slot) -> mlua::Result<()> {
let method = format!("Set{name}Texture");
// The highlight setter's own line names its blend argument (`0x87a240`), unclosed as in the
// reference; the others share `0x87a1b4`.
let usage = if slot == Slot::Highlight {
format!("{method}(texture or \"texture\" or nil [, \"blendmode\")")
} else {
format!("{method}(texture or \"texture\" or nil)")
};
m.set(
format!("Set{name}Texture"),
method.as_str(),
lua.create_function(move |lua, (this, args): (Table, MultiValue)| {
set_slot_texture(lua, &this, slot, &args)
set_slot_texture(lua, &this, slot, &usage, &args)
})?,
)?;
m.set(
@@ -922,11 +936,11 @@ pub(super) fn wants_press_visual(model: &Model, h: FrameHandle, button: &str) ->
}
/// The click shared by the input path and `Click()`: a disabled Button fires nothing, a CheckButton
/// toggles before `OnClick` runs (`0x785550`), then `OnClick` gets the button name and `down`, true
/// only for a click a `…ButtonDown` registration fired. The second argument is not 1.12's, whose
/// `OnClick` gets the name alone (`0x779540`). `scripted` marks a Lua `Click()` (`0x7826c0` passes
/// 1, the mouse `0x779280`/`0x7793a4` pass 0): a LootButton then does nothing at all, not even its
/// `OnClick` (`0x4c182b`); every other kind ignores it.
/// toggles before `OnClick` runs (`0x785550`), then `OnClick` gets the button name alone as `arg1`
/// (`0x779540` fires it with the format `"%s"`); `down`, true only for a click a `…ButtonDown`
/// registration fired, reaches the nameplate's select alone. `scripted` marks a Lua `Click()`
/// (`0x7826c0` passes 1, the mouse `0x779280`/`0x7793a4` pass 0): a LootButton then does nothing at
/// all, not even its `OnClick` (`0x4c182b`); every other kind ignores it.
pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripted: bool) {
let mut take_loot = None;
let fire = {
@@ -981,8 +995,7 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
Ok(s) => Value::String(s),
Err(_) => return,
};
if let Err(e) = event::fire_widget_handler(lua, id, "OnClick", vec![btn, Value::Boolean(down)])
{
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());
+17 -26
View File
@@ -164,7 +164,7 @@ impl Default for UnitCombatStats {
/// [`super::container::ContainerSlot`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct InvSlotView {
/// The item's template entry (`GetInventoryItemID`).
/// The item's template entry, the id inside `GetInventoryItemLink`'s link.
pub item_id: u32,
/// Whether the item may go on an action bar, by the same filter as a bag slot's.
pub bar_placeable: bool,
@@ -745,17 +745,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetInventoryItemID(unit, slot) → itemId or nil.
g.set(
"GetInventoryItemID",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
match player_inv_slot(lua, &token, slot) {
Some(v) if v.item_id != 0 => Ok(Value::Integer(i64::from(v.item_id))),
_ => Ok(Value::Nil),
}
})?,
)?;
// GetInventoryItemTexture(unit, slot) → icon path, or nil until the item's template arrives.
g.set(
"GetInventoryItemTexture",
@@ -1558,6 +1547,14 @@ mod tests {
);
}
/// The item id in `GetInventoryItemLink("player", slot)`'s link, 1.12's only road to it.
fn link_id(slot: &str) -> String {
format!(
r#"local _, _, id = string.find(GetInventoryItemLink("player", {slot}), "item:(%d+)")
return tonumber(id)"#
)
}
#[test]
fn inventory_item_bindings_serve_occupied_empty_and_absent_shapes() {
let mut s = UiScript::new().unwrap();
@@ -1569,6 +1566,7 @@ mod tests {
count: 1,
quality: 2,
name: Some("Brawler's Harness".into()),
link: Some("|cff1eff00|Hitem:2263:0:0:0|h[Brawler's Harness]|h|r".into()),
..Default::default()
});
slots[0] = Some(InvSlotView {
@@ -1577,15 +1575,12 @@ mod tests {
count: 200,
quality: 1,
name: Some("Rough Arrow".into()),
link: Some("|cffffffff|Hitem:2512:0:0:0|h[Rough Arrow]|h|r".into()),
..Default::default()
});
s.set_inventory_slots(slots);
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemID("player", 1)"#)
.unwrap(),
2263
);
assert_eq!(s.eval::<i64>(&link_id("1")).unwrap(), 2263);
assert_eq!(
s.eval::<String>(r#"return GetInventoryItemTexture("player", 1)"#)
.unwrap(),
@@ -1602,11 +1597,7 @@ mod tests {
2
);
// The ammo slot (0) reads through the same family.
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemID("player", 0)"#)
.unwrap(),
2512
);
assert_eq!(s.eval::<i64>(&link_id("0")).unwrap(), 2512);
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemCount("player", 0)"#)
.unwrap(),
@@ -1614,7 +1605,7 @@ mod tests {
);
// An empty slot: nil id, texture and quality, and count 1 (`0x4c8797`).
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("player", 5) == nil"#)
.eval::<bool>(r#"return GetInventoryItemLink("player", 5) == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return GetInventoryItemTexture("player", 5) == nil"#)
@@ -1629,7 +1620,7 @@ mod tests {
.unwrap());
// A token with no items behind it: the empty shape.
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("target", 1) == nil"#)
.eval::<bool>(r#"return GetInventoryItemLink("target", 1) == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemCount("target", 1)"#)
@@ -1639,10 +1630,10 @@ mod tests {
);
// Out-of-range slots: the empty shape, no error.
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("player", 25) == nil"#)
.eval::<bool>(r#"return GetInventoryItemLink("player", 25) == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("player", -1) == nil"#)
.eval::<bool>(r#"return GetInventoryItemLink("player", -1) == nil"#)
.unwrap());
}
+33 -63
View File
@@ -264,9 +264,7 @@ pub enum UiCursorMode {
/// displaced item in the source slot, never on the cursor (no `SetCursorItem`, `0x5e0c40`);
/// - a split carry places onto an empty or same-item slot, and stays held over another item;
/// - a spell or action payload stays held.
///
/// Returns whether the caller should repaint.
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool {
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
// An armed gift wrap completes here, before the cursor is read. `PickupContainerItem` is the
// only caller of the wrap sender `0x5edfc0`, so an equipped item cannot be wrapped.
if let Some(wrap) = model.pending_wrap {
@@ -277,7 +275,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
.and_then(|c| c.slots.get(&slot))
.is_some_and(|s| s.item_id != 0)
{
return false;
return;
}
// A vanished paper also bails, the wrap armed and the paper locked (`0x5edfef`).
if !model
@@ -286,7 +284,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
.and_then(|c| c.slots.get(&wrap.slot))
.is_some_and(|s| s.item_id != 0)
{
return false;
return;
}
// No eligibility test: the server refuses with an `ERR_CANT_WRAP_*` reason
// (`SMSG_INVENTORY_CHANGE_FAILURE` 43-48), and a local gate would eat that error line.
@@ -297,7 +295,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
cursor::queue_lock_changed(model, wrap.bag, wrap.slot);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
return true;
return;
}
// Only a held item (`0x4f9c38`) or vendor row (`0x4f9c43`) takes the click first. Past them
// an empty slot does nothing (`0x4f9c4e`), an armed item-targeting spell binds the item
@@ -318,7 +316,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
} else if occupied {
model.container_repairs.push((bag, slot));
}
return false;
return;
}
match model.cursor.take() {
None => {
@@ -338,20 +336,15 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
});
match picked {
Some(item) => {
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
true
}
None => false,
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
}
}
Some(CursorPayload::Item(held)) if held.bag == bag && held.slot == slot => {
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, held.bag, held.slot);
true
}
Some(CursorPayload::Item(held)) => {
// Cloned so the moves below can borrow `model`; an unresolved slot reads as empty.
@@ -365,7 +358,6 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
// A split carry cannot swap with a different item: it stays held.
(Some(_), Some(d)) if d.item_id != held.item_id => {
model.cursor = Some(CursorPayload::Item(held));
false
}
// Anything else queues the move and clears: a split, a merge the server tops up,
// or a swap that lands the displaced item in the source slot.
@@ -373,7 +365,6 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
queue_move(model, &held, bag, slot, count);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, held.bag, held.slot);
true
}
}
}
@@ -392,7 +383,6 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
model.merchant_slot_buys.push((bag, slot, entry));
}
cursor::queue_cursor_update(model);
true
}
Some(
other @ (CursorPayload::Spell(_)
@@ -405,7 +395,6 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
| CursorPayload::Money(_)),
) => {
model.cursor = Some(other);
false
}
}
}
@@ -593,23 +582,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaGetContainerItemID(bag, slot): the item id behind a slot. Not a 1.12 verb, where the
// id is parsed from `GetContainerItemLink`; the `Benilla` prefix keeps it off the 1.12 surface.
lua.globals().set(
"BenillaGetContainerItemID",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// nil, not 0, while the template is in flight: a caller would index a table with 0.
Ok(model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.map(|s| s.item_id)
.filter(|&id| id != 0)
.map(i64::from))
})?,
)?;
lua.globals().set(
"UseContainerItem",
lua.create_function(|lua, (bag, slot, _rest): (i64, u32, mlua::MultiValue)| {
@@ -636,13 +608,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// PickupContainerItem(bag, slot) (`0x4f9b30`): pick up, or place and swap. Its boolean return,
// whether to repaint, is not 1.12's, whose binding returns nothing.
// PickupContainerItem(bag, slot) (`0x4f9b30`): pick up, or place and swap; no return value on
// any path.
lua.globals().set(
"PickupContainerItem",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_container_item(&mut model, bag, slot))
pickup_container_item(&mut model, bag, slot);
Ok(())
})?,
)?;
@@ -853,13 +826,13 @@ mod tests {
assert_eq!(icon, "Interface\\Icons\\INV_Misc_Food_16");
assert_eq!((count, quality), (5, 1));
assert_eq!(
s.eval::<i64>("return BenillaGetContainerItemID(0, 1)")
.unwrap(),
s.eval::<i64>(
"local _, _, id = string.find(GetContainerItemLink(0, 1), 'item:(%d+)') \
return tonumber(id)"
)
.unwrap(),
117
);
assert!(s
.eval::<bool>("return GetContainerItemLink(0, 1) ~= nil")
.unwrap());
// readable: the letter in 4, not the jerky in 1.
assert!(!s
.eval::<bool>("local _, _, _, _, readable = GetContainerItemInfo(0, 1) return readable")
@@ -876,7 +849,7 @@ mod tests {
)
.unwrap());
assert!(s
.eval::<bool>("return BenillaGetContainerItemID(0, 3) == nil")
.eval::<bool>("return GetContainerItemLink(0, 3) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetContainerItemInfo(0, 2) == nil")
@@ -933,7 +906,7 @@ mod tests {
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
s.run("PickupContainerItem(0, 1)").unwrap();
let held = s.cursor_item().expect("cursor holds the picked item");
assert_eq!((held.bag, held.slot, held.item_id), (0, 1, 117));
assert_eq!(
@@ -946,12 +919,9 @@ mod tests {
.unwrap());
assert!(s.take_container_moves().is_empty());
let (kind, id) = s
.eval::<(String, i64)>("local k, id = GetCursorInfo() return k, id")
.unwrap();
assert_eq!((kind.as_str(), id), ("item", 117));
assert_eq!(s.cursor_item().map(|c| c.item_id), Some(117));
assert!(s.eval::<bool>("return PickupContainerItem(0, 5)").unwrap());
s.run("PickupContainerItem(0, 5)").unwrap();
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert_eq!(
@@ -998,10 +968,10 @@ mod tests {
);
s.set_container(0, Some(state));
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.cursor_item().unwrap().item_id, 117);
assert!(s.eval::<bool>("return PickupContainerItem(0, 5)").unwrap());
s.run("PickupContainerItem(0, 5)").unwrap();
assert!(
s.cursor_item().is_none(),
"a swap clears the cursor — the displaced item never hops on"
@@ -1038,7 +1008,7 @@ mod tests {
s.set_container(0, Some(state));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return PickupContainerItem(0, 5)").unwrap());
s.run("PickupContainerItem(0, 5)").unwrap();
assert!(
s.cursor_item().is_none(),
"same-item merge clears the cursor"
@@ -1062,7 +1032,7 @@ mod tests {
state.slots.get_mut(&1).unwrap().locked = true;
s.set_container(0, Some(state));
assert!(!s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_none());
}
@@ -1099,7 +1069,7 @@ mod tests {
(0, 1, 117, Some(3))
);
assert!(s.eval::<bool>("return PickupContainerItem(0, 2)").unwrap());
s.run("PickupContainerItem(0, 2)").unwrap();
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
@@ -1113,12 +1083,12 @@ mod tests {
);
s.run("SplitContainerItem(0, 1, 3)").unwrap();
assert!(!s.eval::<bool>("return PickupContainerItem(0, 9)").unwrap());
s.run("PickupContainerItem(0, 9)").unwrap();
let held = s.cursor_item().expect("kept — can't swap a partial stack");
assert_eq!(held.count, Some(3));
assert!(s.take_container_moves().is_empty());
assert!(s.eval::<bool>("return PickupContainerItem(0, 7)").unwrap());
s.run("PickupContainerItem(0, 7)").unwrap();
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
@@ -1152,9 +1122,9 @@ mod tests {
fn pickup_same_slot_cancels_no_move() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some());
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_none());
assert!(s.take_container_moves().is_empty());
}
@@ -1164,9 +1134,9 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
// Slot 2 is empty and slot 3 unresolved: neither can be picked up.
assert!(!s.eval::<bool>("return PickupContainerItem(0, 2)").unwrap());
s.run("PickupContainerItem(0, 2)").unwrap();
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return PickupContainerItem(0, 3)").unwrap());
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(s.cursor_item().is_none());
}
+2 -83
View File
@@ -3,7 +3,7 @@
//! and the spellbook all transition it through [`queue_cursor_update`] and [`queue_lock_changed`],
//! so sounds, `CURSOR_UPDATE`, grid events and lock display fire from one place.
use mlua::{Lua, Value};
use mlua::Lua;
use super::{Model, ScriptValue};
@@ -455,64 +455,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
}
// `GetCursorInfo()` is not a 1.12 verb. It answers ("item", id, link), ("spell", book slot,
// book, spell id), ("action", slot), ("macro", index) or ("petaction", slot), padded with nil
// to four values; any other payload, or none, is four nils.
g.set(
"GetCursorInfo",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let str_or_nil = |lua: &Lua, s: &Option<String>| -> mlua::Result<Value> {
match s {
Some(s) => Ok(Value::String(lua.create_string(s)?)),
None => Ok(Value::Nil),
}
};
match &model.cursor {
Some(CursorPayload::Item(c)) => Ok((
Value::String(lua.create_string("item")?),
Value::Integer(i64::from(c.item_id)),
str_or_nil(lua, &c.link)?,
Value::Nil,
)),
Some(CursorPayload::Spell(s)) => Ok((
Value::String(lua.create_string("spell")?),
Value::Integer(i64::from(s.book_slot)),
Value::String(lua.create_string(&s.book_type)?),
Value::Integer(i64::from(s.spell_id)),
)),
Some(CursorPayload::Action(a)) => Ok((
Value::String(lua.create_string("action")?),
Value::Integer(i64::from(a.src_slot)),
Value::Nil,
Value::Nil,
)),
Some(CursorPayload::Macro(m)) => Ok((
Value::String(lua.create_string("macro")?),
Value::Integer(i64::from(m.index)),
Value::Nil,
Value::Nil,
)),
Some(CursorPayload::PetAction(p)) => Ok((
Value::String(lua.create_string("petaction")?),
Value::Integer(i64::from(p.src_slot)),
Value::Nil,
Value::Nil,
)),
Some(CursorPayload::StablePet(_)) => {
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
}
Some(CursorPayload::Money(_)) => {
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
}
Some(CursorPayload::Merchant(_)) => {
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
}
None => Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil)),
}
})?,
)?;
g.set(
"CursorHasItem",
lua.create_function(|lua, ()| {
@@ -652,12 +594,9 @@ mod tests {
}
#[test]
fn get_cursor_info_and_has_checks_per_arm() {
fn the_has_checks_answer_per_arm() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("local k = GetCursorInfo() return k == nil")
.unwrap());
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
@@ -672,13 +611,6 @@ mod tests {
quality: Some(3),
equip_slots: Vec::new(),
}));
let (kind, id, link) = s
.eval::<(String, i64, String)>("local k, id, link = GetCursorInfo() return k, id, link")
.unwrap();
assert_eq!(
(kind.as_str(), id, link.as_str()),
("item", 117, "|Hitem:117|h[Tough Jerky]|h")
);
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
@@ -689,15 +621,6 @@ mod tests {
spell_id: 133,
texture: None,
}));
let (kind, slot, book, spell_id) = s
.eval::<(String, i64, String, i64)>(
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id",
)
.unwrap();
assert_eq!(
(kind.as_str(), slot, book.as_str(), spell_id),
("spell", 3, "spell", 133)
);
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(s.eval::<bool>("return CursorHasSpell()").unwrap());
@@ -707,10 +630,6 @@ mod tests {
action: 133,
texture: None,
}));
let (kind, slot) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!((kind.as_str(), slot), ("action", 12));
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
}
+5 -8
View File
@@ -149,10 +149,11 @@ mod tests {
!s.eval::<bool>("return HasAction(1)").unwrap(),
"removed from the engine's optimistic mirror"
);
let (kind, id) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!((kind.as_str(), id), ("action", 1));
assert!(
matches!(s.cursor_payload(), Some(CursorPayload::Action(c)) if c.src_slot == 1),
"{:?}",
s.cursor_payload()
);
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
}
@@ -199,10 +200,6 @@ mod tests {
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
);
let (kind, src) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!((kind.as_str(), src), ("action", 2));
assert_eq!(
s.cursor_payload(),
Some(CursorPayload::Action(CursorAction {
+2 -2
View File
@@ -369,7 +369,7 @@ mod tests {
s.set_inventory_slots(doll_slots());
s.set_container(0, Some(one_fitting_bag_item()));
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.cursor_item().unwrap().equip_slots, vec![1]);
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
@@ -762,7 +762,7 @@ mod tests {
s.run("ClearCursor()").unwrap();
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(!s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(
s.take_item_picks(),
vec![(EQUIPMENT_BAG, 1), (0, 1)],
+1 -4
View File
@@ -276,10 +276,7 @@ mod tests {
fn the_trade_pair_moves_coins_between_the_cursor_and_the_offer() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
s.set_trade(Some(crate::script::TradeState {
partner_name: Some("Bob".into()),
..Default::default()
}));
s.set_trade(Some(crate::script::TradeState::default()));
s.run("PickupPlayerMoney(300) AddTradeMoney()").unwrap();
assert_eq!(
s.take_trade_money(),
+15 -15
View File
@@ -331,10 +331,10 @@ mod tests {
fn picking_up_a_spell_blanks_its_slot_and_sends() {
let mut s = bar_script();
assert!(s.eval::<bool>("return PickupPetAction(4)").unwrap());
assert_eq!(
s.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap(),
("petaction".to_string(), 4)
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c)) if c.src_slot == 4),
"{:?}",
s.cursor_payload()
);
assert_eq!(
s.take_pet_set_actions(),
@@ -387,11 +387,10 @@ mod tests {
s.take_pet_set_actions(),
vec![vec![(3, CLAW & 0xFFFF_0000)], vec![(4, CLAW)]]
);
assert_eq!(
s.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap(),
("petaction".to_string(), 5),
"Growl was displaced and is now held, addressed as the slot it came from"
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c)) if c.src_slot == 5),
"Growl was displaced and is now held, addressed as the slot it came from: {:?}",
s.cursor_payload()
);
}
@@ -409,7 +408,7 @@ mod tests {
"the relocation pair FIRST, then the write — the binary's own order. And the slot the \
spell was just picked OUT of is the first candidate, so Attack lands there."
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
assert!(s.cursor_payload().is_none());
}
/// The reachable refusal is a passive spell, which the pickup does not gate; its blank
@@ -431,7 +430,7 @@ mod tests {
"the pickup's own blank went through; the drop wrote and sent nothing"
);
assert!(
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
s.cursor_payload().is_none(),
"and the cursor is empty — the reference cleared it before it ever decided"
);
}
@@ -442,7 +441,7 @@ mod tests {
assert!(!s.eval::<bool>("return PickupPetAction(11)").unwrap());
assert!(!s.eval::<bool>("return PickupPetAction(0)").unwrap());
assert!(s.take_pet_set_actions().is_empty());
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
assert!(s.cursor_payload().is_none());
}
/// The third flag, `pickup_allowed`, is false under `UNIT_FLAG_POSSESSED`.
@@ -492,9 +491,10 @@ mod tests {
);
// PlaceAction refuses it outright and leaves it held.
assert!(!s.eval::<bool>("return PlaceAction(1)").unwrap());
assert!(s
.eval::<bool>("local k = GetCursorInfo() return k == 'petaction'")
.unwrap());
assert!(matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::PetAction(_))
));
assert!(
s.take_action_sets().is_empty(),
"and writes nothing to the action bar"
+2 -21
View File
@@ -3,8 +3,8 @@
//!
//! `GetGossipOptions()` returns flat `(text, type)` pairs, `type` the lowercase icon name the app
//! maps from the wire `GOSSIP_ICON`. A coded option is not built: the app drops its select, where
//! the reference opens a password box (`UIParent.lua:563`). `IsGossipOptionCoded(i)` is benilla's
//! own, not a 1.12 global, and no stock file calls it.
//! the reference fires `GOSSIP_ENTER_CODE` and the stock popup (`StaticPopup.lua:1383`) calls
//! `SelectGossipOption(index, code)`.
//!
//! A menu is pushed only once its greeting (`SMSG_NPC_TEXT_UPDATE`) has arrived; until then the
//! VM keeps its last menu, as the reference's frame keeps its last paint: its handler returns on a
@@ -23,8 +23,6 @@ pub struct GossipOptionView {
/// The lowercase icon type (`"gossip"`, `"vendor"`, `"taxi"`, …); `GossipFrame.lua:123` draws
/// `Interface\GossipFrame\<Type>GossipIcon`.
pub icon_type: String,
/// A password-gated option, which the app never selects.
pub coded: bool,
}
/// One quest row of `SMSG_GOSSIP_MESSAGE`; a click sends `CMSG_QUESTGIVER_QUERY_QUEST`. `active`,
@@ -110,18 +108,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
g.set(
"IsGossipOptionCoded",
lua.create_function(|lua, i: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.gossip
.as_ref()
.and_then(|m| i.checked_sub(1).and_then(|n| m.options.get(n)))
.is_some_and(|o| o.coded))
})?,
)?;
// SelectGossipOption(i [, ...]): queue the 1-based position. Further arguments, a code among
// them, are ignored: coded options are not built, so no code is ever sent.
g.set(
@@ -226,12 +212,10 @@ mod tests {
GossipOptionView {
label: "Let me browse your goods.".into(),
icon_type: "vendor".into(),
coded: false,
},
GossipOptionView {
label: "I would like to sign the petition.".into(),
icon_type: "gossip".into(),
coded: true,
},
],
}
@@ -261,9 +245,6 @@ mod tests {
);
assert_eq!(l2, "I would like to sign the petition.");
assert_eq!(t2, "gossip");
assert!(!s.eval::<bool>("return IsGossipOptionCoded(1)").unwrap());
assert!(s.eval::<bool>("return IsGossipOptionCoded(2)").unwrap());
assert!(!s.eval::<bool>("return IsGossipOptionCoded(9)").unwrap()); // out of range
s.run("SelectGossipOption(1)").unwrap();
s.run("SelectGossipOption(2, 'unused-code')").unwrap(); // extra arg ignored
-22
View File
@@ -245,18 +245,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.set_named_registry_value(REG_LOOTBUTTON_METHODS, m)?;
}
// BenillaTakeLootSlot(slot): not a 1.12 verb. It queues the row click's take by 1-based row,
// which has no Lua binding in the reference: only `CLootButton::OnClick 0x4c1820` reaches the
// dispatcher `0x4c2790` with flag 0.
g.set(
"BenillaTakeLootSlot",
lua.create_function(|lua, slot: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.loot_picks.push(slot);
Ok(())
})?,
)?;
// LootSlot(slot): the LOOT_BIND confirmation only (`0x4c2e70`: `luaL_checknumber(1)`,
// `dec eax`, `0x4c2790(slot, 1)`, whose arm at `0x4c27c0` returns unless the slot is the
// pending confirm, `cmp edi, [0x847cec]`). On an ordinary row it does nothing.
@@ -527,16 +515,6 @@ mod tests {
assert!(s.take_loot_picks().is_empty());
}
#[test]
fn take_loot_slot_queues_picks() {
let mut s = UiScript::new().unwrap();
s.set_loot(Some(loot()));
s.run("BenillaTakeLootSlot(1)").unwrap(); // coin
s.run("BenillaTakeLootSlot(2)").unwrap(); // item
assert_eq!(s.take_loot_picks(), vec![1, 2]);
assert!(s.take_loot_picks().is_empty(), "drained");
}
/// In the pick queue `LootSlot` would loot any row an addon named, which the reference refuses.
#[test]
fn loot_slot_queues_confirms_not_picks() {
-5
View File
@@ -621,11 +621,6 @@ mod tests {
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
}))
);
assert_eq!(
s.eval::<(String, i64)>("local k, i = GetCursorInfo() return k, i")
.unwrap(),
("macro".to_string(), 1)
);
assert!(
!s.eval::<bool>("return PickupMacro(1)").unwrap(),
"a macro button is a source, never a drop target"
+8 -114
View File
@@ -38,8 +38,9 @@ pub struct MerchantItem {
pub max_stack: Option<u32>,
}
/// An item template's tooltip stat head, resolved per row by the app. The reference's
/// `GameTooltip:SetMerchantItem` reads these in C++; no 1.12 Lua API carries them.
/// An item template's tooltip stat head, resolved per row by the app, which `SetMerchantItem` and
/// `SetBuybackItem` draw while the VM's template is in flight. The reference reads the template in
/// C++ (`0x534080`); no 1.12 Lua API carries these.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ItemStatsHead {
/// 0 poor to 6 artifact; colours the tooltip's name line.
@@ -256,38 +257,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaGetMerchantItemStats(index) → quality, invType, class, subclass, dmgMin, dmgMax,
// dmgType, delayMs, armor, block, or nil. Not a 1.12 verb: the reference's tooltip reads the
// template in C++ (`SetMerchantItem 0x534080`).
g.set(
"BenillaGetMerchantItemStats",
lua.create_function(|lua, index: usize| {
let stats = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.merchant
.as_ref()
.and_then(|m| index.checked_sub(1).and_then(|n| m.items.get(n)))
.and_then(|it| it.stats)
};
let Some(s) = stats else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
Ok(MultiValue::from_vec(vec![
Value::Integer(i64::from(s.quality)),
Value::Integer(i64::from(s.inventory_type)),
Value::Integer(i64::from(s.class)),
Value::Integer(i64::from(s.subclass)),
Value::Number(f64::from(s.dmg_min)),
Value::Number(f64::from(s.dmg_max)),
Value::Integer(i64::from(s.dmg_type)),
Value::Integer(i64::from(s.delay_ms)),
Value::Integer(i64::from(s.armor)),
Value::Integer(i64::from(s.block)),
]))
})?,
)?;
g.set(
"BuyMerchantItem",
lua.create_function(|lua, (index, quantity): (u32, Option<u32>)| {
@@ -418,36 +387,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaGetBuybackItemStats(index): the buyback hover's stat head, as above.
g.set(
"BenillaGetBuybackItemStats",
lua.create_function(|lua, index: usize| {
let stats = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.merchant
.as_ref()
.and_then(|m| index.checked_sub(1).and_then(|n| m.buyback.get(n)))
.and_then(|it| it.stats)
};
let Some(s) = stats else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
Ok(MultiValue::from_vec(vec![
Value::Integer(i64::from(s.quality)),
Value::Integer(i64::from(s.inventory_type)),
Value::Integer(i64::from(s.class)),
Value::Integer(i64::from(s.subclass)),
Value::Number(f64::from(s.dmg_min)),
Value::Number(f64::from(s.dmg_max)),
Value::Integer(i64::from(s.dmg_type)),
Value::Integer(i64::from(s.delay_ms)),
Value::Integer(i64::from(s.armor)),
Value::Integer(i64::from(s.block)),
]))
})?,
)?;
g.set(
"BuybackItem",
lua.create_function(|lua, index: u32| {
@@ -736,41 +675,6 @@ mod tests {
.unwrap());
}
#[test]
fn merchant_stats_feed_reads_the_tooltip_head() {
let mut s = UiScript::new().unwrap();
let mut stock = stock();
// A sword, so every stat column is distinct.
stock.items[0].stats = Some(ItemStatsHead {
quality: 2,
inventory_type: 21,
class: 2,
subclass: 7,
dmg_min: 5.0,
dmg_max: 9.0,
dmg_type: 2,
delay_ms: 2600,
armor: 0,
block: 0,
sell_price: 0,
});
s.set_merchant(Some(stock));
let (quality, inv, class, sub, dmin, dmax, dtype, delay, armor, block) = s
.eval::<(i64, i64, i64, i64, f64, f64, i64, i64, i64, i64)>(
"return BenillaGetMerchantItemStats(1)",
)
.unwrap();
assert_eq!((quality, inv, class, sub), (2, 21, 2, 7));
assert_eq!((dmin, dmax, dtype, delay), (5.0, 9.0, 2, 2600));
assert_eq!((armor, block), (0, 0));
assert!(s
.eval::<bool>("return BenillaGetMerchantItemStats(2) == nil")
.unwrap());
assert!(s
.eval::<bool>("return BenillaGetMerchantItemStats(9) == nil")
.unwrap());
}
#[test]
fn buy_merchant_item_queues_intents() {
let mut s = UiScript::new().unwrap();
@@ -819,9 +723,6 @@ mod tests {
let (name, _tex, price): (String, String, i64) =
s.eval("return GetBuybackItemInfo(1)").unwrap();
assert_eq!((name.as_str(), price), ("Worn Dagger", 47));
assert!(s
.eval::<bool>("return BenillaGetBuybackItemStats(1) ~= nil")
.unwrap());
s.run("BuybackItem(1)").unwrap();
assert_eq!(s.take_merchant_buybacks(), vec![1]);
@@ -1012,10 +913,6 @@ mod tests {
"CursorHasItem is nil for mode 5"
);
assert!(s.eval::<bool>("return not CursorHasSpell()").unwrap());
assert!(
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
"GetCursorInfo reports nothing — no binding exposes mode 5"
);
// Yet it is held: a second call toggles it off (`0x4fb818`), a third re-grabs.
s.run("PickupMerchantItem(1)").unwrap();
@@ -1036,17 +933,14 @@ mod tests {
s.run("PickupMerchantItem(1)").unwrap(); // hold something first
s.run(bad)
.unwrap_or_else(|e| panic!("{bad} must not raise: {e}"));
assert!(
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
"{bad} cleared the cursor"
);
assert!(s.cursor_payload().is_none(), "{bad} cleared the cursor");
}
// A missing argument is the same silent clear.
s.run("PickupMerchantItem(1)").unwrap();
s.run("PickupMerchantItem()").unwrap();
// `2^32 + 1` narrows to row 0 on a naive cast.
s.run("PickupMerchantItem(4294967297)").unwrap();
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
assert!(s.cursor_payload().is_none());
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(
s.take_merchant_slot_buys().is_empty(),
@@ -1055,7 +949,7 @@ mod tests {
// A numeric string is accepted, and 1.9 truncates to the held row 1, toggling it off.
s.run(r#"PickupMerchantItem("1")"#).unwrap();
s.run("PickupMerchantItem(1.9)").unwrap();
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
assert!(s.cursor_payload().is_none());
}
#[test]
@@ -1097,7 +991,7 @@ mod tests {
"the row's item entry, aimed at the dropped-on slot"
);
assert!(
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
s.cursor_payload().is_none(),
"the cursor clears on the drop"
);
}
@@ -1119,7 +1013,7 @@ mod tests {
s.take_merchant_slot_buys().is_empty(),
"no buy goes out for a row that no longer resolves"
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
assert!(s.cursor_payload().is_none());
}
#[test]
+2 -2
View File
@@ -663,8 +663,8 @@ pub(crate) struct Model {
pub(crate) bind_on_use_confirms: u32,
/// The open loot, the row clicks and whether `CloseLoot` ran. A click is the reference's take
/// `0x4c2790` with flag 0, raising `LOOT_BIND` for a bind-on-pickup row (the C `CLootButton`,
/// here `BenillaTakeLootSlot`); `LootSlot` passes 1 (`0x4c2e70`), taking it after the confirm.
/// `0x4c2790` with flag 0, raising `LOOT_BIND` for a bind-on-pickup row (a `LootButton`'s own
/// click, `0x4c1820`); `LootSlot` passes 1 (`0x4c2e70`), taking it after the confirm.
pub(crate) loot: Option<loot::LootState>,
pub(crate) loot_picks: Vec<u32>,
/// `LootSlot(slot)` rows, 1-based, apart from clicks for the reference's pending-slot gate.
+3 -4
View File
@@ -209,9 +209,9 @@ fn enum_token(s: &str) -> String {
s.trim().to_ascii_uppercase()
}
/// A strata name. The reference's table (`0x8119f8`) holds the eight `BACKGROUND`..`TOOLTIP`;
/// `BLIZZARD` is a later client's, not 1.12's. Stratum 0, `WORLD`, has no name: only the
/// WorldFrame's constructor sets it (`0x481aff`).
/// A strata name, from the reference's eight-row table (`0x8119f8`), `BACKGROUND`..`TOOLTIP`, walked
/// by `0x6f17d0`. Stratum 0, `WORLD`, has no name: only the WorldFrame's constructor sets it
/// (`0x481aff`).
pub(crate) fn strata_from_str(s: &str) -> Option<Strata> {
Some(match enum_token(s).as_str() {
"BACKGROUND" => Strata::Background,
@@ -222,7 +222,6 @@ pub(crate) fn strata_from_str(s: &str) -> Option<Strata> {
"FULLSCREEN" => Strata::Fullscreen,
"FULLSCREEN_DIALOG" => Strata::FullscreenDialog,
"TOOLTIP" => Strata::Tooltip,
"BLIZZARD" => Strata::Blizzard,
_ => return None,
})
}
@@ -356,8 +356,20 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetFrameStrata",
lua.create_function(|lua, (this, strata): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let s = strata_from_str(&strata)
.ok_or_else(|| mlua::Error::runtime(format!("unknown frameStrata '{strata}'")))?;
// A name off the table raises, naming the frame or `<unnamed>` (`0x774450`), and the
// stratum stays.
let Some(s) = strata_from_str(&strata) else {
let name = lua
.app_data_ref::<Model>()
.expect("model")
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string());
return Err(mlua::Error::runtime(format!(
"{name}:SetFrameStrata(): Unknown frame strata: {strata}"
)));
};
lua.app_data_mut::<Model>()
.expect("model")
.arena
@@ -772,7 +784,6 @@ fn strata_name(s: Strata) -> &'static str {
Strata::Fullscreen => "FULLSCREEN",
Strata::FullscreenDialog => "FULLSCREEN_DIALOG",
Strata::Tooltip => "TOOLTIP",
Strata::Blizzard => "BLIZZARD",
}
}
+90 -38
View File
@@ -24,7 +24,8 @@ pub enum QuestPanel {
/// One choice, reward or required item row; its 1-based index is its place in its list.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct QuestItemView {
/// `None`, answered as nil, while the item template is in flight.
/// `None` while the item template is in flight: `GetQuestItemInfo` answers an empty name, the
/// log getters their miss.
pub name: Option<String>,
pub texture: Option<String>,
pub count: u32,
@@ -32,8 +33,8 @@ pub struct QuestItemView {
pub quality: u32,
/// The item id the quest tooltips render by; 0 until the wire row resolves.
pub item_id: u32,
/// Whether the player can use the item; stock tints the row red when not
/// (`QuestFrame.lua:393`). The app always sends `true`.
/// Whether the player can use the item, `isUsable` as 1 or nil; stock tints the row red when
/// not (`QuestFrame.lua:393`). The app always sends `true`.
pub usable: bool,
/// The escaped item link `GetQuestItemLink` and `GetQuestLogItemLink` serve; `None` until the
/// template lands, since it embeds the name and the quality.
@@ -176,6 +177,10 @@ pub(super) fn reward_spell_returns(
Ok(MultiValue::from_vec(vec![texture, name, tradeskill]))
}
/// The icon an empty offer row answers: display 0 has no `ItemDisplayInfo` row, so the icon
/// lookup falls back to the question mark (`0x5d88b0`, `0x847fe4`) under the icon folder.
const EMPTY_ROW_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
/// The list `GetQuestItemInfo(type, index)` reads for `type`.
pub(super) fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec<QuestItemView>> {
match kind {
@@ -260,37 +265,63 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_title(lua, "GetActiveTitle", true)?;
install_title(lua, "GetAvailableTitle", false)?;
// GetQuestItemInfo(type, index) → name, texture, numItems, quality, isUsable.
// GetQuestItemInfo(type, index) → name, texture, numItems, quality, isUsable (`0x501f80`): five
// values, `isUsable` 1 or nil. A type other than `"reward"`, `"choice"` or `"required"` (any
// case, `0x64a4c0`) or an index outside 1..7 (`0x501719`) raises `0x84edd8`, as does a
// non-string type or non-number index. An index inside that range with no item answers the
// zeroed row the offer publisher leaves (`0x500ef0`): no name, the question-mark icon of
// display 0, count 0, quality 0 and usable. A template in flight answers an empty name.
g.set(
"GetQuestItemInfo",
lua.create_function(|lua, (kind, index): (String, usize)| {
lua.create_function(|lua, (kind, index): (Value, Value)| {
let invalid =
|| mlua::Error::runtime(r#"Invalid quest item in GetQuestItemInfo("type", index)"#);
let kind = match &kind {
Value::String(_) | Value::Integer(_) | Value::Number(_) => lua
.coerce_string(kind.clone())?
.map(|s| s.to_string_lossy().to_ascii_lowercase()),
_ => None,
}
.ok_or_else(invalid)?;
let index = lua.coerce_number(index)?.ok_or_else(invalid)?;
// `_ftol` then `dec`, compared unsigned against 6.
let row = index.trunc() as i64 - 1;
if !(0..=6).contains(&row) {
return Err(invalid());
}
let item = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.quest.as_ref().and_then(|q| {
item_vec(q, &kind)
.and_then(|v| index.checked_sub(1).and_then(|n| v.get(n)))
.cloned()
})
let empty = Vec::new();
let list = match model.quest.as_ref() {
Some(q) => item_vec(q, &kind),
None => ["reward", "choice", "required"]
.contains(&kind.as_str())
.then_some(&empty),
};
let Some(list) = list else {
return Err(invalid());
};
list.get(row as usize).cloned()
};
let Some(it) = item else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let name = match &it.name {
Some(n) => Value::String(lua.create_string(n)?),
None => Value::Nil,
};
let texture = match &it.texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
let (name, texture, count, quality, usable) = match item {
Some(it) => (
it.name.unwrap_or_default(),
it.texture,
it.count,
it.quality,
it.usable,
),
None => (String::new(), Some(EMPTY_ROW_ICON.to_string()), 0, 0, true),
};
Ok(MultiValue::from_vec(vec![
name,
texture,
Value::Integer(i64::from(it.count)),
Value::Integer(i64::from(it.quality)),
Value::Boolean(it.usable),
// Not 1.12's: a sixth return, the item id, after its five; nothing reads it.
Value::Integer(i64::from(it.item_id)),
Value::String(lua.create_string(&name)?),
match texture {
Some(t) => Value::String(lua.create_string(&t)?),
None => Value::Nil,
},
Value::Integer(i64::from(count)),
Value::Integer(i64::from(quality)),
super::binding_abi::flag(usable),
]))
})?,
)?;
@@ -530,27 +561,48 @@ mod tests {
assert_eq!(s.eval::<i64>("return GetNumQuestRewards()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetRewardMoney()").unwrap(), 1234);
// Five values, `isUsable` 1 or nil (`0x501f80`), the type matched in any case.
assert_eq!(s.arity(r#"GetQuestItemInfo("reward", 1)"#).unwrap(), 5);
let (name, texture, count, quality, usable) = s
.eval::<(String, String, i64, i64, bool)>("return GetQuestItemInfo(\"reward\", 1)")
.eval::<(String, String, i64, i64, i64)>(r#"return GetQuestItemInfo("REWARD", 1)"#)
.unwrap();
assert_eq!(name, "Brdle Leather Boots");
assert_eq!(texture, "Interface\\Icons\\INV_Boots_01");
assert_eq!((count, quality, usable), (1, 2, true));
assert_eq!((count, quality, usable), (1, 2, 1));
// Choice row 2 is in flight: name and texture nil, the rest present.
// Choice row 2 is in flight: an empty name, the rest present.
assert!(s
.eval::<bool>(
"local n, t, c = GetQuestItemInfo(\"choice\", 2)\n\
return n == nil and t == nil and c == 1"
return n == '' and t == nil and c == 1"
)
.unwrap());
// Out of range, and an unknown type: nil.
assert!(s
.eval::<bool>("return GetQuestItemInfo(\"reward\", 9) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetQuestItemInfo(\"bogus\", 1) == nil")
.unwrap());
// Inside 1..7 with no item: the zeroed row, display 0's question mark.
assert_eq!(
s.eval::<(String, String, i64, i64, i64)>(r#"return GetQuestItemInfo("reward", 7)"#)
.unwrap(),
(
String::new(),
"Interface\\Icons\\INV_Misc_QuestionMark".to_string(),
0,
0,
1
)
);
// Outside 1..7, an unknown type or a missing argument raises `0x84edd8`.
for call in [
r#"GetQuestItemInfo("reward", 8)"#,
r#"GetQuestItemInfo("reward", 0)"#,
r#"GetQuestItemInfo("bogus", 1)"#,
r#"GetQuestItemInfo("reward")"#,
"GetQuestItemInfo()",
] {
let e = s.run(call).expect_err(call).to_string();
assert!(
e.contains(r#"Invalid quest item in GetQuestItemInfo("type", index)"#),
"{call}: {e}"
);
}
// The link is nil for the in-flight row, out of range, and for an unknown type.
assert_eq!(
+52 -19
View File
@@ -347,7 +347,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?;
// `name, texture, numItems, quality, isUsable`, the giver panels' shape, as one stock routine
// lays out both (`QuestFrameItems_Update`, `QuestFrame.lua:311`).
// lays out both (`QuestFrameItems_Update`, `QuestFrame.lua:311`): five values, `isUsable` 1 or
// nil (`0x4e0b00`, `0x4e0cf0`). No selected quest, an index past the list or a template in
// flight answers `nil, nil, 1, 0, nil` (`0x4e0ca8`); a non-number index raises the reward
// getter's Usage line from both (`0x84b6ac`).
fn install_detail_item(
lua: &Lua,
name: &str,
@@ -355,32 +358,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
) -> mlua::Result<()> {
lua.globals().set(
name,
lua.create_function(move |lua, i: usize| {
lua.create_function(move |lua, index: Value| {
let i = lua
.coerce_number(index)?
.ok_or_else(|| mlua::Error::runtime("Usage: GetQuestLogRewardInfo(index)"))?;
let row = i.trunc() as i64 - 1;
let item = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.selected_quest_detail()
.and_then(|d| i.checked_sub(1).and_then(|n| pick(d).get(n)).cloned())
model.selected_quest_detail().and_then(|d| {
usize::try_from(row)
.ok()
.and_then(|n| pick(d).get(n))
.cloned()
})
};
let Some(it) = item else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let name_v = match &it.name {
Some(n) => Value::String(lua.create_string(n)?),
None => Value::Nil,
let Some((it, name)) = item.and_then(|it| it.name.clone().map(|n| (it, n))) else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(1),
Value::Integer(0),
Value::Nil,
]));
};
let texture = match &it.texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
name_v,
Value::String(lua.create_string(&name)?),
texture,
Value::Integer(i64::from(it.count)),
Value::Integer(i64::from(it.quality)),
Value::Boolean(it.usable),
// The item id, a sixth return that is not 1.12's.
Value::Integer(i64::from(it.item_id)),
crate::script::binding_abi::flag(it.usable),
]))
})?,
)
@@ -1009,15 +1019,38 @@ mod tests {
40
);
assert_eq!(s.eval::<i64>("return GetNumQuestLogRewards()").unwrap(), 1);
// Five values, `isUsable` 1 or nil (`0x4e0b00`).
assert_eq!(s.arity("GetQuestLogRewardInfo(1)").unwrap(), 5);
assert!(s
.eval::<bool>(
"local n, _, c, q, u = GetQuestLogRewardInfo(1)\n\
return n == 'Militia Hammer' and c == 1 and q == 1 and u == true"
return n == 'Militia Hammer' and c == 1 and q == 1 and u == 1"
)
.unwrap());
assert!(s
.eval::<bool>("return GetQuestLogChoiceInfo(1) == nil")
.unwrap());
// No row answers the miss, `nil, nil, 1, 0, nil` (`0x4e0ca8`), from either getter.
for call in [
"GetQuestLogChoiceInfo(1)",
"GetQuestLogRewardInfo(2)",
"GetQuestLogRewardInfo(0)",
] {
assert_eq!(s.arity(call).unwrap(), 5, "{call}");
assert!(
s.eval::<bool>(&format!(
"local n, t, c, q, u = {call}\n\
return n == nil and t == nil and c == 1 and q == 0 and u == nil"
))
.unwrap(),
"{call}"
);
}
// A non-number index raises the reward getter's line from both (`0x84b6ac`).
for call in ["GetQuestLogRewardInfo()", "GetQuestLogChoiceInfo({})"] {
let e = s.run(call).expect_err(call).to_string();
assert!(
e.contains("Usage: GetQuestLogRewardInfo(index)"),
"{call}: {e}"
);
}
assert_eq!(
s.eval::<String>("return GetQuestLogItemLink(\"reward\", 1)")
-16
View File
@@ -140,22 +140,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaSetBoothTexture(texture, slotToken): `SetPortraitTexture` without the circular mask;
// not a 1.12 global.
lua.globals().set(
"BenillaSetBoothTexture",
lua.create_function(|lua, (region, token): (Table, String)| {
let rh = region_handle_of(lua, &region)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
data.portrait_unit = Some(token);
data.texture = None;
data.fill = None;
data.circular = false;
Ok(())
})?,
)?;
// `__index` is the method table itself, here and on the leaf metatables, not a function
// (~9 ns against ~195 ns per method access); sound while nothing replaces these tables.
let region_meta = lua.create_table()?;
+22 -8
View File
@@ -349,25 +349,39 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// SetDrawLayer(layer) (`0x79a780`) reads the layer name alone, against the five-row table at
// `0x811a80` (`0x6f18b0`); a further argument is never read, as 1.12 has no sub-level within a
// layer. A name off the table, or no name, raises the Usage line naming the region
// (`0x87c42c`), and the layer stays.
m.set(
"SetDrawLayer",
lua.create_function(|lua, (this, layer, sub): (Table, String, Option<i64>)| {
lua.create_function(|lua, (this, layer): (Table, Value)| {
let rh = region_handle_of(lua, &this)?;
let dl = draw_layer_from_str(&layer)
.ok_or_else(|| mlua::Error::runtime(format!("unknown draw layer '{layer}'")))?;
let dl = match &layer {
Value::String(s) => draw_layer_from_str(&s.to_str()?),
_ => None,
};
let Some(dl) = dl else {
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
crate::script::object::decode_id(&this)
.ok()
.and_then(|id| super::region_name_of(&model, id))
};
return Err(mlua::Error::runtime(format!(
"Usage: {}:SetDrawLayer(\"layer\")",
name.as_deref().unwrap_or("<unnamed>")
)));
};
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(region) = model.arena.region_mut(rh) {
region.draw_layer = dl;
if let Some(s) = sub {
region.sub_level = s.clamp(i64::from(i8::MIN), i64::from(i8::MAX)) as i8;
}
}
Ok(())
})?,
)?;
// GetDrawLayer (Texture `0x79a6c0`, FontString `0x79c660`): the layer name alone, since
// 1.12's pair is layer-only; the sub-level `SetDrawLayer` accepts is not 1.12's.
// GetDrawLayer (Texture `0x79a6c0`, FontString `0x79c660`): the layer name alone.
m.set(
"GetDrawLayer",
lua.create_function(|lua, this: Table| {
+55 -54
View File
@@ -1,11 +1,10 @@
//! The sound bindings: `PlaySound`, `PlaySoundFile`, `PlayMusic` and `StopMusic` each queue a
//! plain request for the app to drain, since the engine does not touch the app's mixer.
//!
//! `PlaySound` takes a kit name, as the reference's binding does (`0x4586d0` reaches only
//! `PlaySoundByName`, `0x458030`), and also Era's numeric kit id, which 1.12's binding does not
//! take (the client plays a kit id internally through `0x458850`). Both verbs answer Era's
//! `willPlay, soundHandle`, true and nil, not 1.12's shapes: the reference's `PlaySound` answers
//! nothing and its `PlaySoundFile` one value (`reference/1.12-shapes.tsv`).
//! `PlaySound` takes a kit name (`0x4586d0` reaches only `PlaySoundByName`, `0x458030`); a number
//! is `lua_tostring`'s decimal name, which no `SoundEntries` row carries, so it plays nothing (the
//! client plays a kit id internally, through `0x458850`). It answers nothing; `PlaySoundFile`
//! answers one value (`0x458780`).
//!
//! The music pair drives its own slot: the reference gives Lua a stream of its own (`[0xb06ccc]`,
//! opened in `0x460450` at `0x4604a7`) beside the zone track's (`[0xb06cc4]`), one of the client's
@@ -19,9 +18,7 @@ use super::Model;
/// One queued sound for the app's kit player.
#[derive(Clone, Debug, PartialEq)]
pub enum SoundRequest {
/// `PlaySound(id)`: a `SoundEntries` kit id, Era's form.
KitId(u32),
/// `PlaySound("name")`: a kit name, the 1.12 form.
/// `PlaySound("name")`: a kit name, a number's decimal spelling included.
KitName(String),
/// `PlaySoundFile("path")`: a file path, no kit.
File(String),
@@ -61,42 +58,38 @@ impl super::UiScript {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"PlaySoundFile",
// `0x458780`: `lua_isstring`, so a number is a file name, else the literal at `0x835f98`.
// It answers 1 when the play started and nil when `0x7a5450` refused it. Deviation: the
// app plays the queued file after the call, so the refusals (the bus-3 cap, a muted SFX
// channel, a missing file) come too late to answer, and a queued file answers 1.
lua.create_function(|lua, args: mlua::MultiValue| {
let Some(Value::String(s)) = args.front() else {
return Err(mlua::Error::runtime("Usage: PlaySoundFile(\"filePath\")"));
};
let path = s.to_str()?.to_owned();
let path = super::binding_abi::string_arg(
lua,
args.front().cloned().unwrap_or(Value::Nil),
"Usage: PlaySoundFile(\"soundfile\")",
)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.sound_queue.push(SoundRequest::File(path));
drop(model);
Ok((true, Value::Nil))
Ok(1)
})?,
)?;
lua.globals().set(
"PlaySound",
// `0x4586d0`: `lua_isstring`, then `lua_tostring`, so a number becomes its decimal name,
// else the literal at `0x835f60`; no return value on any path.
lua.create_function(|lua, args: mlua::MultiValue| {
let req = match args.front() {
Some(Value::Integer(i)) if *i >= 0 => Some(SoundRequest::KitId(*i as u32)),
Some(Value::Number(n)) if *n >= 0.0 => Some(SoundRequest::KitId(*n as u32)),
Some(Value::String(s)) => Some(SoundRequest::KitName(s.to_str()?.to_owned())),
_ => None,
};
let Some(req) = req else {
// A bad argument raises a usage error, as the reference's does.
return Err(mlua::Error::runtime(
"Usage: PlaySound(soundKitID or \"KitName\")",
));
};
let name = super::binding_abi::string_arg(
lua,
args.front().cloned().unwrap_or(Value::Nil),
"Usage: PlaySound(\"sound\")",
)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// During the UI load a by-name play is dropped before any other gate (`0x458046`,
// `0x45804d`), while the binding still answers; the id entry (`0x457fb0`) has no
// such gate.
let suppressed = matches!(req, SoundRequest::KitName(_)) && model.sound_suppression > 0;
if !suppressed {
model.sound_queue.push(req);
// `0x45804d`).
if model.sound_suppression == 0 {
model.sound_queue.push(SoundRequest::KitName(name));
}
drop(model);
Ok((true, Value::Nil))
Ok(())
})?,
)?;
lua.globals().set(
@@ -135,29 +128,47 @@ mod tests {
use super::{MusicRequest, SoundRequest};
use crate::script::UiScript;
/// `PlaySound` names a kit and answers nothing (`0x4586d0`); a number is its decimal name,
/// never a `SoundEntries` id. `PlaySoundFile` answers one value, 1 for a play (`0x458780`).
#[test]
fn playsound_queues_by_id_and_name_and_drains() {
fn playsound_queues_by_name_and_answers_nothing() {
let mut s = UiScript::new().unwrap();
let (will_play, handle_is_nil): (bool, bool) = s
.eval("local w, h = PlaySound(1234) return w, h == nil")
.unwrap();
assert!(will_play && handle_is_nil);
// Era's extra arguments are ignored.
assert_eq!(s.arity("PlaySound(1234)").unwrap(), 0);
assert_eq!(s.arity(r#"PlaySound("GAMEOBJECT_DOOROPEN")"#).unwrap(), 0);
// Further arguments are never read.
s.run(r#"PlaySound(841, "SFX", true)"#).unwrap();
s.run(r#"PlaySound("GAMEOBJECT_DOOROPEN")"#).unwrap();
s.run(r#"PlaySoundFile("Sound\\Doodad\\BellTollHorde.wav")"#)
.unwrap();
assert_eq!(
s.eval::<(i64, bool)>(
r#"local n = table.getn({PlaySoundFile("Sound\\Doodad\\BellTollHorde.wav")})
return n, PlaySoundFile(12) == 1"#
)
.unwrap(),
(1, true)
);
assert_eq!(
s.take_sounds(),
vec![
SoundRequest::KitId(1234),
SoundRequest::KitId(841),
SoundRequest::KitName("1234".into()),
SoundRequest::KitName("GAMEOBJECT_DOOROPEN".into()),
SoundRequest::KitName("841".into()),
SoundRequest::File("Sound\\Doodad\\BellTollHorde.wav".into()),
SoundRequest::File("12".into()),
]
);
assert!(s.take_sounds().is_empty());
for (call, usage) in [
("PlaySound()", r#"Usage: PlaySound("sound")"#),
("PlaySound({})", r#"Usage: PlaySound("sound")"#),
("PlaySound(true)", r#"Usage: PlaySound("sound")"#),
("PlaySoundFile()", r#"Usage: PlaySoundFile("soundfile")"#),
("PlaySoundFile(nil)", r#"Usage: PlaySoundFile("soundfile")"#),
] {
let e = s.run(call).expect_err(call).to_string();
assert!(e.contains(usage), "{call}: {e}");
}
assert!(s.take_sounds().is_empty(), "a raise queues nothing");
}
#[test]
@@ -204,14 +215,4 @@ mod tests {
vec![MusicRequest::Play("42".into()), MusicRequest::Stop]
);
}
#[test]
fn playsound_with_a_bad_argument_is_a_usage_error() {
let mut s = UiScript::new().unwrap();
assert!(s.run("PlaySound(nil)").is_err());
assert!(s.run("PlaySound()").is_err());
assert!(s.run("PlaySoundFile()").is_err());
assert!(s.run("PlaySoundFile(42)").is_err());
assert!(s.take_sounds().is_empty());
}
}
+23 -18
View File
@@ -896,14 +896,14 @@ mod tests {
s.run(r#"picked = PickupSpell(1, BOOKTYPE_SPELL)"#).unwrap();
assert!(s.eval::<bool>("return picked").unwrap());
assert!(s.cursor_payload().is_some());
let (kind, book_id, book, spell_id) = s
.eval::<(String, i64, String, i64)>(
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id",
)
.unwrap();
assert_eq!(
(kind.as_str(), book_id, book.as_str(), spell_id),
("spell", 1, "spell", 133)
assert!(
matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::Spell(c))
if c.book_slot == 1 && c.book_type == "spell" && c.spell_id == 133
),
"{:?}",
s.cursor_payload()
);
// Tick first to flush the first pickup's `CURSOR_UPDATE`, so the count below is the
@@ -926,12 +926,14 @@ mod tests {
"refused pickup fires no CURSOR_UPDATE"
);
// Still holding the first pickup: a refusal never clobbers it.
assert_eq!(
s.eval::<(String, i64, String, i64)>(
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id"
)
.unwrap(),
("spell".to_string(), 1, "spell".to_string(), 133)
assert!(
matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::Spell(c))
if c.book_slot == 1 && c.book_type == "spell" && c.spell_id == 133
),
"{:?}",
s.cursor_payload()
);
}
@@ -950,10 +952,13 @@ mod tests {
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#)
.unwrap());
// The original (action) payload survives untouched.
assert_eq!(
s.eval::<String>("local k = GetCursorInfo() return k")
.unwrap(),
"action"
assert!(
matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::Action(_))
),
"{:?}",
s.cursor_payload()
);
}
+10 -4
View File
@@ -427,13 +427,14 @@ fn down_registration_fires_on_press_and_toggles_checked_once() {
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks, seen_down = 0, nil
clicks, seen_button, seen_arg2 = 0, nil, "untouched"
arg2 = "untouched"
local cb = CreateFrame("CheckButton", "QuickSell")
cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetWidth(100); cb:SetHeight(100)
cb:RegisterForClicks("LeftButtonDown")
cb:SetScript("OnClick", function() local self, button, down = this, arg1, arg2
cb:SetScript("OnClick", function()
clicks = clicks + 1
seen_down = down
seen_button, seen_arg2 = arg1, arg2
end)
"#,
)
@@ -442,7 +443,12 @@ fn down_registration_fires_on_press_and_toggles_checked_once() {
s.mouse_button(50.0, 50.0, "LeftButton", true);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
assert!(s.eval::<bool>("return seen_down == true").unwrap());
// `0x779540` fires `OnClick` with the format `"%s"`: the button name in `arg1` and no `arg2`.
assert_eq!(
s.eval::<(String, String)>("return seen_button, seen_arg2")
.unwrap(),
("LeftButton".to_string(), "untouched".to_string())
);
assert!(s.eval::<bool>("return QuickSell:GetChecked()").unwrap());
// `RegisterForClicks` replaced the set, so "LeftButtonUp" no longer fires.
@@ -1,13 +1,38 @@
//! The 1.12 surface as a gate: every global benilla's VM exposes is one the 1.12.1 client has
//! (`reference/1.12-globals.tsv`) or a listed exception, since addons branch on a global's
//! presence; what 1.12 has and benilla lacks is `scripts/api-coverage.sh`'s count.
//! The 1.12 surface as a gate: every global the engine puts in a bare VM is one the 1.12.1 client
//! has (`reference/1.12-globals.tsv`) or a commented entry on [`BEYOND_1_12`], since addons branch
//! on a global's presence; no prefix excuses an engine name, as a host seam belongs in Rust. The
//! production load, core and layer, is `benilla-app`'s `ui_script::surface_gate`; what 1.12 has
//! and benilla lacks is `scripts/api-coverage.sh`'s count.
use std::collections::HashSet;
use super::common::script;
/// `reference/1.12-globals.tsv`, as `name -> origin`.
fn reference() -> Vec<(String, String)> {
/// Globals the engine exposes that 1.12 does not, each with its reason. The list is exact.
const BEYOND_1_12: &[&str] = &[
// Our Lua runtime is 5.1 where 1.12's is 5.0: 1.12's base library does not export `_G` (an
// addon reaches the globals with `getfenv(0)`); ours does, as our `getglobal`/`setglobal` are
// written over it.
"_G",
// The host's pushed state, which the Lua getters over it read (`script::stdlib`): the tick's
// clock for `GetTime`, the zone texts and PvP info for the zone family, the game clock for
// `GetGameTime`, and the default error handler's sink. Each is a slot the app writes, not an
// API; they await a home off `_G`.
"__benilla_game_hour",
"__benilla_game_minute",
"__benilla_now",
"__benilla_pvp_arena",
"__benilla_pvp_faction",
"__benilla_pvp_type",
"__benilla_real_zone_name",
"__benilla_script_error",
"__benilla_subzone_name",
"__benilla_zone_name",
"__benilla_zone_text",
];
/// `reference/1.12-globals.tsv`'s names.
fn reference() -> HashSet<String> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-globals.tsv"
@@ -15,119 +40,246 @@ fn reference() -> Vec<(String, String)> {
let text = std::fs::read_to_string(path).unwrap_or_else(|e| {
panic!("reading {path}: {e} — regenerate with scripts/gen-reference-globals.py")
});
text.lines()
let names: HashSet<String> = text
.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| {
let mut f = l.split('\t');
Some((f.next()?.to_string(), f.nth(1)?.to_string()))
})
.filter_map(|l| l.split('\t').next().map(str::to_string))
.collect();
assert!(
names.len() > 19_000,
"the reference table looks truncated ({} names) — regenerate it",
names.len()
);
names
}
/// The string keys of a bare VM's `_G`.
fn engine_globals() -> HashSet<String> {
script()
.eval::<Vec<String>>(
"local out = {} \
for k in pairs(getfenv(0)) do if type(k) == 'string' then table.insert(out, k) end end \
return out",
)
.expect("dump _G")
.into_iter()
.collect()
}
/// Globals benilla exposes that 1.12 does not. `Benilla*` (the host bridge our own UI files call)
/// and `__benilla_*` (the tick's pushed state) pass by prefix.
fn allowed_beyond_1_12() -> HashSet<&'static str> {
[
// ── our Lua runtime is 5.1 where 1.12's is 5.0 ──
// 1.12's base library does not export `_G` (an addon reaches the globals with
// `getfenv(0)`); ours does, as our `getglobal`/`setglobal` are written over it.
"_G",
// ── WoW API past 1.12 ──
// Each awaits its 1.12 equivalent, removal or a reason; none is called from our UI files.
"CancelUnitBuff",
"GetCursorInfo",
"GetInventoryItemID",
"GetPlayerFacing",
"GetTradePartnerName",
"IsGossipOptionCoded",
"UnitAura",
"UnitIsAFK",
"UnitIsDND",
]
.into_iter()
.collect()
}
/// Every global benilla's VM exposes is one 1.12 has, or a listed exception.
/// Every global the engine exposes is one 1.12 has, or a listed exception.
#[test]
fn our_globals_stay_inside_the_1_12_surface() {
let reference = reference();
let known: HashSet<&str> = reference.iter().map(|(n, _)| n.as_str()).collect();
assert!(
known.len() > 19_000,
"the reference table looks truncated ({} names) — regenerate it",
known.len()
);
let allowed = allowed_beyond_1_12();
let ours: Vec<String> = script()
.eval(
"local out = {} \
for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \
return out",
)
.expect("dump _G");
let mut unlisted: Vec<&str> = ours
.iter()
.map(String::as_str)
.filter(|n| !known.contains(n))
.filter(|n| !allowed.contains(n))
.filter(|n| !n.starts_with("Benilla") && !n.starts_with("__benilla_"))
fn the_engine_adds_no_global_past_1_12() {
let known = reference();
let mut beyond: Vec<String> = engine_globals()
.into_iter()
.filter(|n| !known.contains(n) && !BEYOND_1_12.contains(&n.as_str()))
.collect();
unlisted.sort_unstable();
beyond.sort_unstable();
assert!(
unlisted.is_empty(),
"benilla exposes {} global(s) the 1.12.1 client does not, and they are not listed as \
exceptions:\n {}\n\n\
1.12 is the target. Either give it its 1.12 spelling, or add it to \
`allowed_beyond_1_12` in this file WITH the reason it has to stay — an addon that \
beyond.is_empty(),
"the engine exposes {} global(s) the 1.12.1 client does not:\n {}\n\n\
1.12 is the target. Give it its 1.12 spelling, move a host seam into Rust, or add it to \
`BEYOND_1_12` in this file WITH the reason it has to stay — an addon that \
feature-detects an unexplained superset takes a path we cannot honour.",
unlisted.len(),
unlisted.join(" ")
beyond.len(),
beyond.join(" ")
);
}
/// The exception list is exact: no entry outlives its global, and none excuses a 1.12 name.
#[test]
fn the_exception_list_has_no_dead_entries() {
let known: HashSet<String> = reference().into_iter().map(|(n, _)| n).collect();
let ours: HashSet<String> = script()
.eval::<Vec<String>>(
"local out = {} \
for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \
return out",
)
.expect("dump _G")
.into_iter()
fn the_exception_list_is_exact() {
let known = reference();
let ours = engine_globals();
let wrong: Vec<&str> = BEYOND_1_12
.iter()
.copied()
.filter(|n| known.contains(*n) || !ours.contains(*n))
.collect();
let mut dead: Vec<&str> = allowed_beyond_1_12()
.into_iter()
.filter(|n| !ours.contains(*n))
.collect();
dead.sort_unstable();
assert!(
dead.is_empty(),
"these are excused as beyond-1.12 but benilla no longer exposes them — drop them from \
`allowed_beyond_1_12`:\n {}",
dead.join(" ")
);
let mut wrong: Vec<&str> = allowed_beyond_1_12()
.into_iter()
.filter(|n| known.contains(*n))
.collect();
wrong.sort_unstable();
assert!(
wrong.is_empty(),
"these are excused as beyond-1.12 but the 1.12.1 client DOES have them — they need no \
excuse:\n {}",
wrong.join(" ")
"excused as beyond 1.12, but 1.12 has them or the engine no longer exposes them: {wrong:?}"
);
}
/// The globals 1.12.1 does not have, each once benilla's: the Era verbs (the 1.12 way in each
/// comment) and the host hooks with no caller. An addon that tests for one takes a path 1.12 never
/// does.
#[test]
fn the_era_globals_and_the_uncalled_hooks_are_absent() {
let s = script();
for (name, instead) in [
(
"UnitAura",
"UnitBuff/UnitDebuff (0x519500, 0x5198f0), GetPlayerBuff (0x4e45d0)",
),
("CancelUnitBuff", "CancelPlayerBuff (0x4e49a0)"),
(
"GetCursorInfo",
"CursorHasItem/CursorHasSpell/CursorHasMoney (0x4895d0..0x489630)",
),
(
"GetInventoryItemID",
"the id inside GetInventoryItemLink (0x4c8c10)",
),
("GetPlayerFacing", "the minimap arrow model's GetFacing"),
(
"GetTradePartnerName",
r#"UnitName("NPC") (TradeFrame.lua:43)"#,
),
(
"IsGossipOptionCoded",
"GOSSIP_ENTER_CODE, then SelectGossipOption(index, code)",
),
("UnitIsAFK", "no unit AFK predicate"),
("UnitIsDND", "no unit DND predicate"),
(
"BenillaGetContainerItemID",
"the id inside GetContainerItemLink",
),
("BenillaGetItemStats", "no caller"),
("BenillaGetMerchantItemStats", "no caller"),
("BenillaGetBuybackItemStats", "no caller"),
("BenillaSetBoothTexture", "no caller; SetPortraitTexture"),
("BenillaTakeLootSlot", "a LootButton's own click (0x4c1820)"),
] {
assert!(
s.eval::<bool>(&format!("return {name} == nil")).unwrap(),
"{name} is a global; 1.12 has {instead}"
);
}
}
/// The strata table (`0x8119f8`) has eight rows, `BACKGROUND` to `TOOLTIP`, walked by `0x6f17d0`:
/// `SetFrameStrata("BLIZZARD")` raises `%s:SetFrameStrata(): Unknown frame strata: %s` naming the
/// frame or `<unnamed>` (`0x774450`) and leaves the stratum, and the XML attribute warns and skips
/// (`0x769978`).
#[test]
fn blizzard_is_no_frame_strata() {
let s = script();
s.run(r#"f = CreateFrame("Frame", "StrataProbe") f:SetFrameStrata("HIGH") g = CreateFrame("Frame")"#)
.unwrap();
for (call, who) in [
(r#"f:SetFrameStrata("BLIZZARD")"#, "StrataProbe"),
(r#"g:SetFrameStrata("BLIZZARD")"#, "<unnamed>"),
] {
let e = s.run(call).expect_err(call).to_string();
assert!(
e.contains(&format!(
"{who}:SetFrameStrata(): Unknown frame strata: BLIZZARD"
)),
"{call}: {e}"
);
}
assert_eq!(
s.eval::<String>("return f:GetFrameStrata()").unwrap(),
"HIGH",
"the stratum stays"
);
assert!(crate::script::object::strata_from_str("BLIZZARD").is_none());
assert!(crate::script::object::strata_from_str("tooltip").is_some());
}
/// `SetDrawLayer` (`0x79a780`) reads the layer name alone against the five-row table (`0x811a80`):
/// a third argument orders nothing, and a name off the table raises the Usage line (`0x87c42c`).
#[test]
fn set_draw_layer_reads_the_layer_alone() {
let s = script();
s.run(
r#"
f = CreateFrame("Frame", "LayerProbe")
a = f:CreateTexture("LayerProbeA", "ARTWORK")
b = f:CreateTexture("LayerProbeB", "ARTWORK")
a:SetDrawLayer("ARTWORK", 7)
b:SetDrawLayer("ARTWORK", -7)
"#,
)
.unwrap();
let sub = |name: &str| {
let rh = {
let t: mlua::Table = s.lua().globals().get(name).unwrap();
crate::script::region::region_handle_of(s.lua(), &t).unwrap()
};
s.model_ref().arena.region(rh).unwrap().sub_level
};
assert_eq!((sub("LayerProbeA"), sub("LayerProbeB")), (0, 0));
assert_eq!(
s.eval::<String>("return a:GetDrawLayer()").unwrap(),
"ARTWORK"
);
for (call, who) in [
(r#"a:SetDrawLayer("NOPE")"#, "LayerProbeA"),
("a:SetDrawLayer(2)", "LayerProbeA"),
("f:CreateTexture():SetDrawLayer()", "<unnamed>"),
] {
let e = s.run(call).expect_err(call).to_string();
assert!(
e.contains(&format!(r#"Usage: {who}:SetDrawLayer("layer")"#)),
"{call}: {e}"
);
}
assert_eq!(
s.eval::<String>("return a:GetDrawLayer()").unwrap(),
"ARTWORK",
"a refused name leaves the layer"
);
}
/// `Set<State>Texture` has no colour form (`0x781970`): a number takes the path leg through
/// `lua_isstring` (`0x781b23`) and loads as its decimal name, so `SetNormalTexture(1, 1, 1, 0)`
/// is the file `"1"`; a boolean or no argument raises the Usage line (`0x87a1b4`, the highlight
/// setter's own at `0x87a240`).
#[test]
fn a_state_texture_setter_reads_a_number_as_a_path() {
let s = script();
s.run(
r#"
b = CreateFrame("Button", "StateProbe")
b:SetNormalTexture(1, 1, 1, 0)
c = CreateFrame("CheckButton", "CheckProbe")
c:SetCheckedTexture(3)
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return b:GetNormalTexture():GetTexture()")
.unwrap(),
"1",
"the path \"1\", not a solid white fill"
);
assert_eq!(
s.eval::<String>("return c:GetCheckedTexture():GetTexture()")
.unwrap(),
"3"
);
for (call, usage) in [
(
"b:SetPushedTexture(true)",
r#"Usage: StateProbe:SetPushedTexture(texture or "texture" or nil)"#,
),
(
"b:SetDisabledTexture()",
r#"Usage: StateProbe:SetDisabledTexture(texture or "texture" or nil)"#,
),
(
"CreateFrame('Button'):SetHighlightTexture(false)",
r#"Usage: <unnamed>:SetHighlightTexture(texture or "texture" or nil [, "blendmode")"#,
),
] {
let e = s.run(call).expect_err(call).to_string();
assert!(e.contains(usage), "{call}: {e}");
}
}
/// `PickupContainerItem` (`0x4f9b30`) returns nothing on every path.
#[test]
fn pickup_container_item_answers_nothing() {
let s = script();
for call in ["PickupContainerItem(0, 1)", "PickupContainerItem(4, 16)"] {
assert_eq!(s.arity(call).unwrap(), 0, "{call}");
}
}
/// `Texture:GetTexture()` (`0x79ba70`/`0x79baf0`/`0x835708`): one value, nil when unset, the path
/// without its extension, and the literal `"Solid Texture"` for a colour fill.
#[test]
@@ -281,41 +281,6 @@ fn set_portrait_texture_binds_unit_token_then_settexture_clears_it() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `BenillaSetBoothTexture`, the paper doll's square booth binding: `SetPortraitTexture`'s
/// carriage with `circular` false, as the model pane has no ring to mask for.
#[test]
fn benilla_set_booth_texture_binds_square() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "DollFrame")
f:SetPoint("TOPLEFT", 0, 0)
f:SetWidth(200); f:SetHeight(300)
local m = f:CreateTexture("DollFrameModel", "ARTWORK")
m:SetAllPoints()
BenillaSetBoothTexture(m, "paperdoll")
"#,
)
.unwrap();
s.resolve();
let bound = s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Texture {
portrait_unit: Some(u),
circular,
..
} => Some((u, circular)),
_ => None,
});
let (token, circular) = bound.expect("a booth-bound quad is extracted");
assert_eq!(token, "paperdoll");
assert!(
!circular,
"the booth pane draws square — no inscribed-circle mask"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The quest log detail pane's shape: a region anchor chain about 15 deep (title, objectives,
/// description, rewards) with a frame anchored to its tail, which the resolve runs to a fixpoint.
#[test]
+30 -1
View File
@@ -145,7 +145,6 @@ fn owner_clear_and_hide_lifecycle() {
tt:AddLine("first hover")
tt:Show()
assert(tt:IsOwned(a) and not tt:IsOwned(b), "owned by a")
assert(tt:BenillaGetTooltipOwner() == a, "the owner wrapper reads back")
tt:SetOwner(b, "ANCHOR_LEFT")
assert(cleared >= 1, "SetOwner cleared the old content")
assert(tt:NumLines() == 0, "content cleared on re-own")
@@ -162,6 +161,36 @@ fn owner_clear_and_hide_lifecycle() {
assert!(s.take_errors().is_empty());
}
/// The host reads a tooltip's owner by name; Lua has only `IsOwned(frame)`, and no method of ours
/// answers the owner.
#[test]
fn the_host_reads_the_owner_and_lua_cannot() {
let s = script();
s.run(
r#"
local a = CreateFrame("Button", "OwnA")
local b = CreateFrame("Button", "OwnB")
TTO = CreateFrame("GameTooltip", "TTO")
TTO:SetOwner(a, "ANCHOR_RIGHT")
"#,
)
.unwrap();
assert_eq!(s.tooltip_owner_name("TTO").as_deref(), Some("OwnA"));
s.run(r#"TTO:SetOwner(OwnB, "ANCHOR_LEFT")"#).unwrap();
assert_eq!(s.tooltip_owner_name("TTO").as_deref(), Some("OwnB"));
s.run("TTO:Hide()").unwrap();
assert_eq!(s.tooltip_owner_name("TTO"), None, "the hide un-owns");
assert_eq!(s.tooltip_owner_name("NoSuchTooltip"), None);
assert_eq!(s.tooltip_owner_name("OwnA"), None, "a Button is no tooltip");
for method in ["BenillaGetTooltipOwner", "BenillaSetItemById"] {
assert!(
s.eval::<bool>(&format!("return TTO.{method} == nil"))
.unwrap(),
"GameTooltip:{method} is reachable"
);
}
}
/// `SetText` shows the tooltip and `AddLine` does not. `r, g, b` apply only when the r slot is a
/// number, so `AddLine(text, "", r, g, b)` draws in the default gold, as the reference's zone
/// tooltip does.
@@ -23,8 +23,8 @@ fn item_line_law_and_red_requirements() {
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:BenillaSetItemById(871)
assert(tt:IsShown(), "BenillaSetItemById shows")
SetItemByIdForTest(tt, 871)
assert(tt:IsShown(), "the id render shows")
"#,
)
.unwrap();
@@ -90,7 +90,7 @@ fn red_lines_track_player_state() {
local a = CreateFrame("Button", "Slot2"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:BenillaSetItemById(871)
SetItemByIdForTest(tt, 871)
"#,
)
.unwrap();
@@ -146,7 +146,7 @@ fn verified_families_signable_locked_resists_known() {
local a = CreateFrame("Button", "Slot5"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:BenillaSetItemById(5518)
SetItemByIdForTest(tt, 5518)
"#,
)
.unwrap();
@@ -196,7 +196,7 @@ fn verified_families_signable_locked_resists_known() {
s.run(
r#"
TT:SetOwner(Slot5, "ANCHOR_RIGHT")
TT:BenillaSetItemById(5519)
SetItemByIdForTest(TT, 5519)
assert(TT:NumLines() == 1, "a lone Holy resist prints no line, got " .. TT:NumLines())
"#,
)
@@ -212,7 +212,7 @@ fn verified_families_signable_locked_resists_known() {
..Default::default()
},
);
s.run(r#"TT:SetOwner(Slot5, "ANCHOR_RIGHT"); TT:BenillaSetItemById(5520)"#)
s.run(r#"TT:SetOwner(Slot5, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 5520)"#)
.unwrap();
let lines = lines_of(&mut s);
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
@@ -258,7 +258,7 @@ fn proficiency_and_reputation_reds() {
local a = CreateFrame("Button", "Slot9"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:BenillaSetItemById(871)
SetItemByIdForTest(tt, 871)
"#,
)
.unwrap();
@@ -292,7 +292,7 @@ fn proficiency_and_reputation_reds() {
req2.proficiency.insert(2, 1 << 0);
req2.rep_ranks.insert(72, 4);
s.set_player_req_state(req2.clone());
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(871)"#)
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 871)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
@@ -310,7 +310,7 @@ fn proficiency_and_reputation_reds() {
let mut alt = item;
alt.proficiency_alt = Some(0);
s.set_item_template(871, alt);
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(871)"#)
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 871)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
@@ -338,7 +338,7 @@ fn proficiency_and_reputation_reds() {
let mut req3 = req2.clone();
req3.proficiency.insert(2, 1 << 15);
s.set_player_req_state(req3.clone());
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(872)"#)
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 872)"#)
.unwrap();
let lines = lines_of(&mut s);
// Type 22 takes `INVTYPE_WEAPONOFFHAND`, not `INVTYPE_SHIELD`, though both read "Off Hand".
@@ -354,7 +354,7 @@ fn proficiency_and_reputation_reds() {
);
req3.can_dual_wield = true;
s.set_player_req_state(req3);
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(872)"#)
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 872)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
@@ -373,7 +373,7 @@ fn proficiency_and_reputation_reds() {
..Default::default()
},
);
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(118)"#)
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 118)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
@@ -395,7 +395,7 @@ fn proficiency_and_reputation_reds() {
);
s.run(
r#"
TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(889)
TT:SetOwner(Slot9, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 889)
assert(TTTextLeft2:GetText() == "[INVTYPE_FINGER]")
assert(TTTextRight2:GetText() == nil or TTTextRight2:GetText() == "",
"a ring never prints its Miscellaneous type")
@@ -425,11 +425,11 @@ fn required_level_one_is_hidden() {
r#"
local a = CreateFrame("Button", "SlotR"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:BenillaSetItemById(11)
tt:SetOwner(a, "ANCHOR_RIGHT"); SetItemByIdForTest(tt, 11)
assert(tt:NumLines() == 1, "req 0: name only, got " .. tt:NumLines())
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:BenillaSetItemById(12)
tt:SetOwner(a, "ANCHOR_RIGHT"); SetItemByIdForTest(tt, 12)
assert(tt:NumLines() == 1, "req 1 hides like req 0, got " .. tt:NumLines())
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:BenillaSetItemById(13)
tt:SetOwner(a, "ANCHOR_RIGHT"); SetItemByIdForTest(tt, 13)
assert(tt:NumLines() == 2, "req 2 prints, got " .. tt:NumLines())
assert(TTTextLeft2:GetText() == "[MIN_LEVEL 2]")
"#,
@@ -772,7 +772,7 @@ fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() {
s.run(r#"TT:SetBagItem(0, 2)"#).unwrap();
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]");
s.run(r#"TT:BenillaSetItemById(871)"#).unwrap();
s.run(r#"SetItemByIdForTest(TT, 871)"#).unwrap();
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]");
// A bound quest item stays `ITEM_BIND_QUEST`, the text 4 and 5 print anyway.
@@ -793,7 +793,7 @@ fn damage_matrix_arms_and_the_first_flag() {
s.run(&format!(
r#"local a = CreateFrame("Button", "S{id}"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
if not TT then CreateFrame("GameTooltip", "TT") end
TT:SetOwner(S{id}, "ANCHOR_RIGHT"); TT:BenillaSetItemById({id})"#
TT:SetOwner(S{id}, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, {id})"#
))
.unwrap();
lines_of(s).into_iter().map(|(t, _)| t).collect()
@@ -930,7 +930,7 @@ fn container_slots_line_names_its_subclass() {
s.run(&format!(
r#"local a = CreateFrame("Button", "B{id}"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
if not TT then CreateFrame("GameTooltip", "TT") end
TT:SetOwner(B{id}, "ANCHOR_RIGHT"); TT:BenillaSetItemById({id})"#
TT:SetOwner(B{id}, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, {id})"#
))
.unwrap();
lines_of(s).into_iter().map(|(t, _)| t).collect()
@@ -124,7 +124,7 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
local a = CreateFrame("Button", "Slot4"); a:SetPoint("TOPLEFT", 10, -10); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:BenillaSetItemById(7)
SetItemByIdForTest(tt, 7)
"#,
)
.unwrap();
@@ -164,7 +164,7 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
s.run(
r#"
TT:SetOwner(Slot4, "ANCHOR_RIGHT")
TT:BenillaSetItemById(7)
SetItemByIdForTest(TT, 7)
"#,
)
.unwrap();
@@ -221,7 +221,7 @@ fn item_render_hides_the_unit_health_bar() {
s.run(
r#"
GameTooltip:SetOwner(Slot6, "ANCHOR_RIGHT")
GameTooltip:BenillaSetItemById(9)
SetItemByIdForTest(GameTooltip, 9)
assert(not GameTooltipStatusBar:IsShown(), "item content hides the unit bar")
assert(GameTooltip:IsShown(), "the item tooltip itself shows")
"#,
@@ -277,7 +277,7 @@ fn real_instance_hover_renders_live_durability() {
end
assert(found == "[DURABILITY 30/40]", "live pair on a bag hover, got " .. tostring(found))
-- The template/link hover of the SAME item keeps the authored full pair.
tt:BenillaSetItemById(2264)
SetItemByIdForTest(tt, 2264)
found = nil
for i = 1, tt:NumLines() do
local t = getglobal("TTTextLeft" .. i):GetText()
@@ -421,7 +421,7 @@ fn an_enchanted_instance_renders_its_enchant_line_before_durability() {
"the enchant line is green"
);
s.run(r#"TT:BenillaSetItemById(22816)"#).unwrap();
s.run(r#"SetItemByIdForTest(TT, 22816)"#).unwrap();
let lines = super::lines_of(&mut s);
assert!(
!lines.iter().any(|(t, _)| t.starts_with("Agility")),
@@ -796,7 +796,7 @@ fn random_property_template_hover_shows_the_placeholder() {
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(Slot, "ANCHOR_RIGHT")
tt:BenillaSetItemById(8888)
SetItemByIdForTest(tt, 8888)
"#,
)
.unwrap();
@@ -112,10 +112,18 @@ fn seed_item_strings(s: &mut UiScript) {
.unwrap();
}
/// The fixture VM, with [`seed_item_strings`] run.
/// The fixture VM, with [`seed_item_strings`] run and `SetItemByIdForTest(tooltip, itemId)`, the
/// test VM's door onto the id-keyed render the engine's item hovers call from Rust.
fn script() -> UiScript {
let mut s = bare_script();
seed_item_strings(&mut s);
let f = s
.lua()
.create_function(|lua, (tt, id): (mlua::Table, u32)| {
crate::script::tooltip_item::render_by_id(lua, &tt, id, None, None)
})
.unwrap();
s.lua().globals().set("SetItemByIdForTest", f).unwrap();
s
}
@@ -55,7 +55,7 @@ fn item_set_block_counts_and_colors() {
local a = CreateFrame("Button", "Slot10"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:BenillaSetItemById(6303)
SetItemByIdForTest(tt, 6303)
-- The spacer is the reference's own literal `0x854b2c` — a space and a newline, which is
-- ONE row carrying one space, not the empty string (an empty AddLine adds no line at all
-- in the reference: `0x530270` bails before its `inc [esi+0x31c]`).
@@ -120,7 +120,7 @@ fn item_set_block_counts_and_colors() {
s.set_item_set(161, gated);
s.run(
r#"
TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:BenillaSetItemById(6303)
TT:SetOwner(Slot10, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 6303)
assert(TTTextLeft5:GetText() == "[MIN_SKILL Leatherworking 250]",
"the skill line sits between header and members")
assert(TTTextLeft6:GetText() == " Defias Mark")
@@ -141,7 +141,7 @@ fn item_set_block_counts_and_colors() {
let mut req = PlayerReqState::default();
req.skills.insert(165, 300);
s.set_player_req_state(req);
s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:BenillaSetItemById(6303)"#)
s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 6303)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
@@ -161,7 +161,7 @@ fn item_set_block_counts_and_colors() {
..Default::default()
},
);
s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:BenillaSetItemById(9999)"#)
s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); SetItemByIdForTest(TT, 9999)"#)
.unwrap();
assert_eq!(s.take_item_set_asks(), vec![1], "the set ask recorded");
assert!(s.take_errors().is_empty());
@@ -1,4 +1,5 @@
//! The widget-method surface gate, the widget sibling of [`super::reference_surface`]. The
//! The widget-method surface gate, the widget sibling of [`super::reference_surface`], with no
//! prefix that excuses a method: a host seam is a Rust call, never a method an addon reaches. The
//! reference's 23 widget registrar tables are disjoint, so a class's surface is its own table plus
//! those it inherits ([`WIDGET_CHAINS`]): `0x87c9b8` is the base `Region` map (the 19
//! `REGION_MAP_METHODS`), and `LootButton`'s own `0x847ce4` holds only `SetSlot`.
@@ -320,8 +321,6 @@ fn our_widget_methods_stay_inside_the_1_12_surface() {
let extra: BTreeSet<String> = have
.iter()
.filter(|n| !want.contains(*n))
// Our host bridge and VM internals are marked by prefix, as `reference_surface` does.
.filter(|n| !n.starts_with("Benilla") && !n.starts_with("__benilla_"))
.filter(|n| !listed.get(*n).is_some_and(|c| c.contains(class)))
.cloned()
.collect();
@@ -63,7 +63,10 @@ fn the_world_frame_is_born_in_stratum_world_with_the_mouse_and_wheel() {
.run(r#"plain:SetFrameStrata("WORLD")"#)
.unwrap_err()
.to_string();
assert!(err.contains("unknown frameStrata 'WORLD'"), "{err}");
assert!(
err.contains("Plain:SetFrameStrata(): Unknown frame strata: WORLD"),
"{err}"
);
assert_eq!(
s.eval::<String>("return plain:GetFrameStrata()").unwrap(),
"BACKGROUND"
@@ -318,7 +318,7 @@ fn worldmap_navigation_and_map_info() {
}
/// `SetMapToCurrentZone` lands on the app-fed player zone, and the feed surfaces through
/// `GetPlayerFacing` and `GetPlayerMapPosition` for the player and `party1..4`. A `raid` token
/// `GetPlayerMapPosition` for the player and `party1..4`. A `raid` token
/// answers the off-map sentinel; the reference reads raid positions (`WorldMapFrame.lua:379`).
#[test]
fn worldmap_current_zone_and_player_feed() {
@@ -343,7 +343,6 @@ fn worldmap_current_zone_and_player_feed() {
.eval::<(f64, f64)>(r#"return GetPlayerMapPosition("player")"#)
.unwrap();
assert!((x - 0.25).abs() < 1e-6 && (y - 0.75).abs() < 1e-6);
assert!((s.eval::<f64>("return GetPlayerFacing()").unwrap() - 1.5).abs() < 1e-6);
let (px, py) = s
.eval::<(f64, f64)>(r#"return GetPlayerMapPosition("party1")"#)
.unwrap();
@@ -26,6 +26,21 @@ use crate::widget::{
/// Registry key of the GameTooltip method table, a named registry root (the MAXCSTACK discipline).
pub(super) const REG_TOOLTIP_METHODS: &str = "__benilla_tooltip_methods";
impl super::UiScript {
/// The name of the frame that owns the GameTooltip named `tooltip`; `None` for no such
/// tooltip, no owner or an unnamed one. 1.12 answers only `IsOwned(frame)`, so the host's
/// recorders read the owner here.
pub fn tooltip_owner_name(&self, tooltip: &str) -> Option<String> {
let model = self.model_ref();
let h = model.arena.lookup(tooltip)?;
let owner = match &model.arena.frame(h)?.kind_state {
KindState::Tooltip(t) => t.owner?,
_ => return None,
};
model.arena.frame(owner)?.name.clone()
}
}
/// Runs `f` over a GameTooltip's state under one short write borrow.
fn with_tip<T>(lua: &Lua, this: &Table, f: impl FnOnce(&mut TooltipState) -> T) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
+1 -19
View File
@@ -4,7 +4,7 @@ use mlua::{Lua, Table, Value, Variadic};
use crate::layout::{Anchor, Point};
use crate::script::binding_abi::optional_string;
use crate::script::object::{frame_handle_of, frame_wrapper};
use crate::script::object::frame_handle_of;
use crate::script::region::region_handle_of;
use crate::script::Model;
use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD};
@@ -121,24 +121,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
},
)?,
)?;
// GameTooltip:BenillaGetTooltipOwner(): not a 1.12 method (1.12 has only `IsOwned`); the dev
// hover recorder (`benilla-app/src/hover_log.rs`) reads the owner's name through it.
m.set(
"BenillaGetTooltipOwner",
lua.create_function(|lua, this: Table| {
let owner = with_tip(lua, &this, |t| t.owner)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
owner
.filter(|&h| model.arena.frame(h).is_some())
.map(|h| model.frame_id(h))
};
match id {
Some(id) => Ok(Value::Table(frame_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetAnchorType(): the last SetOwner's mode by name (`0x5313e0`, name table `0x531530`), never
// nil; a never-owned tooltip answers "ANCHOR_NONE". The reference also answers it after any
// hide, whose un-own leaves mode 7; this keeps the last mode. Addons compare it with what they
@@ -140,8 +140,10 @@ fn hyperlink_item_fields(link: &str) -> Option<(u32, u32, u32)> {
(item_id != 0).then_some((item_id, enchant_id, random_property_id))
}
/// The id-keyed render; on a miss, the ask and a name-only line, as `SetBagItem`'s miss path.
fn render_by_id(
/// The id-keyed render; on a miss, the ask and a name-only line, as `SetBagItem`'s miss path. The
/// engine's own item hovers reach it from Rust (the action bar's, `SetTrainerService`,
/// `SetCraftSpell`): 1.12's `Set*Item` bindings all name a container, so no Lua method takes an id.
pub(super) fn render_by_id(
lua: &Lua,
this: &Table,
item_id: u32,
@@ -224,19 +226,6 @@ fn equip_slots_for(inventory_type: u32) -> &'static [u32] {
/// Register the item content channels into the GameTooltip kind method table.
pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// GameTooltip:BenillaSetItemById(itemId [, fallbackName, fallbackQuality]). Not a 1.12 verb,
// whose `Set*Item` bindings all name a container: sibling modules' item hovers (the action
// bar's, `SetTrainerService`, `SetCraftSpell`) reach `render_by_id` by this name through the
// wrapper table, and the prefix keeps it off the 1.12 surface.
m.set(
"BenillaSetItemById",
lua.create_function(
|lua, (this, item_id, fb_name, fb_q): (Table, u32, Option<String>, Option<u32>)| {
render_by_id(lua, &this, item_id, fb_name, fb_q)
},
)?,
)?;
// GameTooltip:SetQuestItem(type, index) / SetQuestLogItem(type, index) (`0x533610`,
// `0x533760`; `QuestFrameTemplates.xml:148`, `QuestLogFrame.xml:113`), no returns. `type` is
// `GetQuestItemInfo`'s; an unknown type or index leaves the tooltip empty, as in the reference.
@@ -729,10 +729,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
},
None,
),
0x80 => {
let f: mlua::Function = this.get("BenillaSetItemById")?;
f.call::<()>((this.clone(), a.action))
}
0x80 => super::tooltip_item::render_by_id(lua, &this, a.action, None, None),
0x40 => {
let h = frame_handle_of(lua, &this)?;
let name = {
@@ -772,8 +769,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// The item builder (`0x52b650`), with no fallback name: id 0 or a template in
// flight renders empty, the builder's own early-out.
TrainerTooltip::Item(item_id) => {
let f: mlua::Function = this.get("BenillaSetItemById")?;
f.call::<()>((this.clone(), item_id))
super::tooltip_item::render_by_id(lua, &this, item_id, None, None)
}
TrainerTooltip::Spell {
spell_id,
@@ -811,8 +807,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
match subject {
CraftTooltip::Item(item_id) => {
let f: mlua::Function = this.get("BenillaSetItemById")?;
f.call::<()>((this.clone(), item_id))
super::tooltip_item::render_by_id(lua, &this, item_id, None, None)
}
CraftTooltip::Spell(spell_id) => {
set_spell_by_id(lua, &this, spell_id, None, SpellRenderOpts::default(), None)
+2 -30
View File
@@ -50,8 +50,6 @@ pub struct TradeState {
pub player: TradeSideState,
/// The partner's offer, the wire's `their_window` snapshot.
pub target: TradeSideState,
/// The partner's name for `GetTradePartnerName()`, `None` while in flight.
pub partner_name: Option<String>,
}
impl super::UiScript {
@@ -273,21 +271,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `GetTradePartnerName()` is not a 1.12 global; the stock header reads `UnitName("NPC")`
// (`TradeFrame.lua:43`).
g.set(
"GetTradePartnerName",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(
match model.trade.as_ref().and_then(|t| t.partner_name.clone()) {
Some(n) => Value::String(lua.create_string(&n)?),
None => Value::Nil,
},
)
})?,
)?;
// InitiateTrade(unit): the unit menu's TRADE row.
g.set(
"InitiateTrade",
@@ -423,11 +406,7 @@ mod tests {
};
target.slots[0] = Some(item(4306, 1, 2));
target.slots[6] = Some(item(6217, 1, 1)); // the enchant slot carries an item
TradeState {
player,
target,
partner_name: Some("Thrall".into()),
}
TradeState { player, target }
}
#[test]
@@ -516,7 +495,7 @@ mod tests {
}
#[test]
fn money_and_partner_name_read_the_pushed_state() {
fn money_reads_the_pushed_state() {
let mut s = UiScript::new().unwrap();
s.set_trade(Some(state()));
assert_eq!(
@@ -524,10 +503,6 @@ mod tests {
12_345
);
assert_eq!(s.eval::<i64>("return GetTargetTradeMoney()").unwrap(), 500);
assert_eq!(
s.eval::<String>("return GetTradePartnerName()").unwrap(),
"Thrall"
);
}
#[test]
@@ -539,9 +514,6 @@ mod tests {
.eval::<bool>("return GetTradePlayerItemInfo(1) == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return GetTargetTradeMoney()").unwrap(), 0);
assert!(s
.eval::<bool>("return GetTradePartnerName() == nil")
.unwrap());
}
#[test]
+2 -4
View File
@@ -104,8 +104,7 @@ pub enum QuadContent {
additive: bool,
/// `None` is the full texture.
tex_coords: Option<TexCoords>,
/// A portrait, masked to the inscribed circle; false with a `portrait_unit` is the square
/// booth pane (`BenillaSetBoothTexture`).
/// A portrait, masked to the inscribed circle.
circular: bool,
/// A live unit portrait's unit: the renderer samples the app's bake of it instead of
/// `path` and `color`.
@@ -362,8 +361,7 @@ pub(crate) struct RegionData {
/// drawn as `texel × colour` per channel, alpha included: a `<Color 1,1,1,0.2>` tinted
/// `(0, 0, 0.75, 0.5)` draws at alpha 0.1, as `SkillFrame`'s row trough does.
pub(crate) vertex_color: Option<[f32; 4]>,
/// A portrait (`SetPortraitToTexture`, `SetPortraitTexture`), masked to the inscribed circle;
/// false with a `portrait_unit` is the square booth pane (`BenillaSetBoothTexture`).
/// A portrait (`SetPortraitToTexture`, `SetPortraitTexture`), masked to the inscribed circle.
pub(crate) circular: bool,
/// A live unit portrait's unit, drawn from the app's bake instead of the texture or colour;
/// cleared by `SetTexture` and `SetPortraitToTexture`.
+1 -14
View File
@@ -580,8 +580,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// The party frame's status predicates. `UnitIsAFK` and `UnitIsDND` do not exist in the 1.12
// client, which has no unit AFK or DND predicate; benilla adds them beyond the 1.12 surface.
// The party frame's status predicates; 1.12 has no unit AFK or DND predicate.
g.set(
"UnitIsConnected",
lua.create_function(|lua, token: Value| {
@@ -593,18 +592,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
unit_predicate(lua, &token, |u| u.is_connected)
})?,
)?;
g.set(
"UnitIsAFK",
lua.create_function(|lua, token: Option<String>| {
unit_predicate(lua, &token, |u| u.is_afk)
})?,
)?;
g.set(
"UnitIsDND",
lua.create_function(|lua, token: Option<String>| {
unit_predicate(lua, &token, |u| u.is_dnd)
})?,
)?;
g.set(
"UnitIsPVP",
lua.create_function(|lua, token: Value| {
-4
View File
@@ -155,10 +155,6 @@ pub struct UnitState {
/// reads disconnected, which greys a stock mana bar (`UnitFrame.lua:214`), so a synthetic
/// live unit must set it.
pub is_connected: bool,
/// `UnitIsAFK`, the roster status byte's `0x40`, fed for party tokens.
pub is_afk: bool,
/// `UnitIsDND`, the roster status byte's `0x80`, fed for party tokens.
pub is_dnd: bool,
/// `UnitIsPVPFreeForAll`: `PLAYER_FLAGS` bit `0x80`, or the roster status byte's `0x10`;
/// independent of [`Self::pvp`].
pub is_pvp_ffa: bool,
@@ -586,8 +586,6 @@ fn party_frame_predicates_report_1_or_nil() {
Some(UnitState {
exists: true,
is_connected: true,
is_afk: true,
is_dnd: false,
pvp: true,
is_pvp_ffa: false,
..Default::default()
@@ -598,10 +596,6 @@ fn party_frame_predicates_report_1_or_nil() {
.unwrap(),
1
);
assert_eq!(s.eval::<i64>(r#"return UnitIsAFK("party1")"#).unwrap(), 1);
assert!(s
.eval::<bool>(r#"return UnitIsDND("party1") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitIsPVP("party1")"#).unwrap(), 1);
assert!(s
.eval::<bool>(r#"return UnitIsPVPFreeForAll("party1") == nil"#)
@@ -612,8 +606,6 @@ fn party_frame_predicates_report_1_or_nil() {
Some(UnitState {
exists: true,
is_connected: false,
is_afk: false,
is_dnd: true,
pvp: false,
is_pvp_ffa: true,
..Default::default()
@@ -622,10 +614,6 @@ fn party_frame_predicates_report_1_or_nil() {
assert!(s
.eval::<bool>(r#"return UnitIsConnected("party1") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitIsAFK("party1") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitIsDND("party1")"#).unwrap(), 1);
assert!(s
.eval::<bool>(r#"return UnitIsPVP("party1") == nil"#)
.unwrap());
@@ -1618,24 +1606,6 @@ fn every_unit_predicate_is_one_or_nil_and_never_a_boolean() {
..Default::default()
},
),
(
"UnitIsAFK",
r#"UnitIsAFK("target")"#,
UnitState {
exists: true,
is_afk: true,
..Default::default()
},
),
(
"UnitIsDND",
r#"UnitIsDND("target")"#,
UnitState {
exists: true,
is_dnd: true,
..Default::default()
},
),
(
"UnitIsPVP",
r#"UnitIsPVP("target")"#,
-9
View File
@@ -635,15 +635,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetPlayerFacing() → the facing in radians. Not a 1.12 global.
g.set(
"GetPlayerFacing",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(f64::from(model.worldmap.player_facing))
})?,
)?;
// ProcessMapClick(x, y) (`0x4a7f30`): at world level the first continent whose sheet rect
// holds the click (`0x4a7100`); otherwise the zone or city under it through the displayed
// rect (`0x4a6ec0`), which it drills into.
+2 -1
View File
@@ -218,7 +218,8 @@ pub struct Region {
pub kind: RegionKind,
pub owner: FrameHandle,
pub draw_layer: DrawLayer,
/// A later client's `textureSubLevel`; 1.12 has no sub-level (`0x76a860`).
/// The engine's own order within a layer, which only the EditBox caret sets, to draw over the
/// text; 1.12 gives Lua and XML no sub-level (`0x76a860`, `0x79a780`).
pub sub_level: i8,
/// The index within the owner frame, the last within-layer draw tiebreak.
pub decl_seq: u32,
+6 -2
View File
@@ -37,8 +37,12 @@ the reference client's `_G`, which is not in this repo, so it is a manual regene
stable: it describes a client that shipped in 2006.
Reading it: `scripts/api-coverage.sh` asks a real `UiScript::new()` what benilla exposes and reports
`have / missing / beyond-1.12`. `crates/benilla-ui/src/script/tests/reference_surface.rs` is the
gate: it fails if benilla grows a global that 1.12 does not have and nobody wrote down why.
`have / missing / beyond-1.12`. Two gates hold the line.
`crates/benilla-ui/src/script/tests/reference_surface.rs` fails if the engine puts a global 1.12
does not have in a bare VM and nobody wrote down why, with no prefix excusing an engine name.
`crates/benilla-app/src/ui_script/surface_gate.rs` fails if the production load, the core and
benilla's layer, adds any global past 1.12 other than the layer's own `Benilla`-prefixed names, or
any widget method.
## `1.12-shapes.tsv` and `1.12-events.tsv`