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:
co-authored by
Claude Opus 5.5
parent
0a1f69466c
commit
78a983b00e
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(®ion, "SetTexture", (c[0], c[1], c[2], c[3]), dbg);
|
||||
}
|
||||
if let Some(mode) = t.attr("alphaMode") {
|
||||
this.call_region(®ion, "SetBlendMode", mode.to_string(), dbg);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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, ®ion)?;
|
||||
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()?;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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")"#,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user