From 63e917965d7d21f407c15ff24cf40fdf0e665c89 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:09:41 +0700 Subject: [PATCH] The quest log counts party members on each quest, as 1.12.1 does (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/ui_script/questlog_tests.rs | 112 +++++++++ .../benilla-app/src/ui_script/reference_ui.rs | 6 - crates/benilla-app/src/ui_unit.rs | 92 +++++++ .../messages/update_object/fields/player.rs | 11 + .../src/messages/update_object/mod.rs | 13 + crates/benilla-ui/src/script/quest_log.rs | 236 +++++++++++++++++- crates/benilla-ui/src/script/unit/mod.rs | 5 + 7 files changed, 466 insertions(+), 9 deletions(-) diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index ee12972f..bb9ea892 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -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::(&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::(&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::( + "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::( + "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]"); +} diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 12e84344..80ad2df5 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -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", diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 41a89027..9b963824 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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`), 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 { + 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::>("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() { diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 8b768369..61035d35 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -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 { diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs index 0621e242..74e60c9c 100644 --- a/crates/benilla-protocol/src/messages/update_object/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -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]); + } } diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index f8c2535a..24b7b3e3 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -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::().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 { + s.eval::>(&format!("return {call}")).unwrap() + }; + assert!(s + .eval::("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::(&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::("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::(&format!("return IsUnitOnQuest({index}, 'party1') == nil")) + .unwrap(), + "index {index}" + ); + assert!( + s.eval::(&format!("return IsUnitOnQuest({index}, 'bogus') == nil")) + .unwrap(), + "index {index} answers before the token is resolved" + ); + } + assert!(s + .eval::("return IsUnitOnQuest(2.9, 'party1') == 1") + .unwrap()); + assert!(s + .eval::("return IsUnitOnQuest('2', 'party1') == 1") + .unwrap()); + for (token, shown) in [("'bogus'", "bogus"), ("5", "5")] { + let e = format!( + "{:?}", + s.eval::(&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::(&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}" + ); + } + } } diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 40e0d26d..d9705849 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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