Files
benilla-octo/crates/benilla-app/src/ui_quest_log.rs
T
SamandClaude Fable 5.1 663851b17d every line is a key, four find their sound, and the ding prints once
- the last app-side lines become GlobalStrings keys — tooltip cells, party, petition, duel, quest, PvP and rest lines read the install's own wording, so a non-enUS chain shows its own text (2045, 2080)
- a locked chest says Requires Pick Lock, ammo and thrown weapons drop their invented slot word, a met set bonus reads Set: without the count, and a 100 s cooldown reads 1.67 min as the template prints it
- the level-up block and the free-professions line printed twice in chat and print once; a declined invite, a friend coming online, a duel request and a failed quest play the sound 1.12 gives them
- Duel cancelled. is the yellow line at the top of the screen, and a trainer refusal or a failed quest with no cached template shows nothing, as on 1.12

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:19:46 +07:00

1632 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The app-side **quest-log state + feed** — decision 0088's deferred second slice (the giver-panel
//! decision named the log window, `SMSG_QUEST_QUERY_RESPONSE`, and the `PLAYER_QUEST_LOG` field
//! accessors as a follow-up; the wire is now pinned in `quest-log-wire-pin.md`, and this is the app
//! layer over it). The inward half of the seam around [`benilla_ui::script::quest_log`], the log's
//! twin of [`crate::ui_quest`]'s questgiver-panel feed.
//!
//! Unlike the questgiver panels (a transient NPC-session window), the quest log is **durable
//! player state**: [`feed_quest_log`] reads the self player's `PLAYER_QUEST_LOG` descriptor slots
//! every frame (the wire pin's field 198 + 3·slot) rather than reacting to one wire event, and
//! resolves EVERY occupied slot's title/level/tag/objectives from the `SMSG_QUEST_QUERY_RESPONSE`
//! template cache ([`QuestLog`] — the exact ask-once twin of [`crate::items::Items`]'s template
//! cache): [`build_objectives`] turns the template's fixed objective quads + the slot's counters
//! (creature/GO objectives) or the live bag count (item objectives; the wire pin: item-objective
//! progress is *not* one of the slot's 6-bit counters, the client counts bags itself) into every
//! entry's [`QuestLogEntryView::objectives`] — not just the selection's, since the Era API answers
//! `GetNumQuestLeaderBoards`/`GetQuestLogLeaderBoard` for ANY index (the watch tracker HUD reads
//! non-selected quests' lines). [`build_detail`] resolves only the selection's description/money/
//! rewards/choices. The snapshot pushes via `set_quest_log` + fires `QUEST_LOG_UPDATE` — plus,
//! per quest whose objectives moved ([`quests_with_progressed_objectives`]), the progress-toast
//! `UI_INFO_MESSAGE`s (each moved leaderboard line's fresh text — the yellow top-center popup —
//! and the COMPLETE flip's "%s (Complete)"), the native `QUEST_WATCH_UPDATE(watchIndex)` (the
//! §5-verified byte arg), and `BENILLA_QUEST_PROGRESS(logIndex)` (the auto-watch's feed — the
//! divergence note at the fire site / decision 0340) — diffed against a `Local`, exactly like
//! every other feed in this crate.
//!
//! [`drain_quest_log_abandons`] maps the confirmed abandon's 1-based Lua entry index back to its
//! descriptor slot (this frame's push order, kept on [`QuestLog`]) and sends
//! `CMSG_QUESTLOG_REMOVE_QUEST`.
//!
//! This module used to also answer the questgiver greeting's active/available split, via a
//! `contains` membership test over the live descriptor slots. **It doesn't any more, and must not
//! again** — the reference splits that list on the wire icon alone and never consults its quest log
//! there ([`crate::ui_quest::row_is_active`], decision 0758). Membership was wrong for exactly the
//! quests that are never in the log: auto-complete turn-ins. The state that backed it is gone
//! rather than left dead, so the retired law can't be reached for by accident.
use crate::ui_items::{count_of, InventoryScope};
use std::collections::{HashMap, HashSet};
use bevy::prelude::*;
use benilla_protocol::messages::{
quest_flags, quest_slot_state, QuestLogSlot, QuestObjective, QuestTemplate,
PLAYER_QUEST_LOG_SLOTS,
};
use benilla_protocol::ObjectFields;
use benilla_ui::script::{
QuestItemView, QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState,
ScriptValue, UiScript,
};
use benilla_ui::strings::{fill, Arg};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// The top bit `QuestObjective::creature_or_go` carries for a gameobject objective —
/// `(−id) | 0x8000_0000` (vmangos `Quest.cpp:512-516`; the wire pin's finding, same raw shape
/// `SMSG_QUESTUPDATE_ADD_KILL`'s `entry` carries for a GO kill/use).
const GO_OBJECTIVE_BIT: u32 = 0x8000_0000;
/// One occupied `PLAYER_QUEST_LOG` slot this frame — the feed's working row before it's turned into
/// a pushed [`QuestLogEntryView`].
struct Row {
slot: u8,
quest_id: u32,
log_slot: QuestLogSlot,
}
/// The quest-log state cache + per-frame bookkeeping.
///
/// - `templates`/`pending` — the `SMSG_QUEST_QUERY_RESPONSE` cache, ask-once by quest id (the exact
/// twin of [`Items`]'s item-template cache).
/// - `entry_slots` — this frame's pushed entry order → descriptor slot, so
/// [`drain_quest_log_abandons`] can turn a confirmed abandon's 1-based Lua index back into the
/// `CMSG_QUESTLOG_REMOVE_QUEST` slot it came from (slots aren't contiguous — an abandoned/turned-in
/// quest leaves a gap).
#[derive(Resource, Default)]
pub(crate) struct QuestLog {
templates: HashMap<u32, QuestTemplate>,
pending: HashSet<u32>,
entry_slots: Vec<Option<u8>>,
/// Collapsed section headers, keyed by header TITLE (two zones sharing a name share a header
/// row, so the fold state naturally shares too). Owned here — the engine only reports the
/// flag and drains toggle intents ([`drain_quest_log_collapses`]).
collapsed: HashSet<String>,
/// This frame's pushed entry order → the header title for header rows (`None` for quests) —
/// the collapse drain's index→identity map, the fold twin of `entry_slots`.
header_keys: Vec<Option<String>>,
}
impl QuestLog {
/// The template for `quest_id`, if known — ask-once: a miss sends `CMSG_QUEST_QUERY` (deduped
/// while in flight) and returns `None`; a cached negative (the server doesn't know the id) is
/// also `None`, without a re-ask. The exact twin of [`Items::template`].
pub(crate) fn template(
&mut self,
quest_id: u32,
commands: &NetCommands,
) -> Option<&QuestTemplate> {
if !self.templates.contains_key(&quest_id) {
if self.pending.insert(quest_id) {
debug!("ui_quest_log: asking quest template (quest {quest_id})");
let _ = commands
.0
.send(ClientCommand::QuestQuery { quest: quest_id });
}
return None;
}
self.templates.get(&quest_id)
}
/// Record a template answer (`SMSG_QUEST_QUERY_RESPONSE`).
pub(crate) fn insert_template(&mut self, template: QuestTemplate) {
self.pending.remove(&template.quest_id);
self.templates.insert(template.quest_id, template);
}
/// Disconnect: drop everything, including the templates. Unlike item templates (which persist —
/// decision: static, stable across sessions), a stale log slice carried across a reconnect would
/// pair fresh descriptor slots (possibly renumbered) with old titles; templates are cheap enough
/// to re-ask that there's no reason to risk it.
pub(crate) fn clear_session(&mut self) {
self.templates.clear();
self.pending.clear();
self.entry_slots.clear();
self.collapsed.clear();
self.header_keys.clear();
}
}
pub(crate) struct UiQuestLogPlugin;
impl Plugin for UiQuestLogPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<QuestLog>()
.add_systems(
Startup,
(load_quest_header_names, load_quest_tag_names)
.after(benilla_assets::AssetSet::Open),
)
.add_systems(
Update,
(
feed_quest_log.in_set(UnitFeed).before(UiInput),
// Before the script tick, so a QuestTimerFrame OnUpdate this frame reads this
// frame's clock (the `minimap::feed_game_time` shape).
feed_server_clock.before(UiInput),
drain_quest_log_abandons.after(UiInput),
drain_quest_log_pushes.after(UiInput),
drain_quest_log_collapses.after(UiInput),
),
);
}
}
/// One leaderboard line for a template objective slot — `None` when the slot carries no content (an
/// unused quad; `SMSG_QUEST_QUERY_RESPONSE`'s objective array is a fixed 4 regardless of how many
/// the quest actually uses — the wire pin). Pure: the caller has already resolved the creature/item
/// name (ask-once — `None` while the answer is in flight) and the live progress (`counter` — the
/// descriptor slot's 6-bit counter, meaningful only for a creature/GO objective; `bag_count` — the
/// live backpack+bags count, meaningful only for an item objective — [`crate::ui_items::count_of`]).
///
/// The custom `text` field, when non-empty, REPLACES the auto-generated name in the line but keeps
/// the "cur/req" suffix — CONFIRMED since (wow-re `ui/scratch/quest-leaderboard-law.md` §5: a
/// creature objective carrying `ObjectiveText[i]` formats with `QUEST_OBJECTS_FOUND`, not the
/// "slain" key, and that text IS the name — while `type` still answers `"monster"`).
///
/// **The three format keys are the leaderboard's own, and are NOT the progress toast's** (decision
/// 2045's central trap: `QUEST_MONSTERS_KILLED` and `ERR_QUEST_ADD_KILL_SII` are the same enUS
/// sentence, and `QUEST_OBJECTS_FOUND`/`QUEST_ITEMS_NEEDED`/`ERR_QUEST_ADD_FOUND_SII`/
/// `ERR_QUEST_ADD_ITEM_SII` are the same four). `GetQuestLogLeaderBoard 0x4e0000` resolves
/// `QUEST_MONSTERS_KILLED` `0x84b61c` (creature, no override), `QUEST_OBJECTS_FOUND` `0x84b634`
/// (the override leg AND both GameObject legs) and `QUEST_ITEMS_NEEDED` `0x84b5f8` (item) — §5's
/// table, address by address. Only the toast handler `0x5e5ad0` names the `ERR_*` twins, and it
/// names them because they are *message-catalog* rows; these three are not.
///
/// A key the install does not carry renders the line with **empty text rather than no line**: the
/// reference's `FrameScript_GetText` hands back its pre-seeded empty string and the leaderboard
/// entry still exists, which is what `GetNumQuestLeaderBoards` counts.
///
/// **Known-wrong (decision 1156, not yet corrected):** `finished` must be `cur >= req` and NOTHING
/// else. The whole-quest COMPLETE bit is read exactly once in the reference binding, by the `event`
/// line, where it is that line's only predicate (`0x4e02a2`); or-ing it in here marks every
/// objective finished the moment the quest completes. The fix must land WITH [`count_of`]'s scope
/// widening (1156) — alone, it would render a banked-item quest's lines unfinished under a
/// COMPLETE quest.
fn objective_line(
obj: &QuestObjective,
counter: u8,
creature_name: Option<&str>,
item_name: Option<&str>,
bag_count: u32,
// The VM's own `GlobalStrings.lua` (decision 2045).
get: &dyn Fn(&str) -> Option<String>,
) -> Option<QuestLogObjectiveView> {
// The reference's cache miss reads `" "` (`0x82ee00`); ours reads this, a longer-standing
// divergence that says "still in flight" rather than looking like a nameless objective.
const PLACEHOLDER: &str = "...";
let line = |key: &str, name: &str, cur: u32, req: u32| {
get(key).map_or_else(String::new, |f| {
fill(
&f,
&[Arg::S(name), Arg::D(i64::from(cur)), Arg::D(i64::from(req))],
)
})
};
if obj.creature_or_go != 0 && obj.required_count > 0 {
let req = obj.required_count;
let is_go = obj.creature_or_go & GO_OBJECTIVE_BIT != 0;
let cur = u32::from(counter).min(req);
let kind = if is_go { "object" } else { "monster" };
// §5's fork, in its own order: a non-empty override IS the name and takes
// `QUEST_OBJECTS_FOUND` on either kind; a GameObject takes that key on both its legs
// (the binding fetches it once, above its cache test, at `0x4e031e`); only a plain
// creature reaches `QUEST_MONSTERS_KILLED`. No gameobject-name cache yet — a later
// CMSG_GAMEOBJECT_QUERY slice — so a GO with no override wears the placeholder.
let (key, name) = if !obj.text.is_empty() {
("QUEST_OBJECTS_FOUND", obj.text.as_str())
} else if is_go {
("QUEST_OBJECTS_FOUND", PLACEHOLDER)
} else {
(
"QUEST_MONSTERS_KILLED",
creature_name.unwrap_or(PLACEHOLDER),
)
};
return Some(QuestLogObjectiveView {
text: line(key, name, cur, req),
kind: kind.into(),
finished: cur >= req,
cur,
req,
});
}
if obj.item_id != 0 && obj.item_count > 0 {
let req = obj.item_count;
let cur = bag_count.min(req);
// Pre-existing divergence, preserved rather than quietly changed here: §5's item row
// resolves its name from the **item cache alone** — the per-objective override buffer
// (`template+0x14dc + i*0x100`) is read only by the creature and GameObject branches — so
// the reference would show the item's name where this shows `ObjectiveText[i]`. The FORMAT
// key is `QUEST_ITEMS_NEEDED` either way, which is what this conversion is about.
let name = if obj.text.is_empty() {
item_name.unwrap_or(PLACEHOLDER)
} else {
obj.text.as_str()
};
return Some(QuestLogObjectiveView {
text: line("QUEST_ITEMS_NEEDED", name, cur, req),
kind: "item".into(),
finished: cur >= req,
cur,
req,
});
}
None
}
/// Split a `SMSG_QUEST_QUERY_RESPONSE` `money` value into (required, reward) — negative demands
/// money at turn-in, non-negative grants it (`QuestLogDetail::required_money`/`reward_money`).
fn money_split(money: i32) -> (u32, u32) {
if money < 0 {
((-money) as u32, 0)
} else {
(0, money as u32)
}
}
/// Resolve one template reward/choice `(itemId, count)` pair into a Lua-facing [`QuestItemView`] —
/// the quest-log twin of [`crate::ui_quest::resolve_item`]. The icon path differs from the
/// questgiver panels: `SMSG_QUEST_QUERY_RESPONSE`'s reward arrays carry **no display id** (the wire
/// pin), so the icon comes from the item template's `display_info_id` instead — the same
/// `ItemDisplayInfo.dbc` catalog lookup a bag slot uses ([`crate::ui_items::resolve_slot`]).
fn resolve_template_item(
entry: u32,
count: u32,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> QuestItemView {
let template = items.template(entry, 0, commands).cloned();
let name = template.as_ref().map(|t| t.name.clone());
let quality = template.as_ref().map(|t| t.quality).unwrap_or(1);
let texture = template.as_ref().and_then(|t| {
icons
.and_then(|i| i.catalog.get(t.display_info_id))
.and_then(|d| d.icon.clone())
});
// `GetQuestLogItemLink`'s payload (decisions 1059/1060) — the same shared link builder, and the
// same in-flight `None`, as the questgiver panels' [`crate::ui_quest::resolve_item`].
let link = name
.as_ref()
.map(|n| crate::ui_items::item_link(entry, n, quality));
QuestItemView {
name,
texture,
count,
quality,
item_id: entry,
usable: true, // v1: soft-gray only, server authoritative (mirrors ui_quest.rs's resolve_item)
link,
}
}
/// Build one occupied slot's objective ("leaderboard") lines from its template + this frame's
/// descriptor slot counters/bag counts — every entry gets these (`GetNumQuestLeaderBoards`/
/// `GetQuestLogLeaderBoard` answer ANY index, not just the selection: the watch tracker HUD reads
/// non-selected quests' lines, ref `QuestLogFrame.lua:613-663`). Pulled out of [`build_detail`]
/// (which used to build this only for the selection) so the per-frame loop below can call it once
/// per occupied slot.
#[allow(clippy::too_many_arguments)]
fn build_objectives(
template: &QuestTemplate,
log_slot: &QuestLogSlot,
store: &ObjectFields,
items: &mut Items,
names: &mut NameCache,
commands: &NetCommands,
// The VM's own `GlobalStrings.lua`, for [`objective_line`]'s three format keys.
get: &dyn Fn(&str) -> Option<String>,
) -> Vec<QuestLogObjectiveView> {
let mut objectives = Vec::with_capacity(template.objectives.len());
for (i, obj) in template.objectives.iter().enumerate() {
let is_go = obj.creature_or_go & GO_OBJECTIVE_BIT != 0;
let creature_name = (obj.creature_or_go != 0 && !is_go)
.then(|| {
names
.resolve_creature(obj.creature_or_go, 0, commands)
.map(str::to_string)
})
.flatten();
let item_name = (obj.item_id != 0)
.then(|| {
items
.template(obj.item_id, 0, commands)
.map(|t| t.name.clone())
})
.flatten();
let bag_count = if obj.item_id != 0 {
count_of(store, items, obj.item_id, InventoryScope::QUEST_ITEMS)
} else {
0
};
if let Some(line) = objective_line(
obj,
log_slot.counters[i],
creature_name.as_deref(),
item_name.as_deref(),
bag_count,
get,
) {
objectives.push(line);
}
}
objectives
}
/// Build the selected quest's detail pane from its template — description/money/rewards/choices
/// only (the objective lines live on every [`QuestLogEntryView::objectives`] now, selection or
/// not — see [`build_objectives`]), so this no longer needs the descriptor slot at all.
fn build_detail(
template: &QuestTemplate,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
macros: &crate::npc_text::MacroContext,
spells: Option<&Spells>,
) -> QuestLogDetail {
let (required_money, reward_money) = money_split(template.money);
let rewards = template
.rewards
.iter()
.filter(|&&(id, _)| id != 0)
.map(|&(id, count)| resolve_template_item(id, count, items, icons, commands))
.collect();
let choices = template
.choices
.iter()
.filter(|&&(id, _)| id != 0)
.map(|&(id, count)| resolve_template_item(id, count, items, icons, commands))
.collect();
QuestLogDetail {
// The wire delivers quest text with its chat macros ($N/$B/$G/$<n>w) un-expanded; the
// client substitutes (crate::npc_text — the 0109 look fix's shared mechanism).
description: crate::npc_text::substitute(&template.details, macros),
objectives_text: crate::npc_text::substitute(&template.objectives_text, macros),
required_money,
reward_money,
choices,
rewards,
reward_spell: crate::ui_quest::reward_spell_view(template.reward_spell, spells),
}
}
/// Map a 1-based Lua entry index to its descriptor slot via this frame's push order (`entry_slots`)
/// — the abandon drain's half of the flow; `None` for a header row (no slot to remove). Pure, so
/// it's testable without a live [`QuestLog`]/ECS.
fn abandon_slot(entry: u32, entry_slots: &[Option<u8>]) -> Option<u8> {
(entry as usize)
.checked_sub(1)
.and_then(|i| entry_slots.get(i))
.copied()
.flatten()
}
/// Re-point the engine's quest-log selection (a 1-based entry INDEX) across a snapshot rebuild —
/// headers/folds shift indexes between pushes, so the selection follows the SAME quest to its new
/// position. A header is NEVER a valid selection (ref `QuestLog_SetSelection`'s header branch only
/// folds, and `QuestLog_GetFirstSelectableQuest` skips headers), so a selection that no longer
/// lands on a visible quest — folded away, abandoned, or resting on a header — resets to 0, and
/// the Lua update's transcribed `SetFirstValidSelection` picks the first visible quest, exactly
/// the ref's maintenance loop (`QuestLog_Update`, ref l.293-296). 0 in, 0 out.
fn remap_selection(old: &[QuestLogEntryView], new: &[QuestLogEntryView], sel: u32) -> u32 {
let Some(prev) = (sel as usize)
.checked_sub(1)
.and_then(|i| old.get(i))
.filter(|e| !e.is_header)
else {
return 0;
};
new.iter()
.position(|e| !e.is_header && e.quest_id == prev.quest_id)
.map(|i| i as u32 + 1)
.unwrap_or(0)
}
/// Push the server's wall clock into the VM every frame (decision 1150) — the number every
/// countdown binding subtracts against.
///
/// Every frame, not on change: it is a *clock*, so "changed" is always, and the whole point is that
/// `GetQuestTimers()` answers a fresh value on each of the reference `QuestTimerFrame`'s OnUpdate
/// calls. Cheap by construction — one scalar write, no event, no diff, nothing downstream unless
/// some frame actually reads it.
///
/// Silent before the first `SMSG_QUERY_TIME_RESPONSE` (the world-enter query answers within a round
/// trip). The bindings then report *no* timer rather than a guessed one — see
/// `benilla_ui::script::quest_log::seconds_left`.
fn feed_server_clock(
script: Option<NonSendMut<UiScript>>,
clock: Res<crate::net::ServerWallClock>,
) {
let (Some(mut script), Some(now)) = (script, clock.now_unix()) else {
return;
};
script.set_server_unix_time(now);
}
/// The header literal the reference paints for a header whose id is exactly `0` (`0x84b4ec`, its
/// only reference image-wide). It is a **designer-data tell, not a transient**: the only value that
/// can become a header id is a CACHED template's `zoneOrSort`, so it cannot appear while a template
/// is in flight — which is what makes it safe to render at all (wow-re
/// `ui/scratch/questlog-list-rebuild.md`, B5).
const MISSING_HEADER: &str = "Missing header! (quest designers)";
/// One row's grouping/ordering inputs — everything [`order_groups`] needs, and nothing else, so
/// the display order is testable without a Bevy world.
struct GroupRow {
zone_or_sort: i32,
/// The resolved header name for `zone_or_sort` — [`MISSING_HEADER`] for `0`, and the empty
/// string for an id neither `AreaTable` nor `QuestSort` names (the reference's lookup simply
/// finds no string there, and collates it as `""`).
header: String,
level: u32,
title: String,
slot: u8,
}
/// **The quest log's display order** — §5-verified (wow-re `ui/scratch/questlog-list-rebuild.md`;
/// rebuild `0x4de510`, group sort `0x4de751`/`0x4de8f0`, row comparator `0x4deac0`). Returns the
/// header groups in order, each with its rows' indices in order.
///
/// - **Groups**: one per distinct `ZoneOrSort`, and `id == 0` is forced **FIRST**, before any name
/// lookup (`0x4de913`). An id that names no row collates as `""` and lands next. The rest go by
/// resolved name through the client's collator `0x64a4c0`, which is **case-INSENSITIVE**
/// (`_strnicmp 0x414310`) — a byte-wise `cmp` puts `"Elwynn Forest"` and `"eastern kingdoms"` in
/// the wrong relative place.
/// - **Within a group**: **level ASC** (`0x4decd9`), then **title** through that same collator
/// (`0x4decfb`). Descriptor-slot order — what this used to do, and what benilla claimed — was
/// REFUTED: `0x4deac0` reads `entry+0x4` into a stack slot it never uses.
/// - The reference's `qsort` is **unstable**, so a full (level, title) tie is unspecified there.
/// We break it on slot, which is deterministic and so cannot contradict an unspecified order.
fn order_groups(rows: &[GroupRow]) -> Vec<(i32, String, Vec<usize>)> {
let mut groups: Vec<(i32, String, Vec<usize>)> = Vec::new();
for (i, r) in rows.iter().enumerate() {
match groups.iter_mut().find(|(z, _, _)| *z == r.zone_or_sort) {
Some((_, _, idxs)) => idxs.push(i),
None => groups.push((r.zone_or_sort, r.header.clone(), vec![i])),
}
}
// `id == 0` first (rank 0), then everything else by case-folded name — `""` for an
// unresolvable id, so it sorts immediately after.
groups.sort_by_key(|(zos, name, _)| {
if *zos == 0 {
(0u8, String::new())
} else {
(1, name.to_lowercase())
}
});
for (_, _, idxs) in &mut groups {
idxs.sort_by_key(|&i| {
let r = &rows[i];
(r.level, r.title.to_lowercase(), r.slot)
});
}
groups
}
/// Read the self player's `PLAYER_QUEST_LOG` descriptor slots each frame, resolve entries/detail,
/// and push a [`QuestLogState`] snapshot on change (diffed against a `Local`, the crate's standard
/// feed shape). Also refreshes [`QuestLog::active_quest_ids`]/`entry_slots` for the greeting split
/// and the abandon drain.
#[allow(clippy::too_many_arguments)]
fn feed_quest_log(
script: Option<NonSendMut<UiScript>>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
mut quest_log: ResMut<QuestLog>,
mut names: ResMut<NameCache>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
header_names: Option<Res<QuestHeaderNamesRes>>,
tag_names: Option<Res<QuestTagNamesRes>>,
states: Res<crate::world_state::WorldStates>,
spells: Option<Res<Spells>>,
// The completion toasts are message-catalog rows, so they go out through the one sink that
// reads the row's surface and sound (`crate::ui_action::show_messages`).
mut sink: crate::ui_action::MessageSink,
mut last: Local<crate::ui_script::VmMemo<QuestLogState>>,
mut prior_quest_ids: Local<crate::ui_script::VmMemo<Option<HashSet<u32>>>>,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
let prior_quest_ids = prior_quest_ids.get(&script);
let Some((store, _)) = self_q.iter().next() else {
// No self player streamed yet — nothing to show; `last` stays at its (empty) default, so
// this is a no-op rather than a repeated empty push.
return;
};
let mut rows = Vec::new();
for slot in 0..PLAYER_QUEST_LOG_SLOTS {
let Some(log_slot) = store.0.player_quest_log(slot) else {
continue;
};
if log_slot.quest_id == 0 {
continue; // an explicitly-cleared slot (abandon/turn-in) — not a row
}
rows.push(Row {
slot,
quest_id: log_slot.quest_id,
log_slot,
});
}
// The accept chord (QUESTADDED → iQuestActivate.wav): the client's C++ plays it when a quest
// ENTERS the log — no Lua handler owns it. Diffed against the previous frame's occupied ids;
// the FIRST snapshot after login is silent (the initial object-create floods the whole log —
// `prior` = None until one real snapshot exists).
{
let current: HashSet<u32> = rows.iter().map(|r| r.quest_id).collect();
if let Some(prior) = prior_quest_ids.as_ref() {
if current.iter().any(|id| !prior.contains(id)) {
script.queue_sound_kit("QUESTADDED");
}
}
*prior_quest_ids = Some(current);
}
// The player's own `GlobalStrings.lua`, for the leaderboard's three format keys (2045). Taken
// here, above the build, because everything between this and the push below reads the VM only.
let get = |key: &str| {
script
.lua()
.globals()
.get::<String>(key)
.ok()
.filter(|t| !t.is_empty())
};
// ── The IN-FLIGHT GATE — all of the log, or none of it ──────────────────────────────────────
// The reference's rebuild (`0x4de510`) counts cache misses into `[0xbb7498]`, skips the row
// for each miss entirely (`0x4de66b`/`0x4de67e` — no row AND no header for its group), and
// then `0x4de545` makes any FURTHER rebuild a total no-op — not even `QUEST_LOG_UPDATE` —
// while the counter is non-zero. Only the arriving response decrements it, rebuilding once
// when it reaches 0. So a cold cache shows an EMPTY log (`GetNumQuestLogEntries()` → `0, 0`,
// and the stock UI paints `EmptyQuestLogFrame`), and then the whole thing appears in ONE step,
// complete. **It is never partially filled** (wow-re `ui/scratch/questlog-list-rebuild.md`,
// §5 trio + arbitration; our B3 claim — a placeholder row per in-flight quest — was REFUTED).
//
// The empty window is the COLD case only: the reference loads `WDB/questcache.wdb` into this
// same cache before world-enter (`0x554f9b`), so a returning character's rows are there
// immediately. We have no such disk cache yet, so ours is the cold path every login — which is
// exactly the reference's own behaviour on a fresh `WDB`, not a deviation.
//
// `template()` is still called for every row first, because the miss is what SENDS the query
// (the reference's rebuild sends `CMSG_QUEST_QUERY` too, once per id ever — our `pending` set
// is that same dedupe).
// Deliberately a loop and NOT `.all(..)`: `template()` is what SENDS the query, and the
// reference asks for every missing id, not just the first. Short-circuiting here would leave
// the rest of a cold log unrequested — and then nothing would ever arrive to un-gate it.
let mut all_cached = true;
for r in &rows {
if quest_log.template(r.quest_id, &commands).is_none() {
all_cached = false;
}
}
let rows: Vec<Row> = if all_cached { rows } else { Vec::new() };
// ── Section headers (the ref's zone/sort groups) ────────────────────────────────────────────
// One header per distinct `ZoneOrSort` (positive → AreaTable zone, negative → QuestSort sort —
// [`benilla_formats::QuestHeaderNames`]), in first-appearance order, then sorted. Every row
// here has a cached template, so `zone_or_sort` is always the real one — the gate above is
// what makes that true.
//
// **The group order** (`0x4de751`, comparator `0x4de8f0`): `id == 0` is forced FIRST, before
// any name lookup (`0x4de913`); an id that names no row collates as `""` and lands next; the
// rest are ordered by resolved name through the client's collator `0x64a4c0`, which is
// **case-INSENSITIVE** (`_strnicmp 0x414310`) — not the byte `cmp` we had.
//
// **`id == 0` renders as the literal `"Missing header! (quest designers)"`** (`0x84b4ec`, its
// only reference image-wide). That string is a **designer-data tell, not a transient**: the
// only value that can become a header id is a CACHED template's `zoneOrSort`, so it cannot
// appear while anything is in flight. Which is precisely why it is safe to render — and why it
// could not have been, before the gate above existed.
let ordered = {
let keyed: Vec<GroupRow> = rows
.iter()
.map(|r| {
let t = quest_log.templates.get(&r.quest_id);
let zos = t.map(|t| t.zone_or_sort).unwrap_or(0);
GroupRow {
zone_or_sort: zos,
header: if zos == 0 {
MISSING_HEADER.to_string()
} else {
header_names
.as_ref()
.and_then(|h| h.0.resolve(zos))
.unwrap_or_default()
.to_string()
},
level: t.map(|t| t.level).unwrap_or(0),
title: t.map(|t| t.title.clone()).unwrap_or_default(),
slot: r.slot,
}
})
.collect();
order_groups(&keyed)
};
let groups = ordered;
let mut entries: Vec<QuestLogEntryView> = Vec::new();
let mut entry_slots: Vec<Option<u8>> = Vec::new();
let mut header_keys: Vec<Option<String>> = Vec::new();
for (_, name, row_idxs) in &groups {
let collapsed = quest_log.collapsed.contains(name);
entries.push(QuestLogEntryView {
quest_id: 0,
title: name.clone(),
level: 0,
tag: None, // a header names a zone/sort, never a quest — it can carry no tag
is_header: true,
collapsed,
complete: 0,
timer: 0, // a header row is not a quest and never carries a timer
pushable: false, // nor is it shareable — there is no quest under the row
objectives: Vec::new(),
});
entry_slots.push(None);
header_keys.push(Some(name.clone()));
for &ri in row_idxs {
let r = &rows[ri];
if collapsed {
continue; // folded: the quest stays in the log, just not in the visible list
}
let (title, level, tag, pushable, objectives) =
match quest_log.template(r.quest_id, &commands) {
Some(t) => (
t.title.clone(),
t.level,
// The `(Elite)`/`(Dungeon)`/`(Raid)`/`(PvP)` suffix: the template's `Type` is
// a `QuestInfo.dbc` row id ([`benilla_formats::QuestTagNames`]), and `Type`
// 0 — the untagged majority — names no row. Absent table (no client data) =
// no tag, never an empty string: the row Lua branches on presence.
tag_names
.as_ref()
.and_then(|tags| tags.0.resolve(t.quest_type))
.map(str::to_string),
// `QUEST_FLAGS_SHARABLE` (vmangos `QuestDef.h:153`) — the whole of the
// shareable test, and it is the CLIENT's alone: vmangos's
// `HandlePushQuestToParty` never checks the bit, it only re-tests it on the
// receiver's accept (`Player::CanShareQuest`). So an unshareable quest whose
// button we wrongly enabled would push, and the party would get a detail panel
// for a quest the server then refuses (decision 1733).
t.flags & quest_flags::SHARABLE != 0,
build_objectives(
t,
&r.log_slot,
&store.0,
&mut items,
&mut names,
&commands,
&get,
),
),
// Unreachable: the in-flight gate above emptied `rows` unless EVERY template is
// cached, which is the reference's own all-or-nothing rebuild. This arm used to
// paint a `"..."` placeholder row — the behaviour the §5 refuted.
None => unreachable!("the in-flight gate leaves only cached rows"),
};
let complete = if r.log_slot.state & quest_slot_state::COMPLETE != 0 {
1
} else if r.log_slot.state & quest_slot_state::FAIL != 0 {
-1
} else {
0
};
entries.push(QuestLogEntryView {
quest_id: r.quest_id,
title,
level,
tag,
is_header: false,
collapsed: false,
complete,
pushable,
// The slot's raw deadline (absolute unix seconds; 0 = untimed) — carried, never
// converted here. The countdown is subtracted per Lua call against the server
// clock, so this snapshot stays stable while the number on screen ticks
// (decision 1150).
timer: r.log_slot.timer,
objectives,
});
entry_slots.push(Some(r.slot));
header_keys.push(None);
}
}
// ── Selection remap ─────────────────────────────────────────────────────────────────────────
let sel = script.quest_log_selection();
let new_sel = remap_selection(&last.entries, &entries, sel);
if new_sel != sel {
script.set_quest_log_selection(new_sel);
}
quest_log.entry_slots = entry_slots;
quest_log.header_keys = header_keys;
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let macros = crate::npc_text::MacroContext {
subject: player.as_ref(),
states: &states,
};
let sel = script.quest_log_selection() as usize;
let detail = sel
.checked_sub(1)
.and_then(|i| entries.get(i))
.filter(|e| !e.is_header)
.map(|e| e.quest_id)
.and_then(|quest_id| {
quest_log.template(quest_id, &commands).map(|t| {
build_detail(
t,
&mut items,
icons.as_deref(),
&commands,
&macros,
spells.as_deref(),
)
})
});
let fresh = QuestLogState {
entries,
num_quests: rows.len() as u32,
detail,
};
if fresh == *last {
return;
}
// The objective-progress announces (the quest-update handler law — wow-re
// `object-layer/scratch/quest-update-ui-feedback-law.md`, §5 trio 2026-07-12, handler
// `0x5e5ad0`), all fired AFTER the log push so a handler reading the log sees the fresh
// state. Per progressed quest ([`quests_with_progressed_objectives`] — present in BOTH
// states; a fresh accept or a turn-in is not "achieved a quest objective"):
// - UI_INFO_MESSAGE with each moved line's fresh text — the yellow top-center toast. The
// verified keys `ERR_QUEST_ADD_KILL_SII "%s slain: %d/%d"` (creature) /
// `ERR_QUEST_ADD_FOUND_SII "%s: %d/%d"` (GO) / `ERR_QUEST_ADD_ITEM_SII "%s: %d/%d"`
// compose exactly the leaderboard line, which is why the line IS the message. And on the
// whole-quest COMPLETE flip: `ERR_QUEST_OBJECTIVE_COMPLETE_S "%s (Complete)"`, falling to
// `ERR_QUEST_UNKNOWN_COMPLETE "Objective Complete."` when the name is unresolvable — the
// verified 0x198 pair (never `ERR_QUEST_COMPLETE_S`, a different call site). INTERIM
// (named divergence): the real client fires from the SMSG handlers and SKIPS a toast
// whose name is uncached (peek-only lookups); ours rides the log diff that feeds the
// lines, so the toast always agrees with the log (an in-flight name shows the log's own
// placeholder). The diff announces an objective only when it ADVANCED — never on a
// regression or a re-render ([`advanced`]; decision 1152 / B237).
// - QUEST_WATCH_UPDATE with the byte law's arg: the quest's **1-based WATCH-LIST position,
// 0 when unwatched** (`0x703f50(0x221)` ← `0x4df880` — NOT the quest-log index the ref
// FrameXML's `AutoQuestWatch_Update(arg1)` treats it as; the shipped 1.12 auto-watch
// chain is broken at exactly this seam).
// - BENILLA_QUEST_PROGRESS with the quest's 1-based LOG index — our own event carrying what
// the ref Lua *needed*: the auto-watch (`OPTION_TOOLTIP_AUTO_QUEST_WATCH`, "watched for 5
// minutes when you achieve a quest objective") runs off this, a deliberate
// intent-over-letter divergence from the ref's broken chain (QuestLogFrame.xml's
// auto-watch comment; decision record with this fold-back).
let progressed = quests_with_progressed_objectives(last, &fresh);
script.set_quest_log(fresh.clone());
script.fire_event("QUEST_LOG_UPDATE", vec![]);
if !progressed.is_empty() {
for quest in progressed {
for line in quest.changed_lines {
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(line)]);
}
if quest.completed {
// The verified 0x198 pair — msgId `0xf8` `ERR_QUEST_OBJECTIVE_COMPLETE_S` when
// the handler has the objective text, `0xf9` `ERR_QUEST_UNKNOWN_COMPLETE` when it
// does not (wow-re `object-layer/scratch/quest-update-ui-feedback-law.md` §5's
// table; never `ERR_QUEST_COMPLETE_S`, which is the turn-in's own call site).
// Both are catalog rows, so the surface and the sound come from there rather than
// from a hand-picked `fire_event` here (decisions 1770/1815).
let line = if quest.title.is_empty() {
crate::ui_action::keyed_line(&script, "ERR_QUEST_UNKNOWN_COMPLETE")
} else {
crate::ui_action::keyed_line_s(
&script,
"ERR_QUEST_OBJECTIVE_COMPLETE_S",
&[&quest.title],
)
};
crate::ui_action::show_messages(&mut script, &mut sink, "ui_quest_log", line);
}
// QUEST_WATCH_UPDATE's arg1 is the quest's LOG index: stock QuestLog_OnEvent hands it
// to AutoQuestWatch_Update(questIndex), whose end is AddQuestWatch(questIndex)
// (QuestLogFrame.lua:68, 752-769). Ours used to pass the watch-list position and
// fire a BENILLA_QUEST_PROGRESS twin carrying the log index; the twin went with the
// window (1944).
script.fire_event(
"QUEST_WATCH_UPDATE",
vec![ScriptValue::Int(i64::from(quest.index))],
);
}
}
*last = fresh;
}
/// Did one objective **advance** between two log states — the announce predicate, and the whole of
/// decision 1152's fix for B237.
///
/// The verified law (wow-re `object-layer/scratch/quest-update-ui-feedback-law.md`, handler
/// `0x5e5ad0`) is that the progress toast fires from the server's *additive* announcements alone —
/// `SMSG_QUESTUPDATE_ADD_KILL` (`ERR_QUEST_ADD_KILL_SII`) and `_ADD_ITEM`
/// (`ERR_QUEST_ADD_ITEM_SII`, via `0x5dd060`). There is no removal opcode and no client-side
/// re-announce, so the reference **cannot** toast on an objective going backwards. Ours rides the
/// log diff (0340's named divergence — the same state feeds the toast and the log, so they always
/// agree), which made the predicate "the rendered line changed" — strictly wider than the law, and
/// wrong in exactly one director-visible way: a **turn-in** destroys the required items via an
/// inline `SMSG_DESTROY_OBJECT` while the quest-log slot clears only in the tick's batched
/// `SMSG_UPDATE_OBJECT` (vmangos `Player::RewardQuest` — `DestroyItemCount` at the top, the
/// `SetQuestSlot(log_slot, 0)` ~40 lines later), so for a frame or two the quest is still in the
/// log with an empty bag and the line dips to `0/req`. The old predicate read that dip as progress
/// and popped "Rumbleshot's Ammo: 0/1" in the middle of the screen (B237).
///
/// Comparing the numbers rather than the string also drops the re-announce 0340 named as INTERIM
/// (an item/creature **name** landing late rewrote `text` with `cur` unmoved, toasting the same
/// line twice) — the reference skips a toast whose name is uncached outright, so suppressing the
/// second is a step toward it, not away.
///
/// The whole-quest COMPLETE flip is deliberately NOT part of this: it is the reference's own
/// separate `SMSG_QUESTUPDATE_COMPLETE` message (`ERR_QUEST_OBJECTIVE_COMPLETE_S`, one line for
/// the quest), carried by [`QuestProgress::completed`].
fn advanced(now: &QuestLogObjectiveView, was: &QuestLogObjectiveView) -> bool {
now.cur > was.cur
}
/// One quest whose objectives advanced between log states — the announce unit of
/// [`feed_quest_log`].
struct QuestProgress {
/// The quest's 1-based index in the NEW log (the Era API's quest-log index space).
index: u32,
/// The quest title (the COMPLETE toast's `%s`; empty falls to the UNKNOWN form).
title: String,
/// The fresh text of every objective line that ADVANCED ([`advanced`]), in objective order.
changed_lines: Vec<String>,
/// The whole-quest COMPLETE state flipped on this diff (the `0x198` toast).
completed: bool,
}
/// The quests present in BOTH states with a same-position objective line that **advanced**, or
/// whose whole-quest COMPLETE state flipped on — "achieved a quest objective", the trigger for
/// the progress toasts and the auto-watch. A quest appearing (fresh accept — or its template
/// resolving late and growing lines from zero) or leaving (turn-in/abandon) does NOT count: the
/// pairwise zip skips added/removed lines by construction. Pure, so it's testable without the
/// ECS/ask-once caches — the exact shape [`abandon_slot`] already keeps this file's
/// feed-vs-logic split in.
fn quests_with_progressed_objectives(
old: &QuestLogState,
new: &QuestLogState,
) -> Vec<QuestProgress> {
fn entry_of(s: &QuestLogState, id: u32) -> Option<&QuestLogEntryView> {
s.entries.iter().find(|e| !e.is_header && e.quest_id == id)
}
new.entries
.iter()
.enumerate()
.filter(|(_, e)| !e.is_header)
.filter_map(|(i, e)| {
let prev = entry_of(old, e.quest_id)?;
let changed_lines: Vec<String> = e
.objectives
.iter()
.zip(prev.objectives.iter())
.filter(|(now, was)| advanced(now, was))
.map(|(now, _)| now.text.clone())
.collect();
let completed = e.complete == 1 && prev.complete != 1;
(!changed_lines.is_empty() || completed).then_some(QuestProgress {
index: i as u32 + 1,
title: e.title.clone(),
changed_lines,
completed,
})
})
.collect()
}
/// Drain the confirmed abandons (1-based Lua entry index, pinned at click time — see
/// `benilla_ui::script::quest_log`'s module doc) and map each to `CMSG_QUESTLOG_REMOVE_QUEST` via
/// this frame's `entry_slots`.
/// The `ZoneOrSort → header name` lookup ([`benilla_formats::QuestHeaderNames`]), loaded once at
/// startup. Absent when the client data didn't load — the feed then buckets everything under
/// "Quests".
#[derive(Resource)]
pub(crate) struct QuestHeaderNamesRes(pub benilla_formats::QuestHeaderNames);
/// Startup: read AreaTable/QuestSort names through the patch chain (the zone-audio catalog's
/// exact load shape).
fn load_quest_header_names(
mut commands: Commands,
assets: Option<Res<benilla_assets::WorldAssets>>,
) {
use benilla_assets::LockRecover;
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_quest_header_names(&mut chain)
};
match loaded {
Ok(names) => commands.insert_resource(QuestHeaderNamesRes(names)),
Err(e) => warn!("ui_quest_log: quest header names failed to load: {e:#}"),
}
}
/// The quest `Type → tag name` lookup ([`benilla_formats::QuestTagNames`] — `QuestInfo.dbc`),
/// loaded once at startup. Absent when the client data didn't load; the feed then pushes no tag,
/// which is what an untagged quest pushes anyway.
#[derive(Resource)]
pub(crate) struct QuestTagNamesRes(pub benilla_formats::QuestTagNames);
/// Startup: read the `QuestInfo.dbc` tag names through the patch chain (the header-names load's
/// exact shape).
fn load_quest_tag_names(mut commands: Commands, assets: Option<Res<benilla_assets::WorldAssets>>) {
use benilla_assets::LockRecover;
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_quest_tag_names(&mut chain)
};
match loaded {
Ok(names) => commands.insert_resource(QuestTagNamesRes(names)),
Err(e) => warn!("ui_quest_log: quest tag names failed to load: {e:#}"),
}
}
/// Drain the header fold intents (`CollapseQuestHeader`/`ExpandQuestHeader`): map each 1-based
/// entry index through this frame's `header_keys` to the header TITLE and toggle it in the
/// app-owned collapse set; index 0 = every header (the ref's collapse-all button). The next
/// [`feed_quest_log`] pass rebuilds the filtered list.
fn drain_quest_log_collapses(
script: Option<NonSendMut<UiScript>>,
mut quest_log: ResMut<QuestLog>,
) {
let Some(mut script) = script else {
return;
};
for (idx, collapse) in script.take_quest_log_collapses() {
if idx == 0 {
if collapse {
let all: Vec<String> = quest_log.header_keys.iter().flatten().cloned().collect();
quest_log.collapsed.extend(all);
} else {
quest_log.collapsed.clear();
}
} else if let Some(Some(name)) = quest_log.header_keys.get(idx as usize - 1).cloned() {
if collapse {
quest_log.collapsed.insert(name);
} else {
quest_log.collapsed.remove(&name);
}
}
}
}
/// `QuestLogPushQuest()` — the *Share Quest* button (decision 1733). The engine already resolved
/// the selection to a quest id at click time, so this is a straight relay; the server addresses the
/// party itself.
fn drain_quest_log_pushes(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
let Some(mut script) = script else {
return;
};
for quest in script.take_quest_log_pushes() {
debug!("ui_quest_log: sharing quest {quest} with the party");
let _ = commands.0.send(ClientCommand::PushQuestToParty { quest });
}
}
fn drain_quest_log_abandons(
script: Option<NonSendMut<UiScript>>,
quest_log: Res<QuestLog>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
for entry in script.take_quest_log_abandons() {
match abandon_slot(entry, &quest_log.entry_slots) {
Some(slot) => {
debug!("ui_quest_log: abandon entry {entry} → slot {slot}");
let _ = commands.0.send(ClientCommand::QuestlogRemove { slot });
}
None => debug!("ui_quest_log: abandon entry {entry} out of range — ignored"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── order_groups — the §5-verified display order ────────────────────────────────────────────
fn grow(zos: i32, header: &str, level: u32, title: &str, slot: u8) -> GroupRow {
GroupRow {
zone_or_sort: zos,
header: header.into(),
level,
title: title.into(),
slot,
}
}
/// Group order: `id == 0` first regardless of its name, then an unnamed id (`""`), then the
/// rest by CASE-INSENSITIVE name — the client's collator is `_strnicmp`, so a byte-wise
/// compare (which is what this used to do) puts an all-lowercase name in the wrong place.
#[test]
fn groups_put_id_zero_first_then_unnamed_then_case_folded_names() {
let rows = vec![
grow(12, "Elwynn Forest", 1, "a", 0),
grow(-3, "warlock", 1, "b", 1), // lowercase: byte-cmp would sort it after "Westfall"
grow(0, MISSING_HEADER, 1, "c", 2),
grow(40, "Westfall", 1, "d", 3),
grow(999, "", 1, "e", 4), // an id neither table names
];
let order: Vec<i32> = order_groups(&rows).into_iter().map(|(z, _, _)| z).collect();
// `warlock` before `Westfall` is the discriminating pair: case-folded, `wa` < `we`; by
// BYTES, `W`(0x57) < `w`(0x77) puts `Westfall` first. Everything else about this list is
// the same either way, which is why the old byte-wise sort looked fine.
assert_eq!(order, vec![0, 999, 12, -3, 40]);
}
/// The `id == 0` header carries the reference's own literal — a designer-data tell, and
/// reachable only because a row is never listed before its template is cached.
#[test]
fn the_zero_header_is_the_references_literal() {
let rows = vec![grow(0, MISSING_HEADER, 1, "a", 0)];
let groups = order_groups(&rows);
assert_eq!(groups[0].1, "Missing header! (quest designers)");
}
/// Within a group: level ASC, then title (case-insensitively) — NOT descriptor-slot order,
/// which is what benilla claimed and `0x4deac0` refuted.
#[test]
fn within_a_group_it_is_level_then_title_not_slot_order() {
// Deliberately seeded in the reverse of the expected order by slot.
let rows = vec![
grow(12, "Elwynn Forest", 9, "Zzz", 0),
grow(12, "Elwynn Forest", 3, "beta", 1),
grow(12, "Elwynn Forest", 3, "Alpha", 2),
grow(12, "Elwynn Forest", 1, "Anything", 3),
];
let groups = order_groups(&rows);
assert_eq!(groups.len(), 1);
let titles: Vec<&str> = groups[0]
.2
.iter()
.map(|&i| rows[i].title.as_str())
.collect();
// level 1, then the two level-3s by case-folded title ("Alpha" < "beta" — a byte compare
// would put uppercase "Alpha" before "beta" too, so the discriminating pair is below),
// then level 9.
assert_eq!(titles, vec!["Anything", "Alpha", "beta", "Zzz"]);
}
/// The case-folding is load-bearing in its own right: `"apple"` must precede `"Banana"`, which
/// a byte-wise compare gets wrong (uppercase `B` = 0x42 sorts before lowercase `a` = 0x61).
#[test]
fn title_order_is_case_insensitive() {
let rows = vec![
grow(12, "Zone", 5, "apple", 0),
grow(12, "Zone", 5, "Banana", 1),
];
let groups = order_groups(&rows);
let titles: Vec<&str> = groups[0]
.2
.iter()
.map(|&i| rows[i].title.as_str())
.collect();
assert_eq!(titles, vec!["apple", "Banana"]);
}
// ── remap_selection ─────────────────────────────────────────────────────────────────────────
fn header(title: &str) -> QuestLogEntryView {
QuestLogEntryView {
quest_id: 0,
title: title.into(),
level: 0,
tag: None,
is_header: true,
collapsed: false,
complete: 0,
timer: 0,
pushable: false,
objectives: Vec::new(),
}
}
fn quest(id: u32) -> QuestLogEntryView {
QuestLogEntryView {
quest_id: id,
title: format!("Quest {id}"),
level: 1,
tag: None,
is_header: false,
collapsed: false,
complete: 0,
timer: 0,
pushable: false,
objectives: Vec::new(),
}
}
#[test]
fn selection_follows_its_quest_across_reindexing() {
let old = vec![header("A"), quest(7), quest(9)];
// A new header lands above: quest 9 moves from entry 3 to entry 5.
let new = vec![header("A"), quest(7), header("B"), header("C"), quest(9)];
assert_eq!(remap_selection(&old, &new, 3), 5);
}
#[test]
fn fold_hiding_the_selection_resets_to_zero_never_the_header() {
// The director's defect: selecting quest 7, folding "A" used to re-point the selection at
// the HEADER row — the detail pane painted the category. A header is never a valid
// selection (ref QuestLog_GetFirstSelectableQuest skips headers): reset to 0 so the Lua
// update's SetFirstValidSelection picks the first visible quest.
let old = vec![header("A"), quest(7)];
let new = vec![header("A")]; // folded: the quest left the visible list
assert_eq!(remap_selection(&old, &new, 2), 0);
}
#[test]
fn a_header_selection_is_evicted_not_preserved() {
let old = vec![header("A"), quest(7)];
let new = vec![header("A"), quest(7)];
// However a header got selected, the remap never keeps it.
assert_eq!(remap_selection(&old, &new, 1), 0);
}
#[test]
fn zero_and_stale_selections_stay_zero() {
let old = vec![header("A"), quest(7)];
let new = vec![header("A"), quest(7)];
assert_eq!(remap_selection(&old, &new, 0), 0);
assert_eq!(remap_selection(&old, &new, 99), 0); // out-of-range: nothing to follow
}
fn obj(
creature_or_go: u32,
required_count: u32,
item_id: u32,
item_count: u32,
text: &str,
) -> QuestObjective {
QuestObjective {
creature_or_go,
required_count,
item_id,
item_count,
text: text.into(),
}
}
// ── objective_line ──────────────────────────────────────────────────────────────────────────
/// A stand-in string table whose three templates are **deliberately not** the shipped wording.
///
/// That is the point, and it is decision 2045's: in enUS `QUEST_MONSTERS_KILLED` and
/// `ERR_QUEST_ADD_KILL_SII` are the same sentence, and `QUEST_OBJECTS_FOUND`,
/// `QUEST_ITEMS_NEEDED` and their two `ERR_QUEST_ADD_*` twins are the same four — so an
/// assertion on English cannot tell a right key from a wrong one. These name the key in the
/// rendered line, so each case below asserts *which lookup happened*. The real wording is
/// checked against the player's own table by
/// [`the_leaderboard_keys_resolve_in_the_real_global_strings`].
fn probe_strings(key: &str) -> Option<String> {
match key {
"QUEST_MONSTERS_KILLED" => Some("<KILLED %s %d of %d>".into()),
"QUEST_OBJECTS_FOUND" => Some("<FOUND %s %d of %d>".into()),
"QUEST_ITEMS_NEEDED" => Some("<NEEDED %s %d of %d>".into()),
_ => None,
}
}
#[test]
fn creature_objective_with_resolved_name() {
let o = obj(100, 10, 0, 0, "");
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0, &probe_strings).unwrap();
assert_eq!(line.text, "<KILLED Kobold Vermin 3 of 10>");
assert_eq!(line.kind, "monster");
assert!(!line.finished);
}
#[test]
fn creature_objective_in_flight_shows_a_placeholder() {
let o = obj(100, 10, 0, 0, "");
let line = objective_line(&o, 0, None, None, 0, &probe_strings).unwrap();
assert_eq!(line.text, "<KILLED ... 0 of 10>");
}
/// A GameObject objective with no name takes `QUEST_OBJECTS_FOUND` on the placeholder — the
/// binding fetches that key once, ABOVE its cache test (`0x4e031e`), so both its legs share
/// it. Before decision 2045 this composed an invented `"Objective: %d/%d"` line, which is a
/// sentence 1.12 never says.
#[test]
fn go_objective_falls_back_without_a_name_cache() {
let go_id = ((-57i32) as u32) | 0x8000_0000;
let o = obj(go_id, 1, 0, 0, "");
let line = objective_line(&o, 1, None, None, 0, &probe_strings).unwrap();
assert_eq!(line.text, "<FOUND ... 1 of 1>");
assert_eq!(line.kind, "object");
assert!(line.finished); // cur (1) >= req (1)
}
#[test]
fn go_objective_prefers_its_custom_text_over_the_fallback() {
let go_id = ((-57i32) as u32) | 0x8000_0000;
let o = obj(go_id, 4, 0, 0, "Destroy the barricade");
let line = objective_line(&o, 1, None, None, 0, &probe_strings).unwrap();
assert_eq!(line.text, "<FOUND Destroy the barricade 1 of 4>");
assert_eq!(line.kind, "object");
}
#[test]
fn item_objective_counts_bags_and_clamps_to_required() {
let o = obj(0, 0, 2000, 5, "");
// 8 in the bags, but required is only 5 — the line clamps, doesn't overshoot.
let line = objective_line(&o, 0, None, Some("Kobold Ear"), 8, &probe_strings).unwrap();
assert_eq!(line.text, "<NEEDED Kobold Ear 5 of 5>");
assert_eq!(line.kind, "item");
assert!(line.finished);
}
#[test]
fn item_objective_in_flight_shows_a_placeholder() {
let o = obj(0, 0, 2000, 5, "");
let line = objective_line(&o, 0, None, None, 2, &probe_strings).unwrap();
assert_eq!(line.text, "<NEEDED ... 2 of 5>");
}
/// **The key fork §5 pins**, and the one an English assertion could never see: a creature
/// objective carrying `ObjectiveText[i]` stops being "slain" — the override IS the name and
/// the format becomes `QUEST_OBJECTS_FOUND` (`mov ecx,0x84b634` @`0x4e03e1`) — while `type`
/// still answers `"monster"`, because that is read from a fresh sign test of
/// `ReqCreatureOrGOId[i]`.
#[test]
fn custom_text_overrides_the_creature_auto_line() {
let o = obj(100, 10, 0, 0, "Slay the vermin");
let line = objective_line(&o, 4, Some("Kobold Vermin"), None, 0, &probe_strings).unwrap();
assert_eq!(line.text, "<FOUND Slay the vermin 4 of 10>");
assert_eq!(line.kind, "monster");
}
/// The three keys against the player's REAL `GlobalStrings.lua` — the guard a key assertion
/// needs beside it, since a typo'd key degrades a line to empty rather than to a wrong
/// sentence. Skips without client data.
#[test]
fn the_leaderboard_keys_resolve_in_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let vm = UiScript::new().expect("VM");
vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let get = |key: &str| {
vm.lua()
.globals()
.get::<String>(key)
.ok()
.filter(|t| !t.is_empty())
};
for key in [
"QUEST_MONSTERS_KILLED",
"QUEST_OBJECTS_FOUND",
"QUEST_ITEMS_NEEDED",
] {
assert!(get(key).is_some(), "{key} missing");
}
let o = obj(100, 10, 0, 0, "");
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0, &get).unwrap();
assert_eq!(line.text, "Kobold Vermin slain: 3/10");
}
/// Decision 1158, correcting 0109: a line's `finished` is `cur >= req` and **nothing else**.
/// The reference reads the whole-quest COMPLETE bit exactly once in `GetQuestLogLeaderBoard`
/// (`0x4e02a2`), in the `event` branch, where it is that line's only predicate — it is never
/// or-ed into a counted line's verdict (wow-re `ui/scratch/quest-leaderboard-law.md` §7). Only
/// the whole-quest turn-in predicate `0x4df580` reads the bit and the counts together.
#[test]
fn a_complete_quest_does_not_mark_a_short_objective_finished() {
let o = obj(100, 10, 0, 0, "");
// The counter (1) is well short of required (10). Whatever the quest's state byte says,
// THIS line is not finished.
let line = objective_line(&o, 1, Some("Kobold"), None, 0, &probe_strings).unwrap();
assert_eq!(line.text, "<KILLED Kobold 1 of 10>");
assert!(!line.finished);
}
#[test]
fn empty_objective_slot_emits_no_line() {
let o = obj(0, 0, 0, 0, "");
assert!(objective_line(&o, 0, None, None, 0, &probe_strings).is_none());
}
// ── build_objectives: every occupied slot gets its own lines, not just the selection ────────────
// (the seam this Part-A change is built on: `QuestLogEntryView::objectives` moved off the
// selection-only `QuestLogDetail` — `benilla-ui/src/script/quest_log.rs`'s module doc.)
fn quest_template(objectives: [QuestObjective; 4]) -> QuestTemplate {
QuestTemplate {
quest_id: 1,
method: 0,
level: 1,
zone_or_sort: 0,
quest_type: 0,
rep_objective_faction: 0,
rep_objective_value: 0,
next_quest_in_chain: 0,
money: 0,
money_max_level: 0,
reward_spell: 0,
src_item_id: 0,
flags: 0,
rewards: [(0, 0); 4],
choices: [(0, 0); 6],
point_map_id: 0,
point_x: 0.0,
point_y: 0.0,
point_opt: 0,
title: "A Threat Within".into(),
objectives_text: String::new(),
details: String::new(),
end_text: String::new(),
objectives,
}
}
#[test]
fn build_objectives_reads_every_occupied_quad_via_the_slots_counters_and_skips_empties() {
let template = quest_template([
obj(100, 10, 0, 0, ""), // creature objective, counter[0]
obj(0, 0, 0, 0, ""), // unused quad — emits no line
obj(0, 0, 2000, 5, ""), // item objective (counted from the bags, not a counter)
obj(0, 0, 0, 0, ""), // unused quad
]);
let log_slot = QuestLogSlot {
quest_id: 1,
counters: [3, 0, 0, 0],
state: 0,
timer: 0,
};
let store = ObjectFields::default(); // empty bags — the item objective reads 0/5
let mut items = Items::default();
let mut names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let objectives = build_objectives(
&template,
&log_slot,
&store,
&mut items,
&mut names,
&commands,
&probe_strings,
);
// The two unused quads emit nothing; the two real ones resolve in order — creature/item
// names are unresolved (ask-once mid-flight, no answer seeded) so they fall to the same
// placeholder path `objective_line`'s own tests already cover directly.
assert_eq!(objectives.len(), 2);
assert_eq!(objectives[0].text, "<KILLED ... 3 of 10>");
assert_eq!(objectives[0].kind, "monster");
assert!(!objectives[0].finished);
assert_eq!(objectives[1].text, "<NEEDED ... 0 of 5>");
assert_eq!(objectives[1].kind, "item");
}
/// The 0109-era behaviour this inverts: a COMPLETE slot state used to mark every line
/// finished. Decision 1158 — the reference's per-line verdict never reads that bit
/// (`0x4e0110` §7); only the whole-quest turn-in predicate (`0x4df580`) combines the two.
#[test]
fn a_complete_slot_state_does_not_finish_a_short_line() {
let template = quest_template([
obj(100, 10, 0, 0, ""),
obj(0, 0, 0, 0, ""),
obj(0, 0, 0, 0, ""),
obj(0, 0, 0, 0, ""),
]);
let log_slot = QuestLogSlot {
quest_id: 1,
counters: [1, 0, 0, 0], // well short of required (10)...
state: quest_slot_state::COMPLETE, // ...but the whole quest is complete
timer: 0,
};
let store = ObjectFields::default();
let mut items = Items::default();
let mut names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let objectives = build_objectives(
&template,
&log_slot,
&store,
&mut items,
&mut names,
&commands,
&probe_strings,
);
assert_eq!(objectives.len(), 1);
assert!(
!objectives[0].finished,
"the counter is 1/10 — the quest's COMPLETE bit is not this line's business"
);
assert_eq!(objectives[0].text, "<KILLED ... 1 of 10>");
}
// ── money_split ──────────────────────────────────────────────────────────────────────────────
#[test]
fn money_sign_splits_into_required_or_reward() {
assert_eq!(money_split(-500), (500, 0));
assert_eq!(money_split(500), (0, 500));
assert_eq!(money_split(0), (0, 0));
}
// ── abandon_slot: entry → descriptor slot across gaps ──────────────────────────────────────────
#[test]
fn abandon_maps_entry_to_slot_across_a_gap_and_skips_headers() {
// A header row (None), then slots 0 and 2 (slot 1 empty in the descriptor array) — the
// push order skips the gap; entry 1 is the header, which maps to NO slot.
let entry_slots = vec![None, Some(0u8), Some(2u8)];
assert_eq!(abandon_slot(1, &entry_slots), None); // header row: nothing to remove
assert_eq!(abandon_slot(2, &entry_slots), Some(0));
assert_eq!(abandon_slot(3, &entry_slots), Some(2));
assert_eq!(abandon_slot(4, &entry_slots), None);
assert_eq!(abandon_slot(0, &entry_slots), None); // 1-based; 0 is never a valid entry
}
// ── quests_with_progressed_objectives: the QUEST_WATCH_UPDATE per-quest trigger ─────────────────
/// A one-objective entry built the way [`objective_line`] builds one: the `cur`/`req` carried
/// on the view are READ BACK OUT of the line's own trailing `%d/%d`, so a fixture can never
/// claim a count its text disagrees with (the production invariant — the text is formatted
/// FROM the numbers).
fn entry(quest_id: u32, objective_text: &str) -> QuestLogEntryView {
let (cur, req) = objective_text
.rsplit_once(' ')
.and_then(|(_, tail)| tail.split_once('/'))
.map(|(c, r)| (c.parse().unwrap(), r.parse().unwrap()))
.expect("fixture line must end in `cur/req`");
QuestLogEntryView {
quest_id,
objectives: vec![QuestLogObjectiveView {
text: objective_text.into(),
kind: "monster".into(),
finished: cur >= req,
cur,
req,
}],
..Default::default()
}
}
fn state(entries: Vec<QuestLogEntryView>) -> QuestLogState {
QuestLogState {
num_quests: entries.iter().filter(|e| !e.is_header).count() as u32,
entries,
detail: None,
}
}
#[test]
fn no_progress_fires_nothing() {
let old = state(vec![entry(1, "Kobold Vermin slain: 3/10")]);
let same = state(vec![entry(1, "Kobold Vermin slain: 3/10")]);
assert!(quests_with_progressed_objectives(&old, &same).is_empty());
}
#[test]
fn a_progressed_quest_fires_its_new_log_index_and_the_moved_lines() {
let old = state(vec![
entry(1, "Kobold Vermin slain: 3/10"),
entry(2, "Tough Wolf Meat: 2/8"),
]);
let progressed = state(vec![
entry(1, "Kobold Vermin slain: 3/10"),
entry(2, "Tough Wolf Meat: 3/8"),
]);
let fired = quests_with_progressed_objectives(&old, &progressed);
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].index, 2);
assert_eq!(fired[0].changed_lines, ["Tough Wolf Meat: 3/8"]);
// Both moving fires both, each at its own 1-based index with its own fresh line.
let both = state(vec![
entry(1, "Kobold Vermin slain: 4/10"),
entry(2, "Tough Wolf Meat: 3/8"),
]);
let fired = quests_with_progressed_objectives(&old, &both);
assert_eq!(
fired
.iter()
.map(|q| (q.index, q.changed_lines.clone()))
.collect::<Vec<_>>(),
[
(1, vec!["Kobold Vermin slain: 4/10".to_string()]),
(2, vec!["Tough Wolf Meat: 3/8".to_string()]),
]
);
}
#[test]
fn appearing_or_leaving_the_log_is_not_progress() {
let empty = state(vec![]);
let with_entry = state(vec![entry(7, "Kobold Worker slain: 0/10")]);
// A fresh accept must NOT toast or auto-watch (the ref triggers on "achieved an
// objective") — and neither may a template resolving late and growing lines from zero.
assert!(quests_with_progressed_objectives(&empty, &with_entry).is_empty());
let grown = state(vec![QuestLogEntryView {
quest_id: 7,
objectives: vec![],
..Default::default()
}]);
assert!(quests_with_progressed_objectives(&grown, &with_entry).is_empty());
// A turn-in/abandon that removes the quest fires nothing either.
assert!(quests_with_progressed_objectives(&with_entry, &empty).is_empty());
}
/// B237 / decision 1152 — the turn-in flash. `Player::RewardQuest` destroys the required items
/// at the top (an inline `SMSG_DESTROY_OBJECT`) and clears the log slot ~40 lines later (the
/// tick's batched `SMSG_UPDATE_OBJECT`), so the client holds a complete collect quest over an
/// empty bag for a frame or two and the line dips to `0/req`. That dip must announce NOTHING:
/// the reference's toast is `SMSG_QUESTUPDATE_ADD_ITEM` only, and no removal opcode exists.
#[test]
fn a_regressing_objective_never_announces() {
let held = state(vec![entry(313, "Rumbleshot's Ammo: 1/1")]);
let emptied = state(vec![entry(313, "Rumbleshot's Ammo: 0/1")]);
assert!(
quests_with_progressed_objectives(&held, &emptied).is_empty(),
"the bag emptying under a still-logged quest is not progress"
);
// The slot clearing on the next frame was already silent (the quest leaves the log).
assert!(quests_with_progressed_objectives(&emptied, &state(vec![])).is_empty());
// The control: the same quest actually being collected still announces.
let looted = state(vec![entry(313, "Rumbleshot's Ammo: 1/1")]);
assert_eq!(
quests_with_progressed_objectives(&emptied, &looted)[0].changed_lines,
["Rumbleshot's Ammo: 1/1"]
);
}
/// The ask-once name caches answer a frame or more after the line first renders. That rewrite
/// moves `text` with the count standing still — the reference skips a toast whose name is
/// uncached outright, so re-announcing the line when the name lands (0340's named INTERIM) is
/// a toast the reference never fires.
#[test]
fn a_name_landing_late_does_not_re_announce() {
let placeholder = state(vec![entry(5, "...: 3/5")]);
let named = state(vec![entry(5, "Small Barnacled Clam: 3/5")]);
assert!(quests_with_progressed_objectives(&placeholder, &named).is_empty());
}
/// The whole-quest COMPLETE flip is its own reference message (`SMSG_QUESTUPDATE_COMPLETE` →
/// `ERR_QUEST_OBJECTIVE_COMPLETE_S`, one line for the quest) — it must not also re-announce
/// every objective line just because their `finished` flag flipped with it.
#[test]
fn the_complete_flip_announces_once_not_per_objective() {
let mut before = entry(9, "Kobold Vermin slain: 10/10");
before.complete = 0;
before.objectives[0].finished = false;
let mut after = entry(9, "Kobold Vermin slain: 10/10");
after.complete = 1;
after.title = "A Threat Within".into();
let fired = quests_with_progressed_objectives(&state(vec![before]), &state(vec![after]));
assert_eq!(fired.len(), 1);
assert!(fired[0].completed);
assert!(
fired[0].changed_lines.is_empty(),
"the objective count did not move — only the quest's COMPLETE state did"
);
}
}