a disarmed fighter punches, and the quest log tags its elite rows

- a disarmed fighter swings bare-fisted: `UNIT_FLAG_DISARMED` was read nowhere but a dev banner, and the reference applies it inside `GetWeapon` on the `visFlag == 0` path, so the swing lands on AttackUnarmed; the RE cross-check (nine workers converging byte-for-byte) corrected the first reading twice — disarm hides exactly ONE weapon, main hand first (`5ec28d je 0x5ec2aa`), so a disarmed dual-wielder keeps swinging its dagger; and the weapon comes OFF the hand: the field-change reflex `0x5ff580` detaches a drawn main-hand weapon when the bit goes up, while a stowed one stays frozen on the back; one pure ladder feeds every unarmed-view site of the 82-site `visFlag` census — parry, the sheath machine, the melee sounds (a disarmed punch does not clang like a sword), Heroic Strike greying out, and the Attack button showing `Spell-Reset` with the weapon still equipped (1863)
- a quest-log row reads `(Elite)`, `(Dungeon)`, `(Raid)` or `(PvP)`: `QuestInfo.dbc` was never parsed and `GetQuestLogTitle`'s third return was a literal nil; the table is seven rows read out of `patch-2.MPQ`, SPARSE (1 Elite · 21 Life · 41 PvP · 62 Raid · 81 Dungeon · 82 World Event · 83 Legendary — vanilla's 1 is "Elite", not TBC's "Group"), and the base `dbc.MPQ` copy has only four, so a chain stopping short would lose Dungeon, World Event and Legendary; the row gains the reference's own right-flush tag string and its precedence — a finished quest's FAILED/COMPLETE word OVERWRITES the tag — replacing the " (Complete)" we appended to the title; the RE round confirmed the tag path and found the arity bug: six values ALWAYS (`mov eax,6`), the level a NUMBER 0 even on a header, where we gave one nil; the reference's title shrink stays out: an explicit width is a WRAP width here (1873)
- the death thud is silent indoors, and that is the content: BOTH the footstep and the thud lookups key on `TerrainType.SoundID`, and TerrainType 10 "None" has SoundID 0 — the footstep table has a row there for 17 classes and `DeathThudLookups` has none, so the floor that creaks underfoot swallows the body that lands on it; 10 075 of 10 299 WMO materials (97.8%) are TerrainType 10 and 694 of 815 WMO roots have no surface that can thud, every inn and tavern among them, the Kharanos inn included — the code was always right and the prose was not; the RE round also confirmed the strict `depth > 2.0` sense and the size-class column, pinned a sound id of 0 as silence, and placed `0x6005d0` as the loot sparkle, not a second sound; `thudcensus` prints all 121 buildings that do thud rather than a head, because a truncated list cannot answer "does THIS one" (1870)
- a questgiver's overhead marker follows its rider on and off the mount: the reference re-runs the attach from the `UNIT_FIELD_MOUNTDISPLAYID` watch handler, and benilla picked the slot once at build, relying on a teardown the reseat rework no longer does (B199), so the seat rode at the WRONG slot; `overhead_slot` is now the one definition of the pick (`0x6074c0`) and the marker rebuilds when it moves (1871); and the chat menu's kit was ours overwriting the reference's — `UIMenu.xml` loads earlier on the chain and our `ChatFrame.xml` carried the same kit under the SAME ten names, faithful enough that deleting it changes nothing; nothing ever opened the chat menu, and now a test does; an event census holds the gap between registered and fired events at exactly eighteen, failing on a surprise AND on a quietly gained producer — features not yet built, none a split pair (1869)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:49:33 +07:00
co-authored by Claude Fable 5.1
parent f38c315d59
commit 94cc4429eb
31 changed files with 2146 additions and 283 deletions
+101 -7
View File
@@ -39,6 +39,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::number_arg;
use super::quest::QuestItemView;
use super::Model;
@@ -55,8 +56,15 @@ pub struct QuestLogEntryView {
pub title: String,
/// The quest's display level (`GetQuestLogTitle` return 2; the ref colors the row by it).
pub level: u32,
/// The quest tag suffix (elite/dungeon/…) — `None` for a plain quest. v1 pushes `None`
/// (the 1.12 wire's giver panels carry no tag; a template-derived tag is a later dressing).
/// The quest tag suffix — the bare word, no parentheses: `Elite`, `Dungeon`, `Raid`, `PvP`,
/// `Life`, `World Event`, `Legendary`. `None` for a plain quest, which is most of them, and
/// always `None` on a header row. The app resolves it from the cached
/// `SMSG_QUEST_QUERY_RESPONSE` template's `Type` through `QuestInfo.dbc`
/// ([`benilla_formats::QuestTagNames`]); `Type` 0 names no row and takes no tag.
///
/// `None`, never `Some("")` — the reference's row Lua branches on the tag's PRESENCE
/// (`if ( questTag )`, ref `QuestLogFrame.lua:194`) to decide whether to shrink the title and
/// reseat the watch check, so an empty string would take the wrong branch.
pub tag: Option<String>,
/// A zone header row (the app synthesizes these from each quest's ZoneOrSort).
pub is_header: bool,
@@ -286,19 +294,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetQuestLogTitle(i) → title, level, tag, isHeader, isCollapsed, isComplete
// (the load-bearing 6-tuple — ref QuestLogFrame.lua:144/:272/:321/:571). Out of range → nil.
// isCollapsed is always false (no headers in v1); isComplete is 1 / -1 / nil.
// (the load-bearing 6-tuple — ref QuestLogFrame.lua:144/:272/:321/:571).
// `tag` is the bare word (`Elite`, `Raid`, …) or nil — the ref's Lua wraps it in the
// parentheses itself (`"("..questTag..")"`, ref l.195). isComplete is 1 / -1 / nil.
//
// **The arity is always SIX, and the two failure shapes are different** (wow-re
// `ui/scratch/questlog-title-tag.md`, §5-verified at `0x4df930`): a MISSING or non-number
// argument raises `Usage:` (shape A — [`number_arg`], truncating toward zero like the
// reference's `_ftol`), while an out-of-range NUMBER is not an error at all — it returns
// `nil, 0, nil, nil, nil, nil` off `mov eax,6` at all three exits. Return 2 is the NUMBER
// `0` there, never nil, and it is `0` for a header row too. We used to answer a single
// `Nil` for both, which reads the same through the reference's own
// `local t, l, … = GetQuestLogTitle(i)` (Lua pads with nil) but hands an addon doing
// arithmetic on the level a nil where the client gives it a zero.
g.set(
"GetQuestLogTitle",
lua.create_function(|lua, i: usize| {
lua.create_function(|lua, i: Value| {
let n = number_arg(lua, i, "Usage: GetQuestLogTitle(index)")?;
let entry = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
i.checked_sub(1)
usize::try_from(n)
.ok()
.and_then(|n| n.checked_sub(1))
.and_then(|n| model.quest_log.entries.get(n))
.cloned()
};
let Some(e) = entry else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Integer(0),
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
]));
};
let tag = match &e.tag {
Some(t) => Value::String(lua.create_string(t)?),
@@ -874,6 +903,71 @@ mod tests {
.unwrap());
}
/// The §5-verified arity contract (wow-re `ui/scratch/questlog-title-tag.md`, `0x4df930`):
/// six values ALWAYS, an out-of-range number is not an error, and return 2 is the number `0`
/// — while a missing or non-number argument raises `Usage:`.
#[test]
fn out_of_range_is_six_values_with_a_zero_level_and_a_bad_arg_raises() {
let mut s = UiScript::new().unwrap();
// Empty log: the first return is still nil (so the ref's `if ( questLogTitleText )`
// reads the same), but the tuple is full and the level is a NUMBER.
assert_eq!(
s.eval::<i64>("return select('#', GetQuestLogTitle(1))")
.unwrap(),
6
);
assert!(s
.eval::<bool>("local t, l = GetQuestLogTitle(1) return t == nil and l == 0")
.unwrap());
s.set_quest_log(two_quests());
// Index 0 and a negative index are out of range, not errors — same six values.
for i in ["0", "-1", "99"] {
assert!(
s.eval::<bool>(&format!(
"local t, l = GetQuestLogTitle({i}) return t == nil and l == 0"
))
.unwrap(),
"index {i} is out of range, not an error"
);
}
// `_ftol` truncates toward zero, so 1.9 addresses entry 1.
assert!(s
.eval::<bool>("return GetQuestLogTitle(1.9) == 'A Threat Within'")
.unwrap());
// A missing / non-number argument is the other shape.
for bad in ["", "nil", "{}", "print"] {
let e = format!(
"{:?}",
s.eval::<mlua::Value>(&format!("return GetQuestLogTitle({bad})"))
.unwrap_err()
);
assert!(
e.contains("Usage: GetQuestLogTitle(index)"),
"arg `{bad}` must raise Usage:, got {e}"
);
}
}
#[test]
fn tag_is_the_bare_word_and_nil_when_absent() {
// The third return is the BARE word — the ref's own Lua adds the parentheses
// (`"("..questTag..")"`, ref QuestLogFrame.lua:195), so pushing "(Elite)" here would
// paint "((Elite))". An untagged quest pushes nil, not "", because the ref branches on
// presence (`if ( questTag )`, ref l.194).
let mut s = UiScript::new().unwrap();
let mut state = two_quests();
state.entries[1].tag = Some("Elite".into());
s.set_quest_log(state);
assert!(s
.eval::<bool>("return select(3, GetQuestLogTitle(1)) == nil")
.unwrap());
assert!(s
.eval::<bool>("return select(3, GetQuestLogTitle(2)) == 'Elite'")
.unwrap());
}
#[test]
fn selection_is_synchronous() {
let mut s = UiScript::new().unwrap();