superwow: the map pair, GetQuestLinkForID, GetWeaponEnchantID
Four more off the DLL's manifest, and two measured corrections. GetMapPositionWorldLoc is the INVERSE of GetPlayerMapPosition, which is what the test asserts rather than three numbers: `map_proj::zone_world` is the reference's `0x4a7100`, whose own note is that used one axis per call it inverts the `zone_uv` that verb answers from. The law is transcribed into benilla-ui (which cannot depend on benilla-world) and the round trip is welded, which is the thing that actually keeps the two in step. GetMapBoundaries shares one new `worldmap::loc_rect_at` with the hover grid's own windowing, so no two readers of a (continent, zone) selection can disagree about which rect it means. GetWeaponEnchantID is the one thing GetWeaponEnchantInfo cannot say: the reference reads that very dword as its gate and then reports only that there WAS an enchant, so nothing on the stock surface can tell Windfury from Rockbiter. The id was already in hand and discarded as a `?` gate, so this was one field on the snapshot, plus a test that the two functions cannot disagree about which hands are enchanted. GetQuestLinkForID answers for a quest in the log and nil otherwise — which is also what upstream answers on the first call for one it fetches. The gap is one feed, not new protocol: the ask-once CMSG_QUEST_QUERY cache already exists app-side. That row also corrected the LINK FORMAT for both quest getters. The DLL's own formatter is `|cffffff00|Hquest:%d:%d|h[%s]|h|r`; ours was built without the colour wrapper on the reasoning that it matched the stock quest-log shift-click — but 1.12 has no quest links at all, so there was no stock shape to match and the measured string is the only authority. The four sound-driver getters are deliberately NOT built, with the reason recorded: every one of their return shapes is unknowable from here, none is called in the corpus, and this client has one backend and no enumeration to report — the same bargain KEY_DOWN/KEY_UP lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1267,10 +1267,13 @@ fn weapon_enchant(
|
||||
.countdowns(guid)
|
||||
.and_then(|c| c.enchant_deadline_ms(u32::from(TEMP_ENCHANTMENT_SLOT)));
|
||||
let obj = objects.object(guid)?;
|
||||
obj.item_enchant(TEMP_ENCHANTMENT_SLOT)?;
|
||||
// The id is the reference's own gate (`0x4c97f8`, a nonzero dword with no DBC lookup) and is
|
||||
// now kept rather than discarded: SuperWoW's `GetWeaponEnchantID` is exactly this number.
|
||||
let enchant_id = obj.item_enchant(TEMP_ENCHANTMENT_SLOT)?;
|
||||
Some(benilla_ui::script::WeaponEnchant {
|
||||
remaining_ms,
|
||||
charges: obj.item_enchant_charges(TEMP_ENCHANTMENT_SLOT),
|
||||
enchant_id,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -645,6 +645,7 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
|
||||
|
||||
s.set_weapon_enchants(
|
||||
Some(benilla_ui::script::WeaponEnchant {
|
||||
enchant_id: 1,
|
||||
remaining_ms: Some(480_000),
|
||||
charges: 0,
|
||||
}),
|
||||
@@ -724,10 +725,12 @@ fn an_idle_enchant_row_rewrites_the_bar_as_the_reference_does() {
|
||||
// Both hands: off hand in slot 1, main hand in slot 2 to its left (`BuffFrame.lua:179-223`).
|
||||
s.set_weapon_enchants(
|
||||
Some(benilla_ui::script::WeaponEnchant {
|
||||
enchant_id: 1,
|
||||
remaining_ms: Some(480_000),
|
||||
charges: 0,
|
||||
}),
|
||||
Some(benilla_ui::script::WeaponEnchant {
|
||||
enchant_id: 1,
|
||||
remaining_ms: Some(120_000),
|
||||
charges: 0,
|
||||
}),
|
||||
|
||||
@@ -8,6 +8,13 @@ use mlua::{Lua, Value};
|
||||
|
||||
use super::super::Model;
|
||||
|
||||
/// A quest chat link, the DLL's own `|cffffff00|Hquest:%d:%d|h[%s]|h|r` (its format string, read
|
||||
/// off `SuperWoWhook.dll`). Shared by `GetQuestLinkForLogIndex` and `GetQuestLinkForID`, which
|
||||
/// upstream builds with the one formatter.
|
||||
fn quest_link(quest_id: u32, level: u32, title: &str) -> String {
|
||||
format!("|cffffff00|Hquest:{quest_id}:{level}|h[{title}]|h|r")
|
||||
}
|
||||
|
||||
/// `(x, y, z)` as three Lua values, nil three times for an unresolved position — never a table,
|
||||
/// as the wiki's `UnitPosition`/`CursorPosition` are read as `local x, y, z = UnitPosition(...)`.
|
||||
fn position_values(position: Option<(f32, f32, f32)>) -> (Value, Value, Value) {
|
||||
@@ -51,9 +58,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// and the bare name cannot disagree. A header row's id is `0`, as that one documents, and an
|
||||
// index past the end is nil.
|
||||
//
|
||||
// The link is `|Hquest:<id>:<level>|h[<title>]|h`, the shape the stock quest-log shift-click
|
||||
// builds and `item_mixin`'s own fixture pins. A header row has no link — nil, not a link to
|
||||
// quest 0 — and the level is the row's, which is what a receiver's tooltip shows.
|
||||
// The link is `|cffffff00|Hquest:%d:%d|h[%s]|h|r`, **the DLL's own format string**, read off
|
||||
// its string pool beside these two names. It had been built here without the colour wrapper,
|
||||
// on the reasoning that it matched the stock quest-log shift-click — but 1.12 has no quest
|
||||
// links at all, so there was no stock shape to match and the measured one is the only
|
||||
// authority. A header row has no link — nil, not a link to quest 0 — and the level is the
|
||||
// row's, which is what a receiver's tooltip shows.
|
||||
g.set(
|
||||
"GetQuestIDForLogIndex",
|
||||
lua.create_function(|lua, index: Option<usize>| {
|
||||
@@ -72,7 +82,34 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.and_then(|i| i.checked_sub(1))
|
||||
.and_then(|i| model.quest_log.entries.get(i))
|
||||
.filter(|e| !e.is_header && e.quest_id != 0)
|
||||
.map(|e| format!("|Hquest:{}:{}|h[{}]|h", e.quest_id, e.level, e.title)))
|
||||
.map(|e| quest_link(e.quest_id, e.level, &e.title)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetQuestLinkForID(questID): the same link for a quest named by **id** rather than by log
|
||||
// row — `GetQuestLinkForLogIndex`'s sibling, and the one of the pair that does not need the
|
||||
// quest to be in the log at all.
|
||||
//
|
||||
// **Partial, and the gap is one feed.** This answers from the quest log, which is the only
|
||||
// id-to-title source the VM holds. Upstream reaches further: its next string after this one's
|
||||
// usage line is `Requesting quest ID %d from server`, so a quest the log does not hold is a
|
||||
// `CMSG_QUEST_QUERY` and a nil until the answer lands. This client already has that exact
|
||||
// machinery app-side — `ui_quest_log.rs`'s ask-once `QuestLog::template` cache, over the same
|
||||
// opcode — so finishing the row means a request queue out of here and a (id → title, level)
|
||||
// feed back, not new protocol work. Until then a quest outside the log is nil, which is also
|
||||
// what upstream answers on the first call for one.
|
||||
g.set(
|
||||
"GetQuestLinkForID",
|
||||
lua.create_function(|lua, quest_id: Option<u32>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(quest_id.filter(|id| *id != 0).and_then(|id| {
|
||||
model
|
||||
.quest_log
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| !e.is_header && e.quest_id == id)
|
||||
.map(|e| quest_link(e.quest_id, e.level, &e.title))
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -218,6 +255,91 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// is dropped by the same rule every routed line follows, which is upstream's behaviour too —
|
||||
// the function appends to an open file.
|
||||
//
|
||||
// GetWeaponEnchantID(): the two temporary weapon enchantments' own ids, main hand then off
|
||||
// hand, nil for a weapon with none — the one thing `GetWeaponEnchantInfo` cannot say. The
|
||||
// reference reads that very dword as its gate (`0x4c97f8`, nonzero, no DBC lookup) and then
|
||||
// reports only that there *was* one, so on the stock surface no addon can tell Windfury from
|
||||
// Rockbiter, or a shaman's imbue from a rogue's poison.
|
||||
//
|
||||
// **The shape is a reading, not a measurement**: upstream publishes no usage string (the
|
||||
// function takes no argument) and nothing in grom's corpus calls it. Two returns in main-then-
|
||||
// off-hand order is the shape its sibling already answers in — `GetWeaponEnchantInfo` is six
|
||||
// values as two triples in that order — and a caller reading only the first gets the hand that
|
||||
// matters either way.
|
||||
g.set(
|
||||
"GetWeaponEnchantID",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let id = |hand: usize| -> Value {
|
||||
match model.weapon_enchants[hand] {
|
||||
Some(e) => Value::Integer(i64::from(e.enchant_id)),
|
||||
None => Value::Nil,
|
||||
}
|
||||
};
|
||||
Ok(mlua::MultiValue::from_vec(vec![id(0), id(1)]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMapBoundaries(continentIndex, zoneIndex) and
|
||||
// GetMapPositionWorldLoc(continentIndex, zoneIndex, mapX, mapY): the world rect a map covers,
|
||||
// and the inverse of `GetPlayerMapPosition` — a point on the map back to world coordinates.
|
||||
// Both indices are the pair `SetMapZoom` takes, 1-based, with zone `0` meaning the whole
|
||||
// continent; they resolve through `worldmap::loc_rect_at`, the same windowing the hover grid
|
||||
// does, so no two readers of a selection can disagree about which rect it means.
|
||||
//
|
||||
// **The position maths is this client's own forward projection, inverted.**
|
||||
// `benilla-world`'s `map_proj::zone_world` is the reference's `0x4a7100`, whose own note is
|
||||
// that used one axis per call it inverts `zone_uv` — the function `GetPlayerMapPosition`
|
||||
// answers from. Transcribed here rather than called because `benilla-ui` does not depend on
|
||||
// `benilla-world`; the round trip is asserted in a test, which is the thing that actually
|
||||
// keeps the two in step.
|
||||
//
|
||||
// `mapX` is the horizontal fraction and so the world **Y** axis, `mapY` the vertical and so
|
||||
// world **X** — `WorldMapArea`'s own convention (its left/right are world Y, its top/bottom
|
||||
// world X), and the reason the returns are named world *loc* rather than x and y.
|
||||
//
|
||||
// **What could not be settled**: upstream publishes the argument list and not the returns, and
|
||||
// nothing in grom's corpus calls either. `GetMapBoundaries` answers the four in
|
||||
// `WorldMapArea.dbc`'s own field order — LocLeft, LocRight, LocTop, LocBottom — which is the
|
||||
// order the row is laid out in and the only order with any evidence behind it.
|
||||
g.set(
|
||||
"GetMapBoundaries",
|
||||
lua.create_function(|lua, (c, z): (Option<u32>, Option<u32>)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some((l, r, t, b)) = c.and_then(|c| {
|
||||
crate::script::worldmap::loc_rect_at(&model.worldmap, c, z.unwrap_or(0))
|
||||
}) else {
|
||||
return Ok(mlua::MultiValue::new());
|
||||
};
|
||||
Ok(mlua::MultiValue::from_vec(vec![
|
||||
Value::Number(f64::from(l)),
|
||||
Value::Number(f64::from(r)),
|
||||
Value::Number(f64::from(t)),
|
||||
Value::Number(f64::from(b)),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"GetMapPositionWorldLoc",
|
||||
lua.create_function(
|
||||
|lua, (c, z, map_x, map_y): (Option<u32>, Option<u32>, Option<f64>, Option<f64>)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let rect = c.and_then(|c| {
|
||||
crate::script::worldmap::loc_rect_at(&model.worldmap, c, z.unwrap_or(0))
|
||||
});
|
||||
let (Some((l, r, t, b)), Some(u), Some(v)) = (rect, map_x, map_y) else {
|
||||
return Ok(mlua::MultiValue::new());
|
||||
};
|
||||
let wy = f64::from(l) - (f64::from(l) - f64::from(r)) * u;
|
||||
let wx = f64::from(t) - (f64::from(t) - f64::from(b)) * v;
|
||||
Ok(mlua::MultiValue::from_vec(vec![
|
||||
Value::Number(wx),
|
||||
Value::Number(wy),
|
||||
]))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// TrackUnit("unit") / UntrackUnit("unit"): put a minimap tracking dot on a named unit, and
|
||||
// take it off. The set is by **guid**, not by token, so a tracked raid member keeps its dot
|
||||
// while the roster shuffles its token out from under it.
|
||||
@@ -623,7 +745,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
s.eval::<Option<String>>("return GetQuestLinkForLogIndex(2)")
|
||||
.unwrap(),
|
||||
Some("|Hquest:1234:5|h[Kobold Camp Cleanup]|h".to_string())
|
||||
Some("|cffffff00|Hquest:1234:5|h[Kobold Camp Cleanup]|h|r".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<u32>>("return C_QuestLog.GetQuestIDForLogIndex(2)")
|
||||
@@ -813,6 +935,148 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetQuestLinkForID` answers the same link as the log-index form for a quest the log holds,
|
||||
/// and nil for one it does not — see its own comment for the feed that would close that.
|
||||
#[test]
|
||||
fn the_link_by_id_agrees_with_the_link_by_row() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_quest_log(crate::script::QuestLogState {
|
||||
entries: vec![
|
||||
crate::script::QuestLogEntryView {
|
||||
title: "Durotar".into(),
|
||||
is_header: true,
|
||||
..Default::default()
|
||||
},
|
||||
crate::script::QuestLogEntryView {
|
||||
title: "Kobold Camp Cleanup".into(),
|
||||
quest_id: 1234,
|
||||
level: 5,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"return GetQuestLinkForID(1234) == GetQuestLinkForLogIndex(2)
|
||||
and GetQuestLinkForID(1234) ~= nil"
|
||||
)
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<Option<String>>("return GetQuestLinkForID(9999)")
|
||||
.unwrap(),
|
||||
None,
|
||||
"a quest the log does not hold: the server round trip is not built"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<String>>("return GetQuestLinkForID(0)")
|
||||
.unwrap(),
|
||||
None,
|
||||
"quest 0 is a header's id, never a quest"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<String>>("return GetQuestLinkForID()")
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The map pair: `GetMapBoundaries` hands back the `WorldMapArea` rect, and
|
||||
/// `GetMapPositionWorldLoc` is the **inverse of `GetPlayerMapPosition`** — which is the
|
||||
/// assertion that matters, because the maths is transcribed from `benilla-world`'s own
|
||||
/// forward projection rather than shared with it.
|
||||
#[test]
|
||||
fn the_map_pair_round_trips_against_the_forward_projection() {
|
||||
use crate::script::{WorldMapContinentView, WorldMapZoneView};
|
||||
|
||||
// Durotar-shaped numbers: left/right are world Y, top/bottom world X, and both axes run
|
||||
// DOWNWARD with the map (the reference's `1 − t` law), which is why left > right.
|
||||
const ZONE: (f32, f32, f32, f32) = (-500.0, -5000.0, 1000.0, -2000.0);
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_world_map_catalog(vec![WorldMapContinentView {
|
||||
name: "Kalimdor".into(),
|
||||
map_file: "Kalimdor".into(),
|
||||
world_rect: (0.0, 0.0, 1.0, 1.0),
|
||||
loc_rect: (0.0, -10000.0, 5000.0, -5000.0),
|
||||
zone_grid: Vec::new(),
|
||||
zones: vec![WorldMapZoneView {
|
||||
name: "Durotar".into(),
|
||||
loc_rect: ZONE,
|
||||
..Default::default()
|
||||
}],
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, f64, f64)>("return GetMapBoundaries(1, 1)")
|
||||
.unwrap(),
|
||||
(-500.0, -5000.0, 1000.0, -2000.0),
|
||||
"WorldMapArea.dbc's own field order: LocLeft, LocRight, LocTop, LocBottom"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, f64, f64)>("return GetMapBoundaries(1, 0)")
|
||||
.unwrap(),
|
||||
(0.0, -10000.0, 5000.0, -5000.0),
|
||||
"zone 0 is the whole continent, as SetMapZoom means it"
|
||||
);
|
||||
|
||||
// The three fixed points of the lerp, each a corner a caller can check by hand.
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return GetMapPositionWorldLoc(1, 1, 0, 0)")
|
||||
.unwrap(),
|
||||
(1000.0, -500.0),
|
||||
"(0,0) is the map's top-left, which is (LocTop, LocLeft) in world terms"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return GetMapPositionWorldLoc(1, 1, 1, 1)")
|
||||
.unwrap(),
|
||||
(-2000.0, -5000.0),
|
||||
"and (1,1) the bottom-right"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return GetMapPositionWorldLoc(1, 1, 0.5, 0.5)")
|
||||
.unwrap(),
|
||||
(-500.0, -2750.0),
|
||||
"the centre"
|
||||
);
|
||||
|
||||
// The round trip, against `map_proj::zone_uv`'s own law transcribed as the forward
|
||||
// direction: a world point projected to a map fraction comes back as itself.
|
||||
for (wx, wy) in [(0.0f32, -1000.0f32), (-1500.0, -4000.0), (900.0, -600.0)] {
|
||||
let (l, r, t, b) = ZONE;
|
||||
let u = 1.0 - (wy - r) / (l - r);
|
||||
let v = 1.0 - (wx - b) / (t - b);
|
||||
let (bx, by) = s
|
||||
.eval::<(f64, f64)>(&format!("return GetMapPositionWorldLoc(1, 1, {u}, {v})"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
(bx - f64::from(wx)).abs() < 0.5 && (by - f64::from(wy)).abs() < 0.5,
|
||||
"round trip from ({wx}, {wy}) through ({u}, {v}) came back ({bx}, {by})"
|
||||
);
|
||||
}
|
||||
|
||||
// A continent or zone index nothing holds answers nothing, not a zero.
|
||||
assert_eq!(
|
||||
s.eval::<Option<f64>>("return GetMapBoundaries(9, 1)")
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<f64>>("return GetMapBoundaries(1, 9)")
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<f64>>("return GetMapPositionWorldLoc(1, 1, 0.5)")
|
||||
.unwrap(),
|
||||
None,
|
||||
"a missing coordinate is not a zero"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<f64>>("return GetMapBoundaries()").unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// `TrackUnit`/`UntrackUnit` keep a set of **guids**, so a tracked raid member keeps its dot
|
||||
/// while the roster shuffles its token; and a token naming nothing is ignored, not raised,
|
||||
/// because pfUI calls these on units that may have just left the group.
|
||||
|
||||
@@ -290,6 +290,16 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// puts the whole raid on the minimap with these.
|
||||
"TrackUnit",
|
||||
"UntrackUnit",
|
||||
// SuperWoW's `GetMapBoundaries`/`GetMapPositionWorldLoc` (script/compat/superwow.rs): the
|
||||
// world rect a map covers, and the inverse of `GetPlayerMapPosition`. 1.12 publishes the
|
||||
// forward direction only — a map fraction for a unit — and `WorldMapArea.dbc`'s rects
|
||||
// nowhere at all, so nothing on that surface can turn a point on the map into a place.
|
||||
"GetMapBoundaries",
|
||||
"GetMapPositionWorldLoc",
|
||||
// SuperWoW's `GetWeaponEnchantID` (script/compat/superwow.rs): the temporary weapon
|
||||
// enchant's own id, which 1.12 reads as its gate and then never reports — so nothing on
|
||||
// that surface can tell Windfury from Rockbiter.
|
||||
"GetWeaponEnchantID",
|
||||
// SuperWoW's `CombatLogAdd(text)` (script/compat/superwow.rs): one line appended to
|
||||
// `WoWCombatLog.txt`, which 1.12 writes C-side off its own chat router and gives Lua no
|
||||
// way into. It is also a detection name in its own right — BigWigs' Spell Requests gates
|
||||
@@ -375,6 +385,10 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
"GetPlayerInfoByGUID",
|
||||
"GetQuestIDForLogIndex",
|
||||
"GetQuestLinkForLogIndex",
|
||||
// SuperWoW's `GetQuestLinkForID(questID)` (script/compat/superwow.rs): the same link for a
|
||||
// quest named by id rather than by log row. 1.12 has no quest links at all, so neither
|
||||
// form is derivable from the stock API.
|
||||
"GetQuestLinkForID",
|
||||
"CancelPlayerAuraSlot",
|
||||
"CancelPlayerAuraSpellId",
|
||||
"CastSpellByNameNoQueue",
|
||||
|
||||
@@ -32,6 +32,11 @@ pub struct WeaponEnchant {
|
||||
pub remaining_ms: Option<u64>,
|
||||
/// The charges dword, 0 for none.
|
||||
pub charges: u32,
|
||||
/// `ITEM_FIELD_ENCHANTMENT` slot 1's own id — the nonzero dword that is this struct's whole
|
||||
/// gate. Carried for SuperWoW's `GetWeaponEnchantID` (`compat/superwow.rs`), which is the
|
||||
/// one thing `GetWeaponEnchantInfo` cannot say: the reference reads the id and reports only
|
||||
/// that there *was* one, so no addon can tell Windfury from Rockbiter.
|
||||
pub enchant_id: i32,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
@@ -77,6 +82,44 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
mod tests {
|
||||
use crate::script::{UiScript, WeaponEnchant};
|
||||
|
||||
/// SuperWoW's `GetWeaponEnchantID`: the ids behind the same two enchants, in the same
|
||||
/// main-then-off-hand order the six-value sibling uses, nil for a hand with none.
|
||||
#[test]
|
||||
fn get_weapon_enchant_id_answers_the_two_ids_in_hand_order() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert_eq!(s.arity("GetWeaponEnchantID()").unwrap(), 2);
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<i64>)>("return GetWeaponEnchantID()")
|
||||
.unwrap(),
|
||||
(None, None),
|
||||
"nothing enchanted is two nils, not two zeroes"
|
||||
);
|
||||
|
||||
s.set_weapon_enchants(
|
||||
Some(WeaponEnchant {
|
||||
remaining_ms: Some(1_800_000),
|
||||
charges: 0,
|
||||
enchant_id: 283, // Windfury Weapon rank 1
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<i64>)>("return GetWeaponEnchantID()")
|
||||
.unwrap(),
|
||||
(Some(283), None),
|
||||
"a one-handed imbue names its hand and leaves the other nil"
|
||||
);
|
||||
// The pair agrees with its sibling about WHICH hands are enchanted, which is the only
|
||||
// thing the two can contradict each other about.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local m, _, _, o = GetWeaponEnchantInfo()
|
||||
local mi, oi = GetWeaponEnchantID()
|
||||
return (m ~= nil) == (mi ~= nil) and (o ~= nil) == (oi ~= nil)"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `BuffFrame.lua:163` destructures all six at once.
|
||||
#[test]
|
||||
fn get_weapon_enchant_info_returns_six_values_on_every_path() {
|
||||
@@ -97,6 +140,7 @@ mod tests {
|
||||
// Main hand only: an 8-minute Windfury with no charges.
|
||||
s.set_weapon_enchants(
|
||||
Some(WeaponEnchant {
|
||||
enchant_id: 0,
|
||||
remaining_ms: Some(480_000),
|
||||
charges: 0,
|
||||
}),
|
||||
@@ -129,10 +173,12 @@ mod tests {
|
||||
// Both hands: a charged poison off hand, no countdown known on it.
|
||||
s.set_weapon_enchants(
|
||||
Some(WeaponEnchant {
|
||||
enchant_id: 0,
|
||||
remaining_ms: Some(1_500),
|
||||
charges: 0,
|
||||
}),
|
||||
Some(WeaponEnchant {
|
||||
enchant_id: 0,
|
||||
remaining_ms: None,
|
||||
charges: 42,
|
||||
}),
|
||||
@@ -150,6 +196,7 @@ mod tests {
|
||||
// An elapsed timer is the number 0, which the row's `if ( expiration )` takes as true.
|
||||
s.set_weapon_enchants(
|
||||
Some(WeaponEnchant {
|
||||
enchant_id: 0,
|
||||
remaining_ms: Some(0),
|
||||
charges: 0,
|
||||
}),
|
||||
@@ -179,6 +226,7 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_weapon_enchants(
|
||||
Some(WeaponEnchant {
|
||||
enchant_id: 0,
|
||||
remaining_ms: Some(480_000),
|
||||
charges: 0,
|
||||
}),
|
||||
|
||||
@@ -327,6 +327,23 @@ fn hover(wm: &WorldMapState, x: f32, y: f32) -> Hover {
|
||||
.unwrap_or(Hover::Miss)
|
||||
}
|
||||
|
||||
/// The `WorldMapArea` loc rect for a 1-based continent and zone, zone `0` meaning the whole
|
||||
/// continent — the same `(continentIndex, zoneIndex)` pair `SetMapZoom` takes and the same
|
||||
/// windowing [`grid_area`] does, so the three cannot disagree about which rect a selection means.
|
||||
///
|
||||
/// For SuperWoW's `GetMapBoundaries`/`GetMapPositionWorldLoc` (`compat/superwow.rs`).
|
||||
pub(in crate::script) fn loc_rect_at(
|
||||
state: &WorldMapState,
|
||||
c: u32,
|
||||
z: u32,
|
||||
) -> Option<(f32, f32, f32, f32)> {
|
||||
let cont = state.continents.get(c.checked_sub(1)? as usize)?;
|
||||
match z {
|
||||
0 => Some(cont.loc_rect),
|
||||
z => Some(cont.zones.get(z as usize - 1)?.loc_rect),
|
||||
}
|
||||
}
|
||||
|
||||
/// The direct area's row by id, bound-checked as the reference's `[0xc0d5bc]` lookup is
|
||||
/// (`0x4a6d24`); an id no row carries is `None`, its NULL.
|
||||
fn direct_row(state: &WorldMapState) -> Option<&WorldMapZoneView> {
|
||||
|
||||
@@ -31,7 +31,8 @@ nothing behind it.
|
||||
| `LootSlot` force-loot arg | done | `script/loot.rs`; truthy (Lua truthiness, e.g. `1`) routes to the take queue instead of the confirm queue |
|
||||
| `SetRaidTarget` `"local"` arg | done | `PartyRequest::SetRaidTarget.local` (`script/party.rs`); the live drain (`ui_party/feed.rs`) applies to `GroupState` directly instead of sending `CMSG`/`MSG_RAID_TARGET_UPDATE` |
|
||||
| Nameplate `frame:GetName(1)` → GUID | done | `script/object/frame_state.rs`'s shared `GetName`, `NamePlates::guid_for_frame` (`script/nameplate.rs`) |
|
||||
| `GetWeaponEnchantInfo([unit])` + `GetWeaponEnchantID` | todo, blocked | needs per-unit weapon-enchant tracking (today only `"player"`'s is fed at all) and no item-template data distinguishing an on-use charge slot from an equip/proc slot — would need new app-side state, not a binding tweak |
|
||||
| `GetWeaponEnchantID` | **done** | the one thing `GetWeaponEnchantInfo` cannot say: the reference reads that very dword as its gate (`0x4c97f8`, nonzero, no DBC lookup) and then reports only that there *was* an enchant, so on the stock surface nothing can tell Windfury from Rockbiter or a shaman's imbue from a rogue's poison. The id was already in hand app-side and discarded — `ui_char.rs`'s `weapon_enchant` used it as a `?` gate — so this was one field on the snapshot. Two returns, main hand then off hand, which is the order its six-value sibling already answers in; the shape is a reading, since upstream publishes no usage string for it and nothing in the corpus calls it. A test asserts the two functions cannot disagree about *which* hands are enchanted |
|
||||
| `GetWeaponEnchantInfo([unit])`'s unit argument | todo, blocked | the extension that takes a **unit** still needs per-unit weapon-enchant tracking; today only `"player"`'s is fed at all |
|
||||
| `GetContainerItemInfo` charges (negative stack count) | todo, blocked | the raw signed field exists (`ObjectFields::item_spell_charges`, benilla-protocol), but nothing in `benilla-formats`' item template says *which* of an item's five spell-trigger slots (if any) is a consumable charge rather than an equip/proc effect — exposing it without that would be a guess, not a port |
|
||||
| New functions (`SpellInfo`, `TrackUnit`/`UntrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `UnitNameplate`, `CanLootUnit`, Import/Export/AppendText, map/world coord conversions, `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed`, mail/quest-link getters) | todo, blocked | each needs new app-side state, not a binding tweak: `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed` need a self-movement snapshot (`move_flags() & SWIMMING`, `unit_mount_display_id()`, `area.rs`'s `indoor` bool, and a `MoveSpeeds` component — none currently reach a `feed_*` system that can push them, and `ui_unit.rs`'s `feed_units` is already at Bevy's 16-param ceiling); **`SpellInfo` is now done** — `compat/superwow.rs`, over `script/spellbook.rs`'s `spell_display`: the player's book first, then the host's `Spell.dbc` probe ([`SpellProbe`], installed by `ui_script::lifecycle::install_spell_probe` on every VM build), so it reaches every shipped spell as the real mod does. The range pair stays nil — `SpellRange.dbc` is not on the probe; the rest (`TrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `CanLootUnit`, `UnitNameplate`, file I/O, map/world coords, quest links) are each plausible but unstarted. **`CombatLogAdd` is done** and has its own row below. **The two mail link getters are now done**, built for ClassicAPI's own `Mail` namespace (`compat/classic_api/merchant_frame.rs`, see its row below) — SuperWoW specifies the same `GetSendMailItemLink`/`GetInboxItemLink`, so that half of this row is closed |
|
||||
| `UnitPosition(unitid)`, `CursorPosition()` | done | `UnitState.position` (`script/unit/mod.rs`), fed by `Transform` from `ui_unit.rs`'s `feed_units` (player/target/targettarget/npc) and `ui_tooltip.rs`'s `drive_mouseover_tooltip` (mouseover); functions in `compat/superwow.rs`. Party/raid/pet aren't wired (their own `snapshot()` call sites in `ui_party/feed.rs`/`ui_pet/unit.rs` pass `None` — each needs its own `Entity`→`Transform` lookup threaded through, not attempted this pass). Reading `Transform` broadly past the usual one-frame-stale tolerance raised `game_plugins.rs`'s `UPDATE_ACTIONABLE_CEILING` by 30 (cited there) — `Transform` is written by enough movement/animation/physics systems that `With<Guid>` alone doesn't shrink the ambiguity count |
|
||||
@@ -82,13 +83,13 @@ The usage strings, verbatim, for what is left:
|
||||
| `GetPlayerBuffID(buffIndex)` | done |
|
||||
| `UnitNameplate("unit")` | **done** — `compat/superwow.rs`, over `NamePlates::frame_for_guid`, through the **same** `plate_frame` wrapper ClassicAPI's `C_NamePlate.GetNamePlateForUnit` uses, so the two names cannot hand back different objects for one plate. Welded by a test that asserts exactly that, because an addon may feature-detect either |
|
||||
| `CanlootUnit("unit")` | **done** — needed no new feed: `UnitState` already carries `dead` and raw `dynamic_flags`, so it is the loot sparkle's own two-part test (`shows_loot_sparkle`: `unit_is_dead() && unit_lootable()`) read through the token map instead of the ECS, and a corpse that sparkles answers 1. Bit `0x1` is **per viewer** on the server — vmangos raises it only for players who may loot — so "can" really means can. **Note the lowercase `l`**: the *usage* string says `CanlootUnit` where the registered name is `CanLootUnit`; the registered name is what an addon calls, and the typo is in the error message only |
|
||||
| `GetMapPositionWorldLoc(continentIndex, zoneIndex, mapX, mapY)` | todo — map fraction to world XY, which `benilla-world`'s own map maths already does for the world map |
|
||||
| `GetMapBoundaries(continentIndex, zoneIndex)` | todo — the zone's world extent, the same table that conversion reads |
|
||||
| `GetMapPositionWorldLoc(continentIndex, zoneIndex, mapX, mapY)` | **done** — the **inverse of `GetPlayerMapPosition`**, which is the assertion the test makes rather than checking three numbers: `benilla-world`'s `map_proj::zone_world` is the reference's `0x4a7100`, whose own note is that used one axis per call it inverts the `zone_uv` that verb answers from. The law is transcribed into `benilla-ui` (which does not depend on `benilla-world`) and the round trip is welded. `mapX` is the horizontal fraction and so the world **Y** axis, `mapY` the vertical and so world **X** — `WorldMapArea`'s own convention, and why the returns are a world *loc* |
|
||||
| `GetMapBoundaries(continentIndex, zoneIndex)` | **done** — both indices are the pair `SetMapZoom` takes, 1-based with zone `0` the whole continent, resolved through one new `worldmap::loc_rect_at` that the hover grid's own windowing now shares, so no two readers of a selection can disagree about which rect it means. **Returns in `WorldMapArea.dbc`'s own field order** (LocLeft, LocRight, LocTop, LocBottom): upstream publishes the argument list and not the returns, and nothing in the corpus calls it, so the row's layout is the only order with evidence behind it |
|
||||
| `GetSpeed()` (no usage string: it takes no argument) | **done** — one new field on the posture snapshot, filled by `ui_unit.rs`'s `feed_self_movement` from `net.rs`'s `current_speed`, the reference's own `CMovement::GetCurrentSpeed 0x7c4c90` walk over our speed block and the same move flags every other field of that snapshot reads. **What could not be settled**: upstream publishes no prose and no addon in the corpus calls it, so whether its number is the *current* speed or the unit's *run* speed is unknowable from here. Current is the reading taken — it is the one client function named after speed that a hook would return, and the strictly more informative of the two (a snare or a mount shows in it). The consequence to know: standing still it is 0, exactly as that getter is |
|
||||
| `GetInboxItemLink(messageIndex)` | done (built for ClassicAPI's `Mail`) |
|
||||
| `GetSoundDriverName(driverIndex)` | todo — with `GetSoundDevices`, `GetNumSoundDrivers`, `GetLoadedSoundDriver`: four getters over the audio backend. benilla has one backend and no enumeration, so these answer a one-row list honestly or not at all; no addon in the corpus calls any of them |
|
||||
| `GetSoundDriverName(driverIndex)` | **deliberately not built** — with `GetSoundDevices`, `GetNumSoundDrivers`, `GetLoadedSoundDriver`: four getters over an audio-driver enumeration. Each one's *return shape* is unknowable from here (is a loaded driver an index or a name? is a device list a table or multiple returns?), nothing in grom's corpus calls any of them, and benilla has one backend and no enumeration to report. So all four would be guessed shapes over invented data, which is the same bargain `KEY_DOWN`/`KEY_UP` lost in the nampower section: a guessed shape an addon switches on is worse than an absent name it can feature-detect |
|
||||
| `GetQuestIDForLogIndex(questIndex)` / `GetQuestLinkForLogIndex(questIndex)` | done |
|
||||
| `GetQuestLinkForID(questID)` | todo — the same link for a quest the log does not hold, which is a server round trip (`Requesting quest ID %d from server` is the DLL's own next string) |
|
||||
| `GetQuestLinkForID(questID)` | **done for a quest in the log**, nil otherwise — which is also what upstream answers on the first call for one it has to fetch (`Requesting quest ID %d from server` is the DLL's own next string). The gap is one feed, not new protocol: `ui_quest_log.rs` already runs an ask-once `CMSG_QUEST_QUERY` template cache, so finishing it means a request queue out of the VM and an (id → title, level) feed back. **This row also corrected the link format for both getters**: the DLL's own formatter is `\|cffffff00\|Hquest:%d:%d\|h[%s]\|h\|r`, and ours had been built without the colour wrapper on the reasoning that it matched the stock shift-click — but 1.12 has no quest links at all, so there was no stock shape to match and the measured one is the only authority |
|
||||
| `ImportFile("filename")` / `ExportFile("filename","text")` | done, under nampower's fence. **Upstream's own folder is `Imports\<name>.txt`** (two more of its literals), where ours is `benilla-config/CustomFiles/` — the deviation is written up in the file-and-credential section below |
|
||||
|
||||
**The four chat-bubble rows are now live.** `ChatBubblesWhisper`, `ChatBubblesRaid` and
|
||||
|
||||
Reference in New Issue
Block a user