diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index e8bdc7d1..c4af740b 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -195,8 +195,69 @@ pub mod field { }; } -/// `MAX_QUEST_LOG_SIZE` (`QuestDef.h:34`). -pub const PLAYER_QUEST_LOG_SLOTS: u8 = 20; +/// `MAX_QUEST_LOG_SIZE` (`QuestDef.h:34`) is 20; OctoWoW carries 25, and the extra five do not +/// follow the first twenty — see [`FIELD_PLAYER_QUEST_LOG_21_1`]. +pub const PLAYER_QUEST_LOG_SLOTS: u8 = 25; + +/// Where OctoWoW's quest-log slots 21-25 live: `PLAYER_FIELD_COMBAT_RATING_1`, 1.12's unused +/// twenty-field block, three fields per slot as the first twenty are. +/// +/// Read out of the realm's own patched `WoW.exe`, which carries the patch in a PE section it names +/// `.octoql` (VA `0xd26000`, 488 bytes of the 20 KB used). `GetNumQuestLogEntries` (`0x4df8b0` in +/// `reference/1.12-shapes.tsv`) begins `jmp 0xd26145`, and the routine the detour calls at +/// `0xd26000` is the whole contract, in eight instructions: +/// +/// ```text +/// cmp $0x14,%ecx ; slot index against 20 +/// jae … +/// imul $0xc,%ecx,%eax ; 12 bytes = 3 dwords per slot +/// add %edx,%eax +/// add $0x28,%eax ; slots 0-19 at base + 0x28 +/// ret +/// sub $0x14,%ecx ; slot -= 20 +/// cmp $0x5,%ecx ; …against 5, so 25 in all +/// jae … ; out of range: a zeroed dummy at 0xd29010 +/// imul $0xc,%ecx,%eax +/// add %edx,%eax +/// add $0x10c8,%eax ; slots 20-24 at base + 0x10C8 +/// ret +/// ``` +/// +/// Both arms index the same dword array off the same `%edx`, so the field-index step between the +/// two blocks is `(0x10C8 - 0x28) / 4` = 1064, and slot 0 is [`FIELD_PLAYER_QUEST_LOG_1_1`] = 198: +/// slot 20 is field 1262. That cross-checks twice over — 198 × 4 = 0x318, so `%edx` is the field +/// array plus `0x318 - 0x28` = 188 dwords, which is exactly `UNIT_END`, i.e. the pointer is the +/// player block's own base and `0x28` is slot 0 within it; and 1262 is +/// `PLAYER_FIELD_COMBAT_RATING_1`, which is what the realm's `QuestLogFrame.lua` says in prose +/// ("update-fields the client's descriptor table calls combat ratings"). Five slots take 15 of +/// that block's 20 fields, 1262..=1276. +/// +/// The detour exists because those fields raise nothing: it walks exactly those 15 dwords against +/// a shadow copy each call and, on a change, calls the client's quest-log-changed path at +/// `0x4de510` itself. That is also why the realm's FrameXML polls `GetNumQuestLogEntries` every +/// 0.2 s — the call is what drives the refresh. +/// +/// Read unconditionally, which is a bet this crate should state: against a server that wrote real +/// combat ratings into that block, slot 21 would read one as a quest id. No vanilla-family server +/// does — the block is dead space in 1.12 and vmangos never touches it — and the symptom would be +/// a phantom quest whose id resolves to nothing rather than a corrupted real one. Gating this on +/// the realm would mean asking the interface (`MAX_QUESTLOG_QUESTS`) what the protocol should +/// parse, which is the wrong direction for a dependency. +pub const FIELD_PLAYER_QUEST_LOG_21_1: u16 = 1262; + +/// The first slot that lives in [`FIELD_PLAYER_QUEST_LOG_21_1`] rather than after slot 1. +pub const PLAYER_QUEST_LOG_VANILLA_SLOTS: u8 = 20; + +/// The field a quest-log slot's three dwords start at, across both blocks; `None` past the cap. +pub fn player_quest_log_field(slot: u8) -> Option { + match slot { + s if s >= PLAYER_QUEST_LOG_SLOTS => None, + s if s < PLAYER_QUEST_LOG_VANILLA_SLOTS => { + Some(FIELD_PLAYER_QUEST_LOG_1_1 + 3 * u16::from(s)) + } + s => Some(FIELD_PLAYER_QUEST_LOG_21_1 + 3 * u16::from(s - PLAYER_QUEST_LOG_VANILLA_SLOTS)), + } +} /// One `PLAYER_QUEST_LOG` slot, the durable quest state; `SMSG_QUESTUPDATE_*` are only toasts. #[derive(Debug, Clone, Copy, PartialEq, Eq)] 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..f4e95feb 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -182,12 +182,10 @@ impl ObjectFields { self.get_u32(FIELD_PLAYER_FIELD_BYTES).unwrap_or(0) & 0x2 != 0 } /// `PLAYER_QUEST_LOG__*`: one quest-log slot, `quest_id == 0` when the server cleared - /// it; the count-state and timer are written with the id (`Player.h:1094-1099`). + /// it; the count-state and timer are written with the id (`Player.h:1094-1099`). Slots 21-25 + /// are the realm's and live elsewhere ([`super::player_quest_log_field`]). pub fn player_quest_log(&self, slot: u8) -> Option { - if slot >= PLAYER_QUEST_LOG_SLOTS { - return None; - } - let base = FIELD_PLAYER_QUEST_LOG_1_1 + 3 * u16::from(slot); + let base = super::player_quest_log_field(slot)?; let quest_id = self.get_u32(base)?; let count_state = self.get_u32(base + 1).unwrap_or(0); let timer = self.get_u32(base + 2).unwrap_or(0); diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index 74984735..49fd185d 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -889,3 +889,55 @@ fn rage_and_happiness_divide_for_display_but_not_for_the_raw_readers() { assert_eq!(feigning_warrior.unit_shown_power(RAGE), Some(0)); assert_eq!(feigning_warrior.unit_shown_max_power(RAGE), Some(100)); } + +/// The quest log's two blocks, pinned to the arithmetic in the realm's own `.octoql` patch +/// (`FIELD_PLAYER_QUEST_LOG_21_1` carries the disassembly): slots 0-19 three fields apart from +/// 198, slots 20-24 three fields apart from 1262, nothing past 25. +/// +/// Worth pinning rather than trusting the formula to read right, because every number in it came +/// off a disassembly and a one-field slip reads a neighbouring quest's counters as this one's. +#[test] +fn the_quest_log_runs_in_two_blocks_as_the_realms_patch_indexes_them() { + use super::{ + player_quest_log_field, FIELD_PLAYER_QUEST_LOG_1_1, FIELD_PLAYER_QUEST_LOG_21_1, + PLAYER_QUEST_LOG_SLOTS, + }; + + assert_eq!(PLAYER_QUEST_LOG_SLOTS, 25); + assert_eq!(player_quest_log_field(0), Some(FIELD_PLAYER_QUEST_LOG_1_1)); + assert_eq!(player_quest_log_field(19), Some(198 + 57)); + assert_eq!( + player_quest_log_field(20), + Some(FIELD_PLAYER_QUEST_LOG_21_1) + ); + assert_eq!(player_quest_log_field(24), Some(1262 + 12)); + assert_eq!(player_quest_log_field(25), None, "the patch's `cmp $0x5`"); + + // `(0x10C8 - 0x28) / 4`, the step between the two blocks, straight off the two `add`s. + assert_eq!( + u32::from(FIELD_PLAYER_QUEST_LOG_21_1 - FIELD_PLAYER_QUEST_LOG_1_1), + (0x10C8 - 0x28) / 4 + ); + // Fifteen fields, the count the detour's own change-poll walks (`cmp $0xf`), and all of them + // inside the player descriptor so the parser hands them over. + let last = player_quest_log_field(24).expect("slot 24") + 2; + assert_eq!(last - FIELD_PLAYER_QUEST_LOG_21_1 + 1, 15); + assert!(last < 1282, "PLAYER_END"); + + // A quest in the last extended slot reads back as its own, not as a neighbour's. + let base = player_quest_log_field(24).expect("slot 24"); + let fields = ObjectFields::from_pairs(&[ + (base, 8942), + (base + 1, 0x0100_0041), + (base + 2, 1_700_000_000), + ]); + let slot = fields.player_quest_log(24).expect("the extended slot"); + assert_eq!(slot.quest_id, 8942); + assert_eq!(slot.counters, [1, 1, 0, 0]); + assert_eq!(slot.state, 1); + assert_eq!(slot.timer, 1_700_000_000); + // The slot below it must not see any of this one's three dwords. (`from_pairs` sets no + // descriptor end, so an untouched field reads `None` here rather than the live `Some(0)`.) + assert_eq!(fields.player_quest_log(23).map(|s| s.quest_id), None); + assert_eq!(fields.player_quest_log(20).map(|s| s.quest_id), None); +}