protocol: read the realm's quest-log slots 21-25

The offsets came out of the realm's own patched `WoW.exe`, which carries
the patch in a PE section it names `.octoql` — 488 bytes used of 20 KB
at VA 0xd26000. `GetNumQuestLogEntries` (0x4df8b0, from
`reference/1.12-shapes.tsv`) begins `jmp 0xd26145`, and the routine the
detour calls at 0xd26000 is the entire contract in eight instructions:
slot against 20, 12 bytes per slot, `+0x28` for the first twenty and
`+0x10C8` for the next five, against the same `%edx`, with a `cmp $0x5`
that is the 25 the realm's Lua also declares.

Both arms index one dword array, so the step between the blocks is
`(0x10C8 - 0x28) / 4` = 1064 and slot 20 is field 198 + 1064 = 1262.
That checks out twice independently: 198 × 4 = 0x318, so `%edx` is the
field array plus 188 dwords, which is exactly `UNIT_END` — the player
block's own base, with `0x28` as slot 0 inside it; and 1262 is
`PLAYER_FIELD_COMBAT_RATING_1`, 1.12's dead twenty-field block, which is
what their `QuestLogFrame.lua` says in prose. Five slots take 15 of
those 20 fields, which is also exactly the run the detour's own
change-poll walks (`cmp $0xf`) before calling the client's
quest-log-changed path itself, those fields raising nothing.

`player_quest_log_field` holds both blocks and every caller already
iterates `0..PLAYER_QUEST_LOG_SLOTS`, so the quest log, the markers and
the probes pick the five up unchanged. The test pins each boundary and
the 1064 step against the disassembly, because every number here came
off a listing and a one-field slip reads a neighbour's counters.

Stated in the doc rather than guarded: a server that wrote real combat
ratings there would show slot 21 as a phantom quest. None does — the
block is dead in 1.12 and vmangos never writes it — and gating this
would mean asking the interface what the protocol should parse.

269 data-gated failures, identical to the baseline, all nine gates green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-23 18:16:02 -06:00
co-authored by Claude Opus 5
parent 02e6fc4208
commit 56bdd15cfe
3 changed files with 118 additions and 7 deletions
@@ -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<u16> {
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)]
@@ -182,12 +182,10 @@ impl ObjectFields {
self.get_u32(FIELD_PLAYER_FIELD_BYTES).unwrap_or(0) & 0x2 != 0
}
/// `PLAYER_QUEST_LOG_<slot+1>_*`: 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<QuestLogSlot> {
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);
@@ -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);
}