classicapi: the last two with data behind them — item sets and area triggers
Both over DBCs 1.12 reads C-side and publishes to Lua nowhere, so both
are facts no addon on this realm could reach.
`C_Item.GetItemSetInfo`: the tooltip prints a set block and `GetItemInfo`
reports no set at all, so nothing can tell which pieces complete one. The
table's shape is its caller's own documentation (`SuperCleveRoidMacros/
ClassicAPI.lua:171-174`) — including the bonus pair being
`{ spellID, threshold }`, which is the REVERSE of the DBC's column order,
so the swap happens once in the host and not in every reader.
`C_Map.GetAreaTriggerInfo`: pfQuest's own comment says it reads trigger
geometry from this "instead of a shipped coordinate table", and the three
fields it takes — areaID, mapX, mapY — go straight into `pfMap:AddNode`,
whose coordinates are 0-100. So the percentages are the consumer's
measured shape rather than a guess. The zone resolution is the host's,
through the same `zone_uv` law the map window's own hover uses
(`WorldMapUiData::zone_at`), so a trigger lands in the zone the map would
name for that point — quirk included: a point exactly on a rect's east or
south edge reads as outside, which is that law's own `(0, 0)`. The three
map fields are absent TOGETHER when the centre resolves to no zone (an
instance interior), which is exactly the case pfQuest guards for.
Both ride host probes rather than pushed tables: the resolution needs the
`WorldMapArea` rects, which do not cross the seam, and neither catalog is
something to copy per VM for a verb nothing calls at boot. Two catalogs
gained an `iter()` for it, each said to be for this.
That leaves four of the original 56. Two have no caller in the corpus;
`C_FriendList.SetFriendNotes` has no note field on the 1.12 wire at all,
so a note typed and lost at logout would be worse than a button that does
nothing; and `C_BattleNet.SendGameData` stays deliberately absent, because
every caller guards it and registering it would claim a BattleNet this
client does not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -38,6 +38,8 @@ fn install_boot_vm(world: &mut World) {
|
||||
install_addon_asset_resolvers(world, &mut script);
|
||||
install_spell_catalog_probe(world, &mut script);
|
||||
install_item_icon_probe(world, &mut script);
|
||||
install_item_set_probe(world, &mut script);
|
||||
install_area_trigger_probe(world, &mut script);
|
||||
install_spell_modifier_probe(world, &mut script);
|
||||
install_custom_files(&mut script);
|
||||
// nampower's `EncryptPassword`: a pure function over the machine-local key file.
|
||||
@@ -203,6 +205,77 @@ pub(crate) fn install_spell_probe(script: &mut UiScript, catalog: &benilla_forma
|
||||
/// icon is the only column either verb wants, the rest of an `ItemDisplay` is model and texture
|
||||
/// paths for the renderer, and a VM build is rare (boot, world entry, `/reload`). Rows without an
|
||||
/// icon are left out, so a hit is always a path.
|
||||
/// `ItemSet.dbc` behind ClassicAPI's `C_Item.GetItemSetInfo`. The same catalog the item
|
||||
/// tooltip's set block reads (`ui_items::ItemSets`), handed over by id rather than pushed: four
|
||||
/// hundred rows of seventeen item columns is not a per-VM copy for a verb nothing calls at boot.
|
||||
fn install_item_set_probe(world: &World, script: &mut UiScript) {
|
||||
let Some(sets) = world.get_resource::<crate::ui_items::ItemSets>() else {
|
||||
return;
|
||||
};
|
||||
// Copied rather than shared by refcount: the catalog is small, the closure outlives the
|
||||
// system run, and `ItemSetCatalog` has no shared-handle accessor the way `SpellCatalog` does.
|
||||
let rows: std::collections::HashMap<u32, benilla_ui::script::ItemSetLookup> = sets
|
||||
.0
|
||||
.iter()
|
||||
.map(|(id, set)| {
|
||||
(
|
||||
id,
|
||||
benilla_ui::script::ItemSetLookup {
|
||||
name: set.name.clone(),
|
||||
// The empty item columns are dropped here, so `items[1]` is a real piece — the
|
||||
// same rule the spell probe applies to the reagent columns.
|
||||
items: set.items.iter().copied().filter(|i| *i != 0).collect(),
|
||||
// The DBC stores `(required count, spell id)`; the reader's documented pair is
|
||||
// `{ spellID, threshold }`, so the swap happens once, here.
|
||||
bonuses: set
|
||||
.bonuses
|
||||
.iter()
|
||||
.filter(|(count, spell)| *spell != 0 && *count != 0)
|
||||
.map(|(count, spell)| (*spell, *count))
|
||||
.collect(),
|
||||
required_skill: set.required_skill,
|
||||
required_skill_rank: set.required_skill_rank,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
script.set_item_set_probe(Box::new(move |id| rows.get(&id).cloned()));
|
||||
}
|
||||
|
||||
/// `AreaTrigger.dbc` behind ClassicAPI's `C_Map.GetAreaTriggerInfo`, each row's centre resolved to
|
||||
/// a zone and a map percentage by [`crate::ui_world_map::WorldMapUiData::zone_at`] — the same law
|
||||
/// the map window's hover uses, so a trigger lands where the map would put it.
|
||||
///
|
||||
/// Resolved **here**, once per VM build, rather than per call: the resolution needs the
|
||||
/// `WorldMapArea` rects, which do not cross the seam, and the whole table is a few thousand rows.
|
||||
/// A world with no map data (or no trigger DBC) installs no probe, and the verb answers nothing.
|
||||
fn install_area_trigger_probe(world: &World, script: &mut UiScript) {
|
||||
let Some(triggers) = world.get_resource::<crate::area_trigger::AreaTriggers>() else {
|
||||
return;
|
||||
};
|
||||
let maps = world.get_resource::<crate::ui_world_map::WorldMapUiData>();
|
||||
let rows: std::collections::HashMap<u32, benilla_ui::script::AreaTriggerLookup> = triggers
|
||||
.0
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.id,
|
||||
benilla_ui::script::AreaTriggerLookup {
|
||||
map_id: row.map_id,
|
||||
position: row.position,
|
||||
radius: row.radius,
|
||||
// `None` for a trigger on no mapped zone — an instance interior, or a point
|
||||
// outside every `WorldMapArea` rect. pfQuest tests for exactly that before
|
||||
// using the pair.
|
||||
zone: maps
|
||||
.and_then(|m| m.zone_at(row.map_id, row.position[0], row.position[1])),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
script.set_area_trigger_probe(Box::new(move |id| rows.get(&id).copied()));
|
||||
}
|
||||
|
||||
fn install_item_icon_probe(world: &World, script: &mut UiScript) {
|
||||
let Some(displays) = world.get_resource::<crate::entities::ItemDisplays>() else {
|
||||
return;
|
||||
|
||||
@@ -279,6 +279,27 @@ pub(crate) struct WorldMapCatalog(pub(crate) Vec<WorldMapContinentView>);
|
||||
/// [`crate::ui_script::load_ingame_ui_on_world_entry`]: addons read `GetMapContinents` and
|
||||
/// `GetMapZones` at file scope (Astrolabe builds its zone table there), and the reference holds
|
||||
/// this DBC data from load.
|
||||
impl WorldMapUiData {
|
||||
/// The zone a world point falls in on `map_id`, as `(AreaTable id, mapX, mapY)` with the two
|
||||
/// fractions in **percent** — the shape ClassicAPI's `C_Map.GetAreaTriggerInfo` answers and
|
||||
/// pfQuest feeds straight into `pfMap:AddNode` (`database.lua:805-806`), whose coordinates are
|
||||
/// 0-100.
|
||||
///
|
||||
/// Containment is [`map_proj::zone_uv`]'s own verdict rather than a fresh rect compare, so a
|
||||
/// point lands in the same zone the map window's hover would name it in — including its one
|
||||
/// quirk, that a point exactly on the east or south edge reads as outside (the law's `1 − …`
|
||||
/// hits 0 there, and the function reports `(0, 0)` for anything off the rect). The first
|
||||
/// matching zone wins; 1.12's zone rects do not overlap within a continent.
|
||||
pub(crate) fn zone_at(&self, map_id: u32, wx: f32, wy: f32) -> Option<(u32, f32, f32)> {
|
||||
let cont = self.continents.iter().find(|c| c.map_id == map_id)?;
|
||||
cont.zones.iter().find_map(|z| {
|
||||
let (u, v) = map_proj::zone_uv(z.rect, wx, wy);
|
||||
// `(0, 0)` is this law's "not on this map", which is why it cannot be a hit.
|
||||
((u, v) != (0.0, 0.0)).then_some((z.area_id, u * 100.0, v * 100.0))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn seed_world_map_catalog(world: &mut World, script: &mut UiScript) {
|
||||
if !world.contains_resource::<WorldMapCatalog>() {
|
||||
let Some((views, data)) = build_catalog_from_world(world) else {
|
||||
|
||||
@@ -85,6 +85,13 @@ impl AreaTriggerCatalog {
|
||||
self.by_map.values().flatten().find(|t| t.id == id)
|
||||
}
|
||||
|
||||
/// Every row, in no particular order. For the host probe behind ClassicAPI's
|
||||
/// `C_Map.GetAreaTriggerInfo`, which resolves the whole table once per VM build rather than
|
||||
/// walking the buckets per call the way [`Self::get`] does.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &AreaTriggerRow> {
|
||||
self.by_map.values().flatten()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ impl ItemSetCatalog {
|
||||
self.sets.get(&id)
|
||||
}
|
||||
|
||||
/// Every set as `(id, row)`. For the host probe behind ClassicAPI's `C_Item.GetItemSetInfo`,
|
||||
/// which needs the whole table once rather than one row at a time.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u32, &ItemSetInfo)> {
|
||||
self.sets.iter().map(|(id, set)| (*id, set))
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.sets.len()
|
||||
}
|
||||
|
||||
@@ -761,6 +761,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetItemSetInfo(setID)` -> the `ItemSet.dbc` row as a table, nil for a set id nothing
|
||||
// carries. The shape is the caller's own documentation (`SuperCleveRoidMacros/
|
||||
// ClassicAPI.lua:171-174`): `{ setID, name, requiredSkill, requiredSkillRank, items = {...},
|
||||
// bonuses = { { spellID, threshold }, ... } }` — note that the bonus pair is spell-then-count
|
||||
// where the DBC stores count-then-spell, so the order here is the reader's and not the file's.
|
||||
//
|
||||
// Through a host probe, like `C_Spell`'s: `ItemSet.dbc` is the app's catalog
|
||||
// (`ui_items::ItemSets`, which the item tooltip's set block already reads), and four hundred
|
||||
// rows of seventeen item columns is not a table to push across the seam for a verb nothing
|
||||
// calls at boot. 1.12 answers none of this in Lua — the tooltip prints the set block C-side
|
||||
// and `GetItemInfo` reports no set at all, so an addon cannot tell which pieces complete one.
|
||||
t.set(
|
||||
"GetItemSetInfo",
|
||||
lua.create_function(|lua, set_id: Option<u32>| {
|
||||
let found = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let probe = model.item_set_probe.as_ref();
|
||||
set_id
|
||||
.filter(|id| *id != 0)
|
||||
.and_then(|id| probe.and_then(|p| p(id)).map(|set| (id, set)))
|
||||
};
|
||||
let Some((id, set)) = found else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let out = lua.create_table()?;
|
||||
out.set("setID", id)?;
|
||||
out.set("name", set.name)?;
|
||||
out.set("requiredSkill", set.required_skill)?;
|
||||
out.set("requiredSkillRank", set.required_skill_rank)?;
|
||||
let items = lua.create_table()?;
|
||||
for (i, item) in (1..).zip(set.items) {
|
||||
items.set(i, item)?;
|
||||
}
|
||||
out.set("items", items)?;
|
||||
let bonuses = lua.create_table()?;
|
||||
for (i, (spell, threshold)) in (1..).zip(set.bonuses) {
|
||||
let pair = lua.create_table()?;
|
||||
pair.set(1, spell)?;
|
||||
pair.set(2, threshold)?;
|
||||
bonuses.set(i, pair)?;
|
||||
}
|
||||
out.set("bonuses", bonuses)?;
|
||||
Ok(Value::Table(out))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetWeaponEnchantInfo()`: the namespaced spelling of 1.12's own global
|
||||
// (`script/weapon_enchant.rs`), delegated whole — six values, two triples, main hand then
|
||||
// off hand.
|
||||
@@ -1488,4 +1534,41 @@ mod tests {
|
||||
(1, 3, 0)
|
||||
);
|
||||
}
|
||||
/// `GetItemSetInfo` over a probe, in the shape its one caller documents
|
||||
/// (`SuperCleveRoidMacros/ClassicAPI.lua:171-174`) — including the bonus pair being
|
||||
/// `{ spellID, threshold }`, which is the reverse of the DBC's own column order.
|
||||
#[test]
|
||||
fn the_item_set_table_is_the_shape_its_caller_documents() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_item_set_probe(Box::new(|id| {
|
||||
(id == 181).then(|| crate::script::ItemSetLookup {
|
||||
name: "Lightforge Armor".into(),
|
||||
items: vec![16729, 16730, 16731],
|
||||
// Already swapped by the host: spell then count.
|
||||
bonuses: vec![(13664, 3), (13665, 5)],
|
||||
required_skill: 0,
|
||||
required_skill_rank: 0,
|
||||
})
|
||||
}));
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"local set = C_Item.GetItemSetInfo(181)
|
||||
return set.setID == 181
|
||||
and set.name == "Lightforge Armor"
|
||||
and table.getn(set.items) == 3 and set.items[1] == 16729
|
||||
and table.getn(set.bonuses) == 2
|
||||
and set.bonuses[1][1] == 13664 and set.bonuses[1][2] == 3
|
||||
and set.bonuses[2][2] == 5
|
||||
and set.requiredSkill == 0"#
|
||||
)
|
||||
.unwrap());
|
||||
assert!(
|
||||
s.eval::<bool>("return C_Item.GetItemSetInfo(999) == nil")
|
||||
.unwrap(),
|
||||
"a set id nothing carries is nil, not an empty set"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return C_Item.GetItemSetInfo() == nil")
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +466,51 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetAreaTriggerInfo(triggerID)` -> the trigger's geometry and where it is on the map, or
|
||||
// nil for an id `AreaTrigger.dbc` does not carry.
|
||||
//
|
||||
// **pfQuest is the reader, and it replaces a shipped coordinate table with this**: its own
|
||||
// comment says it "reads the trigger's geometry live from the client's AreaTrigger.dbc via
|
||||
// ClassicAPI instead of a shipped coordinate table" (`database.lua:781-782`), and the three
|
||||
// fields it takes — `areaID`, `mapX`, `mapY` — go straight into `pfMap:AddNode`, whose
|
||||
// coordinates are 0-100. So the percentages are the measured shape, from the consumer.
|
||||
//
|
||||
// `areaID`/`mapX`/`mapY` are **absent together** when the centre resolves to no zone rect (an
|
||||
// instance interior, or a trigger outside every `WorldMapArea`), which is exactly the case
|
||||
// pfQuest tests for before using them. The geometry fields are always there, since they are
|
||||
// the DBC row itself.
|
||||
//
|
||||
// Resolution is the host's, through the same `zone_uv` law the map window's own hover uses
|
||||
// (`ui_world_map.rs`'s `WorldMapUiData::zone_at`), so a trigger lands in the zone the map
|
||||
// would name for that point rather than in one this file decided on.
|
||||
t.set(
|
||||
"GetAreaTriggerInfo",
|
||||
lua.create_function(|lua, trigger_id: Option<u32>| {
|
||||
let found = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let probe = model.area_trigger_probe.as_ref();
|
||||
trigger_id
|
||||
.filter(|id| *id != 0)
|
||||
.and_then(|id| probe.and_then(|p| p(id)))
|
||||
};
|
||||
let Some(row) = found else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let out = lua.create_table()?;
|
||||
out.set("mapID", row.map_id)?;
|
||||
out.set("x", f64::from(row.position[0]))?;
|
||||
out.set("y", f64::from(row.position[1]))?;
|
||||
out.set("z", f64::from(row.position[2]))?;
|
||||
out.set("radius", f64::from(row.radius))?;
|
||||
if let Some((area_id, map_x, map_y)) = row.zone {
|
||||
out.set("areaID", area_id)?;
|
||||
out.set("mapX", f64::from(map_x))?;
|
||||
out.set("mapY", f64::from(map_y))?;
|
||||
}
|
||||
Ok(Value::Table(out))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
@@ -1048,4 +1093,58 @@ mod tests {
|
||||
"the second read of the same row re-measures nothing"
|
||||
);
|
||||
}
|
||||
/// `GetAreaTriggerInfo` in the shape pfQuest reads (`database.lua:789-806`): the three fields
|
||||
/// it tests for, in percent, and absent **together** when the centre resolves to no zone.
|
||||
#[test]
|
||||
fn the_area_trigger_table_carries_what_pfquest_tests_for() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_area_trigger_probe(Box::new(|id| match id {
|
||||
// A trigger on a mapped zone: resolved.
|
||||
4128 => Some(crate::script::AreaTriggerLookup {
|
||||
map_id: 0,
|
||||
position: [-8900.0, -130.0, 80.0],
|
||||
radius: 7.0,
|
||||
zone: Some((1519, 48.5, 62.25)),
|
||||
}),
|
||||
// One inside an instance, where no `WorldMapArea` rect holds it.
|
||||
100 => Some(crate::script::AreaTriggerLookup {
|
||||
map_id: 33,
|
||||
position: [0.0, 0.0, 0.0],
|
||||
radius: 0.0,
|
||||
zone: None,
|
||||
}),
|
||||
_ => None,
|
||||
}));
|
||||
|
||||
// pfQuest's own guard and reads, transcribed.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"local t = C_Map.GetAreaTriggerInfo(4128)
|
||||
if not t or not t.areaID or not t.mapX or not t.mapY then return false end
|
||||
return t.areaID == 1519 and t.mapX == 48.5 and t.mapY == 62.25"#
|
||||
)
|
||||
.unwrap());
|
||||
// The geometry is there either way, since it is the DBC row itself.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"local t = C_Map.GetAreaTriggerInfo(4128)
|
||||
return t.mapID == 0 and t.radius == 7 and t.x == -8900"#
|
||||
)
|
||||
.unwrap());
|
||||
// An unresolvable centre drops the three together — which is what that guard is for.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"local t = C_Map.GetAreaTriggerInfo(100)
|
||||
return t ~= nil and t.areaID == nil and t.mapX == nil and t.mapY == nil
|
||||
and t.mapID == 33"#
|
||||
)
|
||||
.unwrap());
|
||||
// An id the DBC does not carry is nil, not an empty table.
|
||||
assert!(s
|
||||
.eval::<bool>("return C_Map.GetAreaTriggerInfo(999999) == nil")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return C_Map.GetAreaTriggerInfo() == nil")
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,11 @@ pub use mail::{MailInboxRow, MailInvoice, MailSendRequest, MailState, Stationery
|
||||
pub use measure::TextMeasure;
|
||||
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
|
||||
pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs;
|
||||
pub use model::{
|
||||
AreaTriggerLookup, AreaTriggerProbe, FontProbe, ItemIconProbe, ItemSetLookup, ItemSetProbe,
|
||||
SpellDbcExtras, SpellIconProbe, SpellLookup, SpellMechanicProbe, SpellProbe, SpellUsable,
|
||||
TextureProbe, TextureSizeProbe,
|
||||
};
|
||||
pub use model::{
|
||||
BlipOverrideKey, BlipOverrideRequest, ClassInfo, CreatureModelProbe, CreatureModelView,
|
||||
RaceInfo,
|
||||
@@ -249,10 +254,6 @@ pub use model::{
|
||||
QueueRequest, RecValue, SelfCast, SelfMovement, ServerClock, SpellModifierProbe,
|
||||
SpellRangeProbe, SpellRecFieldProbe, SpellRecordProbe,
|
||||
};
|
||||
pub use model::{
|
||||
FontProbe, ItemIconProbe, SpellDbcExtras, SpellIconProbe, SpellLookup, SpellMechanicProbe,
|
||||
SpellProbe, SpellUsable, TextureProbe, TextureSizeProbe,
|
||||
};
|
||||
pub(crate) use model::{Model, PendingTimer};
|
||||
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
|
||||
pub use pet::{PetActionView, PetStats};
|
||||
|
||||
@@ -79,6 +79,48 @@ pub struct SpellLookup {
|
||||
/// back to the spellbook.
|
||||
pub type SpellProbe = Box<dyn Fn(u32) -> Option<SpellLookup>>;
|
||||
|
||||
/// One `ItemSet.dbc` row, for `C_Item.GetItemSetInfo`: the host's catalog answers by set id, since
|
||||
/// `ItemSet.dbc` is the app's (`benilla-app`'s `ui_items::ItemSets`) and 400-odd rows of ten items
|
||||
/// each is not something to push across the seam for a verb nothing calls at boot.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ItemSetLookup {
|
||||
/// `ItemSet.dbc`'s localized `Name`.
|
||||
pub name: String,
|
||||
/// The member item ids, empty rows dropped — so `items[1]` is a real item and a set with
|
||||
/// three pieces has three entries, not seventeen with holes.
|
||||
pub items: Vec<u32>,
|
||||
/// `(spell id, required equipped count)` per bonus, in stored slot order. **That order is the
|
||||
/// caller's**: `SuperCleveRoidMacros/ClassicAPI.lua:171` documents the pair as
|
||||
/// `{ spellID, threshold }`, where the DBC's own layout is the other way round.
|
||||
pub bonuses: Vec<(u32, u32)>,
|
||||
pub required_skill: u32,
|
||||
pub required_skill_rank: u32,
|
||||
}
|
||||
|
||||
/// `ItemSet.dbc` by set id.
|
||||
pub type ItemSetProbe = Box<dyn Fn(u32) -> Option<ItemSetLookup>>;
|
||||
|
||||
/// One `AreaTrigger.dbc` row with its position already resolved to a zone, for
|
||||
/// `C_Map.GetAreaTriggerInfo`. The resolution is the host's: it needs the `WorldMapArea` rects
|
||||
/// and the same `zone_uv` law the map window's hover uses, neither of which crosses the seam.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AreaTriggerLookup {
|
||||
/// `Map.dbc` id the volume lives on.
|
||||
pub map_id: u32,
|
||||
/// Centre, WoW world space.
|
||||
pub position: [f32; 3],
|
||||
/// Sphere radius in yards; `0` makes the row an oriented box.
|
||||
pub radius: f32,
|
||||
/// The `AreaTable` id of the zone the centre falls in, and the point as **percent** across
|
||||
/// that zone's map. `None` when the centre resolves to no zone rect — an instance interior,
|
||||
/// or a trigger outside every `WorldMapArea` — which is exactly the case pfQuest tests for
|
||||
/// (`if not trigger.areaID or not trigger.mapX`).
|
||||
pub zone: Option<(u32, f32, f32)>,
|
||||
}
|
||||
|
||||
/// `AreaTrigger.dbc` by trigger id, zone-resolved.
|
||||
pub type AreaTriggerProbe = Box<dyn Fn(u32) -> Option<AreaTriggerLookup>>;
|
||||
|
||||
/// The host's answer for an `ItemDisplayInfo` row id: its inventory icon as an extensionless
|
||||
/// `Interface\Icons\…` path. Keyed on the **display** id, not the item id, because that is the
|
||||
/// column an item template carries and the one the reference's own vendor-cursor path resolves
|
||||
@@ -552,6 +594,10 @@ pub(crate) struct Model {
|
||||
/// `Spell.dbc` by id for `C_Spell`'s getters, past the player's own book; `None` restricts
|
||||
/// them to the book, which is all a VM with no host can see.
|
||||
pub(crate) spell_probe: Option<SpellProbe>,
|
||||
/// `ItemSet.dbc`, for `C_Item.GetItemSetInfo`.
|
||||
pub(crate) item_set_probe: Option<ItemSetProbe>,
|
||||
/// `AreaTrigger.dbc`, for `C_Map.GetAreaTriggerInfo`.
|
||||
pub(crate) area_trigger_probe: Option<AreaTriggerProbe>,
|
||||
pub(crate) spell_mechanic_probe: Option<SpellMechanicProbe>,
|
||||
/// Saved-variables folders, one `<Addon>.lua` each: the account's, then this character's.
|
||||
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
|
||||
@@ -1855,6 +1901,8 @@ impl Model {
|
||||
measurer: None,
|
||||
texture_probe: None,
|
||||
spell_probe: None,
|
||||
item_set_probe: None,
|
||||
area_trigger_probe: None,
|
||||
item_icon_probe: None,
|
||||
spell_modifier_probe: None,
|
||||
spell_rec_field_probe: None,
|
||||
|
||||
@@ -204,6 +204,13 @@ impl super::UiScript {
|
||||
self.model_mut().spell_probe = Some(probe);
|
||||
}
|
||||
|
||||
/// The host's `ItemSet.dbc`, for `C_Item.GetItemSetInfo`. Installed on every VM build like the
|
||||
/// spell probe above; a VM without one answers nothing, which is what an unknown set id
|
||||
/// answers anyway.
|
||||
pub fn set_item_set_probe(&mut self, probe: crate::script::ItemSetProbe) {
|
||||
self.model_mut().item_set_probe = Some(probe);
|
||||
}
|
||||
|
||||
/// Install the `Spell.dbc` mechanic lookup; see [`crate::script::SpellMechanicProbe`].
|
||||
pub fn set_spell_mechanic_probe(&mut self, probe: crate::script::SpellMechanicProbe) {
|
||||
self.model_mut().spell_mechanic_probe = Some(probe);
|
||||
|
||||
@@ -330,6 +330,10 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// 1.12 prints in a tooltip and returns from nothing, so no addon can compare two pieces
|
||||
// of gear; `C_Item.RequestLoadItemDataByID` is the modern async item load over the
|
||||
// ask-once queue the item mixin already drives.
|
||||
// `C_Item.GetItemSetInfo` and `C_Map.GetAreaTriggerInfo` ride host probes over two DBCs
|
||||
// 1.12 reads C-side and publishes nowhere: the tooltip prints a set block and
|
||||
// `GetItemInfo` reports no set at all, and `AreaTrigger.dbc` reaches Lua through nothing —
|
||||
// which is why pfQuest shipped a coordinate table instead and now reads the file.
|
||||
// ClassicAPI's detection pair (script/compat/classic_api/mod.rs): pfUI gates its whole
|
||||
// load on `CLASSIC_API_VERSION`, so this one is load-bearing, not cosmetic.
|
||||
"CLASSIC_API_VERSION",
|
||||
|
||||
@@ -393,6 +393,13 @@ fn revealed_overlays(state: &WorldMapState) -> Vec<&WorldMapOverlayView> {
|
||||
|
||||
impl super::UiScript {
|
||||
/// Pushes the catalog at startup, queueing `WORLD_MAP_UPDATE` so an open map repaints.
|
||||
/// The host's `AreaTrigger.dbc`, zone-resolved, for `C_Map.GetAreaTriggerInfo`. Installed on
|
||||
/// every VM build like the spell and item-set probes; a VM without one answers nothing, which
|
||||
/// is what an unknown trigger id answers anyway.
|
||||
pub fn set_area_trigger_probe(&mut self, probe: crate::script::AreaTriggerProbe) {
|
||||
self.model_mut().area_trigger_probe = Some(probe);
|
||||
}
|
||||
|
||||
pub fn set_world_map_catalog(&mut self, continents: Vec<WorldMapContinentView>) {
|
||||
let n = continents.len();
|
||||
{
|
||||
|
||||
@@ -228,7 +228,7 @@ separately, so "cannot say" has to be distinguishable from "no".
|
||||
| `EventRegistry`, `CallbackRegistryMixin:RegisterFrameEvent`/`RegisterFrameEventAndCallback` | done | `compat/classic_api/callback_registry.rs`. The bridge from real frame events into the callback bus; pfUI's new-item tracker is `EventRegistry:RegisterFrameEventAndCallback("BAG_NEW_ITEMS_UPDATED", ...)` (`modules/newitem.lua:60`). 1.12's `OnEvent` takes **no parameters** — the handler reads the globals `event`/`arg1..arg9` with `this` as the frame — so the forwarder cannot be upstream's `function(_, event, ...)`. It is defined at **chunk scope** on purpose: written as a closure inside `RegisterFrameEvent` it would capture that function's `event` parameter and shadow the per-fire global, making the frame report every event under the name of whichever registered first. A two-event test catches exactly that and was verified to fail against the closure version (`left: (1, 0)` — the wrong callback fired) |
|
||||
| `GameTooltip_Hide` | done | `compat/classic_api/tooltip.rs`. A later-client FrameXML helper: `reference/1.12-globals.tsv` lists exactly six `GameTooltip_*` functions and this is not among them. Its purpose is to be passed *by name* as an `OnLeave` handler, which is all five of its call sites in the installed set — and nil there is not a no-op, it raised inside `HookScript` and took the rest of the module with it (`modules/friendnotes.lua:90`). Checked at the same time: `SetItemButtonTexture`/`SetItemButtonCount`, also called by the addons, **are** genuine 1.12 FrameXML globals and need nothing |
|
||||
| `C_Spell.GetSpellReagents` | done | `compat/classic_api/spell.rs`, over new `SpellLookup::reagents` — `Spell.dbc`'s `Reagent[8]`/`ReagentCount[8]`, which `benilla-formats` already parses (`spells/display.rs:122`, with `catalog_tests.rs` pinning Slow Fall's `(17056, 1)`). Empty columns are dropped at the feed, so `reagents[1]` is the first *real* reagent. Returns an empty table for a spell that needs nothing and **nil** for an unknown id — genuinely different answers, both of which pfUI's `if reagents and reagents[1]` reads correctly (`modules/actionbar.lua:1553`). The subtle part: `spellbook.rs`'s `spell_display` resolves a *known* spell from the book, and a book slot carries no reagent columns — so the book arm now takes reagents from the probe while keeping the book's name/rank/icon. Without that, the spells most likely to be asked about (the ones on your bars) would be exactly the ones wrongly reporting no reagents; a test pins it and was verified to fail otherwise |
|
||||
| **ClassicAPI tail — the measured remainder** | **6 left of 56** | Re-measured from scratch rather than trusted: of the **153** distinct `C_<Namespace>.<Member>` names grom's addons reference, a fresh VM answered all but **56** — 24 where the namespace existed and the member did not, and **32 where the whole namespace was absent**, which is the half that *raises* rather than returning nil. (The earlier sweep's "46 across 15 namespaces" undercounted, and its own grouping had `C_Spell.UnitCastingInfo` down as missing when it was built.) Fifty are now in, in four commits: `C_ChatInfo`, `C_LossOfControl`, `C_EncodingUtil`, `C_EquipmentSet`, `C_LootHistory`, the seven `C_AddOns` members, five on `C_Spell`, two on `C_SpellBook`, three on `C_Item` and the `C_QuestLog` abandon pair. **The six that remain, each with what it needs:** `C_Map.GetAreaTriggerInfo` (pfQuest reads trigger geometry from it instead of a shipped coordinate table — `AreaTrigger.dbc` *is* parsed in `benilla-formats`, so this is a host probe plus the world-point-to-zone resolution `GetMapPositionWorldLoc` already inverts); `C_Item.GetItemSetInfo` (likewise — `ItemSet.dbc` is parsed, and SuperCleveRoids documents the exact table shape it wants, so this is a probe); `C_Item.GetEnchantInfo` and `C_Item.UseAtCursor` (no caller in the corpus beyond the name); `C_FriendList.SetFriendNotes` (1.12 has **no note field on the wire at all** — notes arrived in 2.x — so a note the player typed would live until logout and then be gone, which is worse than a button that does nothing); and `C_BattleNet.SendGameData`, which stays **deliberately absent**: every caller guards it (`_G.C_BattleNet and _G.C_BattleNet.SendGameData or _G.BNSendGameData`), and registering it would claim a BattleNet this client does not have, so the guard is doing exactly its job |
|
||||
| **ClassicAPI tail — the measured remainder** | **4 left of 56** | Re-measured from scratch rather than trusted: of the **153** distinct `C_<Namespace>.<Member>` names grom's addons reference, a fresh VM answered all but **56** — 24 where the namespace existed and the member did not, and **32 where the whole namespace was absent**, which is the half that *raises* rather than returning nil. (The earlier sweep's "46 across 15 namespaces" undercounted, and its own grouping had `C_Spell.UnitCastingInfo` down as missing when it was built.) Fifty are now in, in four commits: `C_ChatInfo`, `C_LossOfControl`, `C_EncodingUtil`, `C_EquipmentSet`, `C_LootHistory`, the seven `C_AddOns` members, five on `C_Spell`, two on `C_SpellBook`, three on `C_Item` and the `C_QuestLog` abandon pair. **Two more went in behind host probes**, each over a DBC 1.12 reads C-side and publishes nowhere: `C_Item.GetItemSetInfo` (the tooltip prints a set block and `GetItemInfo` reports no set at all, so no addon can tell which pieces complete one) and `C_Map.GetAreaTriggerInfo` — pfQuest's own comment says it reads trigger geometry from this "instead of a shipped coordinate table", and the three fields it takes go straight into `pfMap:AddNode`, so the map percentages are the *consumer's* measured shape. The zone resolution is the host's, through the same `zone_uv` law the map window's hover uses, so a trigger lands in the zone the map would name for that point. **The four that remain, each with what it needs:** `C_Item.GetEnchantInfo` and `C_Item.UseAtCursor` (no caller in the corpus beyond the name); `C_FriendList.SetFriendNotes` (1.12 has **no note field on the wire at all** — notes arrived in 2.x — so a note the player typed would live until logout and then be gone, which is worse than a button that does nothing); and `C_BattleNet.SendGameData`, which stays **deliberately absent**: every caller guards it (`_G.C_BattleNet and _G.C_BattleNet.SendGameData or _G.BNSendGameData`), and registering it would claim a BattleNet this client does not have, so the guard is doing exactly its job |
|
||||
| `select` is nil, and real addons call it | open question, maintainer's call | Evidence for the standing stdlib-restore question in `lua_ext.rs`/`string_methods.rs`, not a new one. `lua50.rs` keeps `select` nil for 1.12 fidelity, and pfUI calls `select(7, GetTotemInfo(id))` in live handlers (`modules/totems.lua:18`, `:32`) — so on a real OctoWoW install, where ClassicAPI backports the 5.1 stdlib, those work and here they raise. Currently unreachable (the totem bar is always empty, see that row), so it costs nothing *today*; it is logged because it is the same decision as the string-metatable deviation and should be settled once, for the whole corpus, rather than per call site. Also confirmed while working here: **`#` does not parse in this VM at all**, so any chunk written for it must use `table.getn` |
|
||||
| Lua 5.1 stdlib: `select`, `string.match`, `string.gmatch`, `math.fmod`, `math.modf` | done — **decision taken** | `compat/classic_api/stdlib_backport.rs`. This **resolves** the standing open question above, decided by the maintainer on live evidence (pfUI's `modules/marktracking.lua:300` uses `string.match` to parse a raid-target token and raised 8 times in one session). Scoped by **measurement**, not wholesale: these are the only five members any installed addon calls (12/7/5/2/1 sites). Deliberately still absent — `string.reverse`, `strrev`, `table.maxn`, `math.huge`, the hyperbolics, `coroutine`: nothing calls them, and `table.maxn` would fight `lua50.rs`'s `n`-based table library, which is 5.0 semantics this client needs. **Lives in `compat/`, not `lua50.rs`**: the dialect layer keeps describing 1.12 truthfully and compat re-adds what ClassicAPI adds, so a build that drops `compat/` is still strict 1.12. The restored functions are the **real 5.1 originals**, stashed under `lua50::REG_LUA51_PREFIX` before nil-ing — nothing is reimplemented, because a hand-rolled `string.match` would be a second, subtly different pattern engine, and a test proves the genuine one is in place (position captures, the non-greedy `-` quantifier). **Accepted cost, stated plainly**: an addon writing `if string.gmatch then` now takes the modern branch — which on this server, with ClassicAPI loaded, is the correct answer. Four fidelity gates updated rather than relaxed: `lua50.rs`'s own test now asserts the dialect still lists these on `REMOVED` *and* that they are reachable in the finished VM, so "compat restores it" is not licence for the dialect to stop removing it |
|
||||
| `SetOwner: unknown anchor 'BOTTOMRIGHT'` | **unexplained — diagnostic added** | 566 occurrences in one live session, the loudest line in the log by 20×, and the cause is **not yet known**. Ruled out: every `SetOwner` call site in every installed addon `.lua` and `.xml` passes a valid `ANCHOR_*` string (verified with a paren-matching scan, after a naive regex truncated nested calls and gave a false reading); `script/tooltip/verbs.rs` does no prefix-stripping, matching the full uppercased name; and it does **not** reproduce under the addon harness, so it needs real in-world hovering. It is a *warning*, not an error — the anchor falls back to `ANCHOR_LEFT` and the tooltip still draws, just anchored wrongly. The warning now names the **owner frame**, which distinguishes an addon caller from the stock FrameXML off the player's patch chain; the next session log should identify it. Note `"ANCHOR_TOP"` (`pfUI/modules/mapreveal.lua:44`) and `"NONE"` (`modules/buffwatch.lua:117`) are separately invalid in 1.12 — the reference has nine anchor modes and neither is among them — so defaulting those to Left matches the reference's own silent behaviour |
|
||||
|
||||
Reference in New Issue
Block a user