a Vanish clears your target at once, and a painted road covers the grass
- a Vanish, a stealth or a unit leaving range clears your target at once, not two seconds later, and TAB reaches a mob behind you (2345) - a fully painted terrain layer covers fully: roads and rock no longer let about 6% of the grass underneath show through (2345) - companion pets show their names, the saved camera pitch survives a login, and a missed resurrect offer no longer blocks later ones (2345) - ESC on a quest giver closes it, browsing the auction house by category searches the right items, and abandon removes the quest you chose (2345) - a profession window open at logout no longer reopens for the next character (2345) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -149,7 +149,13 @@ pub struct AuctionCategory {
|
||||
/// The wire's `mainCategory` — an item class id, **not** the menu position.
|
||||
pub class_id: u32,
|
||||
pub name: String,
|
||||
/// `(subclass id, name, offers inventory-slot filters)`.
|
||||
/// The `0x807060` table's second column: whether the class offers subclass rows at all.
|
||||
/// `false` (Consumable, Trade Goods, Reagent, Miscellaneous) makes `GetAuctionItemSubClasses`
|
||||
/// and `GetAuctionInvTypes` answer nothing — but `QueryAuctionItems` never reads it, so
|
||||
/// [`Self::subclasses`] is still the class's full non-excluded list either way.
|
||||
pub has_subclass_filter: bool,
|
||||
/// The class's `ItemSubClass.dbc` rows whose `DisplayFlags & 2` is clear, in file order —
|
||||
/// the list both the menu and the query's index scan walk.
|
||||
pub subclasses: Vec<AuctionSubCategory>,
|
||||
}
|
||||
|
||||
@@ -159,9 +165,8 @@ pub struct AuctionSubCategory {
|
||||
pub sub_id: u32,
|
||||
pub name: String,
|
||||
/// Whether selecting this subclass offers the 14 inventory-slot rows beneath it
|
||||
/// (`GetAuctionInvTypes`). INTERIM (decision 1511): the exact predicate is pinned to the
|
||||
/// in-flight §5's TU-4, and lives here as *data* precisely so correcting it is a one-line
|
||||
/// change app-side rather than an engine change.
|
||||
/// (`GetAuctionInvTypes`) — the row's `ItemSubClass.Flags & 0x200` (`0x4cfb63`), read
|
||||
/// app-side off the player's own DBC.
|
||||
pub has_inv_types: bool,
|
||||
}
|
||||
|
||||
@@ -176,15 +181,17 @@ pub struct AuctionState {
|
||||
pub deposit_percent: u32,
|
||||
}
|
||||
|
||||
/// A drained `QueryAuctionItems` — the Browse search, exactly as the reference builds it. The app
|
||||
/// maps this to `CMSG_AUCTION_LIST_ITEMS` (decision 1511 P1).
|
||||
/// A drained `QueryAuctionItems` — the Browse search, exactly as the reference builds it: every
|
||||
/// filter is already the wire's item-template id (the binding maps the Lua's menu positions, as
|
||||
/// `0x4ce980` does). The app copies this into `CMSG_AUCTION_LIST_ITEMS` (decision 1511 P1).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AuctionQuery {
|
||||
pub name: String,
|
||||
/// `0` = no filter, for both.
|
||||
pub min_level: u32,
|
||||
pub max_level: u32,
|
||||
/// `None` = no filter (the wire's `0xFFFFFFFF` sentinel).
|
||||
/// `None` = no filter (the wire's `0xFFFFFFFF` sentinel). Ids, never menu positions: an
|
||||
/// `InventoryType`, an item class, a subclass of that class.
|
||||
pub inv_type: Option<u32>,
|
||||
pub class: Option<u32>,
|
||||
pub sub_class: Option<u32>,
|
||||
@@ -578,7 +585,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetAuctionItemSubClasses(classIndex) → the subclass names under a 1-based menu class. An
|
||||
// out-of-range index returns nothing, which is the reference's own bound-with-no-error.
|
||||
// out-of-range index, or a class whose `0x807060` flag is 0, returns nothing — the
|
||||
// reference's own bound-with-no-error (`0x4cfa00`, `0x4cfa0e`).
|
||||
g.set(
|
||||
"GetAuctionItemSubClasses",
|
||||
lua.create_function(|lua, class_index: usize| {
|
||||
@@ -587,6 +595,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
class_index
|
||||
.checked_sub(1)
|
||||
.and_then(|i| model.auction_item_classes.get(i))
|
||||
.filter(|c| c.has_subclass_filter)
|
||||
.map_or_else(Vec::new, |c| {
|
||||
c.subclasses.iter().map(|s| s.name.clone()).collect()
|
||||
})
|
||||
@@ -600,9 +609,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetAuctionInvTypes(classIndex, subclassIndex) → the 14 inventory-slot GlobalString KEYS, or
|
||||
// nothing. The pair only decides WHETHER the list is offered — the list itself is fixed. Which
|
||||
// pairs offer it is pushed as data (`has_inv_types`), so decision 1511's INTERIM on that
|
||||
// predicate is corrected app-side, not here.
|
||||
// nothing. The pair only decides WHETHER the list is offered — the list itself is fixed.
|
||||
// `0x4cfab0`: no class (index out of range, or its `0x807060` flag 0) → nothing; a FOUND
|
||||
// subclass offers the list only with `ItemSubClass.Flags & 0x200` (pushed as
|
||||
// `has_inv_types`); and a subclass index the scan runs off the end of skips that gate and
|
||||
// offers all fourteen (wow-re auction-house §9.3 — the exhaust edge lands past the test).
|
||||
g.set(
|
||||
"GetAuctionInvTypes",
|
||||
lua.create_function(|lua, (class_index, sub_index): (usize, usize)| {
|
||||
@@ -611,14 +622,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
class_index
|
||||
.checked_sub(1)
|
||||
.and_then(|i| model.auction_item_classes.get(i))
|
||||
.and_then(|c| sub_index.checked_sub(1).and_then(|i| c.subclasses.get(i)))
|
||||
.is_some_and(|s| s.has_inv_types)
|
||||
.filter(|c| c.has_subclass_filter)
|
||||
.is_some_and(|c| {
|
||||
sub_index
|
||||
.checked_sub(1)
|
||||
.and_then(|i| c.subclasses.get(i))
|
||||
.is_none_or(|s| s.has_inv_types)
|
||||
})
|
||||
};
|
||||
if !offers {
|
||||
return Ok(MultiValue::new());
|
||||
}
|
||||
let mut out = Vec::with_capacity(AUCTION_INV_TYPES.len());
|
||||
for key in AUCTION_INV_TYPES {
|
||||
for (_, key) in AUCTION_INV_TYPES {
|
||||
out.push(Value::String(lua.create_string(key)?));
|
||||
}
|
||||
Ok(MultiValue::from_vec(out))
|
||||
@@ -627,8 +643,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
// QueryAuctionItems(name, minLevel, maxLevel, invTypeIndex, classIndex, subclassIndex, page,
|
||||
// isUsable, qualityIndex) — nine arguments (TBC's tenth, exactMatch, does not exist on 5875).
|
||||
// The level boxes hand us strings, and every filter index is 1-based into the pushed tables;
|
||||
// the app turns those into the wire's class/subclass ids and its 0xFFFFFFFF sentinels.
|
||||
// The level boxes hand us strings. Every filter argument is a 1-based MENU POSITION, and —
|
||||
// exactly as the reference's `0x4ce980` does — it is turned into the wire's item-template id
|
||||
// here, before anything is queued: invType through the `0x8070b0` rows, class through the
|
||||
// `0x807060` table, subclass as the Nth non-excluded subclass row of that class (a scan that
|
||||
// never reads the class's filter flag). Anything that does not resolve is the wire's "any".
|
||||
g.set(
|
||||
"QueryAuctionItems",
|
||||
lua.create_function(|lua, args: MultiValue| {
|
||||
@@ -658,14 +677,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Some(Value::Number(n)) if *n >= 0.0 => Some(*n as u32),
|
||||
_ => None,
|
||||
};
|
||||
// A 1-based position into a table, `None` for a missing, zero or negative argument.
|
||||
let position = |i: usize| num(i).and_then(|n| (n as usize).checked_sub(1));
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let inv_type = position(3)
|
||||
.and_then(|i| AUCTION_INV_TYPES.get(i))
|
||||
.map(|&(id, _)| id);
|
||||
let category = position(4).and_then(|i| model.auction_item_classes.get(i));
|
||||
let class = category.map(|c| c.class_id);
|
||||
let sub_class = category.and_then(|c| {
|
||||
position(5)
|
||||
.and_then(|i| c.subclasses.get(i))
|
||||
.map(|s| s.sub_id)
|
||||
});
|
||||
model.auction_query = Some(AuctionQuery {
|
||||
name,
|
||||
min_level: num(1).unwrap_or(0),
|
||||
max_level: num(2).unwrap_or(0),
|
||||
inv_type: num(3).filter(|n| *n > 0),
|
||||
class: num(4).filter(|n| *n > 0),
|
||||
sub_class: num(5).filter(|n| *n > 0),
|
||||
inv_type,
|
||||
class,
|
||||
sub_class,
|
||||
quality,
|
||||
page: num(6).unwrap_or(0),
|
||||
usable_only: truthy(7),
|
||||
@@ -823,24 +854,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The fourteen inventory-slot rows the reference offers, as GlobalString **keys** — the Lua
|
||||
/// resolves each through the player's own strings, so no English ships here. Inventory ids 1..12
|
||||
/// plus CLOAK(16) and HOLDABLE(23); the order is the reference's own.
|
||||
const AUCTION_INV_TYPES: [&str; 14] = [
|
||||
"INVTYPE_HEAD",
|
||||
"INVTYPE_NECK",
|
||||
"INVTYPE_SHOULDER",
|
||||
"INVTYPE_BODY",
|
||||
"INVTYPE_CHEST",
|
||||
"INVTYPE_WAIST",
|
||||
"INVTYPE_LEGS",
|
||||
"INVTYPE_FEET",
|
||||
"INVTYPE_WRIST",
|
||||
"INVTYPE_HAND",
|
||||
"INVTYPE_FINGER",
|
||||
"INVTYPE_TRINKET",
|
||||
"INVTYPE_CLOAK",
|
||||
"INVTYPE_HOLDABLE",
|
||||
/// The fourteen inventory-slot rows the reference offers — the `0x8070b0` table's
|
||||
/// `{u32 invTypeId; char name[0x20]}` rows, in its own order. The name is a GlobalString **key**
|
||||
/// the Lua resolves through the player's own strings, so no English ships here; the id is what
|
||||
/// `QueryAuctionItems` puts on the wire for the row's 1-based position.
|
||||
const AUCTION_INV_TYPES: [(u32, &str); 14] = [
|
||||
(1, "INVTYPE_HEAD"),
|
||||
(2, "INVTYPE_NECK"),
|
||||
(3, "INVTYPE_SHOULDER"),
|
||||
(4, "INVTYPE_BODY"),
|
||||
(5, "INVTYPE_CHEST"),
|
||||
(6, "INVTYPE_WAIST"),
|
||||
(7, "INVTYPE_LEGS"),
|
||||
(8, "INVTYPE_FEET"),
|
||||
(9, "INVTYPE_WRIST"),
|
||||
(10, "INVTYPE_HAND"),
|
||||
(11, "INVTYPE_FINGER"),
|
||||
(12, "INVTYPE_TRINKET"),
|
||||
(16, "INVTYPE_CLOAK"),
|
||||
(23, "INVTYPE_HOLDABLE"),
|
||||
];
|
||||
|
||||
/// Attach the cursor's held item to the sell slot, or — with an empty cursor — pick the attached
|
||||
@@ -1028,6 +1060,105 @@ mod tests {
|
||||
assert_eq!(deposit_for(5, 10_000, 60), 0);
|
||||
}
|
||||
|
||||
/// The browse tree as the reference's `0x807060` table shapes it: Weapon (filterable) first,
|
||||
/// then Armor, then Consumable (flag 0 — no subclass rows offered, but its subclasses still
|
||||
/// exist for the query's own scan).
|
||||
fn tree() -> Vec<AuctionCategory> {
|
||||
let sub = |sub_id: u32, name: &str, has_inv_types: bool| AuctionSubCategory {
|
||||
sub_id,
|
||||
name: name.into(),
|
||||
has_inv_types,
|
||||
};
|
||||
vec![
|
||||
AuctionCategory {
|
||||
class_id: 2,
|
||||
name: "Weapon".into(),
|
||||
has_subclass_filter: true,
|
||||
subclasses: vec![sub(0, "Axe", false), sub(1, "Axe", false)],
|
||||
},
|
||||
AuctionCategory {
|
||||
class_id: 4,
|
||||
name: "Armor".into(),
|
||||
has_subclass_filter: true,
|
||||
subclasses: vec![sub(1, "Cloth", true), sub(6, "Shield", false)],
|
||||
},
|
||||
AuctionCategory {
|
||||
class_id: 0,
|
||||
name: "Consumable".into(),
|
||||
has_subclass_filter: false,
|
||||
subclasses: vec![sub(0, "Consumable", false)],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// `QueryAuctionItems` takes menu POSITIONS and puts item-template IDS on the wire — the
|
||||
/// reference does the mapping itself (`0x4ce980`: invType through `0x8070b0`, class through
|
||||
/// `0x807060`, subclass as the Nth non-excluded `ItemSubClass.dbc` row of that class). Sending
|
||||
/// the position instead asked vmangos for class 1 (Container) when the player picked Weapon.
|
||||
#[test]
|
||||
fn query_sends_ids_not_menu_positions() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_auction_item_classes(tree());
|
||||
|
||||
// Weapon / its first subclass / Back (the 13th inventory row).
|
||||
s.run(r#"QueryAuctionItems("", "", "", 13, 1, 1, 0, nil, -1)"#)
|
||||
.unwrap();
|
||||
let q = s.take_auction_query().expect("queued");
|
||||
assert_eq!(q.inv_type, Some(16), "INVTYPE_CLOAK is inventory type 16");
|
||||
assert_eq!(q.class, Some(2), "Weapon is item class 2, not position 1");
|
||||
assert_eq!(q.sub_class, Some(0), "the first weapon subclass is id 0");
|
||||
|
||||
// Armor / its SECOND subclass skips the id gap: Shield is 6, not 2.
|
||||
s.run(r#"QueryAuctionItems("", "", "", 14, 2, 2, 0, nil, -1)"#)
|
||||
.unwrap();
|
||||
let q = s.take_auction_query().expect("queued");
|
||||
assert_eq!(
|
||||
(q.inv_type, q.class, q.sub_class),
|
||||
(Some(23), Some(4), Some(6))
|
||||
);
|
||||
|
||||
// The query's scan ignores the class's filter flag: Consumable offers no subclass rows,
|
||||
// yet an index still resolves against its (non-excluded) subclasses.
|
||||
s.run(r#"QueryAuctionItems("", "", "", 0, 3, 1, 0, nil, -1)"#)
|
||||
.unwrap();
|
||||
let q = s.take_auction_query().expect("queued");
|
||||
assert_eq!((q.inv_type, q.class, q.sub_class), (None, Some(0), Some(0)));
|
||||
|
||||
// Out of range, or no class to scan under: the wire's "any".
|
||||
s.run(r#"QueryAuctionItems("", "", "", 15, 11, 1, 0, nil, -1)"#)
|
||||
.unwrap();
|
||||
let q = s.take_auction_query().expect("queued");
|
||||
assert_eq!((q.inv_type, q.class, q.sub_class), (None, None, None));
|
||||
s.run(r#"QueryAuctionItems("", "", "", nil, 1, 3, 0, nil, -1)"#)
|
||||
.unwrap();
|
||||
let q = s.take_auction_query().expect("queued");
|
||||
assert_eq!((q.inv_type, q.class, q.sub_class), (None, Some(2), None));
|
||||
}
|
||||
|
||||
/// `0x4cf9c0` / `0x4cfab0` return nothing for a class whose `0x807060` flag is 0, and
|
||||
/// `0x4cfab0` gates a FOUND subclass on `ItemSubClass.Flags & 0x200` but pushes all fourteen
|
||||
/// when the scan runs off the end (wow-re auction-house §9.1, §9.3).
|
||||
#[test]
|
||||
fn subclass_and_inv_type_menus_follow_the_reference_gates() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_auction_item_classes(tree());
|
||||
assert_eq!(s.arity("GetAuctionItemSubClasses(1)").unwrap(), 2);
|
||||
assert_eq!(
|
||||
s.arity("GetAuctionItemSubClasses(3)").unwrap(),
|
||||
0,
|
||||
"Consumable's flag is 0: no subclass rows"
|
||||
);
|
||||
assert_eq!(s.arity("GetAuctionInvTypes(2, 1)").unwrap(), 14, "Cloth");
|
||||
assert_eq!(s.arity("GetAuctionInvTypes(2, 2)").unwrap(), 0, "Shield");
|
||||
assert_eq!(s.arity("GetAuctionInvTypes(3, 1)").unwrap(), 0, "flag 0");
|
||||
assert_eq!(s.arity("GetAuctionInvTypes(11, 1)").unwrap(), 0, "no class");
|
||||
assert_eq!(
|
||||
s.arity("GetAuctionInvTypes(1, 9)").unwrap(),
|
||||
14,
|
||||
"an exhausted scan skips the gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sort stack answers for any key it remembers, not just the primary — the reason a
|
||||
/// non-primary column's arrow can still point down.
|
||||
#[test]
|
||||
|
||||
@@ -403,6 +403,19 @@ fn live_rights(model: &Model, rank_one_based: u32) -> u32 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The rank-name gate `GuildControlSaveRank` (`0x4d20d0`) and `GuildControlAddRank` (`0x4d2210`)
|
||||
/// both run before any packet, byte-for-byte the same: the C string (so up to the first NUL) must
|
||||
/// be non-empty and `0x65b250(name) - 1` — its UTF-16 code-unit count — must lie in `1..=16`.
|
||||
/// Nothing else: no trim, so a whitespace name passes. The name the gate measured is the name
|
||||
/// sent. vmangos's own cap is 15 code points and it kicks over it; that server-side limit is the
|
||||
/// app's to enforce at the send (`ui_guild::feed::capped_rank_name`), not the binding's.
|
||||
fn gated_rank_name(name: &str) -> Option<&str> {
|
||||
let c_name = name.split('\0').next().unwrap_or_default();
|
||||
(1..=16)
|
||||
.contains(&c_name.encode_utf16().count())
|
||||
.then_some(c_name)
|
||||
}
|
||||
|
||||
/// Register the guild globals against the snapshot store.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
@@ -852,7 +865,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// GuildControlSaveRank(name) (`0x4d20d0`) — flush the staged edits as one CMSG_GUILD_RANK.
|
||||
// The argument is the rank's (possibly renamed) NAME, taken from the popup's edit box
|
||||
// (`FriendsFrame.lua:839`); the rank it applies to is whichever GuildControlSetRank loaded.
|
||||
// Converted to the wire's 0-based rank id here, once.
|
||||
// Converted to the wire's 0-based rank id here, once. A name that fails
|
||||
// [`gated_rank_name`] — empty above all, which vmangos would otherwise persist as the rank's
|
||||
// name guild-wide — is a silent no-op, before any packet.
|
||||
//
|
||||
// **The fold is a masked set-or-clear over a freshly re-read LIVE baseline** — verified: the
|
||||
// binding does `or mask[k]` / `and ~mask[k]` per flag against the current rights, never a
|
||||
@@ -865,9 +880,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"GuildControlSaveRank",
|
||||
lua.create_function(|lua, name: String| {
|
||||
let Some(name) = gated_rank_name(&name).map(str::to_string) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let edit = model.guild_control.clone();
|
||||
if edit.rank >= 1 {
|
||||
// `[0x84966c] >= [0xb73120]`: no rank loaded (the reference's -1, our 0) or one at or
|
||||
// past the rank count.
|
||||
if edit.rank >= 1 && (edit.rank as usize) <= model.guild.ranks.len() {
|
||||
let mut rights = live_rights(&model, edit.rank);
|
||||
for (i, bit) in RANK_RIGHT_BITS.iter().enumerate() {
|
||||
if edit.staged[i] {
|
||||
@@ -886,14 +906,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GuildControlAddRank(name) (`0x4d2210`) — append a rank. The client refuses at
|
||||
// GuildControlAddRank(name) (`0x4d2210`) — append a rank, behind the same silent name gate
|
||||
// as SaveRank ([`gated_rank_name`]). The client refuses at
|
||||
// `numRanks >= MAX_RANKS` **silently**, before any packet, which is why the reference's Add
|
||||
// button greys itself at ten (`FriendsFrame.lua:887`) rather than relying on a refusal.
|
||||
g.set(
|
||||
"GuildControlAddRank",
|
||||
lua.create_function(|lua, name: String| {
|
||||
let Some(name) = gated_rank_name(&name).map(str::to_string) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if !name.trim().is_empty() && model.guild.ranks.len() < MAX_RANKS {
|
||||
if model.guild.ranks.len() < MAX_RANKS {
|
||||
model.guild_requests.push(GuildRequest::AddRank(name));
|
||||
}
|
||||
Ok(())
|
||||
@@ -1020,6 +1044,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A guild with `n` named ranks.
|
||||
fn guild_with_ranks(n: usize) -> crate::script::UiScript {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
s.set_guild(GuildState {
|
||||
in_guild: true,
|
||||
ranks: (0..n)
|
||||
.map(|i| GuildRankInfo {
|
||||
name: format!("Rank{i}"),
|
||||
rights: 0,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
/// **`0x4d20d0` refuses a bad name silently, before any packet** — NULL, empty, or a length
|
||||
/// outside 1..16 UTF-16 code units (`0x65b250` counts units plus the terminator), and a rank
|
||||
/// index at or past the rank count. vmangos persists whatever name arrives — an empty one
|
||||
/// blanks the rank guild-wide — so this gate is the only thing standing in the way.
|
||||
#[test]
|
||||
fn saving_a_rank_refuses_what_the_reference_refuses() {
|
||||
let mut s = guild_with_ranks(5);
|
||||
let saves = |s: &mut crate::script::UiScript| {
|
||||
s.take_guild_requests()
|
||||
.into_iter()
|
||||
.filter(|r| matches!(r, GuildRequest::SaveRank { .. }))
|
||||
.count()
|
||||
};
|
||||
|
||||
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank("")"#)
|
||||
.unwrap();
|
||||
assert_eq!(saves(&mut s), 0, "an empty name is a silent no-op");
|
||||
|
||||
// Sixteen UTF-16 units is the ceiling; seventeen is refused.
|
||||
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank(string.rep("a", 16))"#)
|
||||
.unwrap();
|
||||
assert_eq!(saves(&mut s), 1);
|
||||
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank(string.rep("a", 17))"#)
|
||||
.unwrap();
|
||||
assert_eq!(saves(&mut s), 0, "seventeen units");
|
||||
// Units, not bytes: sixteen two-byte letters are sixteen units.
|
||||
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank(string.rep("é", 16))"#)
|
||||
.unwrap();
|
||||
assert_eq!(saves(&mut s), 1, "32 bytes, 16 units");
|
||||
// Whitespace is not empty to the reference.
|
||||
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank(" ")"#)
|
||||
.unwrap();
|
||||
assert_eq!(saves(&mut s), 1);
|
||||
|
||||
// No rank loaded (a fresh popup — the selection persists across saves, as
|
||||
// `[0x84966c]` does), or one past the ladder.
|
||||
let mut fresh = guild_with_ranks(5);
|
||||
fresh.run(r#"GuildControlSaveRank("Officer")"#).unwrap();
|
||||
assert_eq!(saves(&mut fresh), 0, "nothing selected");
|
||||
s.run(r#"GuildControlSetRank(6); GuildControlSaveRank("Officer")"#)
|
||||
.unwrap();
|
||||
assert_eq!(saves(&mut s), 0, "rank index 5 >= 5 ranks");
|
||||
}
|
||||
|
||||
/// `0x4d2210` runs the same name gate as `SaveRank` — and nothing more: a whitespace name is
|
||||
/// not empty to it.
|
||||
#[test]
|
||||
fn adding_a_rank_runs_the_same_name_gate() {
|
||||
let mut s = guild_with_ranks(5);
|
||||
s.run(r#"GuildControlAddRank(""); GuildControlAddRank(string.rep("a", 17))"#)
|
||||
.unwrap();
|
||||
assert!(s.take_guild_requests().is_empty());
|
||||
s.run(r#"GuildControlAddRank(" ")"#).unwrap();
|
||||
assert_eq!(s.take_guild_requests().len(), 1);
|
||||
}
|
||||
|
||||
/// A 1-based index past the end, and the 0 the reference passes on every `GuildStatus_Update`
|
||||
/// before it checks the selection, both have to answer `None` rather than panic.
|
||||
#[test]
|
||||
|
||||
@@ -1448,16 +1448,16 @@ pub(crate) struct Model {
|
||||
pub(crate) death_actions: Vec<death::DeathAction>,
|
||||
|
||||
/// The quest-log seam ([`quest_log`]): the snapshot the app pushes, the engine-owned
|
||||
/// synchronous selection + click-time abandon mark (1-based entry indices, `0` = none), and
|
||||
/// the drained abandon intents.
|
||||
/// synchronous selection (a 1-based entry index, `0` = none), the click-time abandon mark (a
|
||||
/// QUEST ID, `0` = none — the reference's `0xbb7484`), and the drained abandon intents (quest
|
||||
/// ids).
|
||||
pub(crate) quest_log: quest_log::QuestLogState,
|
||||
pub(crate) quest_log_selection: u32,
|
||||
pub(crate) quest_log_abandon_mark: u32,
|
||||
pub(crate) quest_log_abandons: Vec<u32>,
|
||||
/// Quest ids `QuestLogPushQuest()` queued for the app to push to the party (decision 1733).
|
||||
/// **Ids, not entry indices** — unlike the abandon mark above, whose two-step confirm is the
|
||||
/// whole reason it pins an index: a push is one click, so the id is resolved right then and a
|
||||
/// log shuffle between click and drain cannot retarget it.
|
||||
/// **Ids, not entry indices**, like the abandon mark above: the id is resolved at click time,
|
||||
/// so a log shuffle between click and drain cannot retarget it.
|
||||
pub(crate) quest_log_pushes: Vec<u32>,
|
||||
/// How many times `ConfirmAcceptQuest()` was called — the escort confirm's Yes. A counter, not
|
||||
/// a quest id: the reference's verb takes no argument and answers whatever confirm the client
|
||||
|
||||
@@ -160,18 +160,25 @@ pub enum QuestAction {
|
||||
/// Reward panel Complete → `CMSG_QUESTGIVER_CHOOSE_REWARD` with the chosen choice index (0 when
|
||||
/// the quest has no choice rewards).
|
||||
Reward(u32),
|
||||
/// **The quest session's end**, however it was reached: `DeclineQuest()` (the detail panel's
|
||||
/// Decline, the progress/reward panels' Cancel, the greeting's Goodbye) and `CloseQuest()`
|
||||
/// (the window's own OnHide — ESC, a UIPanel eviction) alike.
|
||||
/// `DeclineQuest()` — the detail panel's Decline and the progress/reward panels' Cancel
|
||||
/// (stock `QuestFrame.lua`: `QuestDetailDeclineButton_OnClick`, `QuestGoodbyeButton_OnClick`,
|
||||
/// `QuestRewardCancelButton_OnClick`). Its binding `0x501d30` calls `0x5013f0`, the ONLY
|
||||
/// routine that re-opens the giver: a four-way fork on the source's object type — a
|
||||
/// gossip-flagged unit → `CMSG_GOSSIP_HELLO`, a plain unit → `CMSG_QUESTGIVER_HELLO`, an
|
||||
/// item/player (or the `0xbe0824` latch set) → the teardown `0x501130(0,1)`, a GameObject →
|
||||
/// its interact virtual. The app owns the fork, because only it knows the giver.
|
||||
Decline,
|
||||
/// `CloseQuest()` — the window's own `QuestFrame_OnHide`: ESC, the close button, the
|
||||
/// greeting's Goodbye (`HideUIPanel(QuestFrame)`), a UIPanel eviction. Its binding `0x501a10`
|
||||
/// calls the teardown `0x501130(0,1)` and **nothing else**; that routine's one send is
|
||||
/// `MSG_QUEST_PUSH_RESULT{DECLINE}` for a PLAYER source, so closing an NPC's window is
|
||||
/// network-silent (wow-re `system/ui/scratch/quest-share-flow.md` §4.1 caller table, §6).
|
||||
///
|
||||
/// **One action, because the reference has one routine.** 1733 briefly split this into
|
||||
/// `Decline` and `Close` on the assumption that only the first was an answer on the wire; the
|
||||
/// §5 that landed after it found both Lua verbs calling `0x501130`, along with the walk-away
|
||||
/// watchdog and the leave-world teardown, and that routine both answers a share
|
||||
/// (`MSG_QUEST_PUSH_RESULT{DECLINE_QUEST}`) and re-opens an NPC's list
|
||||
/// (`CMSG_QUESTGIVER_HELLO`/`CMSG_GOSSIP_HELLO`). Which of the two it does is decided by the
|
||||
/// GIVER, never by the button — so the app owns that fork and the engine reports one intent
|
||||
/// (decision 1738).
|
||||
/// **Two actions, because the reference has two routines.** 1738 merged this into
|
||||
/// `DeclineQuest`'s action on the reading that both verbs are one routine; the note's caller
|
||||
/// table shows they share only the teardown, and the HELLO re-open lives in `DeclineQuest`'s
|
||||
/// fork alone. The merge made ESC on an NPC re-open its list — a multi-quest greeting could
|
||||
/// not be closed.
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -409,10 +416,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)
|
||||
}
|
||||
// AcceptQuest → Accept; DeclineQuest / CloseQuest → Close (the ONE session-end routine, see
|
||||
// `QuestAction::Close`); CompleteQuest (the progress panel's Continue) → Continue.
|
||||
// AcceptQuest → Accept; DeclineQuest → Decline (the re-opening fork `0x5013f0`); CloseQuest →
|
||||
// Close (the teardown `0x501130` alone — see `QuestAction::Close`); CompleteQuest (the
|
||||
// progress panel's Continue) → Continue.
|
||||
install_action(lua, "AcceptQuest", super::QuestAction::Accept)?;
|
||||
install_action(lua, "DeclineQuest", super::QuestAction::Close)?;
|
||||
install_action(lua, "DeclineQuest", super::QuestAction::Decline)?;
|
||||
install_action(lua, "CloseQuest", super::QuestAction::Close)?;
|
||||
install_action(lua, "CompleteQuest", super::QuestAction::Continue)?;
|
||||
|
||||
@@ -731,7 +739,7 @@ mod tests {
|
||||
QuestAction::Continue,
|
||||
QuestAction::Reward(1),
|
||||
QuestAction::Reward(0),
|
||||
QuestAction::Close,
|
||||
QuestAction::Decline,
|
||||
]
|
||||
);
|
||||
assert!(s.take_quest_actions().is_empty(), "drained");
|
||||
@@ -747,19 +755,18 @@ mod tests {
|
||||
assert!(s.eval::<bool>("return GetRewardSpell() == nil").unwrap());
|
||||
}
|
||||
|
||||
/// `DeclineQuest` and `CloseQuest` are the SAME intent — one reference routine, `0x501130`
|
||||
/// (decision 1738, correcting 1733's split). What differs between a decline and a close is
|
||||
/// nothing; what differs between an NPC's quest and a shared one is everything, and that fork
|
||||
/// belongs to the app, which knows the giver.
|
||||
/// `DeclineQuest` and `CloseQuest` are TWO intents — two reference routines (`0x5013f0` and
|
||||
/// `0x501a10`) that share only the teardown `0x501130`; the giver re-open is `DeclineQuest`'s
|
||||
/// alone (wow-re `quest-share-flow.md` §4.1/§6).
|
||||
#[test]
|
||||
fn decline_and_close_are_one_intent() {
|
||||
fn decline_and_close_are_two_intents() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_quest(Some(detail()));
|
||||
s.run("DeclineQuest()").unwrap();
|
||||
s.run("CloseQuest()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_quest_actions(),
|
||||
vec![QuestAction::Close, QuestAction::Close]
|
||||
vec![QuestAction::Decline, QuestAction::Close]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
//! one detail per push instead made a whole log walk answer with a single row's text.
|
||||
//! - **The abandon mark** (`SetAbandonQuest`/`GetAbandonQuestName`/`AbandonQuest`) — the ref's
|
||||
//! two-step confirm (mark on button click, act on the popup's Yes — ref `QuestLogFrame.xml:463-472`,
|
||||
//! `StaticPopup.lua:749-761`). The mark pins the *entry index at click time* so a log shuffle
|
||||
//! between click and confirm can't retarget the abandon.
|
||||
//! `StaticPopup.lua:749-761`). The mark is the selected **quest id** at click time, as the
|
||||
//! reference's is (`0x4dfb50` copies the selection `0xbb7480`, which holds a quest id, into
|
||||
//! `0xbb7484`), so a log that re-indexes between click and confirm — a fold, a quest arriving
|
||||
//! or leaving — can't retarget the abandon.
|
||||
//!
|
||||
//! A third engine-owned bit joined later: the **watch set** (`IsQuestWatched`/`AddQuestWatch`/
|
||||
//! `RemoveQuestWatch`/`GetNumQuestWatches`/`GetQuestIndexForWatch`) — keyed by the entries' stable
|
||||
@@ -208,9 +210,9 @@ impl super::UiScript {
|
||||
self.model_ref().quest_log_selection
|
||||
}
|
||||
|
||||
/// Drain the abandon intents: the 1-based entry index pinned by `SetAbandonQuest` at
|
||||
/// click time, confirmed by the popup's `AbandonQuest()`. The app maps index → descriptor
|
||||
/// slot → `CMSG_QUESTLOG_REMOVE_QUEST`.
|
||||
/// Drain the abandon intents: the QUEST ID `SetAbandonQuest` marked at click time, confirmed
|
||||
/// by the popup's `AbandonQuest()`. The app maps id → descriptor slot →
|
||||
/// `CMSG_QUESTLOG_REMOVE_QUEST`.
|
||||
pub fn take_quest_log_abandons(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().quest_log_abandons)
|
||||
}
|
||||
@@ -593,25 +595,38 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetAbandonQuest() — pin the current selection as the abandon target (ref
|
||||
// QuestLogFrame.xml:464).
|
||||
// SetAbandonQuest() — mark the selected QUEST as the abandon target (ref
|
||||
// QuestLogFrame.xml:464). The reference's `0x4dfb50` is `mov eax,[0xbb7480]; mov [0xbb7484],eax`:
|
||||
// it copies the selection, and the selection is a quest id (`0x4def30` stores the row's +0x0
|
||||
// at `0x4def5d`). Ours holds the selection as a row index, so the id is resolved here, at the
|
||||
// click — never at the confirm, when the rows may have moved.
|
||||
g.set(
|
||||
"SetAbandonQuest",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.quest_log_abandon_mark = model.quest_log_selection;
|
||||
let sel = model.quest_log_selection as usize;
|
||||
model.quest_log_abandon_mark = sel
|
||||
.checked_sub(1)
|
||||
.and_then(|n| model.quest_log.entries.get(n))
|
||||
.filter(|e| !e.is_header)
|
||||
.map_or(0, |e| e.quest_id);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// GetAbandonQuestName() → the pinned target's title ("" when none — the popup shows it).
|
||||
// GetAbandonQuestName() → the marked quest's title ("" when none — the popup shows it). The
|
||||
// reference (`0x4dfb60`) peeks its quest cache by the marked id; the rows carry the same
|
||||
// cached title.
|
||||
g.set(
|
||||
"GetAbandonQuestName",
|
||||
lua.create_function(|lua, ()| {
|
||||
let title = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
(model.quest_log_abandon_mark as usize)
|
||||
.checked_sub(1)
|
||||
.and_then(|n| model.quest_log.entries.get(n))
|
||||
let mark = model.quest_log_abandon_mark;
|
||||
model
|
||||
.quest_log
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| mark != 0 && !e.is_header && e.quest_id == mark)
|
||||
.map(|e| e.title.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
@@ -624,13 +639,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"GetAbandonQuestItems",
|
||||
lua.create_function(|_, ()| Ok(Value::Nil))?,
|
||||
)?;
|
||||
// AbandonQuest() — queue the pinned target as an outbound intent (the popup's Yes).
|
||||
// AbandonQuest() — the popup's Yes (`0x4dfe00` → `0x4df070`). The reference searches its
|
||||
// whole row table `0xbb71c0` (folded quests included) for a quest row carrying the marked id;
|
||||
// a hit sends that row's slot and clears the mark, a miss does nothing and KEEPS the mark.
|
||||
// Ours checks the same membership — a visible quest row, or one folded under a collapsed
|
||||
// header — and queues the id; the app resolves it to the slot.
|
||||
g.set(
|
||||
"AbandonQuest",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let mark = std::mem::take(&mut model.quest_log_abandon_mark);
|
||||
if mark != 0 {
|
||||
let mark = model.quest_log_abandon_mark;
|
||||
let in_log = mark != 0
|
||||
&& (model
|
||||
.quest_log
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_header && e.quest_id == mark)
|
||||
|| model.quest_log.hidden_quest_ids.contains(&mark));
|
||||
if in_log {
|
||||
model.quest_log_abandon_mark = 0;
|
||||
model.quest_log_abandons.push(mark);
|
||||
}
|
||||
Ok(())
|
||||
@@ -1228,9 +1255,10 @@ mod tests {
|
||||
s.eval::<String>("return GetAbandonQuestName()").unwrap(),
|
||||
"Kobold Camp Cleanup"
|
||||
);
|
||||
// Selection moves before the confirm — the mark must not follow it.
|
||||
// Selection moves before the confirm — the mark must not follow it. What queues is the
|
||||
// marked QUEST ID (entry 2 is quest 7), never a row index (0x4dfb50).
|
||||
s.run("SelectQuestLogEntry(1); AbandonQuest()").unwrap();
|
||||
assert_eq!(s.take_quest_log_abandons(), vec![2]);
|
||||
assert_eq!(s.take_quest_log_abandons(), vec![7]);
|
||||
assert!(s.take_quest_log_abandons().is_empty(), "drained");
|
||||
// A bare AbandonQuest with no mark queues nothing.
|
||||
s.run("AbandonQuest()").unwrap();
|
||||
@@ -1463,10 +1491,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The abandon mark follows its QUEST across a re-index, and a mark whose quest has left the
|
||||
/// log is a no-op that is KEPT, as `0x4df070`'s miss path is (`ret` before the clear at
|
||||
/// `0x4df0cf`). A quest folded under a collapsed header is still in the log.
|
||||
#[test]
|
||||
fn abandon_mark_is_a_quest_id_that_survives_a_reindex() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_quest_log(two_quests());
|
||||
s.run("SelectQuestLogEntry(2); SetAbandonQuest()").unwrap(); // quest 7
|
||||
|
||||
// The log re-indexes under the popup: quest 7 is now row 1, and a stranger takes row 2.
|
||||
let mut moved = two_quests();
|
||||
moved.entries.swap(0, 1);
|
||||
moved.entries[1].quest_id = 999;
|
||||
s.set_quest_log(moved.clone());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetAbandonQuestName()").unwrap(),
|
||||
"Kobold Camp Cleanup"
|
||||
);
|
||||
s.run("AbandonQuest()").unwrap();
|
||||
assert_eq!(s.take_quest_log_abandons(), vec![7]);
|
||||
|
||||
// Folded away: still abandonable.
|
||||
s.run("SelectQuestLogEntry(1); SetAbandonQuest()").unwrap();
|
||||
let mut folded = moved.clone();
|
||||
folded.entries.remove(0);
|
||||
folded.hidden_quest_ids = vec![7];
|
||||
s.set_quest_log(folded);
|
||||
s.run("AbandonQuest()").unwrap();
|
||||
assert_eq!(s.take_quest_log_abandons(), vec![7]);
|
||||
|
||||
// Gone from the log: nothing queues, and the mark stays for the quest's return.
|
||||
s.set_quest_log(moved.clone());
|
||||
s.run("SelectQuestLogEntry(1); SetAbandonQuest()").unwrap();
|
||||
let mut gone = moved.clone();
|
||||
gone.entries.remove(0);
|
||||
s.set_quest_log(gone);
|
||||
s.run("AbandonQuest()").unwrap();
|
||||
assert!(s.take_quest_log_abandons().is_empty());
|
||||
s.set_quest_log(moved);
|
||||
s.run("AbandonQuest()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_quest_log_abandons(),
|
||||
vec![7],
|
||||
"the kept mark still names 7"
|
||||
);
|
||||
}
|
||||
|
||||
/// `QuestLogPushQuest` queues the selected entry's **quest id**, resolved at click time — not
|
||||
/// its index, so a log that reshuffles between the click and the app's drain cannot retarget
|
||||
/// the push. (Contrast the abandon mark, which pins an index on purpose: its two-step confirm
|
||||
/// is what makes the index the right thing to hold.)
|
||||
/// the push — the same keying as the abandon mark.
|
||||
/// A party of one other, so the C verb's own party check (below) passes.
|
||||
fn in_a_party(s: &mut UiScript) {
|
||||
s.set_party(crate::script::PartyState {
|
||||
|
||||
Reference in New Issue
Block a user