world books open, and anvils stop offering a cursor gear

- clicking a book in the world opens it and turns its pages: letters and world books share one reader
- the cursor's object rules are the client's own 31-row strategy table now, so decorative objects stop pretending to be usable
- trainer and tradeskill windows draw each row's icon by the reference's own law, and the trainer's filter dropdown toggles again
- water color follows the zone's light blend, so lakes stop snapping between area tints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:03:35 +07:00
co-authored by Claude Fable 5
parent 19461d1e0f
commit d16d33dfca
55 changed files with 2485 additions and 358 deletions
+9
View File
@@ -1016,6 +1016,15 @@ pub enum SessionEvent {
equip_error: Option<u32>,
item: Option<(u32, u32)>,
},
/// `SMSG_PAGE_TEXT_QUERY_RESPONSE` — one page of a book, answering `CMSG_PAGE_TEXT_QUERY`.
/// The ask-once page cache both readables reach: a readable item template's `PageText` and a
/// `GAMEOBJECT_TYPE_TEXT` object's `data[0]` (decision 1105). `next_page_id == 0` ends the
/// chain; vmangos pushes the whole chain in answer to the first query.
PageText {
page_id: u32,
text: String,
next_page_id: u32,
},
/// `SMSG_ITEM_TEXT_QUERY_RESPONSE` — a letter's body text, answering `CMSG_ITEM_TEXT_QUERY`
/// (a mail's nonzero `item_text_id` triggers the ask-once fetch; decision 0544).
MailItemText { text_id: u32, text: String },
@@ -650,6 +650,15 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
racial_leader,
}]
}
ServerPacket::PageTextQueryResponse {
page_id,
text,
next_page_id,
} => vec![SessionEvent::PageText {
page_id,
text,
next_page_id,
}],
ServerPacket::GameObjectQueryResponse { entry, info } => {
let event = match info {
Some(i) => SessionEvent::GameObjectInfo {
@@ -37,6 +37,7 @@ mod movement;
pub mod opcode;
mod opcode_names;
mod packet;
mod page_text;
mod parse;
mod pet;
mod pose;
@@ -128,6 +129,7 @@ pub use mirror_timer::{
pub use movement::{JumpInfo, MoveMode, MovementInfo, SpeedKind, TransportPose};
pub use opcode_names::opcode_name;
pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket};
pub use page_text::page_text_query;
pub use parse::parse_server;
pub use pet::{
pet_abandon, pet_action, pet_cancel_aura, pet_rename, pet_set_action, pet_spell_autocast,
@@ -23,6 +23,15 @@ pub const SMSG_GAMEOBJECT_QUERY_RESPONSE: u16 = 0x005F; // 95
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 93. Answers `CMSG_QUEST_QUERY`; body in
/// [`super::quest::read_quest_query_response`] ([`super::QuestTemplate`]).
pub const SMSG_QUEST_QUERY_RESPONSE: u16 = 0x005D; // 93
// The **page-text** query pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 90/91) — the ask-once cache
// behind every readable BOOK, and a different one from the mail letter's `CMSG_ITEM_TEXT_QUERY`
// further down: a `PageText.wdb` id names one *page*, chained by `nextPageId`, and it is what a
// readable item TEMPLATE (`PageText`) and a world book/plaque GameObject (`GAMEOBJECT_TYPE_TEXT`'s
// `data[0]`) both read from. Bodies in [`super::page_text`]; decision 1105.
pub const CMSG_PAGE_TEXT_QUERY: u16 = 0x005A; // 90
pub const SMSG_PAGE_TEXT_QUERY_RESPONSE: u16 = 0x005B; // 91
pub const SMSG_NEW_WORLD: u16 = 0x003E;
// The far-teleport preamble pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 63/64). TRANSFER_PENDING's
// optional transport block decides how NEW_WORLD's coordinates read — boat-local when present,
@@ -260,6 +260,16 @@ pub enum ServerPacket {
entry: u32,
info: Option<GameObjectQueryInfo>,
},
/// `SMSG_PAGE_TEXT_QUERY_RESPONSE` — one page of a book, answering `CMSG_PAGE_TEXT_QUERY`
/// (the ask-once page cache every readable reaches: a readable item template's `PageText` and a
/// `GAMEOBJECT_TYPE_TEXT` object's `data[0]`, decision 1105). Pages **chain** —
/// `next_page_id == 0` is the last one — and vmangos answers a single query with one of these
/// per page of the whole chain.
PageTextQueryResponse {
page_id: u32,
text: String,
next_page_id: u32,
},
/// `SMSG_GAMEOBJECT_CUSTOM_ANIM` — a GameObject plays a one-shot Custom animation. Payload
/// VERIFIED vmangos `GameObject::SendGameObjectCustomAnim`: `u64 guid, u32 animId`. The
/// client arms GO substate `8 + animId` (AnimationData 153..156, `animId >= 4` rejected) —
@@ -1110,6 +1120,7 @@ impl ServerPacket {
ServerPacket::CreatureQueryResponse { .. } => "SMSG_CREATURE_QUERY_RESPONSE".into(),
ServerPacket::PetNameQueryResponse { .. } => "SMSG_PET_NAME_QUERY_RESPONSE".into(),
ServerPacket::GameObjectQueryResponse { .. } => "SMSG_GAMEOBJECT_QUERY_RESPONSE".into(),
ServerPacket::PageTextQueryResponse { .. } => "SMSG_PAGE_TEXT_QUERY_RESPONSE".into(),
ServerPacket::GameObjectCustomAnim { .. } => "SMSG_GAMEOBJECT_CUSTOM_ANIM".into(),
ServerPacket::FishNotHooked => "SMSG_FISH_NOT_HOOKED".into(),
ServerPacket::FishEscaped => "SMSG_FISH_ESCAPED".into(),
@@ -0,0 +1,92 @@
//! The **page-text** query pair (decision 1105) — the ask-once cache behind every readable *book*.
//!
//! Distinct from the mail letter's `CMSG_ITEM_TEXT_QUERY` ([`super::mail`]), which fetches one
//! whole player-written body by an *instance* text id. A `PageText.wdb` id names a single **page**,
//! and pages **chain**: each answer carries the next page's id (`0` = last). Two things read from
//! this cache, and both reach it the same way — the client opens its reader on an object guid and
//! asks the object for its page id:
//!
//! - a readable **item template** (`ItemPrototype::PageText`) — a book in your bags;
//! - a **`GAMEOBJECT_TYPE_TEXT`** world object (its template `data[0]`) — a book, plaque or sign
//! lying in the world.
//!
//! The client's request carries the asking object's guid after the page id (VERIFIED: the cache's
//! request writer `0x564730` appends the 8-byte guid at `0x56485d` under the per-cache "requests
//! carry a guid" flag `[cache+0x38]` — the very address vmangos cites in
//! `QueryPageText::ReadFromWorldPacket` when it reads the tail as optional). vmangos resolves the
//! page from the id alone and skips the guid.
use std::io;
use crate::wire::{read_cstring, read_u32_le};
/// Body of `CMSG_PAGE_TEXT_QUERY` (opcode `0x005A`/90 — VERIFIED vmangos
/// `Server/Protocol/Opcodes_1_12_1.h`): `u32 pageId` then the asking object's `u64 guid` (VERIFIED
/// `QueryPageText::ReadFromWorldPacket`, which reads the guid only `if (rpos() < size())` and
/// discards it; the real client writes it — see the module doc).
pub fn page_text_query(page_id: u32, guid: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&page_id.to_le_bytes());
body.extend_from_slice(&guid.to_le_bytes());
body
}
/// Read `SMSG_PAGE_TEXT_QUERY_RESPONSE` (VERIFIED vmangos `Server/Packets/Query.cpp`,
/// `PageTextQueryResponse::AppendBodyTo`): `u32 pageId, cstr text, u32 nextPageId`.
///
/// vmangos answers **one query with the whole chain** — `HandlePageTextQueryOpcode` loops on
/// `next_page`, sending one of these per page — so asking for page 1 lands every page of the book
/// unsolicited. A page the server doesn't know answers `"Item page missing."` with `nextPageId = 0`,
/// which terminates the chain rather than erroring.
pub(super) fn read_page_text_query_response(r: &mut &[u8]) -> io::Result<(u32, String, u32)> {
Ok((read_u32_le(r)?, read_cstring(r)?, read_u32_le(r)?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::{opcode, parse_server, ServerPacket};
#[test]
fn query_body_is_page_id_then_guid() {
assert_eq!(
page_text_query(333, 0xF110_0000_0000_0042),
[
0x4D, 0x01, 0x00, 0x00, // pageId 333
0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0xF1, // guid
]
);
}
#[test]
fn response_reads_the_chain_link() {
let mut body = 333u32.to_le_bytes().to_vec();
body.extend_from_slice(b"Page one.\0");
body.extend_from_slice(&334u32.to_le_bytes());
match parse_server(opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE, &body).unwrap() {
ServerPacket::PageTextQueryResponse {
page_id,
text,
next_page_id,
} => {
assert_eq!(page_id, 333);
assert_eq!(text, "Page one.");
assert_eq!(next_page_id, 334);
}
other => panic!("expected PageTextQueryResponse, got {}", other.name()),
}
}
#[test]
fn last_page_terminates_the_chain() {
let mut body = 9u32.to_le_bytes().to_vec();
body.extend_from_slice(b"The end.\0");
body.extend_from_slice(&0u32.to_le_bytes());
match parse_server(opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE, &body).unwrap() {
ServerPacket::PageTextQueryResponse { next_page_id, .. } => {
assert_eq!(next_page_id, 0)
}
other => panic!("expected PageTextQueryResponse, got {}", other.name()),
}
}
}
@@ -12,7 +12,7 @@ use crate::wire::{
use super::{
action_bar, area_trigger, attack, bank, channel, chat, combat_log, death, duel, gameobject,
gossip, group, items, loot, mail, mirror_timer, monster_move, movement, opcode, pet,
gossip, group, items, loot, mail, mirror_timer, monster_move, movement, opcode, page_text, pet,
progression, quest, social, spellbook, spells, taxi, trade, trainer, update_object, vendor,
world_state, Character, CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
};
@@ -808,6 +808,14 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
let (entry, info) = gameobject::read_gameobject_query_response(&mut r)?;
ServerPacket::GameObjectQueryResponse { entry, info }
}
opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE => {
let (page_id, text, next_page_id) = page_text::read_page_text_query_response(&mut r)?;
ServerPacket::PageTextQueryResponse {
page_id,
text,
next_page_id,
}
}
opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM => {
let (guid, anim_id) = gameobject::read_gameobject_custom_anim(&mut r)?;
ServerPacket::GameObjectCustomAnim { guid, anim_id }
@@ -34,4 +34,17 @@ impl WorldWriter {
&messages::gameobject_query(entry, guid),
)
}
/// Ask for one page of a book (`CMSG_PAGE_TEXT_QUERY`, layout in
/// [`messages::page_text_query`]) — the ask-once page cache a readable reaches when the reader
/// opens on it (decision 1105). `guid` names the asking object (the book item or the
/// `GAMEOBJECT_TYPE_TEXT` world object); the server resolves the page from the id alone.
/// Answered by `SMSG_PAGE_TEXT_QUERY_RESPONSE` — once per page of the whole chain, not just
/// the one asked for.
pub fn page_text_query(&mut self, page_id: u32, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_PAGE_TEXT_QUERY,
&messages::page_text_query(page_id, guid),
)
}
}