The quest log counts party members on each quest, as 1.12.1 does (#824)

The quest log never showed the `[n]` count of party members on a quest, because `IsUnitOnQuest` was a stub that always answered nil.

1.12.1 (`0x4dfe10`) checks both arguments first. It reads the row's quest id (`0x4df150`; a header or out-of-range row answers nil before the unit is resolved), resolves the token, and admits only you or one of the four party slots, by guid (`0x4e7f70`). It then needs the unit held as a player and scans the 20 `PLAYER_QUEST_LOG` ids for that quest. The answer is 1 or nil. The ids reach party members on the wire. `UNIT_QUEST_LOG_CHANGED` (`0x51bc63` → `0x51bd90`) fires once whenever any slot of a unit's window changes, so an open log recounts.

Unit snapshots now carry the window, the binding follows those steps, and the event fires. Tests cover each path and run the stock `QuestLogFrame.xml` to a `[2]` row; they fail against the stub.

Fixes #818

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-08 08:09:41 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 80b0848628
commit 63e917965d
7 changed files with 466 additions and 9 deletions
@@ -1693,3 +1693,115 @@ fn the_stock_title_width_cap_bites_and_is_sticky_per_row_button() {
"the capped box survives the row going untagged (the ref's own 275 reset never fires)"
);
}
// ── The group-mates count ──
/// A party member held with `ids` in their quest-log window, named `name`.
fn mate_on(guid: u64, name: &str, ids: &[u32]) -> benilla_ui::script::UnitState {
let mut quest_log = [[0; 3]; 20];
for (slot, &id) in quest_log.iter_mut().zip(ids) {
*slot = [id, 0, 0];
}
benilla_ui::script::UnitState {
exists: true,
has_object: true,
is_player: true,
guid,
name: Some(name.into()),
quest_log,
..Default::default()
}
}
/// Each quest row counts the party members on it as `[n]` (`QuestLogFrame.lua:168-183`), through
/// `IsUnitOnQuest(questIndex, "partyN")`; the row tooltip names them (`:515-541`), and a mate's
/// `UNIT_QUEST_LOG_CHANGED` recounts (`:69-72`).
#[test]
fn quest_rows_count_the_party_members_on_each_quest() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
s.set_quest_log(eight_entries());
s.set_party(party(2));
s.set_unit("party1", Some(mate_on(0x300, "Mate0", &[1, 3])));
s.set_unit("party2", Some(mate_on(0x301, "Mate1", &[1])));
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let mates = |s: &mut UiScript, row: u32| {
s.eval::<String>(&format!(
"return QuestLogTitle{row}GroupMates:GetText() or ''"
))
.unwrap()
};
assert_eq!(mates(&mut s, 1), "[2]", "both mates are on quest 1");
assert_eq!(mates(&mut s, 2), "", "nobody is on quest 2");
assert_eq!(mates(&mut s, 3), "[1]", "party1 is on quest 3");
// The row tooltip, as `QuestLogTitleButton_OnEnter` builds it for row 1, then row 2.
let line = |s: &mut UiScript, n: u32| {
s.eval::<String>(&format!("return GameTooltipTextLeft{n}:GetText() or ''"))
.unwrap()
};
s.run("this = QuestLogTitle1 QuestLog_UpdatePartyInfoTooltip()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(line(&mut s, 1), "Quest 1");
assert!(s
.eval::<bool>(
"return GameTooltipTextLeft2:GetText() == \
HIGHLIGHT_FONT_COLOR_CODE..PARTY_QUEST_STATUS_ON..FONT_COLOR_CODE_CLOSE"
)
.unwrap());
assert!(line(&mut s, 3).contains("Mate0"), "{}", line(&mut s, 3));
assert!(line(&mut s, 4).contains("Mate1"), "{}", line(&mut s, 4));
s.run("this = QuestLogTitle2 QuestLog_UpdatePartyInfoTooltip()")
.unwrap();
assert!(s
.eval::<bool>(
"return GameTooltipTextLeft2:GetText() == \
HIGHLIGHT_FONT_COLOR_CODE..PARTY_QUEST_STATUS_NONE..FONT_COLOR_CODE_CLOSE"
)
.unwrap());
// party2 takes quest 3: their quest-log event recounts the open log.
s.set_unit("party2", Some(mate_on(0x301, "Mate1", &[1, 3])));
s.fire_event(
"UNIT_QUEST_LOG_CHANGED",
vec![benilla_ui::script::ScriptValue::Str("party2".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(mates(&mut s, 3), "[2]");
// Out of range, a mate counts for nothing, whatever their last window held (`0x468460` finds
// no object).
s.set_unit(
"party1",
Some(benilla_ui::script::UnitState {
has_object: false,
..mate_on(0x300, "Mate0", &[1, 3])
}),
);
s.run("QuestLog_Update()").unwrap();
assert_eq!(mates(&mut s, 1), "[1]");
assert_eq!(mates(&mut s, 3), "[1]");
}
@@ -1937,12 +1937,6 @@ mod tests {
),
("SYSMSG", "UIErrorsFrame.lua"),
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
(
"UNIT_QUEST_LOG_CHANGED",
"QuestLogFrame.lua — a party member's quest-log fields changing (the reference \
fires it off the unit's PLAYER_QUEST_LOG_* descriptor updates); benilla's unit \
feed does not derive it yet",
),
(
"UNIT_MODEL_CHANGED",
"four files — the paperdoll model refresh",
+92
View File
@@ -929,6 +929,13 @@ pub(crate) fn snapshot(
pvp_team: store.0.unit_race().map_or(-1, race_pvp_team),
// `PLAYER_BYTES_3` byte 2, the city-protector title (`PVP_MEDAL<n>`), unset by vmangos.
pvp_medal: store.0.player_pvp_medal().unwrap_or(0),
// The quest-log window, off a player alone: `IsUnitOnQuest` looks the guid up with
// TYPEMASK_PLAYER (`0x4dfe97`) before it reads `[obj+0xe68]`.
quest_log: if matches!(store.0.object_type(), Some(ObjectType::Player)) {
store.0.player_quest_log_window()
} else {
Default::default()
},
// `is_player` and the creature-record fields come from [`enrich_unit`].
..Default::default()
}
@@ -1145,6 +1152,15 @@ pub(crate) fn fire_transitions(
if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS) {
script.fire_event("UNIT_DYNAMIC_FLAGS", vec![tok()]);
}
// `UNIT_QUEST_LOG_CHANGED` (id 522), the player-window watch over the quest-log slots
// (`0x51bc63`: offset `0x28`, length `0xf0`, callback `0x51bd90`): one compare over the 60
// dwords, so one event however many moved. A group mate's ids are all of it on the wire.
let log = benilla_protocol::field::FIELD_PLAYER_QUEST_LOG_1_1;
if (log..log + 3 * u16::from(benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS))
.any(|i| edges.moved(cur.guid, i))
{
script.fire_event("UNIT_QUEST_LOG_CHANGED", vec![tok()]);
}
// 1.12 names the power events per resource (`UNIT_MANA`, `UNIT_MAXRAGE`, …;
// `UnitFrame.lua:190-199`), and `power_token` yields the suffix.
if changed(|u| u64::from(u.power)) {
@@ -2579,6 +2595,46 @@ mod tests {
);
}
/// `UNIT_QUEST_LOG_CHANGED` (id 522) is the player-window watch over the 60 quest-log dwords
/// (`0x51bc63`: offset `0x28`, length `0xf0`, callback `0x51bd90`): any of them moving fires it
/// once with the token, and a dword either side does not.
#[test]
fn a_quest_log_change_fires_unit_quest_log_changed_with_the_token() {
let fired = |prev: &UnitState, cur: &UnitState, edges: &[(u64, u16)]| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_QUEST_LOG_CHANGED")
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
"#,
)
.unwrap();
fire_transitions(&mut s, "party1", Some(prev), cur, &FieldEdges::of(edges));
s.eval::<Vec<String>>("return SEEN").unwrap()
};
const MATE: u64 = 0x300;
const LOG: u16 = benilla_protocol::field::FIELD_PLAYER_QUEST_LOG_1_1;
let before = UnitState {
exists: true,
has_object: true,
guid: MATE,
..Default::default()
};
let mut after = before.clone();
after.quest_log[4][0] = 783;
let once = vec!["UNIT_QUEST_LOG_CHANGED:party1".to_string()];
assert_eq!(fired(&before, &after, &[(MATE, LOG + 12)]), once);
// One watch over the window: two slots moving in one pass is one event.
assert_eq!(
fired(&before, &after, &[(MATE, LOG), (MATE, LOG + 59)]),
once
);
assert!(fired(&before, &after, &[(MATE, LOG - 1), (MATE, LOG + 60)]).is_empty());
assert!(fired(&before, &after, &[(MATE + 1, LOG + 12)]).is_empty());
}
/// LOST going down, GAINED going up (boot is in control); far sight on every change.
#[test]
fn the_control_and_far_sight_edges_fire_once_each_way() {
@@ -3153,6 +3209,42 @@ mod tests {
);
}
/// The quest-log window rides a player's snapshot whole, and a non-player's is all zero, as
/// `IsUnitOnQuest`'s lookup is TYPEMASK_PLAYER (`0x4dfe97`).
#[test]
fn a_players_snapshot_carries_its_quest_log_window_and_a_creatures_none() {
use benilla_protocol::messages::ObjectFields;
const OBJECT_TYPE: u16 = 2;
const QUEST_LOG_1_1: u16 = 198;
let log = [
(QUEST_LOG_1_1, 783),
(QUEST_LOG_1_1 + 1, 0x0100_0003),
(QUEST_LOG_1_1 + 57, 7),
];
let snap = |type_bits: u32| {
snapshot(
&ObjectStore(ObjectFields::from_pairs(
&[log.as_slice(), &[(OBJECT_TYPE, type_bits)]].concat(),
)),
0,
None,
0,
None,
Default::default(),
)
};
let player = snap(0x19);
assert_eq!(player.quest_log[0], [783, 0x0100_0003, 0]);
assert_eq!(player.quest_log[19], [7, 0, 0]);
assert_eq!(player.quest_log[1], [0, 0, 0]);
assert_eq!(
snap(0x09).quest_log,
[[0; 3]; 20],
"a creature has no PLAYER block"
);
}
/// The rank getter's two gates (`0x605620`), through `enrich_unit`'s wiring.
#[test]
fn the_rank_gate_zeroes_a_pet_or_charm() {
@@ -202,6 +202,17 @@ impl ObjectFields {
timer,
})
}
/// The whole `PLAYER_QUEST_LOG` window, `[id, count-state, timer]` per slot as the wire left
/// it, 0 where absent: a group mate's carries the ids alone, the one dword per slot vmangos
/// flags `GROUP_ONLY` (`UpdateFields_1_12_1.h:128-129`).
pub fn player_quest_log_window(&self) -> [[u32; 3]; PLAYER_QUEST_LOG_SLOTS as usize] {
std::array::from_fn(|slot| {
std::array::from_fn(|k| {
self.get_u32(FIELD_PLAYER_QUEST_LOG_1_1 + 3 * slot as u16 + k as u16)
.unwrap_or(0)
})
})
}
/// `PLAYER_EXPLORED_ZONES_1 + i`: bitset dword `i`; bit `n` is `AreaTable` explore flag `n`.
pub fn player_explored_zone_slot(&self, i: u16) -> u32 {
if i >= PLAYER_EXPLORED_ZONES_SLOTS {
@@ -135,4 +135,17 @@ mod tests {
let f = ObjectFields::from_pairs(&[(198, 0), (199, 0), (200, 0)]);
assert_eq!(f.player_quest_log(0).map(|s| s.quest_id), Some(0));
}
#[test]
fn quest_log_window_is_every_slot_raw_and_zero_where_absent() {
// A group mate's view: ids alone in slots 0 and 19, the private dwords never sent; slot 2
// carries a count-state dword, kept as sent rather than unpacked.
let f = ObjectFields::from_pairs(&[(198, 783), (198 + 57, 7), (205, 0x0100_0005)]);
let w = f.player_quest_log_window();
assert_eq!(w.len(), usize::from(PLAYER_QUEST_LOG_SLOTS));
assert_eq!(w[0], [783, 0, 0]);
assert_eq!(w[1], [0, 0, 0]);
assert_eq!(w[2], [0, 0x0100_0005, 0]);
assert_eq!(w[19], [7, 0, 0]);
}
}
+233 -3
View File
@@ -6,8 +6,9 @@
//! does (`0x4dfb50` copies the selection `0xbb7480`, a quest id, to `0xbb7484`), so a re-index
//! before the popup's Yes cannot retarget them.
//!
//! Not built: `IsUnitOnQuest` and `GetAbandonQuestItems` answer nil; the first needs the party
//! members' quest logs.
//! `IsUnitOnQuest` reads a unit's quest-log window off its snapshot ([`super::UnitState`]).
//!
//! Not built: `GetAbandonQuestItems` answers nil.
use mlua::{Lua, MultiValue, Value};
@@ -598,9 +599,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
super::quest::reward_spell_returns(lua, spell)
})?,
)?;
// `IsUnitOnQuest(index, "unit")` (`0x4dfe10`): 1 when the unit's quest log holds row
// `index`'s quest, else nil; the stock rows count `[n]` mates (`QuestLogFrame.lua:175`).
g.set(
"IsUnitOnQuest",
lua.create_function(|_, (_q, _unit): (Value, Value)| Ok(flag(false)))?,
lua.create_function(|lua, (index, token): (Value, Value)| {
const USAGE: &str = "Usage: IsUnitOnQuest(index, \"unit\")";
// Both are checked before either is read: `lua_isnumber` (`0x4dfe1e`), `lua_isstring`
// (`0x4dfe32`); the index is `_ftol`'d (`0x4dfe4d`).
let n = number_arg(lua, index, USAGE)?;
let token = super::binding_abi::string_arg(lua, token, USAGE)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
// The row's quest id (`0x4df150`): 0 for a header or a row past the list, which
// answers nil before the token is resolved (`0x4dfe5e`).
let quest_id = usize::try_from(n)
.ok()
.and_then(|n| n.checked_sub(1))
.and_then(|row| model.quest_log.entries.get(row))
.filter(|e| !e.is_header)
.map_or(0, |e| e.quest_id);
if quest_id == 0 {
return Ok(Value::Nil);
}
// The token's guid (`0x515970`), which raises on an unknown token.
super::unit::check_unit_token(&Some(token.clone()))?;
let Some(unit) = model.unit(&token) else {
return Ok(Value::Nil);
};
// The active player or one of the four party slots, by guid (`0x4e7f70`): a raid mate
// outside our subgroup, a pet or a stranger answers nil.
let me = model.unit("player").map_or(0, |p| p.guid);
let grouped = unit.guid != 0
&& (unit.guid == me || model.party.members.iter().any(|m| m.guid == unit.guid));
// Held as a player (`0x468460`, typemask `0x10`) and not deactivated (`0x614ea0`, a
// state the app's despawn never leaves held), then the 20 slots' ids, stride 12
// (`0x4dfebd`-`0x4dfed7`); a non-player's window is all zero.
Ok(flag(
grouped && unit.has_object && unit.quest_log.iter().any(|s| s[0] == quest_id),
))
})?,
)?;
// ── The party share and the quest watch ──────────────────────────────────────────────────────
/// The selected row, which the share verbs read.
@@ -1412,4 +1449,197 @@ mod tests {
"a header is not a quest"
);
}
// ── IsUnitOnQuest (`0x4dfe10`) ──
const ME: u64 = 0x10;
const MATE: u64 = 0x300;
const FAR_MATE: u64 = 0x301;
/// A held unit whose quest-log window holds `ids`, one per slot from the last slot down, so the
/// scan must reach slot 19.
fn held(guid: u64, ids: &[u32]) -> crate::script::UnitState {
let mut quest_log = [[0; 3]; 20];
for (slot, &id) in quest_log.iter_mut().rev().zip(ids) {
*slot = [id, 0, 0];
}
crate::script::UnitState {
exists: true,
has_object: true,
is_player: true,
guid,
quest_log,
..Default::default()
}
}
/// A header, then quest 783 (row 2) and quest 7 (row 3). We hold both; `party1` holds 783 and
/// `party2` is out of range. `target` is `party1`; `mouseover` a stranger and `raid7` a raid
/// mate outside our subgroup, both holding 783.
fn on_quest_world() -> UiScript {
let mut s = UiScript::new().unwrap();
let mut state = two_quests();
state.entries.insert(
0,
QuestLogEntryView {
title: "Elwynn Forest".into(),
is_header: true,
..Default::default()
},
);
s.set_quest_log(state);
s.set_party(crate::script::PartyState {
members: vec![
crate::script::PartyMemberInfo {
name: "Mate".into(),
guid: MATE,
},
crate::script::PartyMemberInfo {
name: "Far".into(),
guid: FAR_MATE,
},
],
..Default::default()
});
s.set_unit("player", Some(held(ME, &[7, 783])));
s.set_unit("party1", Some(held(MATE, &[783])));
s.set_unit(
"party2",
Some(crate::script::UnitState {
exists: true,
is_player: true,
guid: FAR_MATE,
..Default::default()
}),
);
s.set_unit("target", Some(held(MATE, &[783])));
s.set_unit("mouseover", Some(held(0x500, &[783])));
s.set_unit("raid7", Some(held(0x700, &[783])));
s.set_unit(
"pet",
Some(crate::script::UnitState {
exists: true,
has_object: true,
guid: 0xF140_0000_0000_0001,
..Default::default()
}),
);
s
}
/// The number 1 when the unit's log holds the row's quest (`0x4dfef5` pushes 1.0), else nil
/// (`0x4dfedb`), never a boolean.
#[test]
fn is_unit_on_quest_answers_one_for_the_player_and_a_held_party_member_on_it() {
let mut s = on_quest_world();
let on = |s: &mut UiScript, call: &str| -> Option<i64> {
s.eval::<Option<i64>>(&format!("return {call}")).unwrap()
};
assert!(s
.eval::<bool>("return type(IsUnitOnQuest(2, 'party1')) == 'number'")
.unwrap());
assert_eq!(on(&mut s, "IsUnitOnQuest(2, 'party1')"), Some(1));
assert_eq!(on(&mut s, "IsUnitOnQuest(3, 'party1')"), None, "not on 7");
assert_eq!(on(&mut s, "IsUnitOnQuest(2, 'player')"), Some(1));
assert_eq!(on(&mut s, "IsUnitOnQuest(3, 'player')"), Some(1));
assert_eq!(
on(&mut s, "IsUnitOnQuest(2, 'PARTY1')"),
Some(1),
"tokens fold case"
);
// The guid decides, not the token: `target` names party1 (`0x4e7f70`'s slot compare).
assert_eq!(on(&mut s, "IsUnitOnQuest(2, 'target')"), Some(1));
}
/// `0x4e7f70` admits the active player and the four party slots alone, and `0x468460` needs
/// the object held: every other unit answers nil whatever its log holds.
#[test]
fn is_unit_on_quest_is_nil_off_the_party_or_out_of_range() {
let mut s = on_quest_world();
for token in [
"party2", // in the party, not held
"party3", // no such slot
"mouseover", // held, on the quest, not grouped
"raid7", // a raid mate outside our subgroup
"pet", // not a player, no quest log
"", // names nobody
] {
assert!(
s.eval::<bool>(&format!("return IsUnitOnQuest(2, '{token}') == nil"))
.unwrap(),
"{token:?} answers nil"
);
}
// The party stops at four slots of guids: a member who left reads nil off a stale log.
s.set_party(crate::script::PartyState::default());
assert!(s
.eval::<bool>("return IsUnitOnQuest(2, 'target') == nil")
.unwrap());
}
/// The index is `_ftol`'d and 1-based (`0x4dfe4d`, `dec` at `0x4dfe52`); a header or a row
/// past the list has no quest id (`0x4df150`) and answers nil before the token is read, so an
/// unknown token raises only on a quest row (`0x515c14`).
#[test]
fn is_unit_on_quest_reads_the_row_before_the_token() {
let s = on_quest_world();
for index in ["1", "0", "-1", "4", "99", "-0.5"] {
assert!(
s.eval::<bool>(&format!("return IsUnitOnQuest({index}, 'party1') == nil"))
.unwrap(),
"index {index}"
);
assert!(
s.eval::<bool>(&format!("return IsUnitOnQuest({index}, 'bogus') == nil"))
.unwrap(),
"index {index} answers before the token is resolved"
);
}
assert!(s
.eval::<bool>("return IsUnitOnQuest(2.9, 'party1') == 1")
.unwrap());
assert!(s
.eval::<bool>("return IsUnitOnQuest('2', 'party1') == 1")
.unwrap());
for (token, shown) in [("'bogus'", "bogus"), ("5", "5")] {
let e = format!(
"{:?}",
s.eval::<mlua::Value>(&format!("return IsUnitOnQuest(2, {token})"))
.unwrap_err()
);
assert!(
e.contains(&format!("Unknown unit name: {shown}")),
"{token}: {e}"
);
}
}
/// `lua_isnumber` on 1 and `lua_isstring` on 2, both ahead of any read (`0x4dfe1e`,
/// `0x4dfe32`), raise the one Usage line (`0x84b564`), an out-of-range index included.
#[test]
fn is_unit_on_quest_raises_usage_on_a_bad_argument() {
let s = on_quest_world();
for args in [
"",
"2",
"99",
"nil, 'party1'",
"'x', 'party1'",
"{}, 'party1'",
"2, nil",
"2, {}",
"2, true",
] {
let e = format!(
"{:?}",
s.eval::<mlua::Value>(&format!("return IsUnitOnQuest({args})"))
.unwrap_err()
);
assert!(
e.contains("Usage: IsUnitOnQuest(index, \\\"unit\\\")")
|| e.contains("Usage: IsUnitOnQuest(index, \"unit\")"),
"({args}) must raise Usage:, got {e}"
);
}
}
}
+5
View File
@@ -200,6 +200,11 @@ pub struct UnitState {
/// `UnitAffectingCombat` (`0x517e10`): `UNIT_FIELD_FLAGS` bit `0x00080000`, for every token
/// including `"player"`; the reference has no separate player combat flag.
pub in_combat: bool,
/// A held player's `PLAYER_QUEST_LOG` window, `[quest id, count-state, timer]` per slot, all
/// zero off a player: the 20 slots of 12 bytes at `[player+0xe68]+0x28` that `IsUnitOnQuest`
/// scans (`0x4dfebd`) and the `UNIT_QUEST_LOG_CHANGED` watch compares (`0x51bc63`). Whole, so
/// a change anywhere in it is a snapshot change; a group mate's carries the ids alone.
pub quest_log: [[u32; 3]; 20],
}
/// The name of a unit whose name is not yet known, as the reference's `UnitName` (`0x517220`) and