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:
Sam
2026-09-23 12:22:10 +07:00
co-authored by Claude Opus 5.5
parent 293b8e57fb
commit ef0fcc327c
53 changed files with 2666 additions and 423 deletions
+168 -37
View File
@@ -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]
+100 -4
View File
@@ -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]
+5 -5
View File
@@ -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
+28 -21
View File
@@ -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]
);
}
+93 -19
View File
@@ -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 {