other players walk the world, and NPCs speak your name

- Other players walk the world. A watched player's motion between server updates now runs through
  the same grounded movement resolve as your own character — same capsule, same colliders, same
  step-up and ground election — so they follow terrain every frame instead of every packet, and
  stop at a wall instead of walking into it and being snapped back. Their jumps are swept against
  the world too, and a standstill jump steered mid-air reports that steer, the one mid-air press
  the reference client broadcasts rather than defers.
- The move relay rebuilt: relayed moves ride a per-unit chain on real time, a due move never jumps
  ahead of its unit's queue, and SMSG_COMPRESSED_MOVES — the batched carrier the server sends under
  load — is decoded rather than skipped. Our own send path now matches the reference client's:
  facing streams while moving, Nagle is off so each packet is one write, and the strafe axis nets so
  left and right can never ride the same packet.
- NPC and quest text speaks the whole $-macro grammar, carved from the reference bytes — your name,
  class and race, the gendered forms, and the server's live world states — and the same
  substitution feeds gossip, quest text, the quest log and unit frames. Greetings are a weighted
  draw, rolled per NPC per open, and a quest giver with no quest to give is no longer clickable,
  where it used to open a dialog holding nothing but a placeholder greeting.
- Spell tooltips finish the reference's line law: cast time and cooldown behind the real passive
  gate, the required tool or form, the reagents block, the chance-to-X line, a multi-bit
  equipped-item requirement named properly instead of by raw bits, and a buff's dispel class.
- The UI layout engine got a dense, reusable anchor solver, and a change gate that skips the resolve
  entirely on frames where no layout input moved — in practice, almost every idle frame. Pets are
  named by their pet number rather than a creature-template entry, so the client asks the query the
  server can actually answer. And the asset extractor grew a stray-geometry census for tracking
  down geometry that renders where it should not.

This snapshot replaces the six dated `sync` commits that stood here before it, whose subjects said
nothing but the date. If you cloned before now: `git fetch && git reset --hard origin/main`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-25 14:57:54 +07:00
co-authored by Claude Opus 5
parent 2ad0043523
commit c48d8c6b7e
75 changed files with 5389 additions and 1004 deletions
Generated
+1
View File
@@ -590,6 +590,7 @@ dependencies = [
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
"rand 0.8.6",
]
[[package]]
+7 -3
View File
@@ -1,6 +1,6 @@
# benilla
[![Discord](https://img.shields.io/badge/chat-Discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/8r5kUgu9ts)
[![Discord](https://img.shields.io/badge/chat-Discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/wJSJx467G4)
[![YouTube](https://img.shields.io/badge/devlog-YouTube-FF0000?logo=youtube)](https://www.youtube.com/playlist?list=PLdCnpZNKxyb8)
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license)
@@ -58,8 +58,12 @@ Credentials go in at the login screen, or set `WOW_USER` / `WOW_PASS` to skip it
benilla is a solo project and isn't taking pull requests for now. Bug reports and questions are
welcome — open an issue, or bring them to Discord. Comments throughout the code cite numbered
decision records from a private development log; the citations won't resolve here, but the
reasoning they carry is kept in the comments themselves.
decision records and method notes from a private development log; the citations won't resolve
here, but the reasoning they carry is kept in the comments themselves.
Development happens in that private tree, and this repo is its published export: **each commit here
is one squashed snapshot** of everything that landed since the last one, titled with what changed —
not an atomic change, which is why they're large and why there are few of them.
## Acknowledgements
@@ -570,7 +570,11 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> {
Ok(())
}
/// Dump an M2's render batches as the renderer sees them.
/// Dump an M2's render batches as the renderer sees them, preceded by the model-level material
/// state the **static visibility cull** reads (wow-re `m2-alpha-combine-cull`: a batch is skipped
/// when `colorAlpha · transparencyWeight ≤ 0`). Batches this dump *lists* are ones that survived
/// that cull — when one of them turns out to be a stray primitive in game, these tables are where
/// the answer has to be, so they print together.
pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> {
let name = normalize(internal_path);
let data = chain
@@ -579,6 +583,54 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> {
let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or("");
let subs = benilla_formats::parse_m2_render_submeshes(&data, dir, &[])
.with_context(|| format!("parsing M2 render submeshes '{name}'"))?;
if let Ok(format) = benilla_m2::parse_m2(&mut std::io::Cursor::new(data.as_slice())) {
let m = format.model();
let track = |t: &benilla_m2::M2ScalarTrack| match (t.keys.len(), t.constant()) {
(0, _) => "keyless".to_string(),
(_, Some(v)) => format!("const {v:.3}"),
(n, None) => format!("{n} keys {:.3}..{:.3}", t.keys[0].1, t.keys[n - 1].1),
};
let join = |v: Vec<String>| {
if v.is_empty() {
"-".to_string()
} else {
v.join(", ")
}
};
println!(
"materials: {}",
join(
m.materials
.iter()
.map(|mt| format!(
"flags 0x{:02x}/blend {}",
mt.flags.bits(),
mt.blend_mode.bits()
))
.collect()
)
);
println!(
"color alpha: {} transparency: {} transLookup: {:?}",
join(m.color_alpha_tracks.iter().map(track).collect()),
join(m.transparency_tracks.iter().map(track).collect()),
m.transparency_lookup,
);
if let Ok(skin) = m.parse_embedded_skin(&data, 0) {
println!(
"skin batches: {}",
join(
skin.batches()
.iter()
.map(|b| format!(
"flags 0x{:02x}/shader 0x{:02x}/texCount {}",
b.flags, b.shader_id, b.texture_count
))
.collect()
)
);
}
}
println!("{} render batch(es)", subs.len());
for (i, s) in subs.iter().enumerate() {
let mut flags = Vec::new();
@@ -613,8 +665,32 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> {
(None, Some(slot)) => format!("<char:{slot:?}>"),
(None, None) => "NONE".into(),
};
// Model-space extent + centre: a batch that renders as a stray primitive is explained by
// where and how big it actually is (a degenerate zero-area batch is a different bug from a
// real card the reference hides some other way).
let ext = |axis: usize| -> (f32, f32) {
s.positions
.iter()
.fold((f32::MAX, f32::MIN), |(lo, hi), p| {
(lo.min(p[axis]), hi.max(p[axis]))
})
};
let span = if s.positions.is_empty() {
"empty".to_string()
} else {
let (x, y, z) = (ext(0), ext(1), ext(2));
format!(
"span {:.2}x{:.2}x{:.2} @ ({:.2}, {:.2}, {:.2})",
x.1 - x.0,
y.1 - y.0,
z.1 - z.0,
(x.0 + x.1) / 2.0,
(y.0 + y.1) / 2.0,
(z.0 + z.1) / 2.0,
)
};
println!(
" batch {i}: geoset {:>4} {:?} {} verts [{}] tex {}",
" batch {i}: geoset {:>4} {:?} {} verts {span} [{}] tex {}",
s.geoset_id,
s.blend,
s.positions.len(),
@@ -150,6 +150,16 @@ enum Command {
/// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and census the geometry a
/// **non-character** spawn draws that the reference may not: MULTI-GEOSET models (more than
/// one `skinSectionId` — only the character compositor selects among them, so every other
/// spawn path draws all of them), UNTEX batches (no embedded texture and no character runtime
/// slot) and TINY batches (at most 2 faces). The population instrument behind the stray
/// untextured-primitive reports; `m2batch` then explains a single model in full.
Geosetscan {
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and census every particle-emitter
/// FEATURE the corpus authors — shapes, head/tail, blend modes (incl. the folded Mod/Mod2x),
/// every file-flag bit, spin signs, twinkle gates, atlas tiling, spline emitters, and the
@@ -352,6 +362,7 @@ fn main() -> Result<()> {
Command::Blendscan { prefix } => scan::blendscan(&mut chain, prefix.as_deref())?,
Command::Bbscan { prefix } => scan::bbscan(&mut chain, prefix.as_deref())?,
Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?,
Command::Geosetscan { prefix } => scan::geosetscan(&mut chain, prefix.as_deref())?,
Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?,
Command::Partscan { mask, prefix } => {
let mask = parse_u32_maybe_hex(&mask)
@@ -248,6 +248,110 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
Ok(())
}
/// Sweep every `.m2` (under `prefix`, if given) and census the **geometry a non-character model
/// draws that the reference may not** — the population instrument behind the bug channel's
/// "stray untextured primitive" family. Three independent signals per model, all read through the
/// renderer's own batch resolution (`m2batch`'s), so the report can't drift from the mechanism:
///
/// - **MULTI-GEOSET** — more than one distinct `skinSectionId`. The character compositor selects
/// among these; every other spawn path draws **all** of them, so this is exactly the population
/// an unfiltered creature/doodad/effect draw over-renders (`Creature\Banshee\Banshee.m2` is the
/// pinned case: `0`×17 + `402`×9).
/// - **UNTEX** — batches with no texture *and* no runtime slot that fills one: neither a character
/// composite slot ([`benilla_formats::RenderSubmesh::char_slot`]) nor a creature skin variation
/// ([`benilla_formats::RenderSubmesh::skin_slot`], filled at spawn from `CreatureDisplayInfo`).
/// Both fills are ordinary, so counting them would drown the signal — 324 of 420 `Creature\`
/// models carry a skin slot. What is left is geometry nothing can texture.
/// - **TINY** — batches of at most 2 faces: the literal single-triangle/quad primitives.
///
/// A model is listed when it trips any of the three. `m2batch` then explains one model in full.
pub fn geosetscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\"));
let names: Vec<String> = chain
.list()
.context("listing chain contents")?
.into_iter()
.map(|e| e.name)
.filter(|n| {
let l = n.to_ascii_lowercase();
l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p))
})
.collect();
let (mut scanned, mut hits) = (0u32, 0u32);
let (mut multi_models, mut untex_models, mut tiny_models) = (0u32, 0u32, 0u32);
// Top-level directory → multi-geoset model count, so the report says *where* the population
// lives (Creature/, Spells/, World/…) rather than only how big it is.
let mut by_dir: BTreeMap<String, u32> = BTreeMap::new();
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
scanned += 1;
let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or("");
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else {
continue;
};
let mut geosets: BTreeMap<u16, u32> = BTreeMap::new();
let (mut untex, mut tiny) = (0u32, 0u32);
for s in &subs {
*geosets.entry(s.geoset_id).or_default() += 1;
if s.texture.is_none() && s.char_slot.is_none() && s.skin_slot.is_none() {
untex += 1;
}
if !s.indices.is_empty() && s.indices.len() <= 6 {
tiny += 1;
}
}
let multi = geosets.len() > 1;
if !multi && untex == 0 && tiny == 0 {
continue;
}
hits += 1;
if multi {
multi_models += 1;
let top = name.split_once('\\').map(|(d, _)| d).unwrap_or("<root>");
*by_dir.entry(top.to_ascii_lowercase()).or_default() += 1;
}
if untex > 0 {
untex_models += 1;
}
if tiny > 0 {
tiny_models += 1;
}
let hist = geosets
.iter()
.map(|(id, n)| format!("{id}×{n}"))
.collect::<Vec<_>>()
.join(" ");
let mut marks = Vec::new();
if multi {
marks.push(format!("MULTI-GEOSET({})", geosets.len()));
}
if untex > 0 {
marks.push(format!("UNTEX({untex})"));
}
if tiny > 0 {
marks.push(format!("TINY({tiny})"));
}
println!(
"{:>3} batches [{hist}] {} {name}",
subs.len(),
marks.join(" ")
);
}
let dirs = by_dir
.iter()
.map(|(d, n)| format!("{d}={n}"))
.collect::<Vec<_>>()
.join(" ");
eprintln!(
"{scanned} models scanned, {hits} listed — {multi_models} MULTI-GEOSET, \
{untex_models} with UNTEX batches, {tiny_models} with TINY batches; \
multi-geoset by top dir: [{dirs}]"
);
Ok(())
}
/// Sweep every `.m2` (under `prefix`, if given) and report models that author flat ground-plane
/// render geometry — the population instrument for the class of spell effects that lie in the
/// model-space XY plane at z≈0 (WoW axes, Z up) and get buried by sloped terrain (Battle Shout's
+186 -3
View File
@@ -23,6 +23,10 @@ use crate::dbc::{i32_at, parse, u32_at};
use crate::Chain;
const ITEM_SUB_CLASS: &str = "DBFilesClient\\ItemSubClass.dbc";
/// The group-name table read *before* [`ITEM_SUB_CLASS`] whenever a whole subclass **mask** needs a
/// name — see [`ItemSubClassCatalog::requirement_name`]. 11 fields (verified by loading the shipped
/// file): `ClassID@0`, `Mask@1`, `Name_Lang@2..9`, `NameFlags@10`.
const ITEM_SUB_CLASS_MASK: &str = "DBFilesClient\\ItemSubClassMask.dbc";
/// One row's tooltip-relevant fields.
#[derive(Debug, Clone, Copy)]
@@ -44,6 +48,21 @@ pub struct ItemSubClassCatalog {
/// when non-empty, else **DisplayName** (`+0x28`, column 10). "One-Handed Swords" over
/// "Sword"; plain "Cloth" where no verbose form exists.
names: HashMap<(u32, u32), String>,
/// **DisplayName** alone (column 10) — the SINGULAR spelling. The two are not
/// interchangeable, and the reference picks between them by call site: the cast-fail line
/// `SPELL_FAILED_EQUIPPED_ITEM_CLASS` reads "Must have a **Wand** equipped" where the spell
/// tooltip's requirement line reads "Requires **Wands**" — both pinned by reference captures
/// of the same spell (Shoot 5019: class 2, submask bit 19).
display_names: HashMap<(u32, u32), String>,
/// Every `(class, subclass)` key in **file order** — the order the reference walks the rows in,
/// which decides both the comma-join's order and which row counts as "first"
/// ([`Self::requirement_name`] / [`Self::requirement_display_name`]).
order: Vec<(u32, u32)>,
/// `ItemSubClassMask.dbc`: `(classId, mask, name)` — the group names that stand in for a whole
/// mask ("Melee Weapon" for the eleven melee weapon subclasses). Only 3 rows ship. Folded in
/// here rather than given its own catalog because it is stage 1 of one lookup, not a second
/// vocabulary.
mask_groups: Vec<(u32, u32, String)>,
}
impl ItemSubClassCatalog {
@@ -63,6 +82,73 @@ impl ItemSubClassCatalog {
self.names.get(&(class, subclass)).map(String::as_str)
}
/// The SINGULAR subclass name (DisplayName only — [`Self::display_names`]); `None` for an
/// unknown key or an empty column.
pub fn display_name(&self, class: u32, subclass: u32) -> Option<&str> {
self.display_names
.get(&(class, subclass))
.map(String::as_str)
}
/// What a spell's equipped-item requirement is *called*, for `(class, subclass_mask)` — the
/// **plural/verbose** spelling the spell tooltip prints ("Requires Wands", "Requires Melee
/// Weapon"). `None` when nothing names it.
///
/// The reference's law is two-stage (wow-re `tooltip-content-law.md` §3-EQUIPITEM, carved from
/// `0x6e2380` and its two call sites), and both stages matter for a mask with several bits set:
///
/// 1. `ItemSubClassMask.dbc` on **exact whole-mask equality** — not "any bit", the entire mask.
/// Three rows ship: `{2, 0x2a5f3, "Melee Weapon"}`, `{4, 0x60, "Shield"}`,
/// `{2, 0x4000c, "Ranged Weapon"}`. This is why Parry (`0x2a5f3` = eleven melee subclasses)
/// prints one group name rather than eleven weapon types.
/// 2. Otherwise every subclass the mask names, comma-joined in file order, each preferring
/// VerboseName with a DisplayName fallback ([`Self::name`]).
///
/// Only when stage 2 also finds nothing is the line absent. Note `0x6e2380` reads **only**
/// ItemSubClass.dbc — there is no ItemClass.dbc fallback on either path.
pub fn requirement_name(&self, class: u32, mask: u32) -> Option<String> {
self.mask_group(class, mask)
.map(str::to_string)
.or_else(|| {
let joined = self
.masked(class, mask)
.filter_map(|key| self.names.get(&key))
.cloned()
.collect::<Vec<_>>()
.join(", ");
(!joined.is_empty()).then_some(joined)
})
}
/// The **singular** spelling of the same requirement — what the `SPELL_FAILED_EQUIPPED_ITEM_CLASS`
/// cast-fail line says ("Must have a Wand equipped"). Same stage 1, but stage 2 takes only the
/// FIRST matching subclass and its DisplayName: never a join, never VerboseName.
pub fn requirement_display_name(&self, class: u32, mask: u32) -> Option<String> {
self.mask_group(class, mask)
.map(str::to_string)
.or_else(|| {
self.masked(class, mask)
.find_map(|key| self.display_names.get(&key))
.cloned()
})
}
/// Stage 1: the `ItemSubClassMask.dbc` group name for an EXACT `(class, mask)` pair.
fn mask_group(&self, class: u32, mask: u32) -> Option<&str> {
self.mask_groups
.iter()
.find(|(c, m, _)| *c == class && *m == mask)
.map(|(_, _, name)| name.as_str())
}
/// The catalog's keys for `class` whose subclass bit is set in `mask`, in file order.
fn masked(&self, class: u32, mask: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
self.order
.iter()
.copied()
.filter(move |&(c, sub)| c == class && sub < 32 && mask & (1 << sub) != 0)
}
/// Whether the type name is suppressed (displayFlags bit 0).
pub fn hides_name(&self, class: u32, subclass: u32) -> bool {
self.rows
@@ -115,18 +201,52 @@ fn item_sub_class_schema() -> Schema {
s
}
/// Load ItemSubClass.dbc from the patch chain.
fn item_sub_class_mask_schema() -> Schema {
let mut s = Schema::new("ItemSubClassMask");
s.add_field(SchemaField::new("ClassID", FieldType::UInt32));
s.add_field(SchemaField::new("Mask", FieldType::UInt32));
for i in 0..8 {
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
}
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
s
}
/// Load ItemSubClass.dbc — plus the ItemSubClassMask.dbc group names, which are stage 1 of the same
/// lookup ([`ItemSubClassCatalog::requirement_name`]) — from the patch chain.
pub fn load_item_sub_classes(chain: &mut Chain) -> Result<ItemSubClassCatalog> {
let bytes = chain
.read_file(ITEM_SUB_CLASS)
.with_context(|| format!("reading {ITEM_SUB_CLASS}"))?;
let rs = parse(&bytes, item_sub_class_schema(), "ItemSubClass")?;
let mask_bytes = chain
.read_file(ITEM_SUB_CLASS_MASK)
.with_context(|| format!("reading {ITEM_SUB_CLASS_MASK}"))?;
let mask_rs = parse(
&mask_bytes,
item_sub_class_mask_schema(),
"ItemSubClassMask",
)?;
let mut mask_groups = Vec::with_capacity(mask_rs.records().len());
for r in mask_rs.records() {
let (Some(class), Some(mask), Some(name)) = (
u32_at(r, 0),
u32_at(r, 1),
crate::dbc::str_at(&mask_rs, r, 2).filter(|n| !n.is_empty()),
) else {
continue;
};
mask_groups.push((class, mask, name));
}
let mut rows = HashMap::with_capacity(rs.records().len());
let mut names = HashMap::with_capacity(rs.records().len());
let mut display_names = HashMap::with_capacity(rs.records().len());
let mut order = Vec::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(class), Some(subclass)) = (u32_at(r, 0), u32_at(r, 1)) else {
continue;
};
order.push((class, subclass));
rows.insert(
(class, subclass),
ItemSubClassInfo {
@@ -137,14 +257,24 @@ pub fn load_item_sub_classes(chain: &mut Chain) -> Result<ItemSubClassCatalog> {
);
// VerboseName enUS (col 19) first, DisplayName enUS (col 10) fallback — the struct doc's
// byte law. Empty both → no name row (the header renders blank, faithfully unlikely).
let display = crate::dbc::str_at(&rs, r, 10).filter(|n| !n.is_empty());
let name = crate::dbc::str_at(&rs, r, 19)
.filter(|n| !n.is_empty())
.or_else(|| crate::dbc::str_at(&rs, r, 10).filter(|n| !n.is_empty()));
.or_else(|| display.clone());
if let Some(name) = name {
names.insert((class, subclass), name);
}
if let Some(display) = display {
display_names.insert((class, subclass), display);
}
}
Ok(ItemSubClassCatalog { rows, names })
Ok(ItemSubClassCatalog {
rows,
names,
display_names,
order,
mask_groups,
})
}
#[cfg(test)]
@@ -173,6 +303,59 @@ mod tests {
assert_eq!(cat.name(99, 0), None);
}
/// The two-stage equipped-item requirement law (§3-EQUIPITEM) against the real 5875 DBCs —
/// including the multi-bit case we used to give up on. Skips without client data.
#[test]
fn real_requirement_names_take_the_group_before_the_join() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_item_sub_classes(&mut chain).expect("load the two DBCs");
// Stage 1 — the whole mask has a name of its own. All three shipped rows.
assert_eq!(
cat.requirement_name(2, 0x0002_a5f3).as_deref(),
Some("Melee Weapon"),
"Parry's eleven melee subclasses are ONE group name, not a join"
);
assert_eq!(cat.requirement_name(4, 0x60).as_deref(), Some("Shield"));
assert_eq!(
cat.requirement_name(2, 0x0004_000c).as_deref(),
Some("Ranged Weapon")
);
// The group name is the same string in the singular arm — the stages share stage 1.
assert_eq!(
cat.requirement_display_name(2, 0x0002_a5f3).as_deref(),
Some("Melee Weapon")
);
// Stage 2, one bit — the two spellings diverge (Shoot 5019: class 2, bit 19).
assert_eq!(cat.requirement_name(2, 1 << 19).as_deref(), Some("Wands"));
assert_eq!(
cat.requirement_display_name(2, 1 << 19).as_deref(),
Some("Wand")
);
// Stage 2, several bits with no group row — joined for the tooltip, first-only for the
// cast-fail line. Daggers (15) + fist weapons (13) is not a shipped group.
let mask = (1 << 13) | (1 << 15);
assert_eq!(
cat.requirement_name(2, mask).as_deref(),
Some("Fist Weapons, Daggers")
);
assert_eq!(
cat.requirement_display_name(2, mask).as_deref(),
Some("Fist Weapon")
);
// Nothing to name.
assert_eq!(cat.requirement_name(2, 0), None);
assert_eq!(cat.requirement_name(99, 1), None);
}
/// Data-gated on the real 5875 DBC. Prints the live prereq/postreq and displayFlags rows so
/// a schema slip is visible, and pins the known shape. Skips without client data.
#[test]
+7 -7
View File
@@ -106,13 +106,13 @@ mod wmo_area;
pub use wmo_area::{load_wmo_area_catalog, WmoArea, WmoAreaCatalog};
mod spells;
pub use spells::{
load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_durations,
load_spell_radii, load_spell_ranges, substitute, ShapeshiftForm, SpellCastTime,
SpellCastTimeCatalog, SpellCatalog, SpellDisplay, SpellDuration, SpellDurationCatalog,
SpellRadius, SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext,
ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, SPELL_EFFECT_CREATE_ITEM,
SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_SKINNING,
SPELL_EFFECT_TRADE_SKILL,
load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types,
load_spell_durations, load_spell_radii, load_spell_ranges, substitute, ShapeshiftForm,
SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay,
SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
ATTR_ONLY_STEALTHED, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM,
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
};
mod skill_lines;
pub use skill_lines::{
@@ -659,3 +659,33 @@ fn real_crafting_columns_read_created_item_and_focus() {
);
}
}
/// The two tooltip-law reads added for the 2026-07-25 spellbook reports (decision 0620), on pure
/// data — no client install needed.
#[test]
fn the_tooltip_gates_read_effect_and_mask() {
// §3.4: the cast|cooldown line goes on the ATTRIBUTE bit or on Effect[0] ∈ {47, 78}.
let plain = SpellDisplay::default();
assert!(!plain.tooltip_omits_cast_line());
let attribute_passive = SpellDisplay {
passive: true,
..Default::default()
};
assert!(attribute_passive.tooltip_omits_cast_line());
// 6603 "Attack"'s shape: Effect[0] = 78, attributes 0x10 (NOT the passive bit).
let auto_attack = SpellDisplay {
effect_1: 78,
attributes: 0x10,
..Default::default()
};
assert!(!auto_attack.passive, "the attribute bit is clear");
assert!(auto_attack.tooltip_omits_cast_line(), "the Effect[0] leg");
let trade_skill = SpellDisplay {
effect_1: 47,
..Default::default()
};
assert!(trade_skill.tooltip_omits_cast_line());
// §3-EQUIPITEM's naming rule moved to `ItemSubClassCatalog::requirement_name`, where the
// vocabulary it reads lives — see `itemsubclass::tests` for its coverage against the real DBCs.
}
@@ -0,0 +1,108 @@
//! `SpellDispelType.dbc` — the name of a spell's dispel class, and the flag that decides whether
//! the class is *named at all*.
//!
//! Two consumers read exactly this table: the **aura tooltip's right column** ("Magic" on Ice
//! Armor — wow-re `tooltip-content-law.md` §3-BUFF, the aura builder `0x52f880`), and the
//! `debuffType` return of `UnitAura`/`UnitDebuff`, which FrameXML's `DebuffTypeColor` keys the
//! debuff border tint on. Both take the name from this row; neither hard-codes it.
//!
//! **The gate is `[+0x28]`, not the id.** A row is named only when its `+0x28` field is nonzero —
//! byte-verified in wow-re, and confirmed here on the shipped file: the 11 records are `{0 "",
//! 1 Magic, 2 Curse, 3 Disease, 4 Poison, 5 Stealth, 6 Invisibility, 7 All(M+C+D+P), 8 "Special -
//! npc only", 9 Frenzy, 10 ZG Trinkets}` and `[+0x28]` reads **1 for ids 1–4 and 0 for every
//! other row**. So Stealth and Invisibility have names in the file yet print nothing — the flag,
//! not the string, is what withholds them.
//!
//! Record layout (11 records × 12 fields × 4 B = 0x30 stride, read off the extracted file):
//! `ID@0`, `Name_Lang@1..8` (enUS at 1; every other locale is empty on 5875), `NameFlags@9`
//! (`0x007F007E`, `0x003F007E` on the last two rows), the gate at `@10` (= `+0x28`), and `@11` —
//! a second string column holding the *same* string as the name, populated on exactly the same
//! four rows. Whatever `@11` is for, it is not what the tooltip reads; `@10` is.
use std::collections::HashMap;
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
/// The named dispel classes, by `Spell.dbc` `Dispel` id — a row whose `[+0x28]` gate is 0 is
/// **absent**, so a lookup miss is exactly "this class is not named" (see the module doc).
#[derive(Default)]
pub struct SpellDispelTypes {
names: HashMap<u32, String>,
}
impl SpellDispelTypes {
/// The dispel class's name ("Magic"/"Curse"/"Disease"/"Poison" on 5875), or `None` when the
/// spell has no dispel class (`dispel == 0`) or its class is one the gate withholds.
pub fn name(&self, dispel: u32) -> Option<&str> {
self.names.get(&dispel).map(String::as_str)
}
pub fn len(&self) -> usize {
self.names.len()
}
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
}
/// Load `SpellDispelType.dbc` off the patch chain, keeping only the rows the `[+0x28]` gate names.
pub fn load_spell_dispel_types(chain: &mut Chain) -> Result<SpellDispelTypes> {
let bytes = chain
.read_file("DBFilesClient\\SpellDispelType.dbc")
.context("reading SpellDispelType.dbc")?;
let mut schema = Schema::new("SpellDispelType");
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
for i in 0..8 {
schema.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
}
schema.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
schema.add_field(SchemaField::new("Named", FieldType::UInt32));
schema.add_field(SchemaField::new("Unknown11", FieldType::String));
let set = parse(&bytes, schema, "SpellDispelType.dbc")?;
let mut names = HashMap::new();
for r in set.records() {
let Some(id) = u32_at(r, 0) else { continue };
// The gate first: a 0 here withholds the name however good the string is (Stealth, id 5).
if u32_at(r, 10).unwrap_or(0) == 0 {
continue;
}
if let Some(name) = str_at(&set, r, 1).filter(|n| !n.is_empty()) {
names.insert(id, name);
}
}
Ok(SpellDispelTypes { names })
}
#[cfg(test)]
mod tests {
use super::*;
/// The whole shipped table, and the gate that is the point of it: ids 1–4 name themselves,
/// and Stealth/Invisibility/Frenzy — which DO carry strings in the file — do not. Skips
/// without client data.
#[test]
fn real_dispel_types_name_only_what_the_gate_allows() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let types = load_spell_dispel_types(&mut chain).expect("load SpellDispelType");
assert_eq!(types.name(1), Some("Magic"));
assert_eq!(types.name(2), Some("Curse"));
assert_eq!(types.name(3), Some("Disease"));
assert_eq!(types.name(4), Some("Poison"));
// Named in the file, withheld by the gate — the whole reason we read `[+0x28]`.
assert_eq!(types.name(5), None, "Stealth");
assert_eq!(types.name(6), None, "Invisibility");
assert_eq!(types.name(9), None, "Frenzy");
assert_eq!(types.name(0), None, "no dispel class");
assert_eq!(types.len(), 4, "exactly the four the gate allows");
}
}
+32 -20
View File
@@ -53,7 +53,8 @@ pub struct SpellDisplay {
pub open_lock_type: Option<u32>,
/// `Dispel` (column 4, `SpellRec+0x10`) — the `SpellDispelType.dbc` id. The reference client
/// reads exactly this field for `GetPlayerBuffDispelType` / `UnitDebuff`'s third return
/// (byte-verified, decision 0257). Read it through [`Self::debuff_type`].
/// (byte-verified, decision 0257). Name it through [`crate::SpellCatalog::dispel_name`] — the
/// id alone does not say whether the class is named (`SpellDispelType.dbc`'s `[+0x28]` gate).
pub dispel: u32,
/// `Category` (column 2, [`COL_CATEGORY`]) — the shared-cooldown category; `0` = none.
pub category: u32,
@@ -249,27 +250,12 @@ impl Default for SpellDisplay {
}
}
/// The `SpellDispelType.dbc` ids the debuff border tints by (vmangos `DispelType`,
/// `SpellDefines.h:679-692`). The reference's `DebuffTypeColor` table keys on exactly these four
/// names and colors everything else — including `0` (none) and the untinted 5–10 (stealth,
/// invisibility, enrage, …) — with its `"none"` entry.
const DISPEL_NAMES: [(u32, &str); 4] = [(1, "Magic"), (2, "Curse"), (3, "Disease"), (4, "Poison")];
// The dispel-class NAME (the aura tooltip's right column, and the `debuffType` the debuff border
// tints by) is not derivable from [`SpellDisplay::dispel`] alone: `SpellDispelType.dbc` carries
// both the string and the `[+0x28]` gate that decides whether a class is named at all. It lives on
// the catalog that loads that file — [`crate::SpellCatalog::dispel_name`].
impl SpellDisplay {
/// The dispel-class name a debuff border tints by (`"Magic"`/`"Curse"`/`"Disease"`/`"Poison"`),
/// or `None` for an undispellable class — the `debuffType` return of `UnitAura`/`UnitDebuff`,
/// whose `nil` the reference maps to `DebuffTypeColor["none"]`.
///
/// The real client resolves the name through `SpellDispelType.dbc`; we map the four ids the UI
/// actually distinguishes, which are stable and English in that table's enUS column. Nothing
/// reads the other ids.
pub fn debuff_type(&self) -> Option<&'static str> {
DISPEL_NAMES
.iter()
.find(|(id, _)| *id == self.dispel)
.map(|(_, name)| *name)
}
/// The client's ranged-stance gate, verbatim: `AttributesEx2 & 0x20` (auto-repeat: Auto Shot,
/// wand Shoot) **or** `Attributes & 0x2` (uses the ranged slot: every Shoot variant, Throw).
/// Byte-verified in wow-re (`SpellRec+0x20&0x20 || +0x18&0x2` — the test every ranged trigger
@@ -352,6 +338,32 @@ impl SpellDisplay {
self.effect_1 == SPELL_EFFECT_ATTACK
}
/// The spell **tooltip's** passive gate (wow-re `tooltip-content-law.md` §3.4): the
/// CastTime|Cooldown line is skipped whole for `Attributes & 0x40` **or**
/// `Effect[0] ∈ {0x2f, 0x4e}` — `SPELL_EFFECT_TRADE_SKILL` (the profession book entries) and
/// `SPELL_EFFECT_ATTACK` (6603 "Attack"). Wider than [`Self::passive`], which is the
/// spellbook's own gray-and-refuse gate and must keep reading the attribute alone.
///
/// The law's note writes the field as "iconID `[+0xf4]`"; the offset is Effect[0]
/// (`0xf4/4 == 61` — the same `[SpellRec+0xf4]` the byte-verified auto-attack resolvers
/// compare against `0x4e`). Confirmed on the shipped data: 6603 "Attack" carries
/// `Effect[0] = 78` with `Attributes = 0x10`, and the reference's spellbook hover shows it
/// with NO cast-time line.
pub fn tooltip_omits_cast_line(&self) -> bool {
self.passive
|| matches!(
self.effect_1,
SPELL_EFFECT_TRADE_SKILL | SPELL_EFFECT_ATTACK
)
}
// The equipped-item requirement's NAME used to be decided here, by a
// `single_equipped_subclass` that answered only for a one-bit mask — a placeholder standing in
// for the uncarved `0x6e2380`. `0x6e2380` is carved now, and it reads only ItemSubClass.dbc,
// behind an ItemSubClassMask.dbc group lookup that names a wide mask in one word. The whole
// rule (both spellings, both call sites) lives with the vocabulary it reads:
// [`crate::ItemSubClassCatalog::requirement_name`].
/// The **ranged** icon-substitution gate — `Attributes & 0x2` AND `AttributesEx2 & 0x20`,
/// both bits (decision 0231's deferred ranged case; wow-re `attack-icon-substitution.md` §5,
/// byte-verified: the paired tests at `0x4b3f99`/`0x4b3f9f` and `0x4e5a2e`/`0x4e5a34` gate the
+15 -1
View File
@@ -112,6 +112,7 @@
//! simulator (we render and speak the protocol; the server simulates).
mod cast_times;
mod dispel_types;
mod display;
mod duration;
mod forms;
@@ -120,6 +121,7 @@ mod ranges;
mod tokens;
pub use cast_times::{load_spell_cast_times, SpellCastTime, SpellCastTimeCatalog};
pub use dispel_types::{load_spell_dispel_types, SpellDispelTypes};
pub use display::SpellDisplay;
pub use duration::{load_spell_durations, SpellDuration, SpellDurationCatalog};
pub use forms::{load_shapeshift_forms, ShapeshiftForm};
@@ -430,15 +432,18 @@ pub const SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY: u32 = 54;
pub struct SpellCatalog {
spells: HashMap<u32, SpellDisplay>,
learned_spell: HashMap<u32, u32>,
dispel_types: SpellDispelTypes,
}
impl SpellCatalog {
/// Build a catalog from an explicit display map — for tests and synthetic fixtures. The live
/// path is [`load_spell_catalog`]. Carries no learn-spell map (synthetic ids teach nothing).
/// path is [`load_spell_catalog`]. Carries no learn-spell map (synthetic ids teach nothing) and
/// no dispel table, so [`Self::dispel_name`] answers `None` for everything.
pub fn from_displays(spells: HashMap<u32, SpellDisplay>) -> Self {
Self {
spells,
learned_spell: HashMap::new(),
dispel_types: SpellDispelTypes::default(),
}
}
@@ -446,6 +451,13 @@ impl SpellCatalog {
self.spells.get(&id)
}
/// The name of a spell's dispel class — the aura tooltip's right column and the `debuffType`
/// the debuff border tints by. `None` when the spell has no class or the class is one
/// `SpellDispelType.dbc`'s `[+0x28]` gate withholds (see [`dispel_types`]).
pub fn dispel_name(&self, display: &SpellDisplay) -> Option<&str> {
self.dispel_types.name(display.dispel)
}
/// Every loaded spell `(id, display)`, unordered — the corpus instruments' walk
/// (`benilla-extract partcensus` attributes effect models back to the spells that play them).
pub fn iter(&self) -> impl Iterator<Item = (u32, &SpellDisplay)> + '_ {
@@ -502,6 +514,7 @@ fn spell_schema() -> Schema {
/// Load the joined spell display catalog off the patch chain.
pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
let icons = crate::dbc::load_spell_icon_map(chain)?;
let dispel_types = load_spell_dispel_types(chain)?;
let spell_bytes = chain.read_file(SPELL).context("reading Spell.dbc")?;
let spells_set = parse(&spell_bytes, spell_schema(), "Spell.dbc")?;
@@ -634,6 +647,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
Ok(SpellCatalog {
spells,
learned_spell,
dispel_types,
})
}
+7
View File
@@ -20,6 +20,11 @@ pub struct SkinSection {
/// One skin draw batch (section + material + texture-combo references).
pub struct SkinBatch {
/// Batch flags (texUnit `+0x00`) — kept raw, unclassified. Read by the diagnostic dumps while
/// we carve which bits the reference's batch path actually reads; the renderer ignores them.
pub flags: u16,
/// Shader / render-order selector (texUnit `+0x02`), same status as [`Self::flags`].
pub shader_id: u16,
pub skin_section_index: u16,
pub texture_combo_index: u16,
pub material_index: u16,
@@ -118,6 +123,8 @@ impl M2Model {
for i in 0..n_bat {
let u = bytes.bytes_at(o_bat + i * 24, 24).ok_or(Error::Truncated)?;
batches.push(SkinBatch {
flags: u.u16_at(0).ok_or(Error::Truncated)?,
shader_id: u.u16_at(2).ok_or(Error::Truncated)?,
skin_section_index: u.u16_at(4).ok_or(Error::Truncated)?,
material_index: u.u16_at(0x0a).ok_or(Error::Truncated)?,
texture_combo_index: u.u16_at(0x10).ok_or(Error::Truncated)?,
@@ -23,7 +23,7 @@ use clap::Parser;
use probes::{
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, Loot, Probe, QueryNames, Quest,
QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot, Vendor,
QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot, Vendor, WorldState,
};
use world::{DeathArc, Tracked, World};
@@ -167,6 +167,15 @@ struct Cli {
#[arg(long)]
giverstatus: bool,
/// Live-verify the world-state table's two wires (`SMSG_INIT_WORLD_STATES` /
/// `SMSG_UPDATE_WORLD_STATE` — what the NPC-text `$<n>w`/`$<n>e` tokens read): teleport to
/// Elwynn, hop to Stormwind to force a zone-change init, and `.debug send worldstate` one
/// synthetic pair to require back. Prints every state received. Needs a **SEC_DEVELOPER**
/// account: `.debug send worldstate` is gmlevel 5, one above the 3 the slot-keyed probe
/// accounts carry (decision 0450), so the update leg needs a temporary grant.
#[arg(long)]
worldstate: bool,
/// Live-verify the Charge wire (warrior Charge rank 1, spell 100): GM `.learn 100`, teleport to
/// open ground near the Northshire kobold camp ([`CHARGE_TP`]), pick a creature at charge range
/// (8–25 yd), `CMSG_SET_SELECTION` + `CMSG_CAST_SPELL` 100 at it — then require an
@@ -348,6 +357,9 @@ fn main() -> Result<()> {
if cli.spirit {
probes.push(Box::new(Spirit::default()));
}
if cli.worldstate {
probes.push(Box::new(WorldState::default()));
}
// Pre-stream staging: the DeathArc first (its `.revive` + teleport lead --spirit's `.repairitems`),
// then every probe's stage in registry order, then the `--say` lines (after all staging, as today).
@@ -75,6 +75,7 @@ mod spirit;
mod swap_pack_slots;
mod use_pack_slot;
mod vendor;
mod worldstate;
pub(crate) use attack::Attack;
pub(crate) use aura::Aura;
@@ -92,3 +93,4 @@ pub(crate) use spirit::Spirit;
pub(crate) use swap_pack_slots::SwapPackSlots;
pub(crate) use use_pack_slot::UsePackSlot;
pub(crate) use vendor::Vendor;
pub(crate) use worldstate::WorldState;
@@ -0,0 +1,150 @@
//! `--worldstate`: the world-state table's two wires, end to end against the live server.
//!
//! Both are worth a live run for different reasons. `SMSG_UPDATE_WORLD_STATE` is *deterministic* —
//! `.debug send worldstate <id> <value>` makes the server send one pair we chose, so the probe can
//! require that exact pair back and a wrong reader shows up as a mismatch rather than a plausible
//! number. `SMSG_INIT_WORLD_STATES` is the one with real parse risk (two leading dwords, a `u16`
//! count, a run of pairs, a `(0,0)` terminator that *is* counted) and it can't be requested — so
//! the probe forces one by teleporting across a zone boundary, which is what makes vmangos re-send
//! it (`Player::UpdateZone` → `SendInitWorldStates`, `Player.cpp:6660`).
//!
//! **The update leg needs a temporary grant.** `.debug send worldstate` sits in
//! `debugSendCommandTable` at **SEC_DEVELOPER = 5** (vmangos `Chat.cpp:323`) — one above the
//! gmlevel 3 the slot-keyed probe accounts carry (decision 0450). Without it the server answers
//! *"This command is not available to you."* in chat and sends no packet, and the probe fails on
//! the update leg while the init leg still passes.
//!
//! The effective level lives in **`realmd.account_access`**, not `realmd.account.gmlevel` —
//! mangosd reads the former (rows per `RealmID`, plus a `-1` catch-all) and ignores the latter for
//! command gating. Grant, run, revert, with the account offline:
//!
//! ```text
//! UPDATE realmd.account_access SET gmlevel=5 WHERE id=<account id>; -- both rows
//! cargo run -p benilla-protocol --bin benilla-world -- probeN pprobeN --worldstate --seconds 16
//! UPDATE realmd.account_access SET gmlevel=3 WHERE id=<account id>; -- put it straight back
//! ```
use std::time::{Duration, Instant};
use anyhow::{bail, Result};
use benilla_protocol::SessionEvent;
use crate::probes::{Ctx, Probe};
/// Northshire (zone 9, Elwynn Forest) — the same spot the quest probes stage on.
const ELWYNN_TP: &str = ".go xyz -8902.59 -162.606 82.0223";
/// Stormwind's Trade District (zone 1519) — a different zone on the same map, so the hop from
/// [`ELWYNN_TP`] is a genuine `UpdateZone` and forces a fresh `SMSG_INIT_WORLD_STATES`.
const STORMWIND_TP: &str = ".go xyz -8913.0 554.0 93.8";
/// How long to sit in Elwynn before hopping, so the first zone's init has landed and the second is
/// unambiguously a *second* packet.
const HOP_AFTER: Duration = Duration::from_secs(4);
/// A synthetic `(id, value)` no real content uses, so a match proves our reader and not a
/// coincidence with a live Silithus/AQ counter.
const PROBE_ID: u32 = 0xBEEF;
const PROBE_VALUE: u32 = 1_234_567;
/// One received `SMSG_INIT_WORLD_STATES`: the packet's `(map, zone)` scope and its pair run
/// verbatim — terminator included, which [`WorldState::verify`] asserts on.
struct Init {
map: u32,
zone: u32,
states: Vec<(u32, u32)>,
}
#[derive(Default)]
pub(crate) struct WorldState {
staged_at: Option<Instant>,
hopped: bool,
inits: Vec<Init>,
/// Every `SMSG_UPDATE_WORLD_STATE` pair.
updates: Vec<(u32, u32)>,
}
impl Probe for WorldState {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
cx.session.send_chat(ELWYNN_TP)?;
self.staged_at = Some(Instant::now());
println!("worldstate: teleported to Elwynn (zone 9) {ELWYNN_TP}");
Ok(())
}
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
if self.hopped || self.staged_at.is_none_or(|t| t.elapsed() < HOP_AFTER) {
return Ok(());
}
self.hopped = true;
// The zone hop (forces an init) and the one deterministic pair, back to back.
cx.session.send_chat(STORMWIND_TP)?;
cx.session
.send_chat(&format!(".debug send worldstate {PROBE_ID} {PROBE_VALUE}"))?;
println!(
"worldstate: hopped to Stormwind (zone 1519) and sent .debug send worldstate {PROBE_ID} {PROBE_VALUE}"
);
Ok(())
}
fn on_event(&mut self, ev: &SessionEvent, _cx: &mut Ctx) -> Result<()> {
if let SessionEvent::WorldStates { scope, states } = ev {
match *scope {
Some((map, zone)) => self.inits.push(Init {
map,
zone,
states: states.clone(),
}),
None => self.updates.extend(states.iter().copied()),
}
}
Ok(())
}
fn verify(&mut self, _cx: &mut Ctx) -> Result<()> {
println!("\n--- world states ---");
for init in &self.inits {
println!(
"SMSG_INIT_WORLD_STATES map {} zone {} {} pair(s)",
init.map,
init.zone,
init.states.len()
);
for (id, value) in &init.states {
println!(" {id:>6} = {:<12} (raw {value:#010x})", *value as i32);
}
}
for (id, value) in &self.updates {
println!(
"SMSG_UPDATE_WORLD_STATE {id} = {} (raw {value:#010x})",
*value as i32
);
}
if self.inits.is_empty() {
bail!("no SMSG_INIT_WORLD_STATES arrived — the zone hop should have forced one");
}
// The terminator is counted by the server and read by us: a well-formed init ends on (0,0).
// Assert it rather than assume, since dropping it would be the natural "helpful" bug.
for init in &self.inits {
if init.states.last() != Some(&(0, 0)) {
bail!(
"init for map {} zone {} did not end on the (0,0) terminator: {:?}",
init.map,
init.zone,
init.states.last()
);
}
}
if !self.updates.contains(&(PROBE_ID, PROBE_VALUE)) {
bail!(
"no SMSG_UPDATE_WORLD_STATE carrying our ({PROBE_ID}, {PROBE_VALUE}) — got {:?}. \
`.debug send worldstate` is gmlevel 5 (SEC_DEVELOPER); a lower account is silently refused",
self.updates
);
}
println!(
"\nworldstate: OK — {} init packet(s), and the ({PROBE_ID}, {PROBE_VALUE}) round trip landed",
self.inits.len()
);
Ok(())
}
}
+40 -10
View File
@@ -194,10 +194,11 @@ pub enum SessionEvent {
/// is set (`0.0` otherwise): the wire tail exists so observers can integrate a swimmer's
/// vertical between packets (the client's swim velocity basis adds pitch, `0x7c5880`).
pitch: f32,
/// The `MovementInfo` time word — vmangos's own ms clock stamped at receipt (`stime`), one
/// coherent server clock across all movers. The reference schedules a remote's apply from
/// this (fire-time = mapped time + clamped skew, wow-re `remote-apply-timing.md`); the app
/// mirrors that in `RelayClock` (decision 0601).
/// The `MovementInfo` time word — vmangos's own ms clock stamped at receipt (`stime`
/// = `WorldTimer::getMSTime()` in `MovementInfo::Read`, relayed verbatim by `Write`), one
/// coherent server clock across all movers. The reference schedules a remote's apply off the
/// *deltas* between consecutive stamps — replay paced by the sender's own cadence, wow-re
/// `remote-apply-timing.md`; the app mirrors that per unit (decisions 0601/0615).
time: u32,
/// True for `MSG_MOVE_HEARTBEAT` — the periodic mid-move pulse. The reference's reconcile
/// lerp is armed only for NON-heartbeat events (`0x619090` excludes tag `0x26`); a
@@ -310,6 +311,13 @@ pub enum SessionEvent {
gender: u32,
class: u32,
},
/// A pet's display name (`SMSG_PET_NAME_QUERY_RESPONSE`), keyed by **pet number** — the third
/// naming path, alongside [`Self::PlayerName`] and [`Self::CreatureName`], and the only one a
/// pet can use: its guid carries a pet number where a creature carries its template entry
/// ([`crate::guid::pet_number`]), so a creature query for it can only miss. The name is the
/// pet's own — a hunter pet's custom name, or the creature name for anything summoned. There is
/// no miss shape: the server stays silent when the pet is gone or the number disagrees.
PetName { pet_number: u32, name: String },
/// A creature template's display name (`SMSG_CREATURE_QUERY_RESPONSE`). Keyed by template
/// `entry` (shared by every spawn of that template), not by spawn guid. `name` is `None` when
/// the server flagged the entry unknown; `subname` is the tooltip line ("Stable Master", …).
@@ -606,10 +614,15 @@ pub enum SessionEvent {
QuestGiverRefused { quest_id: u32, reason: u32 },
/// The gossip window closes (`SMSG_GOSSIP_COMPLETE`) — no menu is open server-side any more.
GossipComplete,
/// The greeting text for a gossip menu (`SMSG_NPC_TEXT_UPDATE`, answering our
/// `CMSG_NPC_TEXT_QUERY`): the highest-probability text block, already resolved male-with-
/// female-fallback. `$N` (the player name) is a client-substituted token still in the string.
NpcGreeting { text_id: u32, greeting: String },
/// The greeting record for a gossip menu (`SMSG_NPC_TEXT_UPDATE`, answering our
/// `CMSG_NPC_TEXT_QUERY`) — all 8 blocks, **undrawn**: which line greets you depends on the
/// NPC's gender and a die roll, so the app draws it when the frame opens
/// (`messages::gossip::select_greeting`). `$N` and friends are client-substituted tokens still
/// in the strings.
NpcGreeting {
text_id: u32,
blocks: Vec<crate::messages::NpcTextBlock>,
},
/// A vendor's stock (`SMSG_LIST_INVENTORY`, answering our `CMSG_LIST_INVENTORY`): entry, icon,
/// price, and remaining stock per row (`current_count == 0xFFFF_FFFF` = unlimited).
VendorInventory { vendor: u64, items: Vec<VendorItem> },
@@ -887,12 +900,29 @@ pub enum SessionEvent {
/// partner's, per [`TradeStatusExtended::their_window`]); pushed whenever that side changes.
/// Boxed (~460 bytes) so it doesn't bloat every `SessionEvent`.
TradeStatusExtended { state: Box<TradeStatusExtended> },
/// The world-state table changed. `SMSG_INIT_WORLD_STATES` carries the whole table for a zone
/// (`states` is the wire run verbatim, terminator pair included); `SMSG_UPDATE_WORLD_STATE`
/// arrives as a one-entry `states` with `scope: None`, so both wires land on one event and the
/// app has a single write path — the reference likewise funnels both into the one setter.
/// `scope` is the init packet's `(map, zone)` dwords; nothing consumes them yet.
WorldStates {
scope: Option<(u32, u32)>,
states: Vec<(u32, u32)>,
},
}
/// The result of polling the reader for the next packet's events.
pub enum Poll {
/// A packet decoded into these events (possibly empty for an unmodelled opcode).
Events(Vec<SessionEvent>),
/// A packet decoded into these events. `events` is **empty** for an opcode we parse but do not
/// model — a third outcome, distinct from both "decoded into something" and [`Poll::Skipped`],
/// and until decision 0624 it was invisible from outside: no event, no skip, and the inbound
/// census counted it like any other packet. A relayed move landing in an unmodelled opcode was
/// therefore indistinguishable from one that never arrived. `opcode` rides along so the net
/// thread can name what actually came off the wire.
Events {
opcode: u16,
events: Vec<SessionEvent>,
},
/// An unparseable packet was skipped — kept the stream aligned, not an error. Carries the
/// opcode (for the app's dropped-packet tally) and a short description (opcode + error + a hex
/// preview) so the net thread can log *which* packet was dropped.
+17 -2
View File
@@ -16,6 +16,10 @@ fn v3(v: Vector3d) -> [f32; 3] {
pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
match packet {
ServerPacket::UpdateObject { objects } => decode_objects(objects),
// A batch is only a transport: unwrap it in order and decode each move exactly as if it had
// arrived on its own (decision 0624). Order matters — the replay queue in
// `net::motion` reconstructs a mover's arc from packet order.
ServerPacket::CompressedMoves { packets } => packets.into_iter().flat_map(decode).collect(),
ServerPacket::CharEnum { characters } => vec![SessionEvent::CharacterList {
characters,
realm: None,
@@ -251,8 +255,8 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::QuestGiverRefused { quest_id, reason }]
}
ServerPacket::GossipComplete => vec![SessionEvent::GossipComplete],
ServerPacket::NpcText { text_id, greeting } => {
vec![SessionEvent::NpcGreeting { text_id, greeting }]
ServerPacket::NpcText { text_id, blocks } => {
vec![SessionEvent::NpcGreeting { text_id, blocks }]
}
ServerPacket::VendorList { vendor, items } => {
vec![SessionEvent::VendorInventory { vendor, items }]
@@ -536,6 +540,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
gender,
class,
}],
ServerPacket::PetNameQueryResponse { pet_number, name } => {
vec![SessionEvent::PetName { pet_number, name }]
}
ServerPacket::CreatureQueryResponse { entry, info } => {
let (name, subname, creature_type, rank, type_flags, civilian, racial_leader) =
match info {
@@ -687,6 +694,14 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::TradeStatusExtended { state } => {
vec![SessionEvent::TradeStatusExtended { state }]
}
ServerPacket::InitWorldStates(w) => vec![SessionEvent::WorldStates {
scope: Some((w.map, w.zone)),
states: w.states,
}],
ServerPacket::UpdateWorldState { id, value } => vec![SessionEvent::WorldStates {
scope: None,
states: vec![(id, value)],
}],
// An opcode with NO parse arm at all — surface it so the app can tally the coverage gap
// (the debug panel's dropped-opcode instrument); the silent fall-through hid whole wire
// families. Parsed-but-unmodelled packets (the `_` below) stay deliberate no-ops.
+42 -8
View File
@@ -34,11 +34,31 @@ pub fn is_player(guid: u64) -> bool {
guid != 0 && high(guid) == HIGH_PLAYER
}
/// A creature or pet guid (`IsCreatureOrPet`) — the family whose template entry rides in the guid.
/// A creature or pet guid (`IsCreatureOrPet`) — both are `TYPEID_UNIT` to every consumer. They are
/// **not** interchangeable for naming: only the creature half carries a template entry (see
/// [`entry`] and [`pet_number`]).
pub fn is_creature_or_pet(guid: u64) -> bool {
matches!(high(guid), HIGH_UNIT | HIGH_PET)
}
/// A pet guid (`IsPet`) — a summoned or charmed unit, whose guid's middle field is a **pet number**
/// and not a creature template entry. See [`pet_number`].
pub fn is_pet(guid: u64) -> bool {
high(guid) == HIGH_PET
}
/// A pet guid's **pet number** — the per-pet id `CMSG_PET_NAME_QUERY` is keyed by, and the value the
/// server matches against `CharmInfo::GetPetNumber()` before it will answer at all.
///
/// It occupies the same guid bits 24–47 that a creature's template entry does, because vmangos
/// composes a pet with `Object::_Create(guidlow, petNumber, HIGHGUID_PET)` (`Objects/Pet.cpp:2250`,
/// from `Pet::Create(guidlow, pos, cinfo, petNumber)`): the shared `_Create`'s `entry` parameter is
/// fed the pet number, **not** `cinfo->entry`. That is why [`entry`] answers `None` for this family —
/// the slot is occupied, just not by a template id, and a `CMSG_CREATURE_QUERY` for it can only miss.
pub fn pet_number(guid: u64) -> Option<u32> {
is_pet(guid).then_some(((guid >> 24) & 0xFF_FFFF) as u32)
}
/// A (non-zero) item or container guid (`IsItem` — containers included). Items carry **no** entry
/// in the guid; the entry lives in the descriptor (`OBJECT_FIELD_ENTRY`).
pub fn is_item(guid: u64) -> bool {
@@ -55,9 +75,9 @@ pub fn is_transport(guid: u64) -> bool {
/// The embedded template entry, for the guid families that carry one. Two different layouts, both
/// VERIFIED against vmangos:
///
/// - Creatures/pets/GameObjects/**elevators** (`HIGH_UNIT`/`HIGH_PET`/`HIGH_GAMEOBJECT`/
/// - Creatures/GameObjects/**elevators** (`HIGH_UNIT`/`HIGH_GAMEOBJECT`/
/// `HIGH_TRANSPORT`) use the standard `[high:16][entry:24][counter:24]` layout — vmangos's
/// class-internal `HasEntry` switch returns `true` for all four (`ObjectGuid.h:223-240`), and an
/// class-internal `HasEntry` switch returns `true` for these (`ObjectGuid.h:223-240`), and an
/// elevator composes exactly like an ordinary GameObject: `GameObject::Create` calls
/// `Object::_Create(guidlow, goinfo->id, HIGHGUID_TRANSPORT)` for a `GAMEOBJECT_TYPE_TRANSPORT`
/// template (`Objects/GameObject.cpp:207`, reached via `ElevatorTransport::Create` →
@@ -77,13 +97,13 @@ pub fn is_transport(guid: u64) -> bool {
/// id — none exist today, but the 32-bit mask is what vmangos actually does, so that's what we do.
///
/// `None` for players/items/corpses/dynamic objects (no entry part, and not composed like a transport
/// either).
/// either) — and `None` for **pets**, whose entry-shaped slot holds a pet number instead
/// ([`pet_number`]): `HasEntry` is `true` for the family, but what vmangos stores there is not a
/// template id, so answering with it would hand every caller a creature entry that cannot resolve.
pub fn entry(guid: u64) -> Option<u32> {
match high(guid) {
HIGH_MO_TRANSPORT => Some((guid & 0xFFFF_FFFF) as u32),
HIGH_UNIT | HIGH_PET | HIGH_GAMEOBJECT | HIGH_TRANSPORT => {
Some(((guid >> 24) & 0xFF_FFFF) as u32)
}
HIGH_UNIT | HIGH_GAMEOBJECT | HIGH_TRANSPORT => Some(((guid >> 24) & 0xFF_FFFF) as u32),
_ => None,
}
}
@@ -137,10 +157,24 @@ mod tests {
#[test]
fn entry_masks_to_24_bits() {
let g = compose(HIGH_PET, 0xFF_FFFF, 0xFF_FFFF);
let g = compose(HIGH_UNIT, 0xFF_FFFF, 0xFF_FFFF);
assert_eq!(entry(g), Some(0xFF_FFFF));
}
/// A pet's entry-shaped slot is its PET NUMBER — `Pet::Create` feeds `_Create`'s `entry`
/// parameter `petNumber`, not `cinfo->entry` (`Objects/Pet.cpp:2250`). Asking
/// `CMSG_CREATURE_QUERY` for it is what left NPC-summoned pets nameless, so `entry` must refuse.
#[test]
fn a_pet_carries_a_pet_number_not_a_template_entry() {
let g = compose(HIGH_PET, 137, 4242);
assert!(is_pet(g));
assert!(is_creature_or_pet(g));
assert_eq!(pet_number(g), Some(137));
assert_eq!(entry(g), None, "a pet's slot is not a template entry");
// ...and only a pet answers `pet_number`.
assert_eq!(pet_number(compose(HIGH_UNIT, 137, 4242)), None);
}
/// An elevator (`HIGHGUID_TRANSPORT`, 0xF120) composes exactly like an ordinary GameObject —
/// `Object::_Create(guidlow, goinfo->id, HIGHGUID_TRANSPORT)` (`GameObject.cpp:207`): the DB spawn's
/// own low guid in `counter`, the `gameobject_template` entry in the standard entry slot.
@@ -265,6 +265,17 @@ pub fn creature_query(entry: u32, guid: u64) -> Vec<u8> {
body
}
/// Body of a `CMSG_PET_NAME_QUERY`: the pet's **number** then its full guid (VERIFIED vmangos
/// `QueryPetName::ReadFromWorldPacket`, `Server/Packets/Pet.cpp:3-7`). Both values come out of the
/// pet's own guid ([`crate::guid::pet_number`]); the server answers only when the number agrees with
/// the live pet's `CharmInfo` (`PetHandler.cpp:190-192`), and stays silent otherwise.
pub fn pet_name_query(pet_number: u32, guid: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&pet_number.to_le_bytes());
body.extend_from_slice(&guid.to_le_bytes());
body
}
/// Body of a movement message (`MSG_MOVE_*`) — a `MovementInfo`.
pub fn movement(info: &MovementInfo) -> Vec<u8> {
let mut body = Vec::with_capacity(28);
+90 -18
View File
@@ -98,33 +98,105 @@ pub(super) fn read_gossip_message(
Ok((npc_guid, text_id, options, quests))
}
/// One of the 8 greeting variants in an `SMSG_NPC_TEXT_UPDATE` record. Both gender columns are kept:
/// which one is read is decided **per NPC**, not per block — see [`select_greeting`].
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NpcTextBlock {
/// The block's draw weight. Not a rank — the reference sums these and draws (see
/// [`select_greeting`]); vmangos's own fallback record sets all eight to `0.0`.
pub probability: f32,
/// `text0` — shown when the NPC is male, or genderless.
pub male: String,
/// `text1` — shown when the NPC is female.
pub female: String,
}
impl NpcTextBlock {
/// This block's text in the gender column [`select_greeting`] chose for the NPC.
fn column(&self, female: bool) -> &str {
if female {
&self.female
} else {
&self.male
}
}
}
/// The number of greeting blocks in an `SMSG_NPC_TEXT_UPDATE` record — always exactly 8.
pub const NPC_TEXT_BLOCKS: usize = 8;
/// Read `SMSG_NPC_TEXT_UPDATE` (vmangos `GossipDef.cpp:298-369`): `u32 textID` then always exactly 8
/// blocks of `{f32 probability, cstr text0 (male), cstr text1 (female), u32 languageId, 3x(u32
/// emoteDelay, u32 emoteId)}`. The client only needs one line for the greeting: the highest-
/// probability block's `text0`, falling back to `text1` when `text0` is empty (vmangos's own default
/// row, when a text is missing entirely, is literally `"Greetings $N"`). Returns
/// `(text_id, greeting)`; the emote/language tails and the losing blocks are parsed for alignment
/// and dropped.
pub(super) fn read_npc_text_update(r: &mut &[u8]) -> io::Result<(u32, String)> {
/// emoteDelay, u32 emoteId)}`. Returns `(text_id, blocks)` — **all** of them, undecided.
///
/// This layer used to pick the greeting here (highest probability, with a per-block fall back to the
/// other gender column). Both halves of that were wrong, and neither could be fixed here: the real
/// choice needs the NPC and a die roll, which the wire layer has neither of. It belongs at the
/// moment the frame opens, where the reference does it — [`select_greeting`], called from the app.
///
/// The language and emote tails are still parsed for alignment and dropped. Language is the input to
/// the reference's garble pass (`0x49b560`), which is inert at `lang == 0`; we do not garble at all,
/// so a non-zero language would read plainly here. No such record is known in 1.12 data.
pub(super) fn read_npc_text_update(r: &mut &[u8]) -> io::Result<(u32, Vec<NpcTextBlock>)> {
let text_id = read_u32_le(r)?;
let mut best: Option<(f32, String)> = None;
for _ in 0..8 {
let mut blocks = Vec::with_capacity(NPC_TEXT_BLOCKS);
for _ in 0..NPC_TEXT_BLOCKS {
let probability = read_f32_le(r)?;
let text0 = read_cstring(r)?;
let text1 = read_cstring(r)?;
let male = read_cstring(r)?;
let female = read_cstring(r)?;
let _language_id = read_u32_le(r)?;
for _ in 0..3 {
let _emote_delay = read_u32_le(r)?;
let _emote_id = read_u32_le(r)?;
}
let text = if text0.is_empty() { text1 } else { text0 };
let is_new_best = match &best {
Some((best_probability, _)) => probability > *best_probability,
None => true,
};
if is_new_best {
best = Some((probability, text));
blocks.push(NpcTextBlock {
probability,
male,
female,
});
}
Ok((text_id, blocks))
}
/// Pick the greeting line the way the reference does (`0x4e2010`, wow-re
/// `system/ui/scratch/gossip-npctext-law.md`) — a **weighted random draw**, not the highest weight:
///
/// ```text
/// sum = Σ probability over blocks whose CHOSEN-COLUMN text is non-empty
/// thr = (2.0 - roll) * sum ∈ (0, sum] for roll ∈ [1.0, 2.0)
/// walk the same blocks in order, acc += probability, take the first where thr <= acc
/// ```
///
/// Three details are load-bearing, and each is the opposite of what we assumed:
///
/// - **The column is chosen once, from the NPC's own gender** (`UNIT_FIELD_BYTES_0` byte 2, tested
/// `== 1` for female — so genderless `2` reads as male), before any block is examined. It is never
/// chosen per block, never from the emptiness of a string, and never from the *player*, whom the
/// reference resolves only as an existence check and never dereferences for data.
/// - **There is no fallback to the other column.** A block whose chosen-column text is empty is
/// excluded from the draw outright; if that empties the record, the reference takes its error path
/// and shows no greeting at all, rather than reading the other gender's line.
/// - **The predicate is `<=`, not `<`** (the emitted jcc is `jne` on `test ah,0x41`, so the equal
/// case selects). That is what makes an all-`0.0` record — vmangos's fallback, and much of the
/// 1.12 table — deterministically select block 0 instead of nothing.
///
/// `roll` is the reference's uniform float in `[1.0, 2.0)` (it builds one by stuffing PRNG bits into
/// a mantissa: `(rand & 0x7fffff) | 0x3f800000`); passing it in keeps this pure and lets tests pin
/// each branch. Accumulation is `f64` deliberately: the reference's accumulators live on the x87
/// stack at 53-bit precision and round to `f32` exactly once, at the end.
///
/// `None` = the record names no greeting for this NPC (the reference's `"Missing gossip text!"`).
pub fn select_greeting(blocks: &[NpcTextBlock], npc_gender: u8, roll: f32) -> Option<&str> {
let female = npc_gender == 1;
let drawn = || blocks.iter().filter(|b| !b.column(female).is_empty());
let sum: f64 = drawn().map(|b| b.probability as f64).sum();
let threshold = (2.0 - roll as f64) * sum;
let mut acc = 0.0f64;
for block in drawn() {
acc += block.probability as f64;
if threshold <= acc {
return Some(block.column(female));
}
}
Ok((text_id, best.map(|(_, text)| text).unwrap_or_default()))
None
}
+8 -2
View File
@@ -41,6 +41,7 @@ mod trade;
mod trainer;
mod update_object;
mod vendor;
mod world_state;
pub use bank::{
autobank_item, autostore_bank_item, bank_slot_result, banker_activate, buy_bank_slot,
};
@@ -59,14 +60,18 @@ pub use client::{
channel_set_owner, channel_unban, channel_unmoderator, channel_unmute, char_create,
creature_query, force_speed_ack, full_guid, join_channel, leave_channel, messagechat,
messagechat_channel, messagechat_kind, messagechat_whisper, move_flag_ack, move_spline_done,
movement, ping, played_time, random_roll, stand_state_change, teleport_ack, text_emote,
movement, pet_name_query, ping, played_time, random_roll, stand_state_change, teleport_ack,
text_emote,
};
pub use death::{
reclaim_corpse, resurrect_response, spirit_healer_activate, CorpseLocation,
ResurrectRequestBody,
};
pub use gameobject::{gameobj_use, gameobject_query, GameObjectQueryInfo};
pub use gossip::{gossip_hello, gossip_select_option, npc_text_query, GossipOption, QuestOption};
pub use gossip::{
gossip_hello, gossip_select_option, npc_text_query, select_greeting, GossipOption,
NpcTextBlock, QuestOption, NPC_TEXT_BLOCKS,
};
pub use group::{
group_accept, group_assistant_leader, group_change_sub_group, group_decline, group_disband,
group_invite, group_raid_convert, group_set_leader, group_swap_sub_group, group_uninvite,
@@ -135,6 +140,7 @@ pub use vendor::{
buy_item, buy_result, buyback_item, list_inventory, repair_item, sell_item, sell_result,
VendorItem,
};
pub use world_state::InitWorldStates;
/// `SMSG_AUTH_RESPONSE` AuthOk result.
pub const AUTH_OK: u8 = 0x0C;
@@ -11,6 +11,12 @@ pub const CMSG_CHAR_DELETE: u16 = 0x0038;
pub const SMSG_CHAR_DELETE: u16 = 0x003C;
pub const SMSG_NAME_QUERY_RESPONSE: u16 = 0x0051; // 81
pub const SMSG_CREATURE_QUERY_RESPONSE: u16 = 0x0061; // 97
/// VERIFIED vmangos `Opcodes_1_12_1.h:86`: 83. Answers `CMSG_PET_NAME_QUERY` — the only query that
/// can name a pet, whose guid carries no template entry ([`crate::guid::pet_number`]). Body: `u32
/// petNumber`, cstring name, `u32 nameTimestamp` (`Server/Packets/Pet.cpp:79-84`). The server simply
/// does not reply when the guid is not a live pet bearing that number (`PetHandler.cpp:190-192`), so
/// there is no "unknown" answer shape to model.
pub const SMSG_PET_NAME_QUERY_RESPONSE: u16 = 0x0053; // 83
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 95 (decision 0236). Answers `CMSG_GAMEOBJECT_QUERY`; body
/// (`GameObjectQueryInfo`) in [`super::gameobject`].
pub const SMSG_GAMEOBJECT_QUERY_RESPONSE: u16 = 0x005F; // 95
@@ -49,6 +55,12 @@ pub const SMSG_AUTH_RESPONSE: u16 = 0x01EE;
/// a server at the handshake rather than entering a 30-second kick/reconnect cycle.
pub const SMSG_WARDEN_DATA: u16 = 0x02E6;
pub const SMSG_COMPRESSED_UPDATE_OBJECT: u16 = 0x01F6;
/// A zlib envelope holding a **batch of whole movement packets** (763, VERIFIED vmangos
/// `Opcodes_1_12_1.h`). Not an edge case: vmangos moves a session onto this carrier the moment it
/// has pushed 300 movement packets to it inside ten seconds (`Compression.Movement.Count`), which
/// one nearby player moving at frame cadence reaches in about five. See
/// [`super::ServerPacket::CompressedMoves`].
pub const SMSG_COMPRESSED_MOVES: u16 = 0x02FB;
pub const SMSG_LOGIN_VERIFY_WORLD: u16 = 0x0236;
// The server-pushed sound trio (1.12.1 values VERIFIED vmangos `Opcodes_1_12_1.h`:
// 631/632/722).
@@ -187,6 +199,9 @@ pub const CMSG_PLAYER_LOGIN: u16 = 0x003D;
pub const CMSG_LOGOUT_REQUEST: u16 = 0x004B;
pub const CMSG_NAME_QUERY: u16 = 0x0050; // 80
pub const CMSG_CREATURE_QUERY: u16 = 0x0060; // 96
/// VERIFIED vmangos `Opcodes_1_12_1.h:85`: 82. Body in [`super::client::pet_name_query`]. Answered
/// by [`SMSG_PET_NAME_QUERY_RESPONSE`].
pub const CMSG_PET_NAME_QUERY: u16 = 0x0052; // 82
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 94 (decision 0236). Body in
/// [`super::gameobject::gameobject_query`] — the ask-once GO template lookup, identical shape to
/// `CMSG_CREATURE_QUERY`.
@@ -608,3 +623,11 @@ pub const MSG_QUERY_NEXT_MAIL_TIME: u16 = 0x0284; // 644
/// One `u32` (always 0) — sent when a mail *arrives* (instant for text-only, on the delivery
/// timer's expiry otherwise).
pub const SMSG_RECEIVED_MAIL: u16 = 0x0285; // 645
// The world-state table (VERIFIED both ways: vmangos `Opcodes_1_12_1.h` 706-707, and wow-re's read
// of the reference's own handler `0x48f690`, whose registration at `0x48f515-0x48f52f` selects these
// two arms as the ONLY writers of the table setter `0x4c5870`). Bodies in
// [`super::world_state`]. Backs the NPC-text `$<n>w`/`$<n>e` tokens today; the BG/PvP scoreboard
// later.
pub const SMSG_INIT_WORLD_STATES: u16 = 0x02C2; // 706
pub const SMSG_UPDATE_WORLD_STATE: u16 = 0x02C3; // 707
+42 -7
View File
@@ -7,9 +7,9 @@ use crate::wire::Vector3d;
use super::{
ActionButton, AttackerState, CastOutcome, ChannelNotify, Character, ChatMessage,
CorpseLocation, DamageShield, EnvironmentalDamageLog, GameObjectQueryInfo, GossipOption,
GroupLootInfo, GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, Object,
PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList,
GroupLootInfo, GroupMemberEntry, InitWorldStates, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
Object, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList,
QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody,
SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog,
SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell,
@@ -75,6 +75,19 @@ pub enum ServerPacket {
UpdateObject {
objects: Vec<Object>,
},
/// `SMSG_COMPRESSED_MOVES` — a **batch** of whole movement packets in one zlib envelope, each
/// already parsed into its own [`ServerPacket`] (decision 0624).
///
/// vmangos flips a session onto this container once it has been sent
/// `Compression.Movement.Count` (300) movement packets inside a ten-second window
/// (`WorldSession::SendMovementPacket`), and flips back when the rate drops — so it is not an
/// exotic case but the **normal** carrier for any nearby player moving at frame cadence, which
/// since decision 0617 is what our own sender produces. Body: `u32` uncompressed size, then a
/// deflate stream of `[u8 size][u16 opcode][body]` records where `size` counts the opcode's two
/// bytes (`MovementData::AddPacket`).
CompressedMoves {
packets: Vec<ServerPacket>,
},
/// `SMSG_DESTROY_OBJECT` — one object ceased to exist server-side (a corpse decaying ahead of
/// respawn, a despawn), body a plain `u64` guid (vmangos `DestroyObject::AppendBodyTo`). Distinct
/// from the update-object `OutOfRange` block (*we* left its range; the real client keeps those
@@ -222,6 +235,15 @@ pub enum ServerPacket {
/// `Some` on a hit; `None` when the server flagged the entry unknown.
info: Option<CreatureQueryInfo>,
},
/// `SMSG_PET_NAME_QUERY_RESPONSE` — a pet's display name, answering `CMSG_PET_NAME_QUERY`.
/// Keyed by **pet number**, not a template entry (a pet guid carries none — see
/// [`crate::guid::pet_number`]); that is also how the real client's own pet-name cache is keyed.
/// The `nameTimestamp` tail is parsed for alignment and dropped: it exists to age out that
/// on-disk cache, which we have no equivalent of.
PetNameQueryResponse {
pet_number: u32,
name: String,
},
/// `SMSG_GAMEOBJECT_QUERY_RESPONSE` — a GameObject template's type/display/name/`data[24]` head,
/// answering `CMSG_GAMEOBJECT_QUERY` (decision 0236's ask-once template lookup, the GO twin of
/// [`Self::CreatureQueryResponse`]). `data[24]` is the raw type-specific tail (e.g. a chest's
@@ -566,12 +588,13 @@ pub enum ServerPacket {
},
/// `SMSG_GOSSIP_COMPLETE` — the gossip window closes (vmangos `Npc.cpp:90`); an empty body.
GossipComplete,
/// `SMSG_NPC_TEXT_UPDATE` — answers `CMSG_NPC_TEXT_QUERY`: always 8 probability-weighted text
/// blocks (vmangos `GossipDef.cpp:298-369`); `greeting` is the highest-probability block's male
/// text with a female-text fallback (see [`super::gossip::read_npc_text_update`]).
/// `SMSG_NPC_TEXT_UPDATE` — answers `CMSG_NPC_TEXT_QUERY`: always 8 weighted text blocks
/// (vmangos `GossipDef.cpp:298-369`), carried here **undecided**. Which line greets you needs
/// the NPC's gender and a die roll, so it is drawn when the frame opens, not when the packet
/// lands — [`super::gossip::select_greeting`].
NpcText {
text_id: u32,
greeting: String,
blocks: Vec<super::gossip::NpcTextBlock>,
},
/// `SMSG_LIST_INVENTORY` — a vendor's stock, answering `CMSG_LIST_INVENTORY` (vmangos
/// `ItemHandler.cpp:741-810`). Empty stock sends `count = 0` plus a trailing error byte the
@@ -919,6 +942,14 @@ pub enum ServerPacket {
TradeStatusExtended {
state: Box<TradeStatusExtended>,
},
/// `SMSG_INIT_WORLD_STATES` — the whole world-state table for a zone, pushed on login and on
/// every zone change (layout in [`super::world_state::read_init_world_states`]).
InitWorldStates(InitWorldStates),
/// `SMSG_UPDATE_WORLD_STATE` — one `(id, value)` write into that table.
UpdateWorldState {
id: u32,
value: u32,
},
Other {
opcode: u16,
},
@@ -934,6 +965,7 @@ impl ServerPacket {
ServerPacket::CharCreate { .. } => "SMSG_CHAR_CREATE".into(),
ServerPacket::CharDelete { .. } => "SMSG_CHAR_DELETE".into(),
ServerPacket::UpdateObject { .. } => "SMSG_UPDATE_OBJECT".into(),
ServerPacket::CompressedMoves { .. } => "SMSG_COMPRESSED_MOVES".into(),
ServerPacket::DestroyObject { .. } => "SMSG_DESTROY_OBJECT".into(),
ServerPacket::TriggerCinematic { .. } => "SMSG_TRIGGER_CINEMATIC".into(),
ServerPacket::MonsterMove { .. } => "SMSG_MONSTER_MOVE".into(),
@@ -950,6 +982,7 @@ impl ServerPacket {
ServerPacket::SetFactionStanding { .. } => "SMSG_SET_FACTION_STANDING".into(),
ServerPacket::NameQueryResponse { .. } => "SMSG_NAME_QUERY_RESPONSE".into(),
ServerPacket::CreatureQueryResponse { .. } => "SMSG_CREATURE_QUERY_RESPONSE".into(),
ServerPacket::PetNameQueryResponse { .. } => "SMSG_PET_NAME_QUERY_RESPONSE".into(),
ServerPacket::GameObjectQueryResponse { .. } => "SMSG_GAMEOBJECT_QUERY_RESPONSE".into(),
ServerPacket::PlaySound { .. } => "SMSG_PLAY_SOUND".into(),
ServerPacket::PlayMusic { .. } => "SMSG_PLAY_MUSIC".into(),
@@ -1088,6 +1121,8 @@ impl ServerPacket {
ServerPacket::NextMailTime { .. } => "MSG_QUERY_NEXT_MAIL_TIME".into(),
ServerPacket::TradeStatus { .. } => "SMSG_TRADE_STATUS".into(),
ServerPacket::TradeStatusExtended { .. } => "SMSG_TRADE_STATUS_EXTENDED".into(),
ServerPacket::InitWorldStates(_) => "SMSG_INIT_WORLD_STATES".into(),
ServerPacket::UpdateWorldState { .. } => "SMSG_UPDATE_WORLD_STATE".into(),
ServerPacket::Other { opcode } => format!("opcode {opcode:#06x}"),
}
}
+86 -5
View File
@@ -6,13 +6,14 @@
use std::io::{self, Read};
use crate::wire::{
read_cstring, read_f32_le, read_packed_guid, read_u32_le, read_u64_le, read_u8, Vector3d,
read_cstring, read_f32_le, read_packed_guid, read_u16_le, read_u32_le, read_u64_le, read_u8,
Vector3d,
};
use super::{
bank, channel, chat, death, gameobject, gossip, group, items, loot, mail, monster_move,
movement, opcode, quest, spells, taxi, trade, trainer, update_object, vendor, Character,
CreatureQueryInfo, ServerPacket, SpeedKind,
movement, opcode, quest, spells, taxi, trade, trainer, update_object, vendor, world_state,
Character, CreatureQueryInfo, ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -27,6 +28,65 @@ fn read_force_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result<ServerPack
})
}
/// Read an `SMSG_COMPRESSED_MOVES` body: `[u32 uncompressed size][deflate stream]`, the stream a
/// run of `[u8 size][u16 opcode][body]` records where `size` counts the opcode's own two bytes
/// (VERIFIED vmangos `MovementData::AddPacket`/`BuildPacket`). Each record is a whole movement
/// packet, so it goes straight back through [`parse_server`] — the batch is a *transport*, not a
/// message shape, and nothing downstream needs to know a move arrived batched.
///
/// A record that fails to parse fails the **whole** batch (surfacing as one `Poll::Skipped` naming
/// the inner opcode) rather than being dropped quietly. Losing a batch is worse than losing a
/// packet, but silence here is what cost days: an unhandled inner opcode has to be loud. Only the
/// `MSG_MOVE_*` relays vmangos routes through `ObjectViewersMovementDeliverer` can appear, and we
/// model all of them (`the_batch_carries_every_relayed_move_opcode`).
fn read_compressed_moves(r: &mut impl Read) -> io::Result<Vec<ServerPacket>> {
let uncompressed = read_u32_le(r)? as usize;
let mut buf = Vec::with_capacity(uncompressed.min(64 * 1024));
flate2::read::ZlibDecoder::new(r).read_to_end(&mut buf)?;
let mut rest = buf.as_slice();
let mut packets = Vec::new();
while !rest.is_empty() {
let size = read_u8(&mut rest)? as usize;
let opcode = read_u16_le(&mut rest)?;
// `size` spans opcode + body; anything under two bytes cannot even hold the opcode we
// just read, so the stream is misframed and every later record would be garbage.
let body_len = size.checked_sub(2).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("compressed-moves record size {size} < 2"),
)
})?;
if rest.len() < body_len {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"compressed-moves record {opcode:#06x} wants {body_len}B, {}B left",
rest.len()
),
));
}
let (body, tail) = rest.split_at(body_len);
rest = tail;
// A batch inside a batch would recurse without bound; vmangos never nests one.
if opcode == opcode::SMSG_COMPRESSED_MOVES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"compressed-moves nested inside compressed-moves",
));
}
packets.push(parse_server(opcode, body).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"compressed-moves record {opcode:#06x} ({}): {e}",
super::opcode_name(opcode).unwrap_or("?")
),
)
})?);
}
Ok(packets)
}
/// Read one `SMSG_SPLINE_SET_*_SPEED` body — `[packed guid][f32 speed]` (VERIFIED vmangos
/// `MovementPacketSender::SendSpeedChangeToAll` + the mid-spline observer branch, the 5875
/// `> 1_8_4` layout). Observer-only: no counter, no ack (decision 0441).
@@ -133,6 +193,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
objects: update_object::read_update_object(&mut dr)?,
}
}
opcode::SMSG_COMPRESSED_MOVES => ServerPacket::CompressedMoves {
packets: read_compressed_moves(&mut r)?,
},
opcode::SMSG_MONSTER_MOVE => monster_move::read_monster_move(&mut r)?,
opcode::MSG_MOVE_TELEPORT_ACK => {
let guid = read_packed_guid(&mut r)?;
@@ -478,8 +541,8 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
}
opcode::SMSG_GOSSIP_COMPLETE => ServerPacket::GossipComplete,
opcode::SMSG_NPC_TEXT_UPDATE => {
let (text_id, greeting) = gossip::read_npc_text_update(&mut r)?;
ServerPacket::NpcText { text_id, greeting }
let (text_id, blocks) = gossip::read_npc_text_update(&mut r)?;
ServerPacket::NpcText { text_id, blocks }
}
opcode::SMSG_LIST_INVENTORY => {
let (vendor, items) = vendor::read_list_inventory(&mut r)?;
@@ -688,6 +751,15 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
}
}
}
// `u32 petNumber`, cstring name, `u32 nameTimestamp` (VERIFIED vmangos
// `PetNameQueryResponse::AppendBodyTo`, `Server/Packets/Pet.cpp:79-84`). The timestamp ages
// out the reference's on-disk pet-name cache; we keep none, so it is alignment-only.
opcode::SMSG_PET_NAME_QUERY_RESPONSE => {
let pet_number = read_u32_le(&mut r)?;
let name = read_cstring(&mut r)?;
let _name_timestamp = read_u32_le(&mut r)?;
ServerPacket::PetNameQueryResponse { pet_number, name }
}
opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE => {
let (entry, info) = gameobject::read_gameobject_query_response(&mut r)?;
ServerPacket::GameObjectQueryResponse { entry, info }
@@ -851,6 +923,15 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_TRADE_STATUS_EXTENDED => ServerPacket::TradeStatusExtended {
state: Box::new(trade::read_trade_status_extended(&mut r)?),
},
// The world-state table — the `$<n>w`/`$<n>e` NPC-text tokens' source (and a battleground
// scoreboard's, later). Both opcodes write the one table; see [`super::world_state`].
opcode::SMSG_INIT_WORLD_STATES => {
ServerPacket::InitWorldStates(world_state::read_init_world_states(&mut r)?)
}
opcode::SMSG_UPDATE_WORLD_STATE => {
let (id, value) = world_state::read_update_world_state(&mut r)?;
ServerPacket::UpdateWorldState { id, value }
}
// A relayed player movement packet: `[packed mover guid][MovementInfo]`, same opcode the mover
// sent. This is how other players' walking/turning/strafing reaches us (creatures use
// SMSG_MONSTER_MOVE instead). Surface the pose + live move-flags; the app extrapolates between.
@@ -283,6 +283,13 @@ const FIELD_PLAYER_NEXT_LEVEL_XP: u16 = 717;
// UNIT_END(0xBC = 188) + 0x39B(923) = 1111. Cross-checked against COINAGE: 0x3DC − 0x39B = 65 =
// the 64 bitset slots + PLAYER_FIELD_WATCHED_FACTION_INDEX between them (1111 + 64 = 1175, then
// 1176 ✓); the stale hex comment reads 0x451 — the usual 6-low drift.
// The four avoidance/crit percentages the spell tooltip's chance-to-X line reads (law §3-CHANCE),
// consecutive and immediately below EXPLORED_ZONES_1: UNIT_END(188) + 0x396..0x399. FLOAT, and the
// header agrees for once. Anchored on EXPLORED_ZONES_1 = UNIT_END + 0x39B = 1111 just below.
const FIELD_PLAYER_BLOCK_PERCENTAGE: u16 = 1106;
const FIELD_PLAYER_DODGE_PERCENTAGE: u16 = 1107;
const FIELD_PLAYER_PARRY_PERCENTAGE: u16 = 1108;
const FIELD_PLAYER_CRIT_PERCENTAGE: u16 = 1109;
const FIELD_PLAYER_EXPLORED_ZONES_1: u16 = 1111;
/// The bitset's slot count (`Size: 64` in the server enum).
pub const PLAYER_EXPLORED_ZONES_SLOTS: u16 = 64;
@@ -176,6 +176,25 @@ impl ObjectFields {
}
self.get_u32(FIELD_PLAYER_EXPLORED_ZONES_1 + i).unwrap_or(0)
}
/// `PLAYER_BLOCK_PERCENTAGE` — our chance to block, already a percent (2.62 = "2.62%"). The
/// spell tooltip's chance-to-X line (law §3-CHANCE) reads exactly this and its three
/// neighbours; the character sheet reads them too. `None` for a non-player unit.
pub fn player_block_percentage(&self) -> Option<f32> {
self.get_f32(FIELD_PLAYER_BLOCK_PERCENTAGE)
}
/// `PLAYER_DODGE_PERCENTAGE` — see [`Self::player_block_percentage`].
pub fn player_dodge_percentage(&self) -> Option<f32> {
self.get_f32(FIELD_PLAYER_DODGE_PERCENTAGE)
}
/// `PLAYER_PARRY_PERCENTAGE` — see [`Self::player_block_percentage`].
pub fn player_parry_percentage(&self) -> Option<f32> {
self.get_f32(FIELD_PLAYER_PARRY_PERCENTAGE)
}
/// `PLAYER_CRIT_PERCENTAGE` — melee crit; see [`Self::player_block_percentage`]. (The ranged
/// twin sits one slot above and has no consumer yet.)
pub fn player_crit_percentage(&self) -> Option<f32> {
self.get_f32(FIELD_PLAYER_CRIT_PERCENTAGE)
}
/// `PLAYER_FIELD_POSSTAT0 + i` — stat `i`'s **positive** buff delta (float despite the header's
/// "Type: INT" tag; see the field's doc comment). `i >= 5` reads `None`.
pub fn player_posstat(&self, i: u8) -> Option<f32> {
@@ -0,0 +1,108 @@
//! World states — the server's key/value scoreboard table (`SMSG_INIT_WORLD_STATES` `0x2C2`,
//! `SMSG_UPDATE_WORLD_STATE` `0x2C3`). It is what the NPC-text `$<n>w`/`$<n>e` tokens read, and it
//! is what a battleground scoreboard is built from; benilla uses the first today.
//!
//! Both wires are settled from two independent sources that agree:
//!
//! - **The reference binary** (wow-re `system/ui/scratch/questtext-macro-expander.md` §3): the one
//! handler `0x48f690` holds the only two callers of the table setter `0x4c5870`, registered
//! against exactly these two opcodes at `0x48f515-0x48f52f`. `0x2C2` is *two leading dwords*
//! (passed to the unexamined `0x4c5650`), a `u16` count, then `count × (id:u32, value:u32)`;
//! `0x2C3` is a single such pair.
//! - **vmangos's writers** (`Player::SendInitWorldStates`, `Player.cpp:8219-8293`, and
//! `WorldPackets::Misc::UpdateWorldState::AppendBodyTo`, `Misc.cpp:685-694`): `u32 mapId`,
//! `u32 zoneId` (the zone dword is `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`, so 1.12.1
//! gets it), the `u16` count back-patched at the end, then the pairs; and `u32 field, u32 value`
//! respectively (also build-gated — a pre-1.8.4 client would read `u16`s).
//!
//! **The count includes a `(0, 0)` terminator.** vmangos starts `count = 1` and appends
//! `uint32(0) << uint32(0)` last, *"to prevent repeating audio bug"* — so the pair run is read
//! verbatim, terminator and all, exactly as the reference's count loop does. There is no
//! terminator special case on either side and we add none: id 0 simply lands in the table as 0.
//!
//! Ids and values are **raw dwords** on the wire and stay raw here. That matters: the reference's
//! `$<n>e` token reads the table at the *negated* key, so a text asking for `$2077e` looks up
//! `0xFFFFF7E3` — a key only reachable if nothing has re-interpreted the field on the way in.
use std::io;
use crate::wire::{read_u16_le, read_u32_le};
/// `SMSG_INIT_WORLD_STATES` — the whole table for a zone, sent on login and on every zone change
/// (vmangos `Player::UpdateZone` → `SendInitWorldStates`).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InitWorldStates {
/// The map the states are scoped to. The reference hands this and `zone` to `0x4c5650` and
/// nothing else; what that call does with them is **not recorded** (see [`read_init_world_states`]).
pub map: u32,
pub zone: u32,
/// `(id, value)` verbatim, including the trailing `(0, 0)` terminator.
pub states: Vec<(u32, u32)>,
}
/// Read `SMSG_INIT_WORLD_STATES`: `u32 map, u32 zone, u16 count`, then `count` `(id, value)` pairs.
///
/// The count is trusted only as far as the body allows — a truncated run stops at the short read
/// like every other reader here, rather than pre-allocating on a hostile length.
pub(in crate::messages) fn read_init_world_states(r: &mut &[u8]) -> io::Result<InitWorldStates> {
let map = read_u32_le(r)?;
let zone = read_u32_le(r)?;
let count = read_u16_le(r)?;
let mut states = Vec::new();
for _ in 0..count {
states.push((read_u32_le(r)?, read_u32_le(r)?));
}
Ok(InitWorldStates { map, zone, states })
}
/// Read `SMSG_UPDATE_WORLD_STATE`: one `(id, value)` dword pair.
pub(in crate::messages) fn read_update_world_state(r: &mut &[u8]) -> io::Result<(u32, u32)> {
Ok((read_u32_le(r)?, read_u32_le(r)?))
}
#[cfg(test)]
mod tests {
use super::*;
/// A vmangos-shaped `SMSG_INIT_WORLD_STATES`: map, zone, a count that *includes* the trailing
/// `(0, 0)` terminator, then the pairs. The terminator is data, not a sentinel we strip.
#[test]
fn init_reads_every_pair_including_the_terminator() {
let mut body = Vec::new();
body.extend_from_slice(&0u32.to_le_bytes()); // map 0 (Eastern Kingdoms)
body.extend_from_slice(&1519u32.to_le_bytes()); // zone (Stormwind)
body.extend_from_slice(&3u16.to_le_bytes()); // 2 real pairs + the terminator
for (id, value) in [(2264u32, 7u32), (2263u32, 0u32), (0, 0)] {
body.extend_from_slice(&id.to_le_bytes());
body.extend_from_slice(&value.to_le_bytes());
}
let got = read_init_world_states(&mut body.as_slice()).unwrap();
assert_eq!(got.map, 0);
assert_eq!(got.zone, 1519);
assert_eq!(got.states, vec![(2264, 7), (2263, 0), (0, 0)]);
}
/// A count longer than the body is a short read, not a panic or a giant allocation.
#[test]
fn init_truncated_run_errors() {
let mut body = Vec::new();
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&u16::MAX.to_le_bytes());
body.extend_from_slice(&1u32.to_le_bytes());
assert!(read_init_world_states(&mut body.as_slice()).is_err());
}
/// The update wire is the bare pair, and both halves stay raw dwords — a value with the top bit
/// set survives as-is, so the `%d` render downstream is the only place a sign appears.
#[test]
fn update_reads_a_raw_dword_pair() {
let mut body = Vec::new();
body.extend_from_slice(&2264u32.to_le_bytes());
body.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
assert_eq!(
read_update_world_state(&mut body.as_slice()).unwrap(),
(2264, 0xFFFF_FFFF)
);
}
}
+10 -2
View File
@@ -79,8 +79,16 @@ pub(super) fn send_packet(
if let Some(e) = encrypter {
e.encrypt(&mut header);
}
// **One write, not two** (decision 0617). Header-then-body was a self-inflicted Nagle stall: the
// 6-byte header goes out as its own segment, and the body — small, and now behind unacknowledged
// data — is held by the kernel until the server ACKs it. With Nagle now off (see
// [`WorldSession::connect`]) that stall is gone either way, but two segments per packet is still
// two syscalls and two headers for a ≤40-byte message, and the server cannot parse the packet
// until both land. Joined here so one movement report is one segment.
let mut packet = Vec::with_capacity(header.len() + body.len());
packet.extend_from_slice(&header);
packet.extend_from_slice(body);
stream
.write_all(&header)
.and_then(|()| stream.write_all(body))
.write_all(&packet)
.map_err(|e| anyhow!("sending opcode {opcode:#x}: {e}"))
}
+4 -1
View File
@@ -42,7 +42,10 @@ impl WorldReader {
return Err(anyhow!("world stream closed: {e}"));
}
match messages::parse_server(opcode, &body) {
Ok(packet) => Ok(crate::Poll::Events(crate::decode(packet))),
Ok(packet) => Ok(crate::Poll::Events {
opcode,
events: crate::decode(packet),
}),
// Include the raw body (capped) so an unparseable packet can be decoded by hand — a parse
// bug is otherwise invisible past "failed to fill whole buffer". The opcode rides
// separately so the net thread can feed the app's dropped-packet tally.
@@ -63,6 +63,19 @@ impl WorldSession {
session_key: [u8; SESSION_KEY_LENGTH],
) -> Result<Self> {
let mut stream = TcpStream::connect(addr).context("connecting to world server")?;
// **Nagle off** (decision 0617). VERIFIED in the reference client: `0x5bca60` calls
// `setsockopt(s, 6 /* IPPROTO_TCP */, 1 /* TCP_NODELAY */, &1, 4)` unconditionally on its game
// socket (WSOCK32 ordinal 21, IAT slot `0x7ff6f8`; a sibling helper at `0x43dda0` toggles the
// same option from a bool). Rust leaves Nagle *on*, which is exactly wrong for this protocol:
// every packet we send is a sub-MSS write on a latency-critical stream, so the kernel holds
// each one until the server ACKs the last — coalescing our movement stream into delayed-ACK-
// sized clumps. The de-jitter chain on the observing client then sizes its buffer to that
// self-inflicted lateness (decision 0615). Fatal on failure: the option cannot fail on a
// healthy connected socket, so an error here means the next handshake write is doomed anyway —
// better one named failure than a session that silently plays at delayed-ACK cadence.
stream
.set_nodelay(true)
.context("disabling Nagle (TCP_NODELAY) on the world socket")?;
// Handshake reads wait for one specific reply each — silence is failure, so bound them
// (decision 0065). A server that stays connected but never answers must not wedge the net
// thread forever. Cleared in `into_split` for the streaming phase (a quiet world is legal).
@@ -460,6 +460,17 @@ impl WorldWriter {
)
}
/// Ask for a pet's name (`CMSG_PET_NAME_QUERY`: pet number + guid). A pet's guid holds a pet
/// number where a creature's holds its template entry ([`crate::guid::pet_number`]), so
/// [`Self::creature_query`] cannot name one — this is the only query that can. Answered by
/// `SMSG_PET_NAME_QUERY_RESPONSE`, or by silence if the pet is gone.
pub fn pet_name_query(&mut self, pet_number: u32, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_PET_NAME_QUERY,
&messages::pet_name_query(pet_number, guid),
)
}
/// Cast a spell (`CMSG_CAST_SPELL`): `target: None` = a self/implicit cast, `Some(guid)` = an
/// explicit unit target (body in [`messages::cast_spell`]). The server answers
/// `SMSG_CAST_RESULT` (and `SMSG_SPELL_START`/`GO` on success, unmodelled yet).
+60 -18
View File
@@ -201,8 +201,7 @@ fn npc_text_update_greeting_extraction() {
}
}
// Block 3 (probability 0.5) is the highest-probability block with a non-empty male text — it
// wins over both lower-probability blocks and the higher-index female-only block 6.
// The wire carries all 8 blocks through UNDECIDED — the parse layer no longer picks.
let mut body = 321u32.to_le_bytes().to_vec(); // textID
block(&mut body, 0.1, "Low probability greeting", "");
block(&mut body, 0.0, "", "");
@@ -214,34 +213,77 @@ fn npc_text_update_greeting_extraction() {
block(&mut body, 0.0, "", "");
match messages::parse_server(messages::opcode::SMSG_NPC_TEXT_UPDATE, &body).unwrap() {
ServerPacket::NpcText { text_id, greeting } => {
ServerPacket::NpcText { text_id, blocks } => {
assert_eq!(text_id, 321);
assert_eq!(greeting, "Welcome, $N!");
assert_eq!(blocks.len(), messages::NPC_TEXT_BLOCKS);
assert_eq!(blocks[3].probability, 0.5);
assert_eq!(blocks[3].male, "Welcome, $N!");
assert_eq!(blocks[3].female, "Welcome, traveler!");
assert_eq!(blocks[6].female, "Female-only greeting");
}
other => panic!("npc text update, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_NPC_TEXT_UPDATE, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::NpcGreeting { text_id, greeting } => {
assert_eq!((text_id, greeting.as_str()), (321, "Welcome, $N!"));
SessionEvent::NpcGreeting { text_id, blocks } => {
assert_eq!(text_id, 321);
assert_eq!(blocks.len(), messages::NPC_TEXT_BLOCKS);
}
other => panic!("npc greeting event, got {other:?}"),
}
}
// Male-empty fallback: the winning block's text0 is empty, text1 is used instead.
let mut fallback_body = 322u32.to_le_bytes().to_vec();
for _ in 0..7 {
block(&mut fallback_body, 0.0, "", "");
}
block(&mut fallback_body, 1.0, "", "Only the ladies get a hello");
match messages::parse_server(messages::opcode::SMSG_NPC_TEXT_UPDATE, &fallback_body).unwrap() {
ServerPacket::NpcText { text_id, greeting } => {
assert_eq!(text_id, 322);
assert_eq!(greeting, "Only the ladies get a hello");
}
other => panic!("npc text fallback, got {}", other.name()),
/// The greeting DRAW (wow-re `gossip-npctext-law.md`, reference `0x4e2010`) — a weighted random
/// pick over the blocks whose chosen gender column is non-empty. Every branch pinned with an
/// explicit roll, because all three rules here are the opposite of what this client used to do.
#[test]
fn greeting_draw_follows_the_weighted_law() {
use benilla_protocol::messages::{select_greeting, NpcTextBlock};
let b = |probability: f32, male: &str, female: &str| NpcTextBlock {
probability,
male: male.into(),
female: female.into(),
};
// roll ∈ [1, 2) ⇒ thr = (2 - roll) * sum ∈ (0, sum]. roll = 1.0 is the far end (thr = sum),
// roll → 2.0 the near end (thr → 0).
const NEAR: f32 = 1.999_999_9;
const FAR: f32 = 1.0;
// 1 · The all-zero record — vmangos's fallback, and much of the 1.12 table. sum = 0 ⇒ thr = 0,
// and the predicate is `<=`, so block 0 wins on EVERY roll rather than nothing winning.
let zeros: Vec<_> = (0..8).map(|i| b(0.0, &format!("line {i}"), "")).collect();
for roll in [FAR, 1.5, NEAR] {
assert_eq!(select_greeting(&zeros, 0, roll), Some("line 0"));
}
// 2 · Real weights: the draw walks in order, so a near-zero threshold takes the first non-empty
// block and a threshold of the full sum takes the last. The old rule — highest probability
// wins — would have answered "big" for both.
let weighted = vec![b(0.1, "small", ""), b(0.8, "big", ""), b(0.1, "last", "")];
assert_eq!(select_greeting(&weighted, 0, NEAR), Some("small"));
assert_eq!(select_greeting(&weighted, 0, FAR), Some("last"));
// 3 · The column is the NPC's gender, chosen once. Genderless (2) reads as male, because the
// reference tests `== 1`, not `!= 0`.
let gendered = vec![b(1.0, "sir", "madam")];
assert_eq!(select_greeting(&gendered, 0, FAR), Some("sir"));
assert_eq!(select_greeting(&gendered, 1, FAR), Some("madam"));
assert_eq!(
select_greeting(&gendered, 2, FAR),
Some("sir"),
"genderless"
);
// 4 · No fallback to the other column. A female NPC skips male-only blocks outright — she does
// NOT borrow their text (this client used to) — and if that empties the record she gets no
// greeting at all, which is the reference's "Missing gossip text!" path.
let male_only = vec![b(1.0, "men only", ""), b(1.0, "", "ladies")];
assert_eq!(select_greeting(&male_only, 1, FAR), Some("ladies"));
assert_eq!(select_greeting(&male_only, 1, NEAR), Some("ladies"));
assert_eq!(select_greeting(&[b(1.0, "men only", "")], 1, FAR), None);
assert_eq!(select_greeting(&[], 0, FAR), None, "empty record");
}
#[test]
+55
View File
@@ -436,3 +436,58 @@ fn observer_speed_legs_parse_golden() {
other => panic!("expected MountSpecial(player 4), got {other:?}"),
}
}
/// `SMSG_COMPRESSED_MOVES` — the batch carrier vmangos switches a session onto once it has been
/// sent 300 movement packets in ten seconds (decision 0624). The fixture is built exactly as
/// `MovementData::AddPacket` + `BuildPacket` do: `[u32 uncompressed size][zlib(records)]`, each
/// record `[u8 size][u16 opcode][body]` with `size` counting the opcode's two bytes. The two
/// records carry the same relay bodies pinned by `movement_relay_decodes_to_unit_move` above, so
/// what this adds is the *framing* — that a batched move decodes identically to a loose one.
#[test]
fn compressed_moves_unwraps_to_the_same_events_as_loose_relays() {
let body = hx("42000000780153d8cac0b88a91818181859989f1ec75ee63a6752c877ff22f77626058600f146650b805540062602a38005600002f2610d9");
let events =
decode(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &body).unwrap());
// Both records, in wire order, as ordinary moves — order is load-bearing: the remote-replay
// queue reconstructs a mover's arc from it (decision 0618).
match events.as_slice() {
[SessionEvent::UnitMove {
guid: 0xAA,
flags: 0x1,
..
}, SessionEvent::UnitMove {
guid: 0xAA,
flags: 0x0,
..
}] => {}
other => panic!(
"expected the moving then the idle relay for mover 0xAA, in that order, got {other:?}"
),
}
// A batched move must be byte-identical to the same move arriving loose.
let loose = decode(
messages::parse_server(
messages::opcode::MSG_MOVE_START_FORWARD,
&hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000"),
)
.unwrap(),
);
assert_eq!(format!("{:?}", events[0]), format!("{:?}", loose[0]));
}
/// A misframed batch must fail **loudly** (one `Poll::Skipped` naming the inner opcode), never
/// silently drop the moves it carries — the exact silence that made the runaway unattributable for
/// three rounds of fixes (decision 0624).
#[test]
fn a_misframed_batch_errors_rather_than_dropping_moves() {
// A HEARTBEAT record claiming a 40-byte body with only 4 bytes left in the stream.
let truncated = hx("070000007801d37ac700020006c10119");
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &truncated).is_err());
// A record whose size (1) cannot even hold the opcode that follows it — misframing, not an
// empty-body packet, and it must not underflow into a huge body length.
let undersized = hx("030000007801637cc7000001e200f0");
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &undersized).is_err());
}
+367 -226
View File
@@ -30,7 +30,7 @@
//! left`, `height = top − bottom`. Coordinates are screen pixels throughout; `scale` (layoutScale =
//! effective scale, `propagation.md`) scales only the per-anchor offsets and the `width/height` span.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::collections::VecDeque;
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Constants (objdump-VERIFIED in layout.md "Constants")
@@ -48,7 +48,7 @@ pub const SIZE_EPS: f64 = 2.384_185_791_015_625e-7;
/// Recompute change threshold ε — `_DAT_0080c5c8` = `0x3727c5ac` ≈ 9.96e-6 (`layout.md`; applied in
/// `recompute 0x768d20`): an edge must move at least this far vs the cached rect for a re-store +
/// `ApplyRect`. Benilla owns its own caching (see [`LayoutCache`]); this const records the client's
/// `ApplyRect`. Benilla owns its own caching (the caller's dirty gate); this const records the client's
/// documented threshold for fidelity work that needs it.
pub const RESOLVE_EPS: f64 = f32::from_bits(0x3727_c5ac) as f64;
@@ -729,206 +729,246 @@ pub fn resolve_rect_edges(
// detecting cycles. This is benilla's orchestration, not client fidelity math: the client resolves
// lazily-on-read + per-frame-batched via a dependency-ordered deferred queue (layout.md "Resolve /
// dirty / cache flow"); we express the same dependency ordering as an explicit topological pass.
//
// ## Why a dense, reusable solver rather than a fresh map-backed graph
//
// The caller re-solves the whole widget set every round of its fixpoint, and the fixpoint runs
// every frame. A map-backed graph rebuilt per round paid, per round: one `LayoutInput` clone
// (heap `Vec<Anchor>`) per frame, a `BTreeMap` insert per node, and a `BTreeMap` probe per anchor
// target during the solve. Measured on the shipped default UI (1881 frames + 5478 regions,
// release): 1.36 ms per resolve, of which ~0.25 ms was graph construction and ~0.64 ms the solve
// itself — paid on a completely quiet frame.
//
// [`Handle`]s are the caller's own dense id space (benilla's `Model::next_id`, monotonic from 1
// with frames and regions sharing the counter), so every per-node map collapses to an array index
// and every buffer can live across calls. `begin` clears only the slots the previous round
// touched, so a round costs O(nodes), never O(id space). Iteration order is kept **ascending by
// handle** — identical to the `BTreeMap` it replaces — because cycle members are resolved
// best-effort in that order and the result depends on it.
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A frame-anchor graph: frames keyed by [`Handle`], plus *external* pre-resolved rects (the screen
/// root — the client's `CSimpleTop`, whose rect is the physical screen — and any fixed targets).
/// A reusable dependency solver over a set of anchored frames plus *external* pre-resolved rects
/// (the screen root — the client's `CSimpleTop`, whose rect is the physical screen — and any fixed
/// targets, such as already-resolved regions).
///
/// One solver is kept alive across frames so its buffers are reused. A round is:
/// [`begin`](Self::begin) → [`set_external`](Self::set_external) /
/// [`set_frame`](Self::set_frame) → [`solve`](Self::solve) → [`rect`](Self::rect).
#[derive(Clone, Debug, Default)]
pub struct LayoutGraph {
frames: BTreeMap<Handle, LayoutInput>,
externals: BTreeMap<Handle, Rect>,
pub struct LayoutSolver {
/// Per-handle layout input. Persistent storage: [`set_frame`](Self::set_frame) overwrites in
/// place so the anchors `Vec` keeps its allocation across rounds.
input: Vec<LayoutInput>,
/// Is `input[h]` a frame to solve this round?
is_frame: Vec<bool>,
/// Per-handle rect: externals as seeded, frames as they resolve. `None` = not (yet) known.
rect: Vec<Option<Rect>>,
/// Kahn in-degree, and the reverse edges (each inner `Vec` is cleared, never freed).
indeg: Vec<u32>,
dependents: Vec<Vec<Handle>>,
/// Handles whose slots were written this round — `begin` resets exactly these.
touched: Vec<Handle>,
/// Is `h` already in `touched`?
marked: Vec<bool>,
/// The frames to solve, ascending (the `BTreeMap` iteration order this replaces).
live: Vec<Handle>,
/// Kahn scratch + output.
queue: VecDeque<Handle>,
emitted: Vec<bool>,
order: Vec<Handle>,
cycle: Vec<Handle>,
unresolvable: Vec<Handle>,
}
/// The result of resolving a [`LayoutGraph`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Resolution {
/// Every known rect: the externals echoed back, plus each resolvable frame's resolved rect.
pub rects: BTreeMap<Handle, Rect>,
/// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an
/// unresolvable/missing target). May overlap [`Resolution::cycle`].
pub unresolvable: BTreeSet<Handle>,
/// Frames on (or behind) an anchor dependency cycle — no valid topological position. Resolved
/// best-effort against whatever rects were available; a cycle member that could not resolve
/// also appears in `unresolvable`.
pub cycle: BTreeSet<Handle>,
}
impl LayoutGraph {
/// A new empty graph.
pub fn new() -> LayoutGraph {
LayoutGraph::default()
impl LayoutSolver {
/// A new, empty solver.
pub fn new() -> LayoutSolver {
LayoutSolver::default()
}
/// Insert/replace a frame at `handle`.
pub fn set_frame(&mut self, handle: Handle, frame: LayoutInput) {
self.frames.insert(handle, frame);
/// Grow the per-handle arrays to cover `h`.
fn ensure(&mut self, h: Handle) {
let need = h as usize + 1;
if self.input.len() < need {
self.input.resize_with(need, LayoutInput::default);
self.is_frame.resize(need, false);
self.rect.resize(need, None);
self.indeg.resize(need, 0);
self.dependents.resize_with(need, Vec::new);
self.marked.resize(need, false);
self.emitted.resize(need, false);
}
}
/// Remove a frame; returns it if present.
pub fn remove_frame(&mut self, handle: Handle) -> Option<LayoutInput> {
self.frames.remove(&handle)
/// Mark `h` as written this round (so [`begin`](Self::begin) will reset its slot).
fn touch(&mut self, h: Handle) {
let i = h as usize;
if !self.marked[i] {
self.marked[i] = true;
self.touched.push(h);
}
}
/// Register an external (pre-resolved) rect — typically the screen root that top-level frames
/// anchor to.
pub fn set_external(&mut self, handle: Handle, rect: Rect) {
self.externals.insert(handle, rect);
/// Start a round: reset every slot the previous round touched (O(nodes), not O(id space)).
pub fn begin(&mut self) {
for &h in &self.touched {
let i = h as usize;
self.is_frame[i] = false;
self.rect[i] = None;
self.indeg[i] = 0;
self.dependents[i].clear();
self.marked[i] = false;
self.emitted[i] = false;
}
self.touched.clear();
self.live.clear();
self.queue.clear();
self.order.clear();
self.cycle.clear();
self.unresolvable.clear();
}
/// The frames map.
pub fn frames(&self) -> &BTreeMap<Handle, LayoutInput> {
&self.frames
/// Seed a pre-resolved rect at `h` — an anchor target this solve does not compute (the screen
/// root, an already-resolved region). Also used to publish a rect mid-sweep so later nodes in
/// the same pass see it.
pub fn set_external(&mut self, h: Handle, rect: Rect) {
self.ensure(h);
self.touch(h);
self.rect[h as usize] = Some(rect);
}
/// The externals map.
pub fn externals(&self) -> &BTreeMap<Handle, Rect> {
&self.externals
/// Register a frame to solve, copying `src` into the solver's persistent slot (the anchors
/// `Vec` is refilled in place, so a steady round allocates nothing).
pub fn set_frame(&mut self, h: Handle, src: &LayoutInput) {
self.ensure(h);
self.touch(h);
let dst = &mut self.input[h as usize];
dst.anchors.clear();
dst.anchors.extend_from_slice(&src.anchors);
Self::copy_fields(dst, src);
self.is_frame[h as usize] = true;
self.live.push(h);
}
/// Resolve every frame in dependency order (each anchor target before its dependents),
/// detecting cycles. Cycles are reported and everything resolvable is still resolved.
pub fn resolve(&self) -> Resolution {
// Kahn's algorithm over frame→frame anchor dependencies. A dependency on an external or an
// unknown handle does not constrain ordering (its rect is fixed / missing, not produced by
// this pass); only frame→frame edges do. A self-anchor counts as an edge, so a
// self-referential frame is never emitted ⇒ reported as a cycle.
let mut indeg: BTreeMap<Handle, usize> = self.frames.keys().map(|&h| (h, 0)).collect();
let mut dependents: BTreeMap<Handle, Vec<Handle>> = BTreeMap::new();
for (&h, frame) in &self.frames {
let mut seen = BTreeSet::new();
for a in &frame.anchors {
let d = a.relative_to;
if self.frames.contains_key(&d) && seen.insert(d) {
*indeg.get_mut(&h).expect("frame in indeg") += 1;
dependents.entry(d).or_default().push(h);
/// [`set_frame`](Self::set_frame) with `src`'s anchors replaced by exactly `anchor` — the
/// ScrollFrame child override (decision 0112), which otherwise cost a full input clone plus a
/// one-element `Vec` per scroll child per round.
pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) {
self.ensure(h);
self.touch(h);
let dst = &mut self.input[h as usize];
dst.anchors.clear();
dst.anchors.push(anchor);
Self::copy_fields(dst, src);
self.is_frame[h as usize] = true;
self.live.push(h);
}
/// The non-anchor fields, copied verbatim.
fn copy_fields(dst: &mut LayoutInput, src: &LayoutInput) {
dst.width = src.width;
dst.height = src.height;
dst.scale = src.scale;
dst.clamp = src.clamp;
dst.extent_x = src.extent_x;
dst.extent_y = src.extent_y;
}
/// Resolve every registered frame in dependency order (each anchor target before its
/// dependents), detecting cycles. Cycle members are still resolved best-effort, ascending by
/// handle, against whatever rects were available — matching the map-backed driver this
/// replaces.
pub fn solve(&mut self) {
self.live.sort_unstable();
// Frame→frame anchor edges. A dependency on an external or an unknown handle does not
// constrain ordering (its rect is fixed / missing, not produced by this pass); only
// frame→frame edges do. A self-anchor counts as an edge, so a self-referential frame is
// never emitted ⇒ reported as a cycle. Duplicate targets count once (an anchor slot set is
// at most 9, so the dedupe is a linear scan, not a set).
for idx in 0..self.live.len() {
let h = self.live[idx];
let mut seen: [Handle; 9] = [0; 9];
let mut n_seen = 0usize;
for a_idx in 0..self.input[h as usize].anchors.len() {
let d = self.input[h as usize].anchors[a_idx].relative_to;
if !self.is_frame.get(d as usize).copied().unwrap_or(false) {
continue;
}
if seen[..n_seen].contains(&d) {
continue;
}
if n_seen < seen.len() {
seen[n_seen] = d;
n_seen += 1;
}
self.indeg[h as usize] += 1;
self.dependents[d as usize].push(h);
}
}
for &h in &self.live {
if self.indeg[h as usize] == 0 {
self.queue.push_back(h);
}
}
while let Some(h) = self.queue.pop_front() {
self.order.push(h);
self.emitted[h as usize] = true;
for i in 0..self.dependents[h as usize].len() {
let dep = self.dependents[h as usize][i];
let e = &mut self.indeg[dep as usize];
*e -= 1;
if *e == 0 {
self.queue.push_back(dep);
}
}
}
let mut queue: VecDeque<Handle> = indeg
.iter()
.filter(|&(_, &d)| d == 0)
.map(|(&h, _)| h)
.collect();
let mut order: Vec<Handle> = Vec::with_capacity(self.frames.len());
while let Some(h) = queue.pop_front() {
order.push(h);
if let Some(deps) = dependents.get(&h) {
for &dep in deps {
let e = indeg.get_mut(&dep).expect("dependent in indeg");
*e -= 1;
if *e == 0 {
queue.push_back(dep);
}
}
}
for i in 0..self.order.len() {
let h = self.order[i];
self.resolve_one(h);
}
// rects accumulates externals + resolved frames; it is the target-rect lookup.
let mut rects: BTreeMap<Handle, Rect> = self.externals.clone();
let mut unresolvable: BTreeSet<Handle> = BTreeSet::new();
let resolve_one = |h: Handle, rects: &mut BTreeMap<Handle, Rect>| -> bool {
let frame = &self.frames[&h];
match resolve_rect(frame, |t| rects.get(&t).copied()) {
ResolveOutcome::Resolved(r) => {
rects.insert(h, r);
true
}
ResolveOutcome::Unresolvable => false,
// Frames never emitted are on (or behind) a cycle; resolve them best-effort, ascending.
for idx in 0..self.live.len() {
let h = self.live[idx];
if !self.emitted[h as usize] {
self.cycle.push(h);
self.resolve_one(h);
}
}
}
/// Resolve one frame against the rects known so far, recording failure.
fn resolve_one(&mut self, h: Handle) {
let outcome = {
let input = &self.input[h as usize];
let rects = &self.rect;
resolve_rect(input, |t| rects.get(t as usize).copied().flatten())
};
for &h in &order {
if !resolve_one(h, &mut rects) {
unresolvable.insert(h);
}
}
// Frames never emitted are on (or behind) a cycle; resolve them best-effort.
let emitted: BTreeSet<Handle> = order.iter().copied().collect();
let cycle: BTreeSet<Handle> = self
.frames
.keys()
.copied()
.filter(|h| !emitted.contains(h))
.collect();
for &h in &cycle {
if !resolve_one(h, &mut rects) {
unresolvable.insert(h);
}
}
Resolution {
rects,
unresolvable,
cycle,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Dirty-tracking wrapper — a thin recompute-on-demand cache over LayoutGraph. The math above is the
// fidelity surface; this caching strategy is benilla's own (the client uses a lazy-on-read +
// per-frame-batched deferred queue — layout.md — which we may later mirror more closely).
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A [`LayoutGraph`] with a memoized [`Resolution`]. Any mutation marks the whole cache dirty; the
/// next [`LayoutCache::resolve`] recomputes. (Per-frame incremental invalidation — dirtying only a
/// changed frame and its transitive dependents — is a future optimization; correctness lives in the
/// resolver, not the cache.)
#[derive(Clone, Debug, Default)]
pub struct LayoutCache {
graph: LayoutGraph,
cached: Option<Resolution>,
}
impl LayoutCache {
/// Wrap a graph (starts dirty).
pub fn new(graph: LayoutGraph) -> LayoutCache {
LayoutCache {
graph,
cached: None,
match outcome {
ResolveOutcome::Resolved(r) => self.rect[h as usize] = Some(r),
ResolveOutcome::Unresolvable => self.unresolvable.push(h),
}
}
/// Insert/replace a frame and invalidate the cache.
pub fn set_frame(&mut self, handle: Handle, frame: LayoutInput) {
self.graph.set_frame(handle, frame);
self.cached = None;
/// The rect known at `h`: an external as seeded, or a frame's resolved rect. `None` if the
/// handle is unknown or its frame was under-constrained.
#[inline]
pub fn rect(&self, h: Handle) -> Option<Rect> {
self.rect.get(h as usize).copied().flatten()
}
/// Remove a frame and invalidate the cache.
pub fn remove_frame(&mut self, handle: Handle) -> Option<LayoutInput> {
let removed = self.graph.remove_frame(handle);
if removed.is_some() {
self.cached = None;
}
removed
/// Frames on (or behind) an anchor dependency cycle — no valid topological position. Ascending.
pub fn cycle(&self) -> &[Handle] {
&self.cycle
}
/// Set an external rect and invalidate the cache.
pub fn set_external(&mut self, handle: Handle, rect: Rect) {
self.graph.set_external(handle, rect);
self.cached = None;
}
/// Whether the next [`LayoutCache::resolve`] will recompute.
pub fn is_dirty(&self) -> bool {
self.cached.is_none()
}
/// The underlying graph.
pub fn graph(&self) -> &LayoutGraph {
&self.graph
}
/// The resolved layout, recomputing if dirty.
pub fn resolve(&mut self) -> &Resolution {
if self.cached.is_none() {
self.cached = Some(self.graph.resolve());
}
self.cached.as_ref().expect("just populated")
/// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an
/// unresolvable/missing target). May overlap [`cycle`](Self::cycle).
pub fn unresolvable(&self) -> &[Handle] {
&self.unresolvable
}
}
@@ -1166,23 +1206,24 @@ mod tests {
}
#[test]
fn chained_frames_via_graph() {
fn chained_frames_via_solver() {
const A: Handle = 1;
const B: Handle = 2;
let mut g = LayoutGraph::new();
g.set_external(SCREEN, screen_rect());
// Insert B before A to prove topological ordering, not insertion ordering.
g.set_frame(
let mut s = LayoutSolver::new();
s.begin();
s.set_external(SCREEN, screen_rect());
// Register B before A to prove topological ordering, not registration ordering.
s.set_frame(
B,
LayoutInput::sized(
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
g.set_frame(
s.set_frame(
A,
LayoutInput::sized(
&LayoutInput::sized(
vec![Anchor::new(
Point::TopLeft,
SCREEN,
@@ -1194,17 +1235,17 @@ mod tests {
50.0,
),
);
let res = g.resolve();
assert!(res.cycle.is_empty());
assert!(res.unresolvable.is_empty());
s.solve();
assert!(s.cycle().is_empty());
assert!(s.unresolvable().is_empty());
// A: left 100, top 500, right 150, bottom 450
assert_eq!(
bits(res.rects[&A]),
bits(s.rect(A).expect("A resolved")),
bits(Rect::new(450.0, 100.0, 500.0, 150.0))
);
// B hangs off A's bottom-right corner (150, 450), size 20
assert_eq!(
bits(res.rects[&B]),
bits(s.rect(B).expect("B resolved")),
bits(Rect::new(430.0, 150.0, 450.0, 170.0))
);
}
@@ -1241,67 +1282,70 @@ mod tests {
fn dependent_of_unresolvable_is_unresolvable() {
const A: Handle = 1; // no anchors -> unresolvable
const B: Handle = 2; // anchored to A
let mut g = LayoutGraph::new();
g.set_external(SCREEN, screen_rect());
g.set_frame(A, LayoutInput::sized(vec![], 100.0, 40.0));
g.set_frame(
let mut s = LayoutSolver::new();
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(A, &LayoutInput::sized(vec![], 100.0, 40.0));
s.set_frame(
B,
LayoutInput::sized(
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::TopLeft, 0.0, 0.0)],
20.0,
20.0,
),
);
let res = g.resolve();
assert!(res.unresolvable.contains(&A));
assert!(res.unresolvable.contains(&B));
assert!(!res.rects.contains_key(&A));
assert!(!res.rects.contains_key(&B));
s.solve();
assert!(s.unresolvable().contains(&A));
assert!(s.unresolvable().contains(&B));
assert!(s.rect(A).is_none());
assert!(s.rect(B).is_none());
}
#[test]
fn cycle_detected() {
const A: Handle = 1;
const B: Handle = 2;
let mut g = LayoutGraph::new();
g.set_frame(
let mut s = LayoutSolver::new();
s.begin();
s.set_frame(
A,
LayoutInput::sized(
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, B, Point::TopLeft, 0.0, 0.0)],
20.0,
20.0,
),
);
g.set_frame(
s.set_frame(
B,
LayoutInput::sized(
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::TopLeft, 0.0, 0.0)],
20.0,
20.0,
),
);
let res = g.resolve();
assert!(res.cycle.contains(&A));
assert!(res.cycle.contains(&B));
s.solve();
assert!(s.cycle().contains(&A));
assert!(s.cycle().contains(&B));
// neither can resolve (each depends on the other's rect, unavailable)
assert!(res.unresolvable.contains(&A));
assert!(res.unresolvable.contains(&B));
assert!(s.unresolvable().contains(&A));
assert!(s.unresolvable().contains(&B));
}
#[test]
fn self_anchor_is_a_cycle() {
const A: Handle = 1;
let mut g = LayoutGraph::new();
g.set_frame(
let mut s = LayoutSolver::new();
s.begin();
s.set_frame(
A,
LayoutInput::sized(
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
let res = g.resolve();
assert!(res.cycle.contains(&A));
s.solve();
assert!(s.cycle().contains(&A));
}
#[test]
@@ -1315,13 +1359,22 @@ mod tests {
assert!(size_changed(r, big));
}
/// The solver's buffers live across rounds, so `begin` must leave NO trace of the previous
/// one — a stale rect, in-degree, dependent edge, or `is_frame` flag would silently corrupt
/// the next solve. Round 2 registers a *disjoint* frame set that anchors to a handle round 1
/// used, and must see it as unknown (not round 1's rect).
#[test]
fn cache_recomputes_on_dirty() {
let mut g = LayoutGraph::new();
g.set_external(SCREEN, screen_rect());
g.set_frame(
1,
LayoutInput::sized(
fn begin_leaves_no_state_from_the_previous_round() {
const A: Handle = 1;
const B: Handle = 2;
let mut s = LayoutSolver::new();
// Round 1: A anchored to the screen, B hanging off A.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(
Point::TopLeft,
SCREEN,
@@ -1333,27 +1386,115 @@ mod tests {
40.0,
),
);
let mut cache = LayoutCache::new(g);
assert!(cache.is_dirty());
let first = cache.resolve().rects[&1];
assert!(!cache.is_dirty());
// mutate -> dirty -> recompute reflects the change
cache.set_frame(
1,
LayoutInput::sized(
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
let a_first = s.rect(A).expect("A resolved in round 1");
assert!(s.rect(B).is_some());
// Round 2: ONLY B, still anchored to A — but A is no longer registered and no external
// seeds it, so B must be unresolvable rather than reusing round 1's stale A rect.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
assert!(
s.rect(A).is_none(),
"round 1's A rect must not survive begin"
);
assert!(
s.rect(B).is_none(),
"B must not resolve against a stale target"
);
assert!(s.unresolvable().contains(&B));
assert!(s.cycle().is_empty(), "cycle list must be cleared too");
// Round 3: the original pair again — identical results to round 1 (no accumulated
// in-degree or dependent edges from the rounds between).
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
50.0,
0.0,
0.0,
)],
100.0,
40.0,
),
);
assert!(cache.is_dirty());
let second = cache.resolve().rects[&1];
assert_eq!(second.left - first.left, 50.0);
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
assert_eq!(
bits(s.rect(A).expect("A resolved in round 3")),
bits(a_first)
);
assert!(s.unresolvable().is_empty());
}
/// A frame registered with the ScrollFrame override keeps its size/scale but drops its own
/// anchors for exactly the override (decision 0112) — and the slot's anchor `Vec` is reused,
/// so the previous round's anchors must not leak through.
#[test]
fn set_frame_anchored_replaces_only_the_anchors() {
const A: Handle = 1;
let mut s = LayoutSolver::new();
let input = LayoutInput::sized(
vec![
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, 0.0, 0.0),
],
100.0,
40.0,
);
// Plain: both anchors pin all four edges, so the size is ignored.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(A, &input);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
);
// Overridden: the two authored anchors are gone, replaced by one TOPLEFT pin at (10, -5),
// so the explicit 100x40 size now decides the other two edges.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame_anchored(
A,
&input,
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0),
);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(555.0, 10.0, 595.0, 110.0))
);
}
}
+271 -42
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use crate::layout::{self, Anchor, LayoutGraph, LayoutInput, Point, Rect};
use crate::layout::{self, Anchor, LayoutInput, Point, Rect};
use crate::order::ZTarget;
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
@@ -10,6 +10,104 @@ use super::{
Outline, QuadContent, UiScript, SCREEN,
};
/// A 128-bit rolling fingerprint of everything [`UiScript::resolve_layout`] *reads*, so a resolve
/// whose inputs are byte-identical to the last one can be skipped outright.
///
/// ## Why a fingerprint and not a dirty flag
///
/// A dirty flag has to be *set* at every mutation site, and the resolve's read set is spread over
/// `layout_inputs`, the arena (scale, clamp, ScrollFrame child + offset, frame/region liveness),
/// `region_data` (anchors, size, measured), and `screen`. Missing one site is a silently stale
/// layout — the worst failure mode this module has, and one no test reliably catches because the
/// staleness only shows on the frame the missed setter fires. A fingerprint has no sites to miss:
/// it is computed FROM the read set, so any change to any of it changes the value by construction.
/// It also absorbs *idempotent* writes for free — the GameTooltip pre-pass rewrites tooltip sizes
/// and anchor offsets every single call, which would pin a mutation-tracked flag permanently dirty.
///
/// ## Why 128 bits
///
/// A collision here is not self-healing: the skip keeps the stored value, so a colliding change
/// would stay unresolved until something *else* moved. Two independent lanes put that beyond
/// physical concern (~2^-128 per frame) at the cost of one extra multiply per item.
///
/// ## Order sensitivity is safe
///
/// The accumulator is order-dependent, and it is fed by walking `HashMap`s whose iteration order
/// is arbitrary. That order is nevertheless *stable for an unmutated map*, so an unchanged model
/// reproduces the value; a mutated map may reorder and merely re-resolves. The failure mode is a
/// false DIRTY (a wasted resolve), never a false CLEAN (a stale rect).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) struct InputFingerprint(u64, u64);
impl Default for InputFingerprint {
fn default() -> Self {
// Distinct non-zero bases (FNV-1a's, and a 64-bit mix constant) so leading zero-valued
// items still move both lanes.
InputFingerprint(0xcbf2_9ce4_8422_2325, 0x9e37_79b9_7f4a_7c15)
}
}
impl InputFingerprint {
#[inline]
fn feed(&mut self, v: u64) {
self.0 = (self.0 ^ v).wrapping_mul(0x0000_0100_0000_01b3);
self.1 = (self.1 ^ v.rotate_left(29)).wrapping_mul(0xff51_afd7_ed55_8ccd);
}
#[inline]
fn f32(&mut self, v: f32) {
// Bit pattern, not value: `resolve` is bit-exact float math, so -0.0 and NaN payloads are
// distinctions that matter here even where `==` would call them equal (or unequal).
self.feed(u64::from(v.to_bits()));
}
#[inline]
fn anchors(&mut self, anchors: &[Anchor]) {
self.feed(anchors.len() as u64);
for a in anchors {
self.feed(u64::from(a.point.id()));
self.feed(u64::from(a.relative_to));
self.feed(u64::from(a.relative_point.id()));
self.f32(a.x_off);
self.f32(a.y_off);
}
}
#[inline]
fn input(&mut self, i: &LayoutInput) {
self.anchors(&i.anchors);
self.f32(i.width);
self.f32(i.height);
self.f32(i.scale);
self.feed(u64::from(i.clamp));
self.f32(i.extent_x);
self.f32(i.extent_y);
}
#[inline]
fn rect(&mut self, r: Rect) {
self.f32(r.bottom);
self.f32(r.left);
self.f32(r.top);
self.f32(r.right);
}
}
/// `WOW_LAYOUT_VERIFY=1` — the change gate's self-check (decision 0022's instruments-first
/// posture). When set, a resolve the gate wants to SKIP runs in full anyway and asserts it
/// reproduced the previous rects exactly; a divergence means the fingerprint's read set is
/// incomplete, and it fails loudly at the resolve that proves it rather than silently rendering a
/// stale frame days later.
///
/// This is what makes the gate's central claim — "the fingerprint covers everything the rounds
/// read" — machine-checked instead of argued: run the workspace suite under it and every UI test
/// (the shipped windows, driven through real FrameXML + Lua) becomes a probe for a missed input.
/// Read once; when off it costs a relaxed atomic load per resolve.
fn layout_verify_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1"))
}
impl UiScript {
/// [`UiScript::resolve`]'s body, taking `&mut Model` directly rather than `&mut self` — so a
/// Lua binding holding only a `Model` borrow (via `lua.app_data_mut`, no `&Lua`-wrapping
@@ -34,21 +132,26 @@ impl UiScript {
region_data,
region_resolved,
frame_to_id,
id_to_frame,
id_to_region,
region_to_id,
screen,
warnings,
solver,
layout_fingerprint,
layout_solves,
..
} = model;
// One id per live frame, and its layout input with scale synced from the arena.
let mut ids: HashMap<FrameHandle, u32> = HashMap::new();
// One id per live frame, and its layout input with scale synced from the arena. A flat
// `Vec`, not a map: this list is walked (never probed) by every round below, and it is
// rebuilt on each call anyway — a `HashMap` bought nothing and cost 1881 hashes per call.
let mut ids: Vec<(FrameHandle, u32)> = Vec::with_capacity(frame_to_id.len());
for (&h, &id) in frame_to_id.iter() {
if arena.frame(h).is_some() {
ids.insert(h, id);
ids.push((h, id));
}
}
for &h in ids.keys() {
for &(h, _) in &ids {
let (scale, clamp) = arena
.frame(h)
.map(|f| (f.effective_scale, f.clamped_to_screen))
@@ -76,7 +179,7 @@ impl UiScript {
// computed. The child's own width/height (from its own `LayoutInput`) stay whatever they are;
// only its anchors are replaced.
let mut scroll_child_anchor: HashMap<FrameHandle, Anchor> = HashMap::new();
for (&h, &id) in ids.iter() {
for &(h, id) in &ids {
let Some(frame) = arena.frame(h) else {
continue;
};
@@ -122,38 +225,140 @@ impl UiScript {
arena.region(*rh).is_some()
&& region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
});
let round_cap = ids.len() + region_data.len() + 2;
// The per-frame solve plan, resolved ONCE per call rather than per round: each live
// frame's id, a borrow of its (already scale/clamp-synced) layout input, and its
// ScrollFrame anchor override if it is a scroll child. The round loop below then walks a
// flat slice — no map probes, no input clones.
let plan: Vec<(FrameHandle, u32, &LayoutInput, Option<Anchor>)> = ids
.iter()
.filter_map(|&(h, id)| {
layout_inputs
.get(&h)
.map(|input| (h, id, input, scroll_child_anchor.get(&h).copied()))
})
.collect();
// ── The change gate ───────────────────────────────────────────────────────────────────
// Fingerprint exactly what the rounds below read, and skip them outright when nothing has
// moved since the last converged resolve. Measured on the shipped default UI: the layout
// inputs are byte-identical on 34 of 35 idle frames and 39 of 41 with three windows open,
// because almost all per-frame UI traffic (StatusBar values, cooldown sweeps, chat fades,
// colour changes) is read by EXTRACT, not by the anchor solve.
//
// The read set, in the order the rounds consume it:
// * `screen` — the root external, and the clamp extents;
// * `plan` — every live frame's id, its (already scale/clamp-
// synced) `LayoutInput`, and its ScrollFrame override,
// which together distil `frame_to_id`, frame liveness,
// `effective_scale`, `clamped_to_screen`, and the
// ScrollFrame child + vertical offset;
// * `id_to_region` × `region_resolved` — the region rects seeded as externals;
// * `region_data` — each region's anchors, explicit size, and measured
// text extent;
// * `arena.region(rh)` — liveness and owner (a dead region drops out of the
// sweep; the owner supplies the fallback edges).
// `resolved` / `region_resolved` are the previous pass's OUTPUT carried forward as the
// 0294 seed; at convergence seed and output are equal, so re-running against an unchanged
// input set is guaranteed to reproduce them — which is precisely what makes the skip safe.
let mut fp = InputFingerprint::default();
fp.rect(*screen);
fp.feed(plan.len() as u64);
for &(_, id, input, over) in &plan {
fp.feed(u64::from(id));
fp.input(input);
match over {
Some(a) => fp.anchors(std::slice::from_ref(&a)),
None => fp.feed(u64::MAX),
}
}
fp.feed(region_data.len() as u64);
for (&rh, data) in region_data.iter() {
fp.feed(rh.fingerprint_bits());
fp.anchors(&data.anchors);
match data.size {
Some((w, h)) => {
fp.f32(w);
fp.f32(h);
}
None => fp.feed(u64::MAX),
}
match data.measured {
Some(m) => {
fp.f32(m.w);
fp.f32(m.h);
}
None => fp.feed(u64::MAX),
}
// Liveness only. The sweep also reads the region's `owner` and `kind`, but both are
// fixed at creation and can never change under a live handle; the owner's contribution
// to the result — its resolved rect and its `effective_scale` — rides the frame half
// (the scale is synced into `layout_inputs` by the preamble above, and the rect is the
// frame pass's own output). A DESTROYED region does still matter: `WidgetArena::destroy`
// drops the arena entry but leaves `region_data` behind, so membership alone would miss
// it and the sweep's `continue` would go unnoticed.
fp.feed(u64::from(arena.region(rh).is_some()));
}
for (&id, rh) in id_to_region.iter() {
if let Some(r) = region_resolved.get(rh) {
fp.feed(u64::from(id));
fp.rect(*r);
}
}
let gate_skips = *layout_fingerprint == Some(fp);
if gate_skips && !layout_verify_enabled() {
return;
}
// Under `WOW_LAYOUT_VERIFY=1` a skippable resolve runs anyway, against a copy of the rects
// it is supposed to reproduce — see [`layout_verify_enabled`].
let verify_against = gate_skips.then(|| (resolved.clone(), region_resolved.clone()));
// Cleared, not stored, until the fixpoint actually CONVERGES: a run that exhausts the
// round cap (a genuine anchor cycle) leaves its rects mid-flight, and the 0294 cross-frame
// seed is what lets the next frame carry them further. Storing here would freeze a
// pathological graph at whatever partial state it reached.
*layout_fingerprint = None;
// Counted on the gate's DECISION, not on the fixpoint running: under
// `WOW_LAYOUT_VERIFY=1` a skipped resolve still runs below, and a counter that moved for
// it would mean something different in the two modes — exactly the ambiguity the tests
// asserting on it exist to remove.
if !gate_skips {
*layout_solves += 1;
}
let round_cap = plan.len() + region_data.len() + 2;
for round in 0..round_cap {
let mut changed = false;
let mut graph = LayoutGraph::new();
graph.set_external(SCREEN, *screen);
// Seed the solver: the screen root, then every region rect settled so far (regions are
// externals to the frame solve — the fixpoint is what closes the loop between them).
// Frame ids and region ids come from one monotonic counter, so both live in the
// solver's single dense rect array and every anchor-target lookup is an array index.
solver.begin();
solver.set_external(SCREEN, *screen);
for (&id, rh) in id_to_region.iter() {
if let Some(r) = region_resolved.get(rh) {
graph.set_external(id, *r);
solver.set_external(id, *r);
}
}
for (&h, &id) in ids.iter() {
if let Some(input) = layout_inputs.get(&h) {
match scroll_child_anchor.get(&h) {
Some(&over) => {
let mut overridden = input.clone();
overridden.anchors = vec![over];
graph.set_frame(id, overridden);
for &(_, id, input, over) in &plan {
match over {
Some(a) => solver.set_frame_anchored(id, input, a),
None => solver.set_frame(id, input),
}
}
solver.solve();
for &(h, id, _, _) in &plan {
match solver.rect(id) {
Some(r) => {
if resolved.get(&h) != Some(&r) {
resolved.insert(h, r);
changed = true;
}
None => graph.set_frame(id, input.clone()),
}
}
}
let res = graph.resolve();
for (&h, &id) in ids.iter() {
if let Some(&r) = res.rects.get(&id) {
if resolved.get(&h) != Some(&r) {
resolved.insert(h, r);
changed = true;
None => {
if resolved.remove(&h).is_some() {
changed = true;
}
}
} else if resolved.remove(&h).is_some() {
changed = true;
}
}
@@ -165,6 +370,10 @@ impl UiScript {
// owner-fallback that stood in for this was the jutting-plates bug). One region sweep per
// outer round: a not-yet-resolved chain target leaves this region on its owner-edge
// fallback for now, the fixpoint re-sweeps until every link has settled.
//
// `scratch` is hoisted out of the loop so the per-region `LayoutInput` refills its
// anchors `Vec` in place instead of allocating one per region per round.
let mut scratch = LayoutInput::default();
for (&rh, data) in region_data.iter() {
if data.anchors.is_empty() {
continue;
@@ -193,19 +402,20 @@ impl UiScript {
width = m.w;
}
}
let input = LayoutInput {
anchors: data.anchors.clone(),
width,
height,
scale,
..LayoutInput::default()
};
let edges = layout::resolve_rect_edges(&input, |id| {
id_to_frame
.get(&id)
.and_then(|h| resolved.get(h))
.or_else(|| id_to_region.get(&id).and_then(|h| region_resolved.get(h)))
.copied()
scratch.anchors.clear();
scratch.anchors.extend_from_slice(&data.anchors);
scratch.width = width;
scratch.height = height;
scratch.scale = scale;
let edges = layout::resolve_rect_edges(&scratch, |id| {
// SCREEN is a FRAME-pass external only. The map-pair lookup this replaces
// consulted `id_to_frame` and `id_to_region`, and neither can hold the
// reserved id 0 (`next_id` starts at 1) — so a region anchor to the screen
// root has always fallen through to the owner-edge fallback below. Preserved
// deliberately: the solver's dense array DOES carry the screen rect at 0, and
// quietly starting to resolve it here would be a behaviour change riding in
// on a performance fix.
(id != SCREEN).then(|| solver.rect(id)).flatten()
});
// Unpinned edges: textures inherit the owner's edge (the v1 region model,
// decision 0068). A FONTSTRING's implicit extent is its measured TEXT, and
@@ -225,6 +435,12 @@ impl UiScript {
let (bottom, top) = axis(edges[0], edges[2], owner_rect.bottom, owner_rect.top);
let (left, right) = axis(edges[1], edges[3], owner_rect.left, owner_rect.right);
let rect = Rect::new(bottom, left, top, right);
// Publish into the solver as well as the model: a later region in THIS same sweep
// that anchors to this one must see the fresh rect (the sweep has always worked
// that way — it read the same `region_resolved` map it was writing).
if let Some(&id) = region_to_id.get(&rh) {
solver.set_external(id, rect);
}
if region_resolved.get(&rh) != Some(&rect) {
region_resolved.insert(rh, rect);
changed = true;
@@ -232,6 +448,19 @@ impl UiScript {
}
if !changed {
if let Some((frames_before, regions_before)) = &verify_against {
assert!(
frames_before == resolved && regions_before == region_resolved,
"WOW_LAYOUT_VERIFY: the change gate skipped a resolve that would have \
MOVED something — the fingerprint's read set is incomplete. \
frames {} -> {} changed, regions {} -> {} changed",
frames_before.len(),
resolved.len(),
regions_before.len(),
region_resolved.len(),
);
}
*layout_fingerprint = Some(fp);
return;
}
if round + 1 == round_cap {
+20 -1
View File
@@ -1,6 +1,6 @@
use std::collections::{HashMap, HashSet};
use crate::layout::{LayoutInput, Rect};
use crate::layout::{LayoutInput, LayoutSolver, Rect};
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
@@ -25,6 +25,22 @@ pub(crate) struct Model {
pub(crate) layout_inputs: HashMap<FrameHandle, LayoutInput>,
/// The last [`UiScript::resolve`] result: each resolvable frame's rect. Empty until `resolve`.
pub(crate) resolved: HashMap<FrameHandle, Rect>,
/// The anchor-graph solver, kept alive across resolves so its per-handle arrays and the
/// per-frame anchor `Vec`s are reused — a steady round allocates nothing (see
/// [`LayoutSolver`]). Scratch only: it holds no state between rounds that `begin` doesn't
/// clear, and `resolved`/`region_resolved` remain the model's own answer.
pub(crate) solver: LayoutSolver,
/// The fingerprint of the input set the last CONVERGED resolve ran against — the change gate
/// (see `script::layout::InputFingerprint`). `None` forces the next resolve to run: the
/// initial state, and what a non-converging (cyclic) pass leaves behind so its progress can
/// carry into the next frame.
pub(crate) layout_fingerprint: Option<super::layout::InputFingerprint>,
/// How many times the change gate has LET A RESOLVE THROUGH. `UiScript::resolve` is called
/// unconditionally every frame, so the gap between call count and this is the gate's whole
/// value; it is also what lets a test assert that a no-op resolve really was one, rather than
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
pub(crate) layout_solves: u64,
/// Hyperlink spans per frame — `(rect, link payload, full |H…|h markup)`, rects in the
/// engine's y-up screen space. Fed by the app each frame after it rasterizes message lines
/// ([`super::UiScript::set_link_spans`]); consumed by the pointer's release dispatch
@@ -551,6 +567,9 @@ impl Model {
Model {
arena: WidgetArena::new(),
layout_inputs: HashMap::new(),
solver: LayoutSolver::new(),
layout_fingerprint: None,
layout_solves: 0,
resolved: HashMap::new(),
link_spans: HashMap::new(),
chat_tab: false,
@@ -0,0 +1,157 @@
//! The layout change gate: a resolve whose inputs are byte-identical to the last converged one is
//! skipped outright (`script::layout::InputFingerprint`).
//!
//! These assert on `Model::layout_solves` — the count of times the fixpoint actually ran — rather
//! than on the rects, because rect equality alone cannot distinguish "the gate skipped" from "the
//! gate re-solved and got the same answer". The value of the gate is precisely the difference.
use super::common::script;
use crate::script::{Model, UiScript};
/// How many times the fixpoint has run.
fn solves(s: &UiScript) -> u64 {
s.lua()
.app_data_ref::<Model>()
.expect("model app_data")
.layout_solves
}
/// A frame anchored to the screen, plus a child hanging off it — enough that a move has to
/// propagate, so a wrongly-skipped resolve would be visible in the child too.
fn setup(s: &UiScript) {
s.run(
r#"
parent = CreateFrame("Frame", "Parent", nil)
parent:SetWidth(100); parent:SetHeight(40)
parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
child = CreateFrame("Frame", "Child", parent)
child:SetWidth(20); child:SetHeight(20)
child:SetPoint("TOPLEFT", parent, "BOTTOMRIGHT", 0, 0)
"#,
)
.expect("setup");
}
#[test]
fn an_unchanged_resolve_does_not_run_the_fixpoint() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
let after_first = solves(&s);
assert_eq!(after_first, 1, "the first resolve must run");
// Nothing touched in between: every further resolve is a no-op.
for _ in 0..5 {
s.resolve();
}
assert_eq!(
solves(&s),
after_first,
"resolves with identical inputs must be skipped, not re-run"
);
}
#[test]
fn moving_a_frame_reopens_the_gate_and_propagates() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve(); // settle: the gate is now closed
let before = solves(&s);
let child_left = |s: &UiScript| -> f32 { s.eval::<f32>("return Child:GetLeft()").unwrap() };
let first = child_left(&s);
s.run("parent:SetPoint(\"TOPLEFT\", nil, \"TOPLEFT\", 60, -10)")
.expect("move");
s.resolve();
assert_eq!(solves(&s), before + 1, "a SetPoint must reopen the gate");
assert!(
(child_left(&s) - (first + 50.0)).abs() < 0.001,
"the move must propagate to the child: {} -> {}",
first,
child_left(&s)
);
// And it closes again once the move has settled.
let after = solves(&s);
s.resolve();
assert_eq!(
solves(&s),
after,
"the gate must close again after the move"
);
}
/// Resizing the window moves every top-level frame — the gate must never swallow it.
#[test]
fn a_screen_resize_reopens_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
let before = solves(&s);
s.set_screen_size(1024.0, 768.0);
s.resolve();
assert_eq!(
solves(&s),
before + 1,
"a screen-size change must reopen the gate"
);
}
/// A region's own `SetPoint` writes `region_data`, not `layout_inputs` — the half of the read set
/// that is easiest to leave out of a change gate, and the one the region sweep consumes.
#[test]
fn moving_a_region_reopens_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.run(
r#"
tex = parent:CreateTexture(nil, "ARTWORK")
tex:SetWidth(10); tex:SetHeight(10)
tex:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
"#,
)
.expect("region");
s.resolve();
s.resolve();
let before = solves(&s);
s.run("tex:SetPoint(\"TOPLEFT\", parent, \"TOPLEFT\", 25, 0)")
.expect("move region");
s.resolve();
assert_eq!(
solves(&s),
before + 1,
"a region SetPoint must reopen the gate"
);
}
/// Hiding a frame does NOT move any rect (the client resolves hidden frames too — visibility is an
/// extract-time filter), so the gate is right to stay closed. Pinned deliberately: it is the one
/// place where "nothing to re-solve" is surprising, and a future reader tempted to dirty on
/// show/hide should see that the current model does not need it.
#[test]
fn hiding_a_frame_does_not_reopen_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
let before = solves(&s);
s.run("parent:Hide()").expect("hide");
s.resolve();
assert_eq!(
solves(&s),
before,
"visibility does not move rects, so the gate stays closed"
);
}
@@ -13,6 +13,7 @@ mod end_to_end;
mod events;
mod frame_api;
mod input;
mod layout_gate;
mod measure;
mod minimap;
mod object_model;
@@ -209,6 +209,129 @@ fn player_buff_hover_is_the_aura_variant() {
if t.starts_with("Intellect increased") && *c == [1.0, 1.0, 1.0, 1.0])
});
assert!(white, "aura description is white, not gold");
// …and the NAME is GOLD: the aura builder `0x52f880` writes it through the gold wrapper
// `0x530380`, where the spell builder uses the plain `0x530270`. Pinned against the
// reference's own buff hover (2026-07-25 report B53), which the white name did not match.
let gold_name = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "Arcane Intellect" && (c[1] - 210.0 / 255.0).abs() < 1e-6)
});
assert!(gold_name, "aura name is gold, not white");
assert!(s.take_errors().is_empty());
}
/// Law §3-BUFF's right column: the buff hover names the DISPEL CLASS where the spell hover would
/// put a gray "Rank N" — "Magic" on Ice Armor, the half of B53 the gold name didn't cover — and it
/// is gold, sharing the aura name's wrapper. A `rank` on the same view must NOT displace it.
#[test]
fn player_buff_hover_names_the_dispel_class_in_gold() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(
168,
SpellTooltipView {
name: "Ice Armor".into(),
rank: Some("Rank 1".into()), // the spell variant's column — never the aura's
dispel_type: Some("Magic".into()),
aura_description: "Encases the caster in a layer of ice.".into(),
..Default::default()
},
);
s.set_auras(
"player",
Some(vec![AuraState {
spell_id: 168,
name: Some("Ice Armor".into()),
helpful: true,
..Default::default()
}]),
);
s.run(
r#"
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetPlayerBuff(1)
assert(TTTextRight1:GetText() == "Magic",
"the dispel class, got " .. tostring(TTTextRight1:GetText()))
"#,
)
.unwrap();
s.resolve();
let gold_dispel = s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "Magic" && (c[1] - 210.0 / 255.0).abs() < 1e-6)
});
assert!(
gold_dispel,
"the dispel column is gold, not the rank's gray"
);
assert!(s.take_errors().is_empty());
}
/// Law §3.6's equipped-item line and §3.8's reagents line — the two lines the 2026-07-25 reports
/// found missing (B54, B56). Order: cast|cooldown → requires-item → requires-form → reagents →
/// description, each requirement red while unmet.
#[test]
fn requirement_and_reagent_lines_render_in_law_order() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(
5019,
SpellTooltipView {
name: "Shoot".into(),
range: Some("30 yd range".into()),
cast_time: Some("Instant".into()),
requires_item: Some("Requires Wands".into()),
item_met: false,
reagents: Some("Reagents: |cffff2020Light Feather|r".into()),
description: "Attack with an equipped wand.".into(),
..Default::default()
},
);
s.set_spellbook(SpellBookState {
tabs: Vec::new(),
slots: vec![SpellSlotView {
spell_id: 5019,
name: "Shoot".into(),
..Default::default()
}],
});
s.run(
r#"
local a = CreateFrame("Button", "SB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetSpell(1, "spell")
assert(tt:NumLines() == 6, "name + range + cast + requires + reagents + desc, got " .. tt:NumLines())
assert(TTTextLeft1:GetText() == "Shoot")
assert(TTTextLeft2:GetText() == "30 yd range", "cost absent: range moves left")
assert(TTTextLeft3:GetText() == "Instant")
assert(TTTextLeft4:GetText() == "Requires Wands", "law §3.6 sits above the reagents")
assert(TTTextLeft5:GetText():find("Light Feather") ~= nil, "law §3.8")
assert(TTTextLeft6:GetText() == "Attack with an equipped wand.", "description last")
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let red = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "Requires Wands" && *c == [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0])
});
assert!(red, "an unmet equipped-item requirement is red");
// The reagent's inline `|cffff2020` escape reaches the region VERBATIM: the engine paints
// the line's BASE colour white and the app's text layer resolves the escape into colour runs
// (`benilla::ui_text::markup`, covered by its own `color_runs_survive_the_wrap`). So what this
// layer owns is that the escape is neither stripped nor pre-flattened.
let verbatim = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "Reagents: |cffff2020Light Feather|r" && *c == [1.0, 1.0, 1.0, 1.0])
});
assert!(
verbatim,
"the reagents line reaches the region escape-intact, base white"
);
assert!(s.take_errors().is_empty());
}
+64 -21
View File
@@ -2,10 +2,14 @@
//! spell builder `0x52e610` and the aura builder `0x52f880` (wow-re
//! `ui/scratch/tooltip-content-law.md`, the 0276 fold-back):
//!
//! - name (white) | rank (gray) — one double line;
//! - name | rank (gray) — one double line. The name's colour is the BUILDER's: **white** from
//! the spell builder (`0x530270`), **gold** from the aura builder (`0x530380`, the gold
//! wrapper) — the same split `SetTrackingSpell` shows;
//! - **Cost | Range** — ONE double line (either side may be absent);
//! - **CastTime | Cooldown** — ONE double line; a passive spell simply omits it (there is NO
//! "Passive" text in the 1.12 builder);
//! - required tool / form — the equipped-item-class line then the stance line, each white when
//! met and red when not;
//! - reagents — inline red per missing item (the one builder that uses the `|cffff2020` escape;
//! joins when a reagent feed exists);
//! - description — gold, wrapped. An AURA's description is **white** (byte-verified difference),
@@ -34,8 +38,13 @@ const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpellTooltipView {
pub name: String,
/// "Rank N" — the gray right column of the name line.
/// "Rank N" — the gray right column of the name line, on the SPELL variant only.
pub rank: Option<String>,
/// "Magic" / "Curse" / "Disease" / "Poison" — the AURA variant's right column (law §3-BUFF),
/// the `SpellDispelType.dbc` name of the spell's dispel class gated by that table's `[+0x28]`
/// flag, so an undispellable aura (Stealth) carries `None`. Rendered GOLD, like the aura name
/// it shares its line with — not the spell variant's gray.
pub dispel_type: Option<String>,
/// "35 Mana" / "20 Rage" / "Next melee" — the cost cell.
pub cost: Option<String>,
/// "30 yd range" — the range cell.
@@ -46,12 +55,28 @@ pub struct SpellTooltipView {
/// "15 sec cooldown" — the cooldown cell: `max(RecoveryTime, CategoryRecoveryTime)` (the
/// 0276 line law §3.4 — Charge's 15 s lives in the CATEGORY column).
pub cooldown: Option<String>,
/// "Requires Wands" — the equipped-item-class half of law §3.6, over
/// `EquippedItemClass`/`EquippedItemSubClassMask`: white when [`Self::item_met`], red when
/// not. Sits ABOVE [`Self::requires_form`] (the law's tool-then-form order).
pub requires_item: Option<String>,
/// Whether some WORN item satisfies the class + subclass mask (the app re-pushes views when
/// the equipped set changes, so the color tracks live swaps).
pub item_met: bool,
/// "Requires Battle Stance" — the required-form line (law §3.6, `SPELL_REQUIRED_FORM` over
/// the `Stances` mask): white when [`Self::form_met`], red when not.
pub requires_form: Option<String>,
/// Whether the player's CURRENT shapeshift form satisfies the mask (the app re-pushes views
/// on a form change, so the color tracks live stance switches).
pub form_met: bool,
/// "Reagents: Light Feather" — law §3.8, `SPELL_REAGENTS` ("Reagents: ", no format slot) +
/// the 8 reagent slots. Rendered WHITE and wrapped; a reagent the player is short of carries
/// the builder's own **inline** `|cffff2020` escape (the spell builder is the one builder
/// that colors mid-line), so the composed string arrives paint-ready from the app.
pub reagents: Option<String>,
/// "2.62% chance to dodge" — the law's line 10 (§3-CHANCE), selected by the spell's `Effect[0]`
/// and rendered white and unwrapped, BELOW the reagents and ABOVE the description. The
/// percentage is the player's own live avoidance/crit field, so the app re-pushes as it moves.
pub chance: Option<String>,
/// The $-substituted description — gold + wrapped for spells.
pub description: String,
/// The $-substituted AURA description (`Spell.dbc AuraDescription`) — the buff hover's
@@ -118,18 +143,21 @@ fn render_spell(
remaining: Option<String>,
talent: Option<&TalentLines>,
) -> mlua::Result<()> {
// The rank column shows only when the CALLER asks (byte-verified: SetSpell passes
// param6=0 — the spellbook hover never shows "Rank N"; SetAction passes 1).
match v.rank.as_ref().filter(|_| show_rank) {
Some(rank) => append_line(
lua,
this,
(v.name.clone(), WHITE),
Some((rank.clone(), GRAY)),
false,
)?,
None => append_line(lua, this, (v.name.clone(), WHITE), None, false)?,
}
// The name colour splits by BUILDER, byte-verified: the spell builder `0x52e610` writes its
// name line through `0x530270` (white), the aura builder `0x52f880` through `0x530380` — the
// GOLD wrapper (wow-re tooltip-content-law §3 line 1 vs §3-BUFF). SetTrackingSpell's gold,
// already pinned by the director's own A/B, is the same wrapper.
let name_color = if aura { GOLD } else { WHITE };
// The name line's RIGHT column splits by builder too. The spell builder's is the gray "Rank N",
// and it shows only when the CALLER asks (byte-verified: SetSpell passes param6=0 — the
// spellbook hover never shows "Rank N"; SetAction passes 1). The aura builder's is the dispel
// class ("Magic" on Ice Armor — §3-BUFF), and it is GOLD, not gray: a buff never shows a rank.
let right = if aura {
v.dispel_type.clone().map(|t| (t, GOLD))
} else {
v.rank.clone().filter(|_| show_rank).map(|t| (t, GRAY))
};
append_line(lua, this, (v.name.clone(), name_color), right, false)?;
// The talent head: "Rank r/m" (builder line 2, TOOLTIP_TALENT_RANK white) + the red
// requirement lines while locked (position CONFIRMED, decision 0305 — TalentLines doc).
if let Some(t) = talent {
@@ -165,11 +193,25 @@ fn render_spell(
None => append_line(lua, this, (ct.clone(), WHITE), None, false)?,
}
}
// Required form (law §3.6, SPELL_REQUIRED_FORM): white when met, red when not.
// Required tool / form (law §3.6): the equipped-item-class line first, then the stance
// line. Each white when met, red when not.
if let Some(req) = &v.requires_item {
let color = if v.item_met { WHITE } else { RED };
append_line(lua, this, (req.clone(), color), None, false)?;
}
if let Some(req) = &v.requires_form {
let color = if v.form_met { WHITE } else { RED };
append_line(lua, this, (req.clone(), color), None, false)?;
}
// Reagents (law §3.8): white + wrapped, the missing entries inline-red inside the text.
if let Some(reagents) = &v.reagents {
append_line(lua, this, (reagents.clone(), WHITE), None, true)?;
}
// Chance to dodge/parry/block/crit (law line 10, §3-CHANCE) — white, NOT wrapped, and it
// sits here: below the reagents, above the description.
if let Some(chance) = &v.chance {
append_line(lua, this, (chance.clone(), WHITE), None, false)?;
}
}
let desc = if aura && !v.aura_description.is_empty() {
&v.aura_description
@@ -363,12 +405,13 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
}
// GameTooltip:SetTrackingSpell() — the minimap tracking icon's hover. Its shape differs from
// the buff-bar hover: the NAME line is GOLD, the (aura-)description white — pinned by the
// director's reference A/B (2026-07-20: "Find Minerals" gold over white "Finding Minerals.",
// vs SetPlayerBuff's white name). `0x532c50`'s body isn't carved; carve it in wow-re before
// extending this shape. No duration-remaining line (only SetPlayerBuff appends one), and no
// tracking active clears + hides, like the SetUnitBuff miss path.
// GameTooltip:SetTrackingSpell() — the minimap tracking icon's hover: NAME gold over a white
// (aura-)description, pinned by the director's reference A/B (2026-07-20: "Find Minerals"
// gold over white "Finding Minerals."). That is just the AURA builder's shape — its name line
// rides the gold wrapper `0x530380` too — so this is no longer a one-off; `0x532c50`'s body
// still isn't carved, so carve it in wow-re before extending BEYOND that shape. No
// duration-remaining line (only SetPlayerBuff appends one), and no tracking active clears +
// hides, like the SetUnitBuff miss path.
m.set(
"SetTrackingSpell",
lua.create_function(|lua, this: Table| {
+10
View File
@@ -72,6 +72,16 @@ pub struct RegionHandle {
generation: u32,
}
impl RegionHandle {
/// The handle's identity as one integer — index and generation packed — for the layout change
/// gate's fingerprint (`script::layout::InputFingerprint`), which must notice a region being
/// swapped for a different one at the same map size.
#[inline]
pub(crate) fn fingerprint_bits(self) -> u64 {
(u64::from(self.generation) << 32) | u64::from(self.index)
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// A hand-rolled generational arena (no external deps — a slotmap in miniature)
// ─────────────────────────────────────────────────────────────────────────────────────────────
+4
View File
@@ -53,6 +53,10 @@ cosmic-text = "0.16"
fontdb = "0.23"
kira = { version = "0.12", default-features = false, features = ["cpal", "mp3", "wav", "pcm", "adpcm"] }
mint = "0.5"
# The gossip greeting is a weighted random DRAW per menu-open, not a fixed pick (wow-re
# `gossip-npctext-law.md` — the reference re-rolls in its gossip handler), so the app needs a uniform
# source. Pinned to 0.8: the version `benilla-srp` already depends on, so this adds no crate.
rand = "0.8"
# macOS: drive a native NSCursor for a crisp hardware cursor. winit drives the cursor via AppKit
# cursor-rects, which our continuous Metal render loop doesn't honor on mouse-move (custom cursor /
+2 -2
View File
@@ -62,8 +62,8 @@
tab, matching `BenillaPaperDollFrame`'s own lack of a per-page close button.
4. THE RANK TEXT FORMAT IS THE REF'S BYTE-EXACT ONE, not the task brief's own paraphrase:
`ref-ui/SkillFrame.lua` l.178-186 builds `rank .. " (" .. color .. modifier .. close ..
")/" .. max` (e.g. "57 (|cff20ff20+3|r)/75") — transcribed verbatim (the reference
file is the authority on the tuple/format, the brief's prose is a summary).
")/" .. max` (e.g. "57 (|cff20ff20+3|r)/75") — transcribed verbatim (the
reference file is the authority on the tuple/format, the brief's prose is a summary).
5. NO FILLBAR (ref's `$parentFillBar`, driven by `numTempPoints`): [`GetSkillLineInfo`]
always reports `numTempPoints = 0` (skills.rs's own module doc — 1.12 training points are
dead data), so the fill bar could never show — dropped outright, `TradeSkillFrame.xml`'s
+15 -5
View File
@@ -1,8 +1,10 @@
//! The shared debug-trace sink behind `WOW_MOVE_TRACE=<path>`: one file, one clock, written by
//! the player mover's frame lines ([`crate::player`]'s `move_trace`) and the anim driver's event
//! lines ([`crate::creature_anim`]), so the two layers interleave on a common timeline and a feel
//! report ("it snaps when I land") can be read across both. Costs one `OnceLock` read per call
//! when the env var is unset.
//! the player mover's frame lines ([`crate::player`]'s `move_trace`), its outbound wire lines
//! (`snd`), the anim driver's event lines ([`crate::creature_anim`]), and the remote-replay lines
//! (`rly`/`run`, [`crate::net::motion`]) — so the layers interleave on a common timeline and a feel
//! report ("it snaps when I land") can be read across all of them. Each file opens with a
//! `# t0=<unix epoch>` header so **two clients' traces align with each other**, which is what a
//! sender-vs-observer question needs. Costs one `OnceLock` read per call when the env var is unset.
use std::fs::File;
use std::io::Write;
@@ -19,9 +21,17 @@ static SINK: OnceLock<Option<Mutex<Sink>>> = OnceLock::new();
fn sink() -> Option<&'static Mutex<Sink>> {
SINK.get_or_init(|| {
let path = std::env::var("WOW_MOVE_TRACE").ok()?;
let out = File::create(&path)
let mut out = File::create(&path)
.map_err(|e| eprintln!("dbg-trace: cannot create {path}: {e}"))
.ok()?;
// The wall-clock epoch of this file's `t=0`, so **two traces can be read against each other**
// — a sender's `snd` lines beside an observer's `rly`/`run` lines from a different process
// (decision 0619). `t=` alone is per-process seconds and says nothing across clients; with
// this header, wall time is `t0 + t`.
let t0_wall = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0.0, |d| d.as_secs_f64());
let _ = writeln!(out, "# t0={t0_wall:.3} (unix epoch seconds at t=0)");
Some(Mutex::new(Sink {
out,
t0: Instant::now(),
+10 -2
View File
@@ -62,8 +62,16 @@ pub(super) struct EntityPart {
/// already bakes it for the un-swapped parts.
pub(super) two_sided: bool,
/// The part's `skinSectionId` (geoset / mesh-part ID). For a player body the per-entity attach
/// filters parts by it (only the selected hair/facial/body geosets spawn); `0` and ignored for
/// single-geoset models (creatures, GameObjects, WMO).
/// filters parts by it (only the selected hair/facial/body geosets spawn); **unfiltered for
/// everything else — which is correct, but NOT because those models have a single geoset.**
///
/// Plenty of them do not: 101 creature models author several (`Creature\Banshee` carries `0`
/// and `402`). They still draw whole, because the reference's per-model visibility array is
/// allocated **filled with `1`** — all visible — and the only writer is the character
/// compositor, which a creature never reaches (VERIFIED, wow-re `models.md` §"M2 geoset
/// visibility": the `CCharacterComponent` is created on exactly two guarded paths and a
/// creature fails both). That array is indexed by **submesh ordinal**, not by this id — the
/// client never decomposes a `group*100 + variant` id anywhere in its render band.
pub(super) geoset_id: u16,
/// The character runtime texture slot this part carries (M2 type 1 = body, type 6 = hair). For a
/// player the per-entity attach swaps its material to one carrying that per-appearance texture
+61 -56
View File
@@ -157,9 +157,70 @@ impl Items {
}
}
/// A minimal, VALID item template named `name` — the shared test seam for every module that
/// needs a landed template (the sentinels that matter are `allowable_*` = −1 and `stackable` = 1).
#[cfg(test)]
pub(crate) fn test_template(name: &str) -> ItemInfo {
ItemInfo {
class: 0,
subclass: 0,
name: name.into(),
display_info_id: 1,
quality: 1,
flags: 0,
buy_price: 0,
sell_price: 0,
inventory_type: 0,
allowable_class: -1,
allowable_race: -1,
item_level: 0,
required_level: 0,
required_skill: 0,
required_skill_rank: 0,
required_spell: 0,
required_honor_rank: 0,
required_city_rank: 0,
required_rep_faction: 0,
required_rep_rank: 0,
max_count: 0,
stackable: 1,
container_slots: 0,
stats: Vec::new(),
damages: Vec::new(),
dmg_min: 0.0,
dmg_max: 0.0,
dmg_type: 0,
armor: 0,
resistances: [0; 6],
delay_ms: 0,
ammo_type: 0,
ranged_mod_range: 0.0,
spells: Vec::new(),
use_spell: None,
bonding: 0,
description: String::new(),
page_text: 0,
language_id: 0,
page_material: 0,
start_quest: 0,
lock_id: 0,
material: 0,
sheath: 0,
random_property: 0,
block: 0,
item_set: 0,
max_durability: 0,
area: 0,
map: 0,
bag_family: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::test_template as info;
use crossbeam_channel::TryRecvError;
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
@@ -167,62 +228,6 @@ mod tests {
(NetCommands(tx), rx)
}
fn info(name: &str) -> ItemInfo {
ItemInfo {
class: 0,
subclass: 0,
name: name.into(),
display_info_id: 1,
quality: 1,
flags: 0,
buy_price: 0,
sell_price: 0,
inventory_type: 0,
allowable_class: -1,
allowable_race: -1,
item_level: 0,
required_level: 0,
required_skill: 0,
required_skill_rank: 0,
required_spell: 0,
required_honor_rank: 0,
required_city_rank: 0,
required_rep_faction: 0,
required_rep_rank: 0,
max_count: 0,
stackable: 1,
container_slots: 0,
stats: Vec::new(),
damages: Vec::new(),
dmg_min: 0.0,
dmg_max: 0.0,
dmg_type: 0,
armor: 0,
resistances: [0; 6],
delay_ms: 0,
ammo_type: 0,
ranged_mod_range: 0.0,
spells: Vec::new(),
use_spell: None,
bonding: 0,
description: String::new(),
page_text: 0,
language_id: 0,
page_material: 0,
start_quest: 0,
lock_id: 0,
material: 0,
sheath: 0,
random_property: 0,
block: 0,
item_set: 0,
max_durability: 0,
area: 0,
map: 0,
bag_family: 0,
}
}
#[test]
fn template_miss_queries_once_then_serves_the_answer() {
let (cmds, rx) = commands();
+1
View File
@@ -121,6 +121,7 @@ mod wdl;
mod weather;
mod wmo_portal;
mod world_map;
mod world_state;
use assets::AssetPlugin;
use avian3d::prelude::*;
+71 -2
View File
@@ -32,8 +32,13 @@ pub(crate) struct NameCache {
/// the `CreatureType.dbc` id the TAB-target critter filter reads; rank/civilian feed the
/// unit tooltip's level-line word + CIVILIAN line (decision 0276).
creatures: HashMap<u32, Option<CreatureRecord>>,
/// Pet names by **pet number** — the third naming path (see [`Self::resolve`]'s pet branch).
/// No negative entry exists: the server answers for a live pet or says nothing at all, so an
/// unanswered ask stays unanswered, exactly as it does for the real client.
pets: HashMap<u32, String>,
pending_players: HashSet<u64>,
pending_creatures: HashSet<u32>,
pending_pets: HashSet<u32>,
}
/// One cached creature template head (see [`NameCache::creatures`]).
@@ -67,6 +72,11 @@ impl NameCache {
return None;
}
self.players.get(&guid_val).and_then(|n| n.as_deref())
} else if let Some(pet_number) = guid::pet_number(guid_val) {
// A pet is a `TYPEID_UNIT` like any creature, but it carries a pet number where a
// creature carries its template entry, so it has its own query. Asking the creature
// query for that number is what left every summoned pet nameless.
self.resolve_pet(pet_number, guid_val, commands)
} else if guid::is_creature_or_pet(guid_val) {
let entry = guid::entry(guid_val)?;
self.resolve_creature(entry, guid_val, commands)
@@ -75,6 +85,24 @@ impl NameCache {
}
}
/// The name for a live pet by its `pet_number` — the pet twin of [`Self::resolve_creature`],
/// same ask-once discipline. Unlike the creature/player queries this one has **no negative
/// answer**: vmangos returns early without a packet when the guid is not a live pet bearing that
/// number (`PetHandler.cpp:190-192`), so an unanswered ask simply stays unresolved rather than
/// caching a "server doesn't know" — and is re-asked after a reconnect like the others.
fn resolve_pet(&mut self, pet_number: u32, guid: u64, commands: &NetCommands) -> Option<&str> {
if !self.pets.contains_key(&pet_number) {
if self.pending_pets.insert(pet_number) {
debug!("names: asking pet name (pet {pet_number})");
let _ = commands
.0
.send(ClientCommand::PetNameQuery { pet_number, guid });
}
return None;
}
self.pets.get(&pet_number).map(String::as_str)
}
/// The name for a creature template `entry`, if known — the entry-keyed twin of
/// [`Self::resolve`]'s creature branch (same cache, same ask-once discipline), for a caller that
/// has no live spawn guid to decode an entry from: a quest objective names its kill target only
@@ -107,6 +135,8 @@ impl NameCache {
pub(crate) fn peek(&self, guid_val: u64) -> Option<&str> {
if guid::is_player(guid_val) {
self.players.get(&guid_val).and_then(|n| n.as_deref())
} else if let Some(pet_number) = guid::pet_number(guid_val) {
self.pets.get(&pet_number).map(String::as_str)
} else if guid::is_creature_or_pet(guid_val) {
self.creatures
.get(&guid::entry(guid_val)?)
@@ -124,6 +154,12 @@ impl NameCache {
.insert(guid, (!name.is_empty()).then_some(name));
}
/// Record a pet-name answer (`SMSG_PET_NAME_QUERY_RESPONSE`), keyed by pet number.
pub(crate) fn insert_pet(&mut self, pet_number: u32, name: String) {
self.pending_pets.remove(&pet_number);
self.pets.insert(pet_number, name);
}
/// Record a creature-name answer (`SMSG_CREATURE_QUERY_RESPONSE`); `None` = unknown entry.
pub(crate) fn insert_creature(&mut self, entry: u32, record: Option<CreatureRecord>) {
self.pending_creatures.remove(&entry);
@@ -156,11 +192,15 @@ impl NameCache {
.map(|r| r.creature_type)
}
/// Forget the in-flight asks (a disconnect may have dropped them on the writer floor); the
/// resolved names stay — they are stable across sessions.
/// Forget the in-flight asks (a disconnect may have dropped them on the writer floor). Resolved
/// player/creature names stay — those are identities, stable across sessions. Resolved **pet**
/// names do not: a pet number names one live spawn rather than a template, and nothing that
/// answered before the disconnect is still on the map after it.
pub(crate) fn clear_pending(&mut self) {
self.pending_players.clear();
self.pending_creatures.clear();
self.pending_pets.clear();
self.pets.clear();
}
}
@@ -179,6 +219,35 @@ mod tests {
(NetCommands(tx), rx)
}
/// A summoned pet resolves through `CMSG_PET_NAME_QUERY`, keyed by the pet number in its guid —
/// NOT through the creature query, which would ask for a template entry that does not exist and
/// leave the pet nameless (the reported bug: an NPC-summoned voidwalker with no name).
#[test]
fn a_pet_asks_the_pet_query_not_the_creature_query() {
let (cmds, rx) = commands();
let mut cache = NameCache::default();
let voidwalker = compose(guid::HIGH_PET, 137, 9);
assert_eq!(cache.resolve(voidwalker, &cmds), None);
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::PetNameQuery {
pet_number: 137,
..
})
));
// Ask-once, like every other name.
assert_eq!(cache.resolve(voidwalker, &cmds), None);
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
cache.insert_pet(137, "Voidwalker".into());
assert_eq!(cache.resolve(voidwalker, &cmds), Some("Voidwalker"));
assert_eq!(cache.peek(voidwalker), Some("Voidwalker"));
// Pet names are per-spawn, so a disconnect drops them.
cache.clear_pending();
assert_eq!(cache.peek(voidwalker), None);
}
#[test]
fn creature_miss_queries_once_then_serves_the_answer() {
let (cmds, rx) = commands();
+6 -2
View File
@@ -26,7 +26,7 @@ use crossbeam_channel::{Receiver, Sender};
use crate::schedule::WorldStage;
mod apply;
mod io;
pub(crate) mod io;
mod motion;
use apply::{apply_net_updates, tag_self_player};
@@ -60,7 +60,6 @@ impl Plugin for NetPlugin {
.insert_resource(PingShared(handles.ping))
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<motion::RelayClock>()
.init_resource::<PendingTransfer>()
.init_resource::<NetStatus>()
.init_resource::<DroppedOpcodes>()
@@ -71,6 +70,7 @@ impl Plugin for NetPlugin {
.init_resource::<crate::names::NameCache>()
.init_resource::<crate::go_templates::GameObjectTemplates>()
.init_resource::<crate::items::Items>()
.init_resource::<crate::world_state::WorldStates>()
.add_message::<TeleportMessage>()
.add_message::<SpeedChangeMessage>()
.add_message::<ServerSoundMessage>()
@@ -481,6 +481,10 @@ pub(crate) enum ClientCommand {
/// Ask a creature template's name (`CMSG_CREATURE_QUERY`, entry from the guid's bits 24–47);
/// answered by a `CreatureName` event into the cache.
CreatureQuery { entry: u32, guid: u64 },
/// Ask a pet's name (`CMSG_PET_NAME_QUERY`, pet number from the guid's bits 24–47 — where a
/// creature keeps its template entry); answered by a `PetName` event into the cache. A pet
/// cannot be named by [`Self::CreatureQuery`]; see [`benilla_protocol::guid::pet_number`].
PetNameQuery { pet_number: u32, guid: u64 },
/// Ask an item template (`CMSG_ITEM_QUERY_SINGLE`; `guid` = the concrete item when the ask is
/// about one, `0` for template-only); answered by an `ItemTemplate` event into the
/// [`crate::items::Items`] cache. Sent by the cache's ask-once resolve, never directly.
+74 -15
View File
@@ -115,8 +115,9 @@ pub(super) fn apply_net_updates(
ResMut<crate::ui_party::GroupState>,
// The taxi-map session (decision 0484 phase 1) + the mailbox session + its login-scoped
// arrival countdown (decision 0544) + the player-trade session (decision 0592) + the bank
// session and its purchase-refusal queue (decision 0604), grouped to stay under Bevy's
// 16-SystemParam ceiling (this tuple's 16th and last slot).
// session and its purchase-refusal queue (decision 0604) + the world-state table the
// NPC-text `$<n>w` tokens read, grouped to stay under Bevy's 16-SystemParam ceiling (this
// tuple's 16th and last slot).
(
ResMut<crate::ui_taxi::TaxiState>,
ResMut<crate::ui_mail::MailOpen>,
@@ -124,6 +125,7 @@ pub(super) fn apply_net_updates(
ResMut<crate::ui_trade::TradeSession>,
ResMut<crate::ui_bank::BankOpen>,
ResMut<crate::ui_bank::BankErrors>,
ResMut<crate::world_state::WorldStates>,
),
),
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
@@ -215,9 +217,11 @@ pub(super) fn apply_net_updates(
// the purge; the TRANSFER_PENDING transport block routes NEW_WORLD's coordinates).
ResMut<PendingTransfer>,
Query<&crate::transport::Transport>,
// The relayed-move clock map (decision 0601): server MovementInfo time → client
// fire-time, for the scheduled remote apply.
ResMut<crate::net::motion::RelayClock>,
// The REAL-time clock the relayed-move replay runs on (decisions 0601/0615): a remote's
// fire-time is stamped against this, and `drain_pending_moves`/`extrapolate_remote_units`
// read the same clock. Virtual time's `max_delta` clamp falls behind real time under
// occlusion throttling and would displace the whole replay schedule.
Res<Time<bevy::time::Real>>,
),
) {
let play_seq = &mut aura.4;
@@ -244,6 +248,7 @@ pub(super) fn apply_net_updates(
mut trade_session,
mut bank_open,
mut bank_errors,
mut world_states,
),
) = caches;
let (
@@ -405,15 +410,14 @@ pub(super) fn apply_net_updates(
jump,
transport,
} => {
// The scheduled-replay law (decision 0601): map the packet's server timestamp to
// a client fire-time; `unit_move` applies it now if due, else queues it on the
// unit for `drain_pending_moves`.
let now_ms = aura.1.elapsed_secs_f64() * 1000.0;
let fire_ms = aura.8.fire_time(time, now_ms);
// The scheduled-replay law (decisions 0601/0615): `unit_move` runs the mover's own
// replay chain over this packet's wire stamp to get its client fire-time, then
// applies it now if due, else queues it on the unit for `drain_pending_moves`.
let now_ms = aura.8.elapsed_secs_f64() * 1000.0;
objects::unit_move(
guid,
crate::net::motion::PendingMove {
fire_ms,
crate::net::motion::RelayMove {
wire_ms: time,
position,
orientation,
flags,
@@ -528,6 +532,9 @@ pub(super) fn apply_net_updates(
SessionEvent::PlayerName { guid, name, .. } => {
names.insert_player(guid, name);
}
SessionEvent::PetName { pet_number, name } => {
names.insert_pet(pet_number, name);
}
SessionEvent::CreatureName {
entry,
name,
@@ -663,6 +670,18 @@ pub(super) fn apply_net_updates(
// per type, resolves the sender name ask-once, and AddMessages it into ChatFrame1.
// System lines (type 0x0A) are GM dot-command feedback — now visible, not silent.
debug!("net: chat [{:#04x}] {}", m.chat_type, m.text);
// …and on the trace clock too (decision 0624). A GM dot-command is the only way to
// ask the SERVER what it believes — `.gps` reads back the server-side position of a
// mover whose packets may or may not be reaching it — and its answer is only usable
// if it lands on the same timeline as the `snd`/`rly`/`run` lines it must be read
// against. `debug!` timestamps are wall-clock in a different format; this is one
// clock, one file.
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
"sys",
&format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")),
);
}
chat_log.push_wire(m);
}
SessionEvent::ChannelNotify {
@@ -1094,9 +1113,23 @@ pub(super) fn apply_net_updates(
text_id,
options,
quests,
} => npc::gossip_menu(npc, text_id, options, quests, &mut gossip, &net_commands),
SessionEvent::NpcGreeting { text_id, greeting } => {
npc::npc_greeting(text_id, greeting, &mut gossip)
} => {
let gender = npc_gender(npc, &index, &stores);
npc::gossip_menu(
npc,
gender,
text_id,
options,
quests,
&mut gossip,
&net_commands,
);
}
SessionEvent::NpcGreeting { text_id, blocks } => {
// The record answers a query we sent for the OPEN menu, so its NPC is the one whose
// gender picks the column (decision 0081's ask-once flow).
let gender = gossip.npc.map_or(0, |npc| npc_gender(npc, &index, &stores));
npc::npc_greeting(text_id, gender, blocks, &mut gossip)
}
SessionEvent::GossipComplete => npc::gossip_complete(&mut gossip, &mut quest),
// Questgiver panels (decision 0088): fill the `QuestGiver` the quest feed
@@ -1276,6 +1309,19 @@ pub(super) fn apply_net_updates(
SessionEvent::TradeStatusExtended { state } => {
trade::trade_status_extended(&state, &mut trade_session)
}
// The world-state table (`SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE`) — both
// wires funnel into the one setter, as the reference's own handler does. An init does
// NOT clear first: what its `(map, zone)` dwords drive is unrecorded, so we log the
// scope rather than act on it (rationale on `crate::world_state`).
SessionEvent::WorldStates { scope, states } => {
if let Some((map, zone)) = scope {
debug!(
"world states: map {map} zone {zone}, {} entries",
states.len()
);
}
world_states.write(&states);
}
}
}
// Flush the staged descriptor seeds/deltas onto the entities born this drain (now spawned by the
@@ -1287,6 +1333,19 @@ pub(super) fn apply_net_updates(
}
}
/// A streamed unit's gender (`UNIT_FIELD_BYTES_0` byte 2) by guid — the gossip greeting's column
/// selector (wow-re `gossip-npctext-law.md`: tested `== 1` for female, so genderless `2` reads as
/// male). `0` when the guid isn't streamed in or carries no descriptor yet, which is the same
/// column the reference takes for a gossip target that isn't a unit at all.
fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&mut ObjectStore>) -> u8 {
index
.0
.get(&guid)
.and_then(|&e| stores.get(e).ok())
.and_then(|s| s.0.unit_gender())
.unwrap_or(0)
}
/// Tag our own player's streamed entity with [`SelfPlayer`] once we know our guid — by matching the
/// [`Guid`] component against [`SelfGuid`]. The renderer skips this entity (the controller owns our
/// avatar); the controller reads its transform to take control. Done as its own pass (rather than at
+23 -11
View File
@@ -3,7 +3,7 @@
//! 0237), and the taxi-map session (decision 0484 phase 1). Each `pub(super)` fn here is exactly
//! one arm's body; the match at the call site stays the dispatcher, one call per arm.
use benilla_protocol::messages::{GossipOption, TaxiMask, TrainerSpell, VendorItem};
use benilla_protocol::messages::{GossipOption, NpcTextBlock, TaxiMask, TrainerSpell, VendorItem};
use bevy::prelude::*;
use crate::ui_bank::{BankErrors, BankOpen};
@@ -17,10 +17,16 @@ use super::super::{ClientCommand, NetCommands};
/// A gossip menu opened (`SMSG_GOSSIP_MESSAGE`): fill the [`GossipState`] the gossip feed
/// (`crate::ui_gossip`) reads. On a menu opening, auto-send the ask-once `CMSG_NPC_TEXT_QUERY`
/// for its greeting (served from the cache on a revisit); the greeting lands via
/// [`npc_greeting`]; [`gossip_complete`] closes it.
/// for its text record (served from the cache on a revisit); the record lands via [`npc_greeting`];
/// [`gossip_complete`] closes it.
///
/// The greeting is **drawn here**, not at the packet — this is the reference's own moment for it
/// (`0x4e2010`), and the draw needs both this NPC's gender and a fresh roll. `npc_gender` is
/// `UNIT_FIELD_BYTES_0` byte 2 off the NPC's descriptor, defaulting to male when the unit isn't
/// streamed in (which is also what the reference does for a non-unit gossip target).
pub(super) fn gossip_menu(
npc: u64,
npc_gender: u8,
text_id: u32,
options: Vec<GossipOption>,
quests: Vec<(u32, u32, String)>,
@@ -36,9 +42,9 @@ pub(super) fn gossip_menu(
gossip.text_id = text_id;
gossip.options = options;
gossip.quests = quests;
match gossip.cached_greeting(text_id) {
Some(greeting) => gossip.greeting = Some(greeting),
None => {
match gossip.cached_record(text_id).is_some() {
true => gossip.greeting = gossip.draw_greeting(text_id, npc_gender),
false => {
gossip.greeting = None;
let _ = net_commands
.0
@@ -47,12 +53,18 @@ pub(super) fn gossip_menu(
}
}
/// The greeting text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache, and only fill the OPEN
/// menu's greeting (a late answer for a menu we already closed just seeds the cache).
pub(super) fn npc_greeting(text_id: u32, greeting: String, gossip: &mut GossipState) {
gossip.remember_greeting(text_id, greeting.clone());
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache with the whole record, and draw
/// the greeting only for the OPEN menu (a late answer for a menu we already closed just seeds the
/// cache; the next open draws its own line).
pub(super) fn npc_greeting(
text_id: u32,
npc_gender: u8,
blocks: Vec<NpcTextBlock>,
gossip: &mut GossipState,
) {
gossip.remember_record(text_id, blocks);
if gossip.npc.is_some() && gossip.text_id == text_id {
gossip.greeting = Some(greeting);
gossip.greeting = gossip.draw_greeting(text_id, npc_gender);
}
}
+65 -25
View File
@@ -191,15 +191,15 @@ pub(super) fn object_move(
/// A relayed player movement packet (`MSG_MOVE_*`): the mover's authoritative pose + live move
/// flags. The reference SCHEDULES a remote's apply (decision 0601, wow-re
/// `remote-apply-timing.md`): the packet's server timestamp maps to a client fire-time
/// ([`crate::net::motion::RelayClock`]); an already-due move applies now, a future one queues on
/// the unit and fires in `drain_pending_moves` — the dead-reckon covering the mover's own
/// timeline in between, which is what kills the arrival-jitter snap. `WOW_REMOTE_SNAP=1`
/// restores raw apply-at-arrival for an A/B.
/// `remote-apply-timing.md`): the mover's own replay chain gives the packet a client fire-time
/// (decision 0615, [`crate::net::motion::RelayMove`] → `RelayChain::schedule`); an already-due move
/// applies now, a future one queues on the unit and fires in `drain_pending_moves` — the dead-reckon
/// covering the mover's own timeline in between, which is what kills the arrival-jitter snap.
/// `WOW_REMOTE_SNAP=1` restores raw apply-at-arrival for an A/B.
#[allow(clippy::too_many_arguments)] // the wire fields + the apply context, one per concern
pub(super) fn unit_move(
guid: u64,
ev: crate::net::motion::PendingMove,
mv: crate::net::motion::RelayMove,
now_ms: f64,
commands: &mut Commands,
index: &GuidIndex,
@@ -208,35 +208,69 @@ pub(super) fn unit_move(
transforms: &mut Query<&mut Transform>,
landings: &mut MessageWriter<crate::creature_anim::HardLanding>,
) {
use crate::net::motion::{apply_move, arrival_snap};
use crate::net::motion::{apply_move, arrival_snap, trace_relay, PendingMove, RelayOutcome};
// A relayed move belongs to another player; never let one drive our own avatar (the
// controller owns it, and the server doesn't echo our moves back anyway).
if self_guid.0 == Some(guid) {
trace_relay(
guid,
&mv,
&Default::default(),
now_ms,
0,
RelayOutcome::SelfMover,
);
return;
}
if let Some(&e) = index.0.get(&guid) {
let Some(&e) = index.0.get(&guid) else {
// No entity for this guid — the packet changes nothing. Traced rather than dropped in
// silence: a mover that keeps running while these pile up is a streaming bug, not a replay
// one, and the two look identical from the outside (decision 0619).
trace_relay(
guid,
&mv,
&Default::default(),
now_ms,
0,
RelayOutcome::Unknown,
);
return;
};
{
// The server is authoritative now, not any creature path — drop a stale spline.
commands.entity(e).remove::<Spline>();
if let Ok(mut rm) = remote_motion.get_mut(e) {
if arrival_snap() || ev.fire_ms <= now_ms {
apply_move(e, &ev, &mut rm, commands, landings);
// The chain reads the mover's state as it stands BEFORE this move applies — the
// reference times the packet off the live `[esi+0x40]`/`[esi+0x150]`.
let (flags, queue_empty) = (rm.flags, rm.pending.is_empty());
let fire_ms = rm.relay.schedule(mv.wire_ms, now_ms, flags, queue_empty);
let at_arrival = arrival_snap() || rm.fires_at_arrival(fire_ms, now_ms);
trace_relay(
guid,
&mv,
&rm.relay,
now_ms,
rm.pending.len(),
if at_arrival {
RelayOutcome::Now
} else {
RelayOutcome::Queued
},
);
if at_arrival {
apply_move(e, &mv, &mut rm, now_ms, commands, landings);
} else {
// Fire-times stay ascending per unit (the wire is ordered; a clock-estimate
// wobble could reorder them — clamp to the tail so the drain never stalls).
let fire_ms = rm
.pending
.back()
.map_or(ev.fire_ms, |b| b.fire_ms.max(ev.fire_ms));
rm.pending
.push_back(crate::net::motion::PendingMove { fire_ms, ..ev });
// Fire-times are monotone per unit by construction (a chained fire never lands
// before its predecessor — decision 0615), so the queue stays ordered.
rm.pending.push_back(PendingMove { fire_ms, mv });
}
} else {
// First move for this unit: apply immediately (scheduling starts with the next
// packet) and place it this frame — the component insert is deferred, so the
// extrapolator won't see it until next frame.
// First move for this unit: apply immediately (the chain seeds on it — its own fire
// is arrival — and paces from the next packet on) and place it this frame; the
// component insert is deferred, so the extrapolator won't see it until next frame.
let mut rm = RemoteMotion {
wow_pos: ev.position,
orientation: ev.orientation,
wow_pos: mv.position,
orientation: mv.orientation,
flags: 0,
pitch: 0.0,
speed: 0.0,
@@ -244,9 +278,15 @@ pub(super) fn unit_move(
jump_xy_vel: [0.0; 2],
fall_start_z: None,
pending: std::collections::VecDeque::new(),
relay: Default::default(),
last_apply_ms: now_ms,
last_apply_pos: mv.position,
};
apply_move(e, &ev, &mut rm, commands, landings);
write_pose(transforms, e, ev.position, ev.orientation);
let fire_ms = rm.relay.schedule(mv.wire_ms, now_ms, 0, true);
debug_assert_eq!(fire_ms, now_ms, "a seeding packet fires at arrival");
trace_relay(guid, &mv, &rm.relay, now_ms, 0, RelayOutcome::Seed);
apply_move(e, &mv, &mut rm, now_ms, commands, landings);
write_pose(transforms, e, mv.position, mv.orientation);
commands.entity(e).insert(rm);
}
}
+83 -3
View File
@@ -34,6 +34,42 @@ use crossbeam_channel::{Receiver, Sender};
use super::{CharRequest, ChatKind, ClientCommand};
/// **The inbound census** — every packet the read thread pulls off the world socket, whatever its
/// opcode, and the wall-clock of the most recent one (unix ms). Two numbers, one job: telling a
/// *silent server* apart from a *dead socket* (decision 0621).
///
/// When a remote mover freezes into a dead-reckoned runaway, the client cannot otherwise say which
/// of those it is looking at — and they are opposite bugs. If these keep climbing while the mover
/// starves, the connection is healthy and the server simply stopped relaying that unit; if they
/// freeze with it, the whole stream died without anyone noticing. Read by the runaway watch, which
/// prints them on every line ([`crate::net::motion`]).
pub(crate) static INBOUND_PACKETS: AtomicU64 = AtomicU64::new(0);
static LAST_INBOUND_UNIX_MS: AtomicU64 = AtomicU64::new(0);
/// Count one packet off the wire. Called for every `poll()` return — parsed or skipped, since a
/// packet we could not decode still proves the socket is alive.
fn note_inbound() {
INBOUND_PACKETS.fetch_add(1, Ordering::Relaxed);
LAST_INBOUND_UNIX_MS.store(unix_ms(), Ordering::Relaxed);
}
/// Wall-clock unix milliseconds — the one clock two processes share (the trace header's `t0` is in
/// the same units, so a mover's file and an observer's file line up).
fn unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
/// `(packets seen, ms since the last one)` — `None` for the age until a first packet has landed.
pub(crate) fn inbound_census() -> (u64, Option<u64>) {
let last = LAST_INBOUND_UNIX_MS.load(Ordering::Relaxed);
(
INBOUND_PACKETS.load(Ordering::Relaxed),
(last != 0).then(|| unix_ms().saturating_sub(last)),
)
}
/// One submitted login attempt (decision 0539): the credentials, plus the abandon generation at
/// submit time — the thread discards the attempt at its next stage boundary if the shared counter
/// has moved (a Cancel bumps it). A counter, not a flag: a flag cleared by the *next* submit would
@@ -399,10 +435,28 @@ fn run(
// aligned); a long run of consecutive skips means the stream desynced — guard against a busy spin.
let (mut skip_run, mut skip_logged) = (0u32, 0u32);
loop {
match reader.poll()? {
Poll::Events(evs) => {
let polled = reader.poll()?;
note_inbound(); // one packet off the wire, parsed or not — the census counts liveness
match polled {
Poll::Events { opcode, events } => {
skip_run = 0;
for ev in evs {
// The full inbound opcode stream (tag `in`, decision 0624) — the last place a
// packet could hide. `skip` covers what failed to parse and `rly` covers what
// reached the mover replay; between them sits the packet that parsed into *no*
// event, which no instrument could see. With this line every packet off the wire
// is accounted for by name, so "the server stopped relaying" and "we dropped it on
// the floor" are finally different pictures instead of the same silence.
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
"in",
&format!(
"{opcode:#06x} {} ev={}",
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?"),
events.len()
),
);
}
for ev in events {
// A confirmed logout ends the cycle *after* the app hears about it.
let logged_out = matches!(ev, SessionEvent::LoggedOut);
// Receiver dropped → the app exited; end the thread cleanly.
@@ -416,6 +470,14 @@ fn run(
}
Poll::Skipped { opcode, reason } => {
skip_run += 1;
// **Every** skip, uncapped, into the trace (tag `skip`, decision 0623). A packet that
// arrives and fails to parse is indistinguishable, from outside, from one that never
// arrived: the inbound census counts it either way, and no `rly` line is emitted
// either way. That ambiguity is what made a starving remote mover unattributable —
// so the skips get their own line, with the opcode that died.
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("skip", &format!("opcode={opcode:#06x} {reason}"));
}
// Log which packet we dropped (opcode + message name), capped so it can't itself
// flood — enough to capture a post-teleport burst of unparseable object updates.
if skip_logged < 40 {
@@ -492,6 +554,12 @@ fn writer_loop(
recv(cmd_rx) -> cmd => {
let Ok(cmd) = cmd else { return }; // all app senders dropped → app exit
let Some(w) = writer.as_mut() else {
// No live writer: the session is gone and this command evaporates. Traced
// unconditionally — this is the state in which a client keeps *deciding* to send
// movement (`snd` lines) that no one will ever receive (decision 0621).
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("wire", "DROPPED — no live session");
}
if warned < SEND_WARN_CAP {
bevy::log::warn!("net: dropping command — not connected");
warned += 1;
@@ -585,6 +653,9 @@ fn writer_loop(
ClientCommand::PlayedTime => w.played_time(),
ClientCommand::NameQuery { guid } => w.name_query(guid),
ClientCommand::CreatureQuery { entry, guid } => w.creature_query(entry, guid),
ClientCommand::PetNameQuery { pet_number, guid } => {
w.pet_name_query(pet_number, guid)
}
ClientCommand::ItemQuery { entry, guid } => w.item_query(entry, guid),
ClientCommand::UseItem { bag_index, slot } => w.use_item(bag_index, slot, 0),
ClientCommand::AutoEquipItem { bag_index, slot } => {
@@ -795,7 +866,16 @@ fn writer_loop(
nodes,
} => w.activate_taxi_express(guid, total_cost, &nodes),
};
// **What actually reached the socket** (tag `wire`, decision 0621). The controller's
// `snd` line is written before the command is even queued, so it records a decision,
// not a transmission — a client whose session died goes on producing `snd` lines into
// a dead channel forever, which is exactly the ambiguity that cost us a hunt. Only
// failures are traced: a silent `wire` log beside a busy `snd` log means every packet
// went out.
if let Err(e) = result {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("wire", &format!("SEND FAILED: {e:#}"));
}
if warned < SEND_WARN_CAP {
bevy::log::warn!("net: send failed: {e:#}");
warned += 1;
+6 -3
View File
@@ -11,7 +11,8 @@
//!
//! The parent [`super`] module owns the wire→ECS bridge that seeds + corrects these from the packet
//! stream; this module owns the integration, split by concern: [`spline`] (the path walk + its
//! terrain re-ground), [`remote`] (dead-reckoning + jump ballistics), [`facing`] (wire facing
//! terrain re-ground), [`remote`] (dead-reckoning + jump ballistics), [`relay`] (***when*** a relayed
//! move replays — the per-unit fire-time chain), [`facing`] (wire facing
//! resolution + the client-local idle re-face and its [`FacingStep`] shuffle latch). This face keeps
//! the shared pose glue. Poses live canonically in raw WoW space on the components; the `Transform`
//! is derived (translation + facing only — scale, baked by the renderer, is preserved).
@@ -20,6 +21,7 @@ use benilla_assets::coords::wow_to_bevy;
use bevy::prelude::*;
mod facing;
mod relay;
mod remote;
mod spline;
#[cfg(test)]
@@ -27,9 +29,10 @@ mod tests;
pub(crate) use facing::FacingStep;
pub(super) use facing::{face_target, resolve_facing};
pub(in crate::net) use remote::{apply_move, arrival_snap};
pub(crate) use relay::{PendingMove, RelayMove};
pub(crate) use remote::RemoteMotion;
pub(in crate::net) use remote::{apply_move, arrival_snap, trace_relay, RelayOutcome};
pub(super) use remote::{drain_pending_moves, extrapolate_remote_units};
pub(crate) use remote::{PendingMove, RelayClock, RemoteMotion};
pub(super) use spline::{
ground_clamp_creatures, mark_swimming_creatures, monster_move_spline, sample_splines,
};
+175
View File
@@ -0,0 +1,175 @@
//! **When** a remote unit's relayed move replays — the reference's per-unit replay chain, byte-traced
//! (decision 0615; supersedes the invented estimator of 0601).
//!
//! A remote's inbound `MSG_MOVE_*` is not applied at arrival: it is given a client **fire-time** and
//! waits in the unit's queue until the clock reaches it, the dead-reckon covering the mover's own
//! timeline in between (decision 0601, wow-re `remote-apply-timing.md`). The law that picks that
//! fire-time lives in `0x618c30` + its window helper `0x618b50`, and it is **per unit** — five cells
//! on the unit's own CMovement, no manager cursor:
//!
//! | cell | here | meaning |
//! |------|------|---------|
//! | `+0xa8` | [`RelayChain::last_fire_ms`] | the fire-time the previous packet was given |
//! | `+0xac` | [`RelayChain::last_wire_ms`] | the previous packet's wire stamp |
//! | `+0xbc[32]`/`+0x13c` | [`RelayChain::ring`]/`ring_idx` | the lateness ring + its cursor |
//! | `+0x140` | [`RelayChain::base_ms`] | the lateness base the ring is stored against |
//!
//! The law, in one line: **a moving unit replays on the sender's own cadence** — `fire = prev fire +
//! (this wire stamp − prev wire stamp)`, so the spacing our client plays back is exactly the spacing
//! the stamps carry, no matter how the packets clumped in flight. The buffer that absorbs the clumping
//! is only ever re-sized while the mover is **idle with nothing queued**: there the chain re-bases by
//! the worst lateness seen over the last 32 packets, which is a de-jitter buffer sized by measured
//! jitter rather than by an estimate re-derived per packet. Either way the offset from arrival is held
//! in `[−500, +1000] ms`.
//!
//! Byte index: `0x618c30` (chain: seed `@0x618c54`, wire delta `@0x618ca0-cc2`, skew out `@0x618ccf`,
//! idle+empty gate `@0x618ce4`/`@0x618cf3`, re-base `@0x618cfc-d41`, clamps `@0x618d0d`/`@0x618d49`,
//! fire store `@0x618dcc`, due test `@0x618dd2`) · `0x618b50` (the ring) · `0x615c30` (the drain).
use benilla_protocol::{JumpInfo, TransportPose};
/// One relayed move exactly as it came off the wire — the payload of
/// [`benilla_protocol::SessionEvent::UnitMove`], before [`RelayChain`] decides *when* it applies.
#[derive(Clone)]
pub(crate) struct RelayMove {
/// The `MovementInfo` time word — vmangos's own ms clock stamped at **receipt**
/// (`MovementInfo::Read`: `stime = WorldTimer::getMSTime()`, a live `steady_clock`; relayed
/// verbatim by `Write`), so every mover's stamps share one coherent server clock. The chain paces
/// replay by the deltas between consecutive stamps.
pub(crate) wire_ms: u32,
pub(crate) position: [f32; 3],
pub(crate) orientation: f32,
pub(crate) flags: u32,
pub(crate) pitch: f32,
pub(crate) fall_time: u32,
pub(crate) jump: Option<JumpInfo>,
pub(crate) transport: Option<TransportPose>,
/// `MSG_MOVE_HEARTBEAT` — excluded from the pre-fire reconcile lerp (the reference's `0x619090`
/// skips tag `0x26`); it applies as an outright snap.
pub(crate) heartbeat: bool,
}
/// One **scheduled** relayed move — the reference's queued move-event node (`0x617570`: fire-time at
/// `node[+8]`, pose at `node[+0x10]`).
#[derive(Clone)]
pub(crate) struct PendingMove {
/// When to apply, on the real-time ms clock (the reference's `ev[+8]`).
pub(crate) fire_ms: f64,
/// What to apply.
pub(crate) mv: RelayMove,
}
/// The reference's skew clamp (`0x618c30 @0x618d0d` and `@0x618d49`: `lea eax,[skew+0x1f4]; cmp
/// eax,0x5dc` saturating the biased value into `[0, 1500]`): a fire-time never lands more than 500 ms
/// **before** its packet's arrival, nor more than 1000 ms after.
const SKEW_MIN_MS: f64 = -500.0;
const SKEW_MAX_MS: f64 = 1000.0;
/// The lateness window: 32 packets — `0x618b50`'s ring at `CMovement+0xbc` with its cursor at `+0x13c`
/// masked `& 0x1f` (32 dwords is exactly `0x13c − 0xbc`).
const LATENESS_WINDOW: usize = 32;
/// The "this mover is mid-motion" mask the re-base gates on — `0x618c30 @0x618ce4`'s
/// `test [esi+0x40],0x20ff`: the four direction bits, both turn bits, both pitch bits (`0x40`/`0x80`),
/// and `FALLING` (`0x2000`). While any is set the chain keeps the sender's pacing untouched; only a
/// standing, un-queued mover re-sizes the buffer.
const BUSY_MASK: u32 = 0x20ff;
/// A remote unit's replay chain — the per-unit `CMovement` timing cells (see the module header).
/// Lives on [`super::RemoteMotion`]; one per mover, never shared (the pre-0615 estimator was a single
/// app-wide resource, which is structurally wrong: two movers behind different network paths must
/// carry different buffers).
#[derive(Clone, Default)]
pub(crate) struct RelayChain {
/// Whether the chain has seen its first packet — the reference's `CMovement+0x40` bit 31, set
/// alongside the first seed of the two cells below (`0x618c54-6a`).
seeded: bool,
/// `+0xa8` — the fire-time the previous packet was given (client ms). The whole chain hangs off
/// this: the next fire is *this* plus the wire step.
last_fire_ms: f64,
/// `+0xac` — the previous packet's wire stamp. Advanced only by a **forward** step
/// (`@0x618cb8`: `jle` skips the store), so a stale or duplicate stamp can't drag the chain back.
last_wire_ms: u32,
/// `+0xbc[32]` — the lateness ring: how late each of the last 32 packets ran against the chain,
/// each stored **relative to [`Self::base_ms`] as it stood at that packet** (`0x618b50` writes
/// `base + lateness`). That offset is what stops a spike already absorbed into the base from being
/// charged a second time on the next re-base.
ring: [f64; LATENESS_WINDOW],
/// `+0x13c` — the ring write cursor, wrapped at 32.
ring_idx: usize,
/// `+0x140` — the lateness base the ring is stored against, i.e. the buffer the chain currently
/// holds. Re-based to the window max whenever an idle mover with an empty queue takes a packet.
base_ms: f64,
}
impl RelayChain {
/// Give this relayed move its client fire-time (`0x618c30`), advancing the chain.
///
/// `now_ms` is the packet's arrival on our real-time ms clock, `flags`/`queue_empty` the mover's
/// state **before** this move applies (the reference reads `[esi+0x40]` and `[esi+0x150]` off the
/// live CMovement at exactly this point). The caller applies the move at once if the returned
/// fire-time is already due, else queues it for the drain — the reference's `mgr+0x128 ≥ fire`
/// test (`@0x618dd2`), which for us is `fire ≤ now` because our arrival stamp *is* the frame clock
/// the drain compares against.
pub(crate) fn schedule(
&mut self,
wire_ms: u32,
now_ms: f64,
flags: u32,
queue_empty: bool,
) -> f64 {
// First packet for this mover: anchor both cells on it, so the step below is zero and the
// move fires at arrival (`@0x618c54`).
if !self.seeded {
self.seeded = true;
self.last_wire_ms = wire_ms;
self.last_fire_ms = now_ms;
}
// The sender's own step between packets. Wrapping, because the server's ms clock is a `u32`
// (vmangos uptime ms — it wraps every 49.7 days); a non-forward step contributes nothing and
// leaves the reference stamp alone (`@0x618cb6-c2`).
let step = wire_ms.wrapping_sub(self.last_wire_ms) as i32;
let wire_delta = if step > 0 {
self.last_wire_ms = wire_ms;
f64::from(step)
} else {
0.0
};
// How much the flight took beyond the chain's own pacing. `skew` is what the reference writes
// out at `@0x618ccf`; note `now + skew == last_fire + wire_delta`, which IS the law: replay
// paced by the sender's stamps.
let arrival_delta = now_ms - self.last_fire_ms;
let mut skew = wire_delta - arrival_delta;
let window_max = self.record_lateness(arrival_delta - wire_delta);
// The buffer is only ever re-sized on a standing mover with nothing queued (`@0x618ce4`,
// `@0x618cf3`): re-base by the worst lateness the window still remembers, never letting the
// chain step backwards past the previous fire (`@0x618d3b`).
if flags & BUSY_MASK == 0 && queue_empty {
skew = (skew + window_max - self.base_ms).clamp(SKEW_MIN_MS, SKEW_MAX_MS);
if now_ms + skew < self.last_fire_ms {
skew = self.last_fire_ms - now_ms;
}
self.base_ms = window_max;
}
let fire_ms = now_ms + skew.clamp(SKEW_MIN_MS, SKEW_MAX_MS);
self.last_fire_ms = fire_ms;
fire_ms
}
/// Record this packet's lateness and return the window's worst (`0x618b50`): store
/// `base + lateness` at the cursor, advance it mod 32, and return the max over all 32 slots (the
/// entry just written included — the reference seeds its scan with it and walks the whole ring).
/// An unfilled slot reads as the zero the reference's freshly-constructed CMovement holds.
fn record_lateness(&mut self, lateness_ms: f64) -> f64 {
self.ring[self.ring_idx] = self.base_ms + lateness_ms;
self.ring_idx = (self.ring_idx + 1) % LATENESS_WINDOW;
self.ring.iter().copied().fold(f64::NEG_INFINITY, f64::max)
}
/// The scheduling lead this move got — `fire − arrival`, i.e. how long the dead-reckon has to
/// cover before the pose lands. Negative means the move was already due and applies at arrival.
/// Trace/diagnostic only ([`super::remote::trace_schedule`]).
pub(crate) fn lead_ms(&self, now_ms: f64) -> f64 {
self.last_fire_ms - now_ms
}
}
+328 -81
View File
@@ -2,14 +2,17 @@
//! [`super`]'s motion model (decision 0053): flag-driven ground locomotion between the ~2 Hz
//! heartbeats, and the jump as a locally-played ballistic event.
use benilla_assets::coords::wow_to_bevy;
use avian3d::character_controller::move_and_slide::MoveAndSlide;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_protocol::{JumpInfo, MoveSpeeds};
use bevy::prelude::*;
use bevy::time::Real;
use crate::creature_anim::move_flags;
use crate::player::{GRAVITY, TERMINAL_VELOCITY};
use super::super::{SelfPlayer, UnitSpeeds};
use super::relay::{PendingMove, RelayChain, RelayMove};
use super::{yaw_of, Spline};
/// The server-authoritative movement state of a *remote* mover (another player), set from each relayed
@@ -62,80 +65,152 @@ pub(crate) struct RemoteMotion {
/// clock reaches its [`PendingMove::fire_ms`]; until then the dead-reckon covers the mover's
/// own timeline, so the residual at apply time is structurally small.
pub(crate) pending: std::collections::VecDeque<PendingMove>,
/// This mover's replay chain — the per-unit timing cells that pick each packet's fire-time
/// (decision 0615; [`super::relay`]). Per unit, exactly as the reference holds them on the
/// unit's own CMovement.
pub(crate) relay: RelayChain,
/// Real-time ms when a packet last applied to this mover (`0.0` until the first one), and the
/// position it applied at. The dead-reckon's anchor — everything since is *our extrapolation*,
/// not anything the server said. Read by the runaway watch in [`extrapolate_remote_units`].
pub(crate) last_apply_ms: f64,
pub(crate) last_apply_pos: [f32; 3],
}
/// One scheduled relayed move — the payload of [`benilla_protocol::SessionEvent::UnitMove`] plus
/// the client fire-time [`RelayClock`] mapped from its server timestamp. The reference's queued
/// move-event node (`0x617570`: fire-time at `node[+8]`, pose at `node[+0x10]`).
#[derive(Clone)]
pub(crate) struct PendingMove {
/// When to apply, on [`Time::elapsed`]'s ms clock (the reference's `ev[+8]`).
pub(crate) fire_ms: f64,
pub(crate) position: [f32; 3],
pub(crate) orientation: f32,
pub(crate) flags: u32,
pub(crate) pitch: f32,
pub(crate) fall_time: u32,
pub(crate) jump: Option<JumpInfo>,
pub(crate) transport: Option<benilla_protocol::TransportPose>,
/// `MSG_MOVE_HEARTBEAT` — excluded from the pre-fire reconcile lerp (the reference's
/// `0x619090` skips tag `0x26`); it applies as an outright snap.
pub(crate) heartbeat: bool,
/// What [`crate::net::apply`]'s `unit_move` did with an inbound relayed move — the `out=` field of
/// the `rly` trace. Every arrival gets a line, **including the ones we discard**: "the packet never
/// showed up" and "the packet showed up and lost" are different bugs with the same symptom, and the
/// trace has to tell them apart (decision 0619).
#[derive(Clone, Copy)]
pub(in crate::net) enum RelayOutcome {
/// Applied at arrival — it was due and the unit's queue was empty.
Now,
/// Scheduled: waiting in the unit's queue for its fire-time.
Queued,
/// The unit's first packet: seeds the chain and places it immediately.
Seed,
/// Ours. The controller owns our avatar; the server never echoes our own moves anyway.
SelfMover,
/// **No entity for this guid.** The mover isn't in our object index — so this packet, Stop or
/// not, changes nothing. A stale mover that keeps running while these accumulate is a streaming
/// bug, not a replay bug.
Unknown,
}
/// The relayed-move clock map (decision 0601): estimates the offset between the server's
/// `MovementInfo` ms clock (vmangos stamps `stime` at receipt — one coherent clock for all movers)
/// and our render clock, so each relayed move gets a client **fire-time** — the reference's
/// scheduled replay (`ev[+8] = mapped time + clamped skew`, wow-re `remote-apply-timing.md`).
/// The mapping is an EWMA of per-packet `(now − stime)` plus a jitter margin, so a typical packet
/// arrives just *before* its fire-time and waits in the unit's queue; the exact reference cursor
/// arithmetic (`mgr+0x128/+0x140`) is the un-traced residual on wow-re's board, so this estimator
/// is ours — bounded by the reference's verified `[−500, +1000] ms` skew clamp.
#[derive(Resource, Default)]
pub(crate) struct RelayClock {
/// EWMA of `(now_ms − stime)` over recent relays; `None` until the first.
mean: Option<f64>,
/// EWMA of `|sample − mean|` — the jitter estimate feeding the scheduling margin.
mad: f64,
}
/// EWMA weight for the clock mean/jitter — ~16-packet memory (a few seconds of one mover's
/// heartbeats), quick to settle at login, slow enough to ride out one outlier.
const CLOCK_ALPHA: f64 = 1.0 / 16.0;
/// A sample this far off the mean is a re-anchor (server restart, session change), not jitter.
const CLOCK_REANCHOR_MS: f64 = 2000.0;
/// Cap on the jitter-derived scheduling margin: never buffer more than this beyond the mean.
const CLOCK_MARGIN_MAX_MS: f64 = 200.0;
/// The reference's skew clamp ceiling: a fire-time never lands more than this past arrival
/// (`0x618c30 @0x618d0d/49`: skew held in `[−500, +1000]` ms; the floor needs no mirror — an
/// already-due fire-time applies at arrival either way).
const FIRE_DEFER_MAX_MS: f64 = 1000.0;
impl RelayClock {
/// Map a relayed move's server timestamp to a client fire-time (ms on [`Time::elapsed`]),
/// updating the offset estimate with this packet's sample.
pub(crate) fn fire_time(&mut self, stime: u32, now_ms: f64) -> f64 {
let sample = now_ms - f64::from(stime);
let mean = match self.mean {
Some(m) if (sample - m).abs() < CLOCK_REANCHOR_MS => {
let m = m + (sample - m) * CLOCK_ALPHA;
self.mad += ((sample - m).abs() - self.mad) * CLOCK_ALPHA;
m
}
_ => {
self.mad = 0.0;
sample
}
};
self.mean = Some(mean);
// Schedule at the mean-latency timeline plus a jitter margin, so most packets arrive
// early and wait — the buffering that absorbs relay jitter — capped by the reference's
// +1000 ms skew ceiling relative to arrival.
let margin = (3.0 * self.mad).min(CLOCK_MARGIN_MAX_MS);
(f64::from(stime) + mean + margin).min(now_ms + FIRE_DEFER_MAX_MS)
impl RelayOutcome {
fn tag(self) -> &'static str {
match self {
Self::Now => "now",
Self::Queued => "queued",
Self::Seed => "seed",
Self::SelfMover => "self",
Self::Unknown => "UNKNOWN-GUID",
}
}
}
/// One line of the `WOW_MOVE_TRACE` sink per inbound relayed move (tag `rly`), so replay is
/// **measured** rather than eyeballed (method §4): the mover, its wire stamp, the move-flags the
/// packet carries, what we did with it, the lead the schedule gave it (`fire − arrival`, ms), and how
/// deep the unit's queue is. Together with the sender's `snd` lines on the other client, a run's
/// `rly` lines answer the only question that matters when a mover misbehaves: *did the packet arrive,
/// and did it win?* Costs one `OnceLock` read per packet when the trace is off.
pub(in crate::net) fn trace_relay(
guid: u64,
mv: &RelayMove,
chain: &RelayChain,
now_ms: f64,
queued: usize,
out: RelayOutcome,
) {
if !crate::dbg_trace::enabled() {
return;
}
let lead = chain.lead_ms(now_ms);
let kind = if mv.heartbeat { "hb" } else { "tr" };
crate::dbg_trace::line(
"rly",
&format!(
"guid={guid:#x} {kind} wire={} flags={:#x} out={} lead={lead:7.1} q={queued}",
mv.wire_ms,
mv.flags,
out.tag()
),
);
}
/// How long a mover may dead-reckon with **nothing queued** before the runaway watch starts
/// reporting it (ms). A moving unit is normally fed every frame while it turns and at worst every
/// 500 ms by the heartbeat (decision 0617), so two seconds of silence with a direction flag still
/// live means we are inventing motion the server never described.
const RUNAWAY_SILENCE_MS: f64 = 2000.0;
/// The **runaway watch** (tag `run`): one line per silent second per mover that is still moving under
/// our own extrapolation with an empty queue — its flags, how long since anything applied, and how
/// far we have carried it from the last position the server actually gave us.
///
/// This is the instrument the "he keeps running off into the distance" report needed: it timestamps
/// the moment precisely, so the mover client's `snd` lines at that instant say whether the Stop was
/// ever sent, and the observer's `rly` lines say whether it arrived. Nothing in the client — or in
/// the reference — otherwise notices a mover that has travelled a hundred yards on dead reckoning
/// alone. Reporting only; it never corrects the pose.
fn trace_runaway(guid_hint: Entity, rm: &RemoteMotion, now_ms: f64, silent_s: u32) {
let d = [
rm.wow_pos[0] - rm.last_apply_pos[0],
rm.wow_pos[1] - rm.last_apply_pos[1],
];
// The inbound census rides every line, because it is the discriminator: a starving mover with
// packets still landing means the **server** stopped relaying that unit; a starving mover with
// the whole census frozen means the **socket** died with nobody noticing (decision 0621).
let (pkts, age) = crate::net::io::inbound_census();
let age = age.map_or_else(|| "never".to_string(), |ms| format!("{ms}ms"));
crate::dbg_trace::line(
"run",
&format!(
"{guid_hint} RUNAWAY flags={:#x} silent={silent_s}s drift={:.1}yd since={:.0}ms pos=[{:.1},{:.1}] netpkts={pkts} lastpkt={age}",
rm.flags,
d[0].hypot(d[1]),
now_ms - rm.last_apply_ms,
rm.wow_pos[0],
rm.wow_pos[1],
),
);
}
/// How often the remote-pose watch samples a mover (ms) — see [`trace_remote`].
const REMOTE_TRACE_MS: f64 = 500.0;
/// The **remote-pose watch** (tag `rem`): one line per mover per [`REMOTE_TRACE_MS`], reporting what
/// the dead-reckon asked for and what the world gave back. Until decision 0626 nothing measured the
/// *pose* of a watched player at all — `rly` measures the packets that arrive and `run` only fires
/// when a mover starves — so "he sinks into the ground / into the wall and pops back out" had no
/// instrument behind it and could only be argued from reading code. Two numbers close that:
///
/// - **`dz`** — the height the mover gained or lost **this frame**, *excluding* the packet applied
/// this frame (the drain runs first, so the anchor already carries it). A grounded mover riding the
/// surface moves in Z on every sloped frame; before 0626 the only thing that could move it was the
/// pre-fire reconcile lerp, which skips heartbeats — so a mover running *straight* read `+0.000`
/// sample after sample and took its height as a 2 Hz snap, which is the "delayed terrain snap"
/// report. Read it against `age`: a nonzero `dz` at a large `age` is the ground, not the server.
/// - **`held`** — how much of this frame's intended horizontal travel the world took away. Sustained
/// nonzero is a mover being *held* against a wall by our colliders (right); a flat zero for a mover
/// whose own client is stopped dead is the dead-reckon marching into the geometry (wrong), and it
/// is the "sinks in and pops out again and again" report.
///
/// `age` (ms since a packet last applied) rides along so a sample is never read as server truth.
fn trace_remote(e: Entity, rm: &RemoteMotion, held: f32, dz: f32, now_ms: f64) {
crate::dbg_trace::line(
"rem",
&format!(
"{e} flags={:#x} pos=[{:.2},{:.2},{:.2}] dz={dz:+.3} held={held:.3} age={:.0}ms",
rm.flags,
rm.wow_pos[0],
rm.wow_pos[1],
rm.wow_pos[2],
now_ms - rm.last_apply_ms,
),
);
}
/// `WOW_REMOTE_SNAP=1` — the A/B escape: apply every relayed move at arrival as a raw snap
/// (pre-0601 behavior), bypassing the scheduled queue and the reconcile lerp.
pub(in crate::net) fn arrival_snap() -> bool {
@@ -143,6 +218,18 @@ pub(in crate::net) fn arrival_snap() -> bool {
*SNAP.get_or_init(|| std::env::var_os("WOW_REMOTE_SNAP").is_some())
}
/// `WOW_REMOTE_FLAT=1` — the other A/B escape (decision 0626): dead-reckon a remote mover **without
/// the world**, the pre-0626 behaviour — height frozen at the last packet's Z, the step unswept.
/// Restores both defects on demand (a watched player sinking into rising ground and floating over
/// falling ground; a mover marching into the wall its own client is stopped at), which is what makes
/// the fix measurable side by side rather than asserted — and what re-answers "is the ground resolve
/// still doing anything?" for one env var instead of a bisect. Its twin above earns its keep the
/// same way.
fn flat_extrapolation() -> bool {
static FLAT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FLAT.get_or_init(|| std::env::var_os("WOW_REMOTE_FLAT").is_some())
}
/// The reconcile-arm tolerance (squared yards): predicted-vs-event disagreement below this needs
/// no correction. The reference's `0x80c744` = 7.716e-4 ≈ (0.0278 yd)², compared in 2D — Z joins
/// only while SWIMMING (`0x619090`, wow-re `spec-driver-A.md`).
@@ -210,8 +297,9 @@ pub(super) fn facing_lerp(orientation: f32, target: f32, dt: f32, remaining_s: f
/// arrival path ([`crate::net::apply`]'s `unit_move`) and the queue drain ([`drain_pending_moves`]).
pub(in crate::net) fn apply_move(
e: Entity,
ev: &PendingMove,
ev: &RelayMove,
rm: &mut RemoteMotion,
now_ms: f64,
commands: &mut Commands,
landings: &mut MessageWriter<crate::creature_anim::HardLanding>,
) {
@@ -250,14 +338,26 @@ pub(in crate::net) fn apply_move(
rm.pitch = ev.pitch;
rm.vertical_velocity = vertical_velocity;
rm.jump_xy_vel = jump_xy_vel;
// The dead-reckon's anchor: when this mover was last *told* anything, and from where. Read only
// by the runaway watch ([`trace_runaway`]) — a mover extrapolating far past its last packet is
// the shape of every "he ran off into the distance" report.
rm.last_apply_ms = now_ms;
rm.last_apply_pos = ev.position;
}
/// Fire every due queued move (the reference's drain `0x615c30`: bail while `now < ev[+8]`,
/// dequeue + dispatch once due). Runs before [`extrapolate_remote_units`], which then advances
/// the freshly-applied state and runs the pre-fire reconcile lerp against the next queued head.
///
/// **REAL time, deliberately** (decision 0615): this is a replay clock paced against the server's
/// stamps, and the reference's is the OS wall clock (`0x42c010`, a QPC-derived ms counter). Bevy's
/// virtual clock clamps every frame delta to `max_delta` (250 ms), so under macOS occlusion
/// throttling (~1 fps for a backgrounded client — i.e. exactly the side-by-side A/B against the
/// reference) it falls minutes behind real time and drags the whole replay schedule with it. Same
/// lesson, same fix as the UI script clock (`ui_script/extract`).
#[allow(clippy::type_complexity)]
pub(in crate::net) fn drain_pending_moves(
time: Res<Time>,
time: Res<Time<Real>>,
mut commands: Commands,
mut landings: MessageWriter<crate::creature_anim::HardLanding>,
mut q: Query<(Entity, &mut RemoteMotion), (Without<Spline>, Without<SelfPlayer>)>,
@@ -266,7 +366,7 @@ pub(in crate::net) fn drain_pending_moves(
for (e, mut rm) in &mut q {
while rm.pending.front().is_some_and(|ev| ev.fire_ms <= now_ms) {
let ev = rm.pending.pop_front().expect("front checked");
apply_move(e, &ev, &mut rm, &mut commands, &mut landings);
apply_move(e, &ev.mv, &mut rm, now_ms, &mut commands, &mut landings);
}
}
}
@@ -274,17 +374,49 @@ pub(in crate::net) fn drain_pending_moves(
/// Integrate every remote mover's pose from its [`RemoteMotion`] each frame, so another player walks
/// *smoothly* between the sparse relay packets instead of snapping at the ~2 Hz heartbeat rate. The
/// horizontal velocity is derived from the live `moveFlags` in the mover's facing frame at its run /
/// run-back / swim speed; the `TURN_*` flags rotate the facing at `turn_rate`. Z is left to the next
/// packet's correction (the relay carries the authoritative height). A creature [`Spline`] (server-
/// authored path) and our own avatar ([`SelfPlayer`]) are excluded — they have their own motion source.
/// run-back / swim speed; the `TURN_*` flags rotate the facing at `turn_rate`. A creature [`Spline`]
/// (server-authored path) and our own avatar ([`SelfPlayer`]) are excluded — they have their own
/// motion source.
///
/// **The step is then resolved against the world** ([`crate::player::mover::grounded_step`], decision
/// 0626) — the same swept capsule, colliders and step-vs-fall election our own avatar walks on,
/// because the reference drives every mover through one controller (decision 0059). Height therefore
/// comes from the ground under the mover **every frame**, and the dead-reckon cannot walk a watched
/// player into geometry.
///
/// Without it the extrapolation ran in a vacuum, and the two symptoms that follow are the ones the
/// director reported. **Height:** [`RemoteMotion::advance`] never moves a grounded mover's Z, so Z
/// changed only where a *packet* put it — at the apply, or dragged there by the pre-fire reconcile
/// lerp. The lerp skips heartbeats, and a mover running **straight** sends nothing else that carries
/// position (0617's frame-cadence stream is `SET_FACING`, sent only while turning) — so a straight run
/// gave a 2 Hz height staircase under a continuous XY, which sinks a watched player into ground that
/// rises under them and floats them over ground that falls away, at the same rate. **Geometry:** a
/// mover held against a wall by its own client keeps reporting `FORWARD` with an unchanged position —
/// there is no "I am blocked" bit on the wire, and there needn't be, because the watching client is
/// meant to be stopped by the same wall. Ours wasn't: it advanced into the geometry for the whole
/// packet interval, and the next packet popped it back out.
///
/// This is the client's own dead-reckoning, in miniature: extrapolate from the last reported state,
/// snap to the truth when the next packet lands. The pose lives canonically in WoW space on the
/// component; the [`Transform`] is derived from it (translation + facing only — scale is preserved).
///
/// **REAL time, deliberately** — the same clock the replay schedule runs on
/// ([`drain_pending_moves`]); a dead-reckon that advanced on a different (virtual, clamped) clock
/// than the fire-times it converges toward would never land on them.
#[allow(clippy::type_complexity)]
pub(in crate::net) fn extrapolate_remote_units(
time: Res<Time>,
time: Res<Time<Real>>,
mut commands: Commands,
// Avian's kinematic move-and-slide + the player-body capsule — the *same* pair the local
// controller sweeps (decision 0626): one controller, every mover.
move_and_slide: MoveAndSlide,
capsule: Res<crate::player::PlayerCapsule>,
// The runaway watch's per-mover throttle: the last whole silent second reported, so a stuck
// mover writes one line a second rather than one a frame. Trace-only, hence a `Local` and not
// state on the component.
mut warned: Local<bevy::platform::collections::HashMap<Entity, u32>>,
// The remote-pose watch's per-mover throttle: when each mover last wrote a `rem` line.
mut sampled: Local<bevy::platform::collections::HashMap<Entity, f64>>,
mut q: Query<
(
Entity,
@@ -293,6 +425,7 @@ pub(in crate::net) fn extrapolate_remote_units(
Option<&UnitSpeeds>,
Option<&mut crate::creature_anim::BodyTwist>,
Has<super::FacingStep>,
Has<crate::transport::TransportRider>,
),
(Without<Spline>, Without<SelfPlayer>),
>,
@@ -300,9 +433,88 @@ pub(in crate::net) fn extrapolate_remote_units(
use crate::creature_anim::{ease_strafe_yaw, strafe_body_offset, wrap_pi};
let dt = time.delta_secs();
let now_ms = time.elapsed_secs_f64() * 1000.0;
for (e, mut t, mut rm, speeds, twist, latched) in &mut q {
let filter = crate::collision::player_query_filter();
for (e, mut t, mut rm, speeds, twist, latched, riding) in &mut q {
// The runaway watch (trace-only): a mover still carrying a direction flag, with nothing
// queued behind it and nothing applied for seconds, is running on our extrapolation alone.
if crate::dbg_trace::enabled() {
let silent = now_ms - rm.last_apply_ms;
let moving = rm.flags & move_flags::ANY_MOVE != 0;
if moving && rm.pending.is_empty() && silent > RUNAWAY_SILENCE_MS {
let silent_s = (silent / 1000.0) as u32;
if warned.insert(e, silent_s) != Some(silent_s) {
trace_runaway(e, &rm, now_ms, silent_s);
}
} else {
warned.remove(&e);
}
}
let s = speeds.map_or_else(MoveSpeeds::default, |u| u.0);
let prev = rm.wow_pos;
let (mut pos, mut orientation, vertical_velocity, speed) = rm.advance(s, dt);
// How much of the frame's intended horizontal travel the world took away — measured at the
// resolve, before the reconcile lerp, so it is the *collision's* doing and not the
// correction's. Stays 0 for a mover the resolve skips (swimming, on a boat).
let mut held = 0.0f32;
// **The step meets the ground** (decision 0626). What [`RemoteMotion::advance`] produced is
// our *invention* — the mover's own client has not told us anything since the last packet —
// and an invention that ignores the world is what a watched player sinking into a hillside
// and popping back out actually is. Run it through the local controller's own grounded
// resolve: swept capsule (so a mover held against a wall by its own collision is held
// against ours too, instead of marching into it) and the step-vs-fall election (so height
// comes from the surface every frame, instead of standing frozen at the last packet's Z
// until the next one snaps it). The reference makes no distinction here — one controller
// integrates and commits every mover (decision 0059).
//
// **An airborne arc resolves too — but only against walls** (decision 0627). A jump owns its
// Z (the ballistic arc is the whole point), so it gets no election snap and no step-up; what
// it does get is the same swept capsule, because a watched player who jumps into a building
// has to be stopped by it. Unswept, our invented arc carried them *inside* the wall for the
// length of the jump and the landing packet popped them back out — the airborne half of the
// very same defect, closed by the local controller's own airborne slide
// ([`crate::player::mover::airborne_step`]).
//
// Still excluded: a **swimmer** (the wire Z is its depth in the water volume, not a surface
// to resolve against) and a **transport rider** (its pose is transport-local; `compose_riders`
// re-anchors it through the boat's live matrix each frame, and a world-space resolve would
// fight it).
let airborne = rm.flags & move_flags::FALLING != 0;
let afloat = rm.flags & move_flags::SWIMMING != 0;
if !afloat && !riding && !flat_extrapolation() {
let half_h = Vec3::Y * (crate::player::CAPSULE_HEIGHT * 0.5);
let from = wow_to_bevy(rm.wow_pos) + half_h;
// The frame's velocity by construction. Grounded, [`RemoteMotion::advance`] never moves
// Z (the surface does, in the snap), so it is purely horizontal; airborne, the arc's
// vertical rides along so the sweep sees the real displacement.
let vel = if dt > 1.0e-6 {
(wow_to_bevy(pos) + half_h - from) / dt
} else {
Vec3::ZERO
};
let resolved_center = if airborne {
crate::player::mover::airborne_step(
&move_and_slide,
&capsule.0,
&filter,
from,
vel,
time.delta(),
)
} else {
crate::player::mover::grounded_step(
&move_and_slide,
&capsule.0,
&filter,
from,
vel,
time.delta(),
)
.center
};
let resolved = bevy_to_wow(resolved_center - half_h);
held = (pos[0] - resolved[0]).hypot(pos[1] - resolved[1]);
pos = resolved;
}
// The pre-fire reconcile toward the queued head (decisions 0601/0602/0603):
// - **Facing** interpolates toward a NON-heartbeat event's facing (the reference's
// `0x618f80` ω armed by `0x619030`, integrated by `0x7c4f30` — its only smoothed
@@ -320,12 +532,12 @@ pub(in crate::net) fn extrapolate_remote_units(
// `0x619030` skips tag 0x26 exactly as the position arm `0x619090` does (wow-re
// `remote-air-facing.md`, decision 0603) — so it applies as an outright snap at
// fire; the smoothed facings are the transition/SET_FACING family's.
if remaining_s > 0.0 && !ev.heartbeat {
orientation = facing_lerp(orientation, ev.orientation, dt, remaining_s);
if remaining_s > 0.0 && !ev.mv.heartbeat {
orientation = facing_lerp(orientation, ev.mv.orientation, dt, remaining_s);
// Predict from the pre-frame state to the fire-time (this frame's dt + what's left).
let (predicted, ..) = rm.advance(s, dt + remaining_s);
let swimming = ev.flags & move_flags::SWIMMING != 0;
pos = reconcile_lerp(pos, predicted, ev.position, swimming, dt, remaining_s);
let swimming = ev.mv.flags & move_flags::SWIMMING != 0;
pos = reconcile_lerp(pos, predicted, ev.mv.position, swimming, dt, remaining_s);
}
}
// The standing mouse-turn shuffle: a mouse-turning mover streams NO turn flag — only its
@@ -346,7 +558,7 @@ pub(in crate::net) fn extrapolate_remote_units(
| move_flags::SWIMMING)
== 0;
if grounded_still {
wrap_pi(ev.orientation - orientation)
wrap_pi(ev.mv.orientation - orientation)
} else {
0.0
}
@@ -360,6 +572,15 @@ pub(in crate::net) fn extrapolate_remote_units(
rm.orientation = orientation;
rm.vertical_velocity = vertical_velocity;
rm.speed = speed;
// The remote-pose watch (trace-only): one sample per mover per half-second.
if crate::dbg_trace::enabled()
&& sampled
.get(&e)
.is_none_or(|t| now_ms - t >= REMOTE_TRACE_MS)
{
sampled.insert(e, now_ms);
trace_remote(e, &rm, held, pos[2] - prev[2], now_ms);
}
t.translation = wow_to_bevy(pos);
// The strafe body pose, same as our own avatar's (the client's display-facing blend): a
// strafing remote player renders its body at `orientation ± 90°/45°`, eased in aim-relative
@@ -440,6 +661,32 @@ pub(in crate::net) fn fall_arc_step(
}
impl RemoteMotion {
/// Does a freshly-scheduled move apply **at arrival**, or go on the unit's queue?
///
/// Due (`fire ≤ now`) **and the queue empty** (decision 0618). Fire-times are monotone per unit
/// (0615), so a due arrival means every queued packet is due too — and [`drain_pending_moves`]
/// runs *after* us in the same frame ([`crate::net`] chains apply → drain). Applying the arrival
/// directly therefore writes the newest state and then lets the drain replay the older queued
/// packets over the top of it. Last write wins, and the last write is stale: a Stop that races the
/// tail of a burst is undone by the FORWARD packet queued in front of it, the mover then goes
/// silent (a still player sends nothing at all), and the observer extrapolates a run that ended —
/// off into the distance until some unrelated correction lands.
///
/// **The reference needs no such test, and tracing why is what makes this faithful rather than
/// defensive.** Its due test (`@0x618dd2`: `ecx = [edx+0x128]; sub ecx,fire; jns apply`) compares
/// against `mgr+0x128` — the movement manager's clock *cell*, stamped once per movement update
/// (`0x616800`), not the live ms counter. Its drain (`0x615c30`) runs in that same update against
/// that same stamped value. So by the time packets are processed, every event still queued has
/// `fire > mgr+0x128`, and monotonicity puts a new packet's fire at or beyond those: a due arrival
/// and a non-empty queue are mutually exclusive *by construction*. We read a live frame clock with
/// the drain still ahead of us, which breaks that exclusivity; this term restores it.
///
/// Queuing instead costs nothing: the drain applies every due packet later in the same frame,
/// this one included, in arrival order.
pub(crate) fn fires_at_arrival(&self, fire_ms: f64, now_ms: f64) -> bool {
self.pending.is_empty() && fire_ms <= now_ms
}
/// Advance one frame of dead-reckoning: the new `(WoW position, facing, vertical speed, horizontal
/// speed)` given the unit's `speeds` and `dt`. On the **ground**, integrates the velocity the current
/// `flags` imply in the facing frame (forward/back/strafe summed, normalized, at the run / run-back /
+220 -19
View File
@@ -10,9 +10,10 @@ use crate::creature_anim::move_flags;
use crate::player::{GRAVITY, TERMINAL_VELOCITY};
use super::facing::{resolve_facing, turn_toward};
use super::relay::RelayChain;
use super::remote::{facing_lerp, fall_arc_step, jump_seed, reconcile_lerp};
use super::spline::monster_move_spline;
use super::{RelayClock, RemoteMotion, Spline};
use super::{RemoteMotion, Spline};
fn speeds() -> MoveSpeeds {
MoveSpeeds {
@@ -57,6 +58,9 @@ fn motion(flags: u32, orientation: f32) -> RemoteMotion {
vertical_velocity: 0.0,
jump_xy_vel: [0.0, 0.0],
fall_start_z: None,
relay: Default::default(),
last_apply_ms: 0.0,
last_apply_pos: [0.0, 0.0, 0.0],
}
}
@@ -427,25 +431,151 @@ fn monster_move_without_a_travelable_path_clears_the_spline() {
);
}
/// The relay clock maps a packet's server timestamp to a client fire-time on the mover's own
/// timeline (decision 0601): the first packet anchors the offset (fire = arrival), a steady
/// stream schedules by the learned mean, an early burst is capped at the reference's +1000 ms
/// defer ceiling, and a wildly-off sample re-anchors instead of poisoning the mean.
/// Flags that make the chain treat a mover as mid-motion (`0x20ff`'s FORWARD bit is enough).
const MOVING: u32 = move_flags::FORWARD;
/// **The headline property** (decision 0615, the reference's `0x618c30`): while a mover is moving,
/// replay is paced by the *sender's* stamps — `fire = prev fire + wire step` — so however the packets
/// clumped in flight, they replay at the spacing the stamps carry. Here four 500 ms-apart stamps
/// arrive at 0 / 520 / 1450 / 1460 ms (one late, then a two-packet burst) and still fire 500 ms apart.
#[test]
fn relay_clock_schedules_on_the_server_timeline() {
let mut clock = RelayClock::default();
// First sample anchors: offset 4000, zero jitter → fire exactly at arrival.
assert_eq!(clock.fire_time(1000, 5000.0), 5000.0);
// A steady stream (same offset) keeps firing at arrival — no invented delay.
assert_eq!(clock.fire_time(1500, 5500.0), 5500.0);
// An EARLY packet (offset 1500 ms under the mean, still in-family): the raw schedule
// stime + mean lands ~1500 ms past arrival — clamped to the reference's +1000 ceiling.
let fire = clock.fire_time(2000, 4500.0);
assert_eq!(fire, 5500.0, "defer capped at arrival + 1000 ms");
// A sample 6000 ms off the mean is a re-anchor (server restart), not jitter: the clock
// resets to it and the next fire follows the NEW timeline at arrival.
let fire = clock.fire_time(1000, 11_000.0);
assert_eq!(fire, 11_000.0, "re-anchored — fire at arrival again");
fn relay_chain_replays_on_the_senders_cadence() {
let mut chain = RelayChain::default();
let script = [(1000, 0.0), (1500, 520.0), (2000, 1450.0), (2500, 1460.0)];
let fires: Vec<f64> = script
.iter()
.map(|&(wire, now)| chain.schedule(wire, now, MOVING, true))
.collect();
assert_eq!(
fires,
vec![0.0, 500.0, 1000.0, 1500.0],
"the burst is de-clumped onto the sender's cadence"
);
}
/// The chain's two seeds and its guards: the first packet anchors both cells and fires at arrival; a
/// stale/duplicate stamp contributes no step (`@0x618cb8`'s `jle`); and the server's `u32` ms clock
/// wrapping mid-session is just another forward step.
#[test]
fn relay_chain_seeds_holds_stale_stamps_and_survives_the_clock_wrap() {
let mut chain = RelayChain::default();
assert_eq!(
chain.schedule(7_000, 4_000.0, MOVING, true),
4_000.0,
"first packet: fire at arrival, whatever the server's clock reads"
);
// A re-sent / out-of-order stamp: no forward step, so the chain doesn't advance past the
// previous fire (and the reference stamp is left alone — the next real step measures from it).
assert_eq!(chain.schedule(7_000, 4_100.0, MOVING, true), 4_000.0);
assert_eq!(chain.schedule(6_900, 4_200.0, MOVING, true), 4_000.0);
assert_eq!(
chain.schedule(7_300, 4_300.0, MOVING, true),
4_300.0,
"the next forward stamp steps 300 ms from the last one that counted"
);
// The wrap: 150 is 251 ms after u32::MAX − 100 on a wrapping ms clock.
let mut chain = RelayChain::default();
chain.schedule(u32::MAX - 100, 0.0, MOVING, true);
assert_eq!(
chain.schedule(150, 10.0, MOVING, true),
251.0,
"the u32 wrap is a forward step, not a 49-day jump backwards"
);
}
/// The de-jitter buffer is re-sized **only** on a standing mover with an empty queue (`@0x618ce4` /
/// `@0x618cf3`), and it is sized by the window's worst lateness (`0x618b50`) — then held, not
/// re-charged: the ring stores lateness *relative to the base*, so a spike already absorbed doesn't
/// buy a second helping of buffer on the next idle packet.
#[test]
fn relay_chain_rebases_the_buffer_only_when_idle_and_unqueued() {
// A 200 ms-late packet enters the window while the mover is moving: no re-base, no buffer.
let mut chain = RelayChain::default();
chain.schedule(0, 0.0, MOVING, true);
assert_eq!(chain.schedule(500, 700.0, MOVING, true), 500.0);
// Still moving when the next one lands: the chain stays glued to the sender's cadence.
let mut moving = chain.clone();
assert_eq!(
moving.schedule(1000, 1000.0, MOVING, true),
1000.0,
"mid-motion: the 200 ms spike buys no buffer"
);
// The same packet on a mover that has come to a stop with nothing queued: NOW the chain
// re-bases, and the buffer it takes is exactly the window's worst lateness.
let mut idle = chain.clone();
assert_eq!(
idle.schedule(1000, 1000.0, 0, true),
1200.0,
"idle + empty: re-based by the window max (200 ms late)"
);
// ...and the next idle packet holds that buffer rather than charging the spike again.
assert_eq!(
idle.schedule(1500, 1500.0, 0, true),
1700.0,
"the absorbed spike is not re-charged: still 200 ms of lead"
);
// A queued event blocks the re-base even when the mover is idle.
let mut queued = chain.clone();
assert_eq!(
queued.schedule(1000, 1000.0, 0, false),
1000.0,
"a non-empty queue defers the re-base"
);
}
/// The reference's skew clamp (`@0x618d0d`/`@0x618d49`): a fire never lands more than 1000 ms after
/// its packet's arrival, nor more than 500 ms before it.
#[test]
fn relay_chain_holds_the_offset_inside_the_reference_clamp() {
// A 2 s wire step delivered 100 ms after the last fire would schedule 1.9 s out — capped.
let mut chain = RelayChain::default();
chain.schedule(0, 0.0, MOVING, true);
assert_eq!(chain.schedule(2000, 100.0, MOVING, true), 1100.0);
// A stalled sender (no forward step) whose packet lands 4 s after the last fire would schedule
// 4 s in the past — floored at arrival − 500 ms, which is due-on-arrival either way.
let mut chain = RelayChain::default();
chain.schedule(0, 1000.0, MOVING, true);
assert_eq!(chain.schedule(0, 5000.0, MOVING, true), 4500.0);
}
/// Under a scripted jitter pattern — a steady stream, a stalled tail, a catch-up burst, an idle
/// resync, then motion again — the chain stays well-formed: fire-times never go backwards (which is
/// what lets the queue be a plain FIFO with no re-sort), and the lead over arrival stays inside the
/// reference's clamp.
#[test]
fn relay_chain_stays_monotone_and_bounded_under_scripted_jitter() {
// (wire stamp, arrival, moving?) — 500 ms stamps throughout; the arrivals are the abuse.
let script: [(u32, f64, bool); 14] = [
(500, 100.0, true), // first packet
(1000, 600.0, true), // steady
(1500, 1100.0, true), // steady
(2000, 2400.0, true), // a 1.3 s stall
(2500, 2410.0, true), // burst catch-up
(3000, 2420.0, true), // burst catch-up
(3500, 2430.0, true), // burst catch-up
(4000, 4000.0, false), // stopped, queue drained: resync
(4500, 4500.0, false), // idle heartbeats
(5000, 5000.0, false),
(5500, 5480.0, true), // moving again, slightly early
(6000, 6050.0, true), // slightly late
(6500, 6500.0, true),
(7000, 7100.0, true),
];
let mut chain = RelayChain::default();
let mut prev_fire = f64::NEG_INFINITY;
for (wire, now, moving) in script {
let fire = chain.schedule(wire, now, if moving { MOVING } else { 0 }, true);
assert!(
fire >= prev_fire,
"fire-times must never reorder: {fire} after {prev_fire}"
);
let lead = fire - now;
assert!(
(-500.0..=1000.0).contains(&lead),
"lead {lead} outside the reference clamp at wire={wire}"
);
prev_fire = fire;
}
}
/// The pre-fire reconcile lerp (decision 0601; the reference's `0x619090`/`0x6191c0`): an armed
@@ -511,3 +641,74 @@ fn facing_lerp_turns_the_short_way_and_lands_at_fire_time() {
let held = facing_lerp(1.0, 1.0 + 1.0e-8, 0.1, 0.4);
assert_eq!(held, 1.0, "dead-zone: negligible turn skipped");
}
/// The frame loop as [`crate::net`] chains it: `apply_net_updates` routes each arriving packet
/// (applied at arrival, or queued), then `drain_pending_moves` empties everything due — apply
/// **before** drain, both on the same frame clock. Returns the order packets were actually applied
/// in, tagged by their `fall_time`, plus the flags left on the unit at the end.
fn replay_frames(script: &[(u32, f64, u32)]) -> (Vec<u32>, u32) {
use super::relay::{PendingMove, RelayMove};
let mut rm = motion(MOVING, 0.0);
let mut applied = Vec::new();
let apply = |rm: &mut RemoteMotion, mv: &RelayMove, applied: &mut Vec<u32>| {
rm.flags = mv.flags; // the one bit of `apply_move` this ordering question turns on
applied.push(mv.fall_time);
};
for (id, &(wire_ms, arrival_ms, flags)) in script.iter().enumerate() {
let now = arrival_ms;
let mv = RelayMove {
wire_ms,
position: [0.0; 3],
orientation: 0.0,
flags,
pitch: 0.0,
fall_time: id as u32, // the packet's identity, carried through the queue
jump: None,
transport: None,
heartbeat: false,
};
let (live, empty) = (rm.flags, rm.pending.is_empty());
let fire_ms = rm.relay.schedule(wire_ms, now, live, empty);
if rm.fires_at_arrival(fire_ms, now) {
apply(&mut rm, &mv, &mut applied);
} else {
rm.pending.push_back(PendingMove { fire_ms, mv });
}
while rm.pending.front().is_some_and(|p| p.fire_ms <= now) {
let ev = rm.pending.pop_front().expect("front checked");
apply(&mut rm, &ev.mv, &mut applied);
}
}
(applied, rm.flags)
}
#[test]
fn a_due_arrival_never_jumps_the_queue() {
// **The runaway-mover regression** (decision 0618). A mover's packets are applied in the order
// they arrived, always — even when the newest one is already due on arrival while older ones sit
// in the queue. Fire-times are monotone (0615), so a due arrival means everything queued is due
// too: applying the arrival *directly* writes the newest state, and the drain then replays the
// older queued packets over it in the same frame. Last write wins, last write is stale.
//
// The script: a 60 Hz burst that builds a one-deep queue, then a delivery stall (packet 4 lands
// 64 ms late) so its fire-time — chained at 48 + 16 = 64 ms — is already past by arrival at 96.
// Packet 4 is the STOP; packet 3, still queued in front of it, is FORWARD.
let script: [(u32, f64, u32); 5] = [
(1000, 0.0, MOVING), // 0 — seeds the chain, fires at arrival
(1016, 0.0, MOVING), // 1 — same frame; fire 16, queued
(1032, 16.0, MOVING), // 2 — fire 32, queued; the drain releases 1
(1048, 32.0, MOVING), // 3 — fire 48, queued; the drain releases 2
(1064, 96.0, 0), // 4 — the STOP. Fire 64, already due; 3 is still queued
];
let (order, flags) = replay_frames(&script);
assert_eq!(
order,
vec![0, 1, 2, 3, 4],
"relayed moves apply in arrival order — a due arrival waits behind the queue it belongs after"
);
assert_eq!(
flags, 0,
"the mover ends STOPPED: the Stop is the last write, not the FORWARD packet queued in front \
of it — a still player then sends nothing, so a stale FORWARD here runs off forever"
);
}
+294 -63
View File
@@ -1,108 +1,339 @@
//! NPC-text chat macros — the client-side substitution the 1.12 wire leaves **un**-expanded in
//! every server-authored text the player reads: gossip greetings, questgiver panel texts, and the
//! quest log's description/objectives (the director's screenshot showed a literal "$N" in a quest
//! description — the real client substitutes everywhere quest/gossip text renders). One shared
//! mechanism (the 0109 look fix promoted it out of `ui_gossip`); every feed seam that pushes NPC
//! text into the VM runs it.
//! NPC-text macros — the client-side `$`-token expansion the 1.12 wire leaves undone in every
//! server-authored text the player reads: gossip greetings, questgiver panel texts, and the quest
//! log's description/objectives (the director's screenshot showed a literal `$N` in a quest
//! description). One shared mechanism (the 0109 look fix promoted it out of `ui_gossip`); every feed
//! seam that pushes NPC text into the VM runs it — as the reference does, routing all fourteen of
//! its call sites through the one expander.
//!
//! Handled: `$N`/`$n` → the player's name; `$B`/`$b` → a newline (the text renderer splits on
//! `\n`); `$G male:female;` / `$g …` → the gender branch (`gender`: 0 = male, 1 = female). **Left
//! for a follow-up** (and so passed through literally): `$C`/`$c` (class name) and `$R`/`$r` (race
//! name) — the client keeps localized class/race name tables the app doesn't have handy yet. An
//! unterminated `$G…` is passed through literally too (degrade-to-visible, never eat text).
//! The grammar is the reference's, carved at the bytes (wow-re `QuestTextParser.cpp`, driver
//! `0x506f70` → token handler `0x5070a0`):
//!
//! - the accepted set is **exactly** `B C E G N R T W`, in either case. Anything else re-emits the
//! `$` and lets the letter fall through as ordinary literal text;
//! - an optional decimal prefix is scanned and consumed ahead of *every* token, but only `W`/`E`
//! read it;
//! - **case is the output switch, not a separate token**: `$R` is the race string verbatim
//! (`"Night Elf"`), `$r` is that same string through `_strlwr` (`"night elf"`). Same for
//! `$C`/`$c` and `$T`/`$t`; `$N`/`$B`/`$G` are case-insensitive.
//!
//! The reference's *other* `$`-expander — spell descriptions, with its `$/N;` scale prefixes and
//! `$<spellId>` cross-references — is a different function over a different source. Ours is
//! [`benilla_formats::substitute`], kept separate here exactly as it is there.
use bevy::prelude::*;
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, ObjectStore, SelfPlayer};
/// Substitute the chat-text macros into `text` (see the module doc for the handled set).
pub(crate) fn substitute(text: &str, name: &str, gender: u8) -> String {
/// The unit a `$`-macro expands against. Every seam we have passes the **self player** (the
/// reference resolves the subject from the GUID its call site hands over, and only its four chat
/// sites pass the speaker instead), so the reference's non-player arm — where `$R`/`$C` emit the
/// unit's *name* in place of a race/class — is not reachable from here.
pub(crate) struct Subject {
pub name: String,
/// `UNIT_FIELD_BYTES_0` byte 0 / byte 1 — indices into the `ChrRaces`/`ChrClasses` tables the
/// reference reads by locale column. Ours are [`crate::ui_unit`]'s hardcoded English rows, which
/// were checked character-for-character against the shipped DBCs.
pub race: u8,
pub class: u8,
/// `UNIT_FIELD_BYTES_0` byte 2. The `$G`/`$T` branch test is `== 0` → the *first* arm, anything
/// else → the second — the reference compares against zero, so this is not "1 = female".
pub gender: u8,
}
/// Everything a `$`-token can read: the [`Subject`] the person-tokens expand against, and the
/// world-state table `$<n>w`/`$<n>e` index. The reference reads the latter from a process global
/// (`[0xb71ec8]`); we hold it as a resource, so the expander takes it explicitly — the same shape
/// the sibling spell expander already uses (`benilla_formats::TokenContext`).
pub(crate) struct MacroContext<'a> {
/// `None` is the reference's no-subject case — see [`substitute`].
pub subject: Option<&'a Subject>,
pub states: &'a crate::world_state::WorldStates,
}
/// Substitute the macros in `text` against `ctx` (see the module doc for the grammar). A `None`
/// subject is the reference's no-subject case: every token that needs one fails, which re-emits the
/// `$` and leaves the rest of the text literal — so an un-landed player name shows `$N` for the
/// moment rather than a hole, and the feeds re-substitute when it arrives.
pub(crate) fn substitute(text: &str, ctx: &MacroContext) -> String {
let subject = ctx.subject;
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' && i + 1 < chars.len() {
match chars[i + 1] {
'N' | 'n' => {
out.push_str(name);
i += 2;
continue;
if chars[i] != '$' {
out.push(chars[i]);
i += 1;
continue;
}
// The decimal prefix is consumed ahead of the token letter whatever the token turns out to
// be — so `$5N` is just `$N`, and an unaccepted `$5X` loses the digits on its way out.
let mut j = i + 1;
while chars.get(j).is_some_and(char::is_ascii_digit) {
j += 1;
}
// `fail` is the reference's `false` return: the `$` comes back and the cursor stays on the
// letter, which the literal path then copies.
let mut fail = false;
match chars.get(j).copied() {
// Exactly one `\n` (no CR) — the text renderer splits on it. Needs no subject.
Some('B' | 'b') => {
out.push('\n');
i = j + 1;
}
// `$<n>W` / `$<n>E` — the world-state table ([`crate::world_state`]), filled by
// SMSG_INIT_WORLD_STATES (`0x2C2`) and SMSG_UPDATE_WORLD_STATE (`0x2C3`); `$…E` reads
// that same table at the *negated* key, and both render `%d`. A miss prints `"0"` —
// which is every lookup until a zone actually sends states, and is what the reference
// prints then too.
Some(tok @ ('W' | 'w' | 'E' | 'e')) => {
// `SStrToInt` over the prefix. No digits at all reads an uninitialized buffer in
// the reference (undefined); we take it as key 0, and an id too wide for the
// dword the wire carries goes the same way rather than wrapping to a live key.
let n: u32 = chars[i + 1..j]
.iter()
.collect::<String>()
.parse()
.unwrap_or(0);
let key = if matches!(tok, 'E' | 'e') {
n.wrapping_neg()
} else {
n
};
out.push_str(&ctx.states.get(key).to_string());
i = j + 1;
}
Some('N' | 'n') => match subject {
Some(s) => {
out.push_str(&s.name);
i = j + 1;
}
'B' | 'b' => {
out.push('\n');
i += 2;
continue;
None => fail = true,
},
Some(tok @ ('R' | 'r' | 'C' | 'c')) => match subject {
Some(s) => {
// An id outside the table is where the reference dereferences near-NULL and
// faults outright; we emit nothing.
let name = if matches!(tok, 'R' | 'r') {
crate::ui_unit::race_names(s.race).map_or("", |(display, _)| display)
} else {
crate::ui_unit::class_names(s.class).map_or("", |(display, _)| display)
};
if tok.is_ascii_lowercase() {
out.push_str(&name.to_ascii_lowercase());
} else {
out.push_str(name);
}
i = j + 1;
}
'G' | 'g' => {
if let Some((male, female, end)) = parse_gender_branch(&chars, i + 2) {
out.extend(if gender == 1 { female } else { male });
i = end;
continue;
None => fail = true,
},
// `$T`/`$t` is the PvP rank title, with this same gender branch as its fallback when the
// `PVP_RANK_<rank>_<team>` GlobalString misses — which it always does here, since we
// ship no rank titles. So the two tokens share one path, and `$t` lower-cases nothing:
// the reference never lower-cases the fallback text either.
Some('G' | 'g' | 'T' | 't') => match subject {
Some(s) => {
let mut k = j + 1;
while chars.get(k) == Some(&' ') {
k += 1;
}
match parse_branch(&chars, k) {
Some((first, second, end)) => {
out.extend(if s.gender == 0 { first } else { second });
i = end;
}
// A malformed branch swallows the marker and the spaces after it and emits
// nothing at all — its argument survives as plain text, so `$G male female;`
// renders `male female;`. (We used to pass the whole token through; the
// bytes say the marker is consumed.)
None => i = k,
}
}
_ => {}
}
None => fail = true,
},
// Not in `B C E G N R T W`, or the string ended on the `$`.
_ => fail = true,
}
if fail {
out.push('$');
i = j;
}
out.push(chars[i]);
i += 1;
}
out
}
/// Parse a `$G`/`$g` branch body `male:female;` starting at `start` (just past the `G`), returning
/// the two arms (as `&str` slices of `chars`) and the index just past the terminating `;`. `None`
/// if there's no `:` before the `;`, or no terminating `;` (malformed → caller keeps it literal).
fn parse_gender_branch(chars: &[char], start: usize) -> Option<(&[char], &[char], usize)> {
/// Parse a `$G`/`$T` branch body `first:second;` starting at `start` (already past the marker and
/// the spaces behind it), returning the two arms and the index just past the terminating `;`.
/// `None` — malformed — when there is no `:` before the `;`, or no `;` at all.
fn parse_branch(chars: &[char], start: usize) -> Option<(&[char], &[char], usize)> {
let colon = (start..chars.len()).find(|&j| chars[j] == ':' || chars[j] == ';')?;
if chars[colon] != ':' {
return None;
}
let semi = (colon + 1..chars.len()).find(|&j| chars[j] == ';')?;
Some((&chars[start..colon], &chars[colon + 1..semi], semi + 1))
Some((
trim_spaces(&chars[start..colon]),
trim_spaces(&chars[colon + 1..semi]),
semi + 1,
))
}
/// The self player's `(name, gender)` for substitution: the name from the [`NameCache`] (a miss
/// queries the server once, like the unit frames), gender from the descriptor (`unit_gender`:
/// 0 male / 1 female). Falls back to an empty name / male when the self player or its name isn't
/// known yet (the feeds diff on the name, so text re-substitutes when it lands).
/// Drop the spaces the reference's branch copy drops: the leading run (after the marker and after
/// the `:`) and the trailing one. Only `' '` — its skip loops test that byte, not whitespace.
fn trim_spaces(arm: &[char]) -> &[char] {
let start = arm.iter().position(|&c| c != ' ').unwrap_or(arm.len());
let end = arm.iter().rposition(|&c| c != ' ').map_or(start, |p| p + 1);
&arm[start..end]
}
/// The self player as a macro [`Subject`] — the name from the [`NameCache`] (a miss queries the
/// server once, like the unit frames), race/class/gender from the descriptor. `None` until the
/// player is streamed *and* its name has landed; the feeds diff on the substituted text, so it
/// re-substitutes when that happens.
pub(crate) fn player_identity(
self_q: &Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
names: &mut NameCache,
commands: &NetCommands,
) -> (String, u8) {
match self_q.iter().next() {
Some((store, guid)) => {
let name = names.resolve(guid.0, commands).unwrap_or("").to_string();
(name, store.0.unit_gender().unwrap_or(0))
}
None => (String::new(), 0),
}
) -> Option<Subject> {
let (store, guid) = self_q.iter().next()?;
Some(Subject {
name: names.resolve(guid.0, commands)?.to_string(),
race: store.0.unit_race().unwrap_or(0),
class: store.0.unit_class().unwrap_or(0),
gender: store.0.unit_gender().unwrap_or(0),
})
}
#[cfg(test)]
mod tests {
use super::substitute;
use super::{substitute, MacroContext, Subject};
use crate::world_state::WorldStates;
/// Thrall the night-elf priest — race 4 / class 5, so both table lookups are real rows.
fn subject(gender: u8) -> Subject {
Subject {
name: "Thrall".into(),
race: 4,
class: 5,
gender,
}
}
/// Expand against a subject and an empty world-state table — the shape of every test whose
/// concern is a person-token.
fn expand(text: &str, subject: Option<&Subject>) -> String {
substitute(
text,
&MacroContext {
subject,
states: &WorldStates::default(),
},
)
}
#[test]
fn macros_substitute() {
// $N → name, $B → newline.
assert_eq!(substitute("Greetings $N", "Thrall", 0), "Greetings Thrall");
assert_eq!(substitute("Hail,$Bfriend", "X", 0), "Hail,\nfriend");
assert_eq!(substitute("$N,$Bwelcome", "Jaina", 0), "Jaina,\nwelcome");
// $G male:female; branches on gender (0 male / 1 female).
fn name_and_newline() {
let s = subject(0);
assert_eq!(expand("Greetings $N", Some(&s)), "Greetings Thrall");
assert_eq!(expand("Greetings $n", Some(&s)), "Greetings Thrall");
assert_eq!(expand("Hail,$Bfriend", Some(&s)), "Hail,\nfriend");
}
#[test]
fn race_and_class_follow_the_token_case() {
let s = subject(0);
assert_eq!(expand("A $C of $R", Some(&s)), "A Priest of Night Elf");
assert_eq!(expand("a $c of $r", Some(&s)), "a priest of night elf");
// An id outside the table emits nothing (where the reference faults).
let unknown = Subject {
class: 6,
..subject(0)
};
assert_eq!(expand("[$C]", Some(&unknown)), "[]");
}
#[test]
fn gender_branches_on_zero() {
assert_eq!(
substitute("Well met, $Glad:lass;.", "X", 0),
expand("Well met, $Glad:lass;.", Some(&subject(0))),
"Well met, lad."
);
assert_eq!(
substitute("Well met, $Glad:lass;.", "X", 1),
expand("Well met, $Glad:lass;.", Some(&subject(1))),
"Well met, lass."
);
// Unknown macros + malformed $G pass through literally (degrade-to-visible).
assert_eq!(substitute("A $C of $R", "X", 0), "A $C of $R");
assert_eq!(substitute("Broken $Gbranch", "X", 0), "Broken $Gbranch");
// A trailing lone $ survives.
assert_eq!(substitute("Cost: 5$", "X", 0), "Cost: 5$");
// Not "1 = female": zero picks the first arm and everything else the second.
assert_eq!(expand("$Glad:lass;", Some(&subject(2))), "lass");
// The spaces after the marker, after the `:`, and at each arm's end are dropped.
assert_eq!(
expand("Well met, $G lad : lass ;.", Some(&subject(1))),
"Well met, lass."
);
// `$T` has no rank title to find, so it falls back to this same branch, un-lower-cased.
assert_eq!(expand("$tLad:Lass;", Some(&subject(0))), "Lad");
}
#[test]
fn malformed_branch_drops_the_marker_not_the_text() {
let s = subject(0);
assert_eq!(expand("Broken $Gbranch", Some(&s)), "Broken branch");
assert_eq!(expand("$G male female;", Some(&s)), "male female;");
assert_eq!(expand("$G male:female", Some(&s)), "male:female");
}
#[test]
fn world_state_tokens_read_an_empty_table() {
let s = subject(0);
assert_eq!(expand("$2077w gathered", Some(&s)), "0 gathered");
assert_eq!(expand("$2077e gathered", Some(&s)), "0 gathered");
// A bare `$w` is key 0 — a miss like any other.
assert_eq!(expand("$w", Some(&s)), "0");
}
/// The point of the table: once a zone's states land, `$<n>w` renders the value rather than the
/// standing `"0"`. `$<n>e` reads the SAME table at the negated key, so the two tokens with the
/// same digits are different lookups — and a value is rendered `%d`, sign and all.
#[test]
fn world_state_tokens_render_received_values() {
let s = subject(0);
let mut states = WorldStates::default();
states.write(&[
(2077, 12),
(2077u32.wrapping_neg(), 3),
(2264, -5i32 as u32),
]);
let filled = |text: &str| {
substitute(
text,
&MacroContext {
subject: Some(&s),
states: &states,
},
)
};
assert_eq!(filled("$2077w gathered"), "12 gathered");
assert_eq!(filled("$2077e gathered"), "3 gathered");
assert_eq!(filled("$2264w"), "-5", "rendered %d, not %u");
assert_eq!(filled("$9999w"), "0", "an id the zone never sent");
// Case is not the output switch here — unlike `$R`/`$C`, `W` and `w` are one token.
assert_eq!(filled("$2077W"), "12");
}
#[test]
fn unaccepted_tokens_keep_the_dollar() {
let s = subject(0);
assert_eq!(expand("A $X here", Some(&s)), "A $X here");
// The digits are consumed before the letter is judged, so they are lost with it.
assert_eq!(expand("A $5X here", Some(&s)), "A $X here");
assert_eq!(expand("Cost: 5$", Some(&s)), "Cost: 5$");
}
#[test]
fn no_subject_leaves_the_text_literal() {
assert_eq!(expand("Greetings $N", None), "Greetings $N");
assert_eq!(expand("$Glad:lass; $C", None), "$Glad:lass; $C");
// `$B` and the world-state tokens need no subject.
assert_eq!(expand("Hail,$Bfriend $w", None), "Hail,\nfriend 0");
}
}
+62 -40
View File
@@ -34,7 +34,12 @@ mod camera;
mod gait;
mod move_trace;
mod movement_net;
mod mover;
// The kinematic mover step. `pub(crate)` because the grounded walk resolve is **not** the local
// player's alone: a remote mover's dead-reckon (`crate::net::motion::remote`) runs its extrapolated
// step through the very same code, the way the reference runs every mover through one controller
// (decision 0059's byte trail).
pub(crate) mod mover;
mod probe_look;
mod server_ride;
mod setup;
mod state;
@@ -49,13 +54,13 @@ pub(crate) use camera::{head_height, CameraControl, CameraPivot, WorldCamera, CA
// The shared avatar state + movement constants live in [`state`]; the private re-imports below are
// what lets this module and the concern modules beside it keep naming them `super::X` unchanged.
use state::{
MoveSpeed, PlayerCapsule, PlayerRide, AIR_NUDGE_SPEED, CAPSULE_RADIUS, FALL_FAR_DROP,
FALL_FAR_TIME, GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP,
RUN_BACK_RATIO, SETTLE_REACH, SETTLE_TIMEOUT, SKIN_WIDTH, STATIONARY_CHASE_RATE,
STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL,
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, CAPSULE_RADIUS, FALL_FAR_DROP, FALL_FAR_TIME,
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
SETTLE_REACH, SETTLE_TIMEOUT, SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO,
STEP_SNAP_SLACK, STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL,
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
};
pub(crate) use state::{Player, CAPSULE_HEIGHT, GRAVITY, TERMINAL_VELOCITY};
pub(crate) use state::{Player, PlayerCapsule, CAPSULE_HEIGHT, GRAVITY, TERMINAL_VELOCITY};
/// The player/camera subsystem: spawns the camera + move/avatar resources at startup, drives the
/// third-person/free-fly controller each frame. (The cursor is the [`crate::cursor`] subsystem.)
@@ -63,6 +68,9 @@ pub(crate) struct PlayerPlugin;
impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
if let Some(look) = probe_look::from_env() {
app.insert_resource(look);
}
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
// The world camera renders only when the world can be seen (decision 0540): in world,
// or under the opaque loading screen (whose covered render is what compiles the
@@ -80,6 +88,17 @@ impl Plugin for PlayerPlugin {
.run_if(not(resource_exists::<crate::capture::CaptureMode>))
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The scripted mouse-turn (`WOW_PROBE_LOOK`, decision 0621): rotates the aim before
// `control` reads it, so the frame that sees the new facing is the frame that streams
// it. Registered only when the env var asks for it — inert otherwise.
.add_systems(
Update,
probe_look::drive_probe_look
.in_set(WorldStage::Input)
.before(control)
.run_if(resource_exists::<probe_look::ProbeLook>)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// A server-authored spline (Charge/knockback/taxi) driving our own player is mirrored into
// `Player` here, *before* `control` reads `pos` to seat the camera and skip input. Same
// gates as `control` (not while capturing; in-world only).
@@ -451,6 +470,21 @@ fn control(
// the facing tracks the camera; **Q/E always strafe**. Movement basis is the *character* facing,
// so left-drag (camera-only orbit) doesn't change which way W walks.
let mouselook = both_buttons || rig.look == Some(LookButton::Right);
// The strafe axis, **netted exactly like `fwd_axis`** — Q/E always strafe, A/D only while
// mouse-looking. Netting is not a nicety: the two bits are mutually exclusive on the wire.
// Holding both keys used to OR `STRAFE_LEFT | STRAFE_RIGHT` into the flags while the avatar
// stood still (the `dir` sum below cancels), and vmangos **silently drops** every movement
// packet carrying that pair — never relaying it to anyone (decision 0622). Measured: 48 such
// packets in one session, 0 received by a watching client, against 0 in the reference
// client's entire 1.12.1 capture. That is decision 0056's invariant — the wire mirrors the
// avatar's actual motion — violated on this one axis only; the swim branch already nets.
let side_axis = i32::from(keys_pressed(KeyCode::KeyE))
- i32::from(keys_pressed(KeyCode::KeyQ))
+ if mouselook {
i32::from(keys_pressed(KeyCode::KeyD)) - i32::from(keys_pressed(KeyCode::KeyA))
} else {
0
};
// A/D turn the facing when not mouse-looking (yaw increases turning left, matching mouse-left).
let turning = !mouselook && (keys_pressed(KeyCode::KeyA) || keys_pressed(KeyCode::KeyD));
// This frame's keyboard-turn rotation — `seat_camera` carries the camera by it rigidly
@@ -467,8 +501,7 @@ fn control(
}
// 0.75× while also translating — the verified `flags & 0x200f` case, so it reads the
// *net* axis, not the keys: W+S streams no direction bit and turns at the full rate.
let translating =
fwd_axis != 0 || keys_pressed(KeyCode::KeyQ) || keys_pressed(KeyCode::KeyE);
let translating = fwd_axis != 0 || side_axis != 0;
let rate = TURN_RATE * if translating { TURN_RATE_MOVING } else { 1.0 };
turn_delta = turn * rate * dt;
player.face_yaw += turn_delta;
@@ -485,20 +518,12 @@ fn control(
-1 => dir -= move_fwd,
_ => {}
}
// Strafe slides without turning: Q/E always; A/D only while mouse-looking (else they turn, above).
if keys_pressed(KeyCode::KeyE) {
dir += move_right;
}
if keys_pressed(KeyCode::KeyQ) {
dir -= move_right;
}
if mouselook {
if keys_pressed(KeyCode::KeyD) {
dir += move_right;
}
if keys_pressed(KeyCode::KeyA) {
dir -= move_right;
}
// Strafe slides without turning, one step in the netted sign — never a doubled push, and a
// cancelled pair is genuinely no strafe (the same shape as the forward/back axis above).
match side_axis.signum() {
1 => dir += move_right,
-1 => dir -= move_right,
_ => {}
}
// Rooted (dead-unreleased): translation intent dies here — turning above stays live, the
// real rooted client's behavior (decision 0308 slice 1).
@@ -935,21 +960,15 @@ fn control(
-1 => move_flags_now |= move_flags::BACKWARD,
_ => {}
}
// Q/E always strafe; A/D strafe while mouse-looking, else they turn the facing (above).
if keys_pressed(KeyCode::KeyQ) {
move_flags_now |= move_flags::STRAFE_LEFT;
// Straight off the netted strafe axis, so a cancelled press pair streams NO strafe bit —
// the two are mutually exclusive on the wire, and both-set is silently dropped by the
// server (decision 0622).
match side_axis.signum() {
-1 => move_flags_now |= move_flags::STRAFE_LEFT,
1 => move_flags_now |= move_flags::STRAFE_RIGHT,
_ => {}
}
if keys_pressed(KeyCode::KeyE) {
move_flags_now |= move_flags::STRAFE_RIGHT;
}
if mouselook {
if keys_pressed(KeyCode::KeyA) {
move_flags_now |= move_flags::STRAFE_LEFT;
}
if keys_pressed(KeyCode::KeyD) {
move_flags_now |= move_flags::STRAFE_RIGHT;
}
} else {
if !mouselook {
if keys_pressed(KeyCode::KeyA) {
move_flags_now |= move_flags::TURN_LEFT;
}
@@ -1151,10 +1170,13 @@ fn control(
&mut player,
move_flags_now,
swim_pitch,
jumped,
landed,
started_falling,
wire_fall_time,
movement_net::ArcEdges {
jumped,
air_nudged,
landed,
started_falling,
fall_time: wire_fall_time,
},
now,
&speed_acks,
wire_transport,
+28 -5
View File
@@ -1,8 +1,15 @@
//! The mover's side of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]): one line per
//! *interesting* frame of the player mover — a step-down snap, a grounded flip, an airborne
//! frame, or any sizeable vertical delta — so a movement-feel report ("it pops when I step off
//! the fence") can be read back as per-frame numbers instead of re-guessed from watching the
//! screen. The anim driver writes its own `anim` lines into the same file, on the same clock.
//! The mover's side of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]), two tags:
//!
//! - **`move`** — one line per *interesting* frame of the player mover ([`frame`]): a step-down snap,
//! a grounded flip, an airborne frame, or any sizeable vertical delta — so a movement-feel report
//! ("it pops when I step off the fence") can be read back as per-frame numbers instead of
//! re-guessed from watching the screen.
//! - **`snd`** — one line per outbound `MSG_MOVE_*` ([`sent`]), the send-side twin of `net::motion`'s
//! `rly`: it makes **our own wire cadence measurable** (decision 0617), which is the only way to
//! compare it against the reference's — the 1.12.1 sniff's client stream is a list of exactly these
//! fields, so `grep snd` on a run of mouse-turning and strafing is directly diffable against it.
//!
//! The anim driver writes its own `anim` lines into the same file, on the same clock.
use std::sync::atomic::{AtomicBool, Ordering};
@@ -26,6 +33,22 @@ pub(super) struct Frame {
pub climb: Option<f32>,
}
/// One `snd` line per outbound movement packet: the opcode we chose, the live move-flags it carries,
/// and the facing. Read against the sniff's client stream (opcode + flags + orientation per line) this
/// answers "does our wire look like the reference's?" without anyone squinting at a second window.
pub(super) fn sent(kind: crate::net::MoveKind, flags: u32, facing: f32, pos: [f32; 3]) {
if !dbg_trace::enabled() {
return;
}
dbg_trace::line(
"snd",
&format!(
"{kind:?} flags={flags:#x} o={facing:.4} pos=[{:.2},{:.2},{:.2}]",
pos[0], pos[1], pos[2]
),
);
}
static PREV_GROUNDED: AtomicBool = AtomicBool::new(true);
pub(super) fn frame(f: Frame) {
+391 -69
View File
@@ -2,8 +2,9 @@
//! movers). [`stream_self_movement`] diffs this frame's CMovement move-flags against last frame's and
//! emits a `MSG_MOVE_*` per movement-*axis* transition (start/stop forward-back, strafe, turn), the
//! jump/fall lifecycle (JUMP launch, FALL_LAND, step-off heartbeat), a periodic heartbeat while moving,
//! and a rate-limited SET_FACING when turning in place — each carrying the live `MovementInfo`
//! (decisions 0052 + 0053). Split out of the controller: the wire stream is its own concern.
//! and a SET_FACING every frame the facing changes off the turn axis — each carrying the live
//! `MovementInfo` (decisions 0052 + 0053 + 0617). Split out of the controller: the wire stream is its
//! own concern.
//!
//! **Invariant — the wire mirrors the avatar's *actual* local motion** (decision 0056). vmangos relays
//! what we send verbatim and observers extrapolate it from the moveFlags, so any divergence strands them
@@ -32,11 +33,6 @@ use super::Player;
/// `clientTime + 500 ms` (`0x615b80`) — the wire report is the per-transition broadcast plus this
/// ~500 ms-paced heartbeat, independent of the 250 ms physics substeps.
const HEARTBEAT_INTERVAL: f32 = 0.5;
/// Minimum interval (s) between `MSG_MOVE_SET_FACING` packets while turning in place — caps a continuous
/// mouse-turn to ~10 Hz on the wire (smooth for observers, light on bandwidth).
const FACING_INTERVAL: f32 = 0.1;
/// Facing change (rad) below which a standing turn isn't worth a `SET_FACING` packet.
const FACING_EPSILON: f32 = 0.02;
/// The move-flag bits we put on the wire — the base directional / turn / walk set **plus `FALLING`**
/// (= `MOVEFLAG_JUMPING` 0x2000): we serialize the jump tail (`zspeed, cos, sin, xyspeed`) whenever it's
/// set, so observers replay our jump as a ballistic arc (decision 0053) — **`FALLING_FAR`**
@@ -58,33 +54,62 @@ const OUTBOUND_FLAG_MASK: u32 = move_flags::FORWARD
| move_flags::SWIMMING
| move_flags::ON_TRANSPORT;
/// This frame's **arc edges** — the airborne lifecycle as the send law reads it. One struct rather
/// than four adjacent bools in the argument list, where a miscount is silent and the symptom is a
/// wrong opcode on the wire.
pub(super) struct ArcEdges {
/// A jump launched this frame → `MSG_MOVE_JUMP`, carrying the ballistic tail.
pub(super) jumped: bool,
/// The standstill air nudge fired ([`super::mover::step`]) — the one mid-air press that really
/// moves us, and so the one that breaks the airborne send silence (decision 0627).
pub(super) air_nudged: bool,
/// The arc ended this frame → `MSG_MOVE_FALL_LAND`.
pub(super) landed: bool,
/// An arc began with no jump opcode (a walk-off) → an immediate heartbeat, so observers start
/// the arc promptly instead of waiting for the periodic one.
pub(super) started_falling: bool,
/// ms since take-off — the caller's snapshot, because the landing frame's arc bookkeeping has
/// already cleared `airborne_since` and the FALL_LAND must still report the accumulated time.
pub(super) fall_time: u32,
}
/// Stream this frame's movement to the server the way the real client does: a `MSG_MOVE_*` per movement-
/// *axis* transition (start/stop forward-back, strafe, turn), a JUMP on take-off, a SET_FACING when we
/// turn in place, and a HEARTBEAT every ~500 ms while moving — each carrying the current `MovementInfo`.
/// **VERIFIED** against wow-5875-re (collision "move-send cadence"): the move-state-change broadcaster
/// `0x61a820` selects the wire opcode *from the flag delta* (`0x619f00`), and the report is exactly
/// "per-transition broadcast + ~500 ms heartbeat". vmangos relays it to nearby players, who extrapolate
/// from the flags — how they see us walk/turn/strafe. (We claimed the mover with CMSG_SET_ACTIVE_MOVER at
/// login.) **Airborne is its own send law** (VERIFIED, vanilla-sniffs `dwarf_rogue_dun_morogh`): the
/// *axis* transition (start/stop forward-back, strafe, turn), a JUMP on take-off, a SET_FACING every
/// frame the facing changes off the turn axis, and a HEARTBEAT every ~500 ms while moving — each
/// carrying the current `MovementInfo`. **VERIFIED** against wow-5875-re (collision "move-send cadence"):
/// the move-state-change broadcaster `0x61a820` selects the wire opcode *from the flag delta*
/// (`0x619f00`), and the flag report is exactly "per-transition broadcast + ~500 ms heartbeat" — with
/// the *facing* report its own independent emitter alongside it (decision 0617: in the 1.12.1 sniff
/// SET_FACING outnumbers every other client-sent movement opcode combined, moving or standing).
/// vmangos relays it all to nearby players, who extrapolate from the flags — how they see us walk/turn/
/// strafe. (We claimed the mover with CMSG_SET_ACTIVE_MOVER at login.) **Airborne is its own send law** (VERIFIED, vanilla-sniffs `dwarf_rogue_dun_morogh`): the
/// fwd/back/strafe transitions and the periodic heartbeat go silent while FALLING — the live flag state
/// rides the packets that do go out (turn transitions, SET_FACING, the FALL_LAND) — so a normal jump is
/// exactly JUMP → \[turn/facing\] → FALL_LAND, with the landing flags telling observers what the keys
/// say *now*. Sends are fire-and-forget; a down thread no-ops. Mutates `player`'s last-sent flags/facing/
/// say *now*. The one press that breaks that silence is the standstill air nudge (`air_nudged`,
/// decision 0627): it is the case the reference's own deferral excludes, and the only mid-air input
/// that actually moves us. Sends are fire-and-forget; a down thread no-ops. Mutates `player`'s last-sent flags/facing/
/// heartbeat so next frame can diff against them.
// Eight, down from twelve: the arc edges are one struct now (`ArcEdges`). The rest are distinct
// types the compiler can tell apart, so the remaining count is noise rather than a miscount risk.
#[allow(clippy::too_many_arguments)]
pub(super) fn stream_self_movement(
sender: &Sender<ClientCommand>,
player: &mut Player,
move_flags_now: u32,
swim_pitch: f32,
jumped: bool,
landed: bool,
started_falling: bool,
fall_time: u32,
arc: ArcEdges,
now: f32,
speed_acks: &[crate::net::SpeedChangeMessage],
transport: Option<TransportPose>,
) {
let ArcEdges {
jumped,
air_nudged,
landed,
started_falling,
fall_time,
} = arc;
let wow_pos = bevy_to_wow(player.pos);
// Normalize the facing into [0, 2π) before it goes on the wire. `face_yaw` is an unbounded
// accumulator (mouse-look and A/D turning keep growing it), but the real client always sends a
@@ -154,6 +179,7 @@ pub(super) fn stream_self_movement(
facing
);
}
super::move_trace::sent($kind, wire_flags, facing, wow_pos);
let _ = sender.send(ClientCommand::Move {
kind: $kind,
flags: wire_flags,
@@ -180,6 +206,19 @@ pub(super) fn stream_self_movement(
// arc's momentum froze at takeoff), so their transitions aren't motion changes — the live bits
// simply ride every packet that does go out. The TURN axis is the exception (below): turning
// genuinely works mid-air, and the sniff shows START_TURN_RIGHT/STOP_TURN with `Falling` set.
//
// **The standstill air nudge is the other exception** (decision 0627), and it is the *same* rule,
// not a carve-out: the wire mirrors actual motion (0056). The reference's airborne silence is a
// real flags-side mechanism — while FALLING, `StartMove 0x7c6ae0` defers a new press into an inert
// latch (`0x20000`/`0x40000`) instead of flipping the direction bit, "**unless nothing is
// currently moving**" (wow-re `hvel-fall-arc.md` Q3, VERIFIED bytes) — and the broadcaster
// `0x61a820` picks its opcode from the *flag delta*, so a deferred press produces no delta and
// no packet. In the one non-deferred case the bit really flips, so the transition really
// broadcasts. That case is exactly our nudge (`mover::step`: airborne, nothing moving, a
// direction pressed — the one input that genuinely re-seeds the arc's horizontal velocity), and
// it must go out: the packet carries the fresh `JumpInfo` tail, which is the only way an observer
// — integrating our arc from a JUMP that said `xy_speed = 0` — can ever learn we started moving
// before the FALL_LAND lands.
let falling = wire_flags & move_flags::FALLING != 0;
// The airborne lifecycle: a JUMP launch (carries the ballistic tail), a FALL_LAND that closes the
// arc, or — for a step-off with no jump opcode — an immediate heartbeat so observers start the arc
@@ -203,21 +242,24 @@ pub(super) fn stream_self_movement(
} else if removed & move_flags::SWIMMING != 0 {
send_move!(MoveKind::StopSwim);
}
// Forward/back axis — silent while airborne (the flag state rides the next packet instead).
if !falling {
// Forward/back axis — silent while airborne (the flag state rides the next packet instead),
// except on the nudge frame. The **stop** arms stay silent while falling no matter what: a
// mid-air release is the deferred-latch case, always, and the nudge frame is a press by
// definition — so this only keeps a same-frame release on the *other* axis from slipping out.
if !falling || air_nudged {
if added & move_flags::FORWARD != 0 {
send_move!(MoveKind::StartForward);
} else if added & move_flags::BACKWARD != 0 {
send_move!(MoveKind::StartBackward);
} else if removed & FB != 0 && wire_flags & FB == 0 {
} else if !falling && removed & FB != 0 && wire_flags & FB == 0 {
send_move!(MoveKind::Stop);
}
// Strafe axis — same airborne silence.
// Strafe axis — same airborne silence, same one exception.
if added & move_flags::STRAFE_LEFT != 0 {
send_move!(MoveKind::StartStrafeLeft);
} else if added & move_flags::STRAFE_RIGHT != 0 {
send_move!(MoveKind::StartStrafeRight);
} else if removed & STRAFE != 0 && wire_flags & STRAFE == 0 {
} else if !falling && removed & STRAFE != 0 && wire_flags & STRAFE == 0 {
send_move!(MoveKind::StopStrafe);
}
}
@@ -229,6 +271,30 @@ pub(super) fn stream_self_movement(
} else if removed & TURN != 0 && wire_flags & TURN == 0 {
send_move!(MoveKind::StopTurn);
}
// The facing report: one `MSG_MOVE_SET_FACING` per frame in which the facing actually changed —
// **the single biggest thing our wire stream was missing** (decision 0617). **VERIFIED** against the
// real 1.12.1.5875 sniff (`dwarf_rogue_dun_morogh`): 179 of its 336 client-sent movement packets are
// SET_FACING — more than every other movement opcode combined — streamed at *frame* cadence (median
// 41 ms between them, p25 23 ms, minimum 17 ms) and, decisively, **while moving**: 116 of the 179
// carry a direction bit (`Forward` ×68, `StrafeRight` ×12, `Forward+StrafeRight` ×9, `Backward` ×12,
// `Forward+Falling` ×4, …). There is no rate limit and no angular epsilon — wow-re's `0x617100`
// (SetFacing-then-send) reports whenever `0x617170`'s **exact-equality** change detector says the
// facing differs at all, so a frame that didn't move the mouse sends nothing and a frame that did
// sends one packet. (Our own `face_yaw` is likewise only written by real input, so the exact
// comparison neither floods nor misses.)
//
// The one exclusion is the **turn axis**: not a single SET_FACING in the capture carries `TURN_LEFT`
// or `TURN_RIGHT`. A keyboard turn is already fully described by its flag — observers rotate the
// mover at the turn rate for as long as it's set — so the facing needs no separate carrier, and the
// STOP_TURN closes the arc with the final angle.
//
// Not gated on `sent`: the capture repeatedly shows a SET_FACING sharing a millisecond with a
// transition (`+1563` SET_FACING & STOP_STRAFE, `+3009` SET_FACING & STOP, `+4971` SET_FACING &
// START_STRAFE_RIGHT). They are two independent emitters in the reference — the input-phase facing
// report and the move-state broadcaster — not one prioritized channel.
if wire_flags & TURN == 0 && facing != player.last_facing {
send_move!(MoveKind::SetFacing);
}
// Board/deboard: the ON_TRANSPORT flip has no axis opcode of its own, so if nothing else went
// out this frame, a heartbeat announces it promptly — the server learns the new frame (and its
// local-pose tail, or its absence) the frame it changes rather than on the next natural packet.
@@ -247,21 +313,14 @@ pub(super) fn stream_self_movement(
if !sent && wire_flags != 0 && !falling && now - player.last_heartbeat >= HEARTBEAT_INTERVAL {
send_move!(MoveKind::Heartbeat);
}
// The facing changed with no packet to carry it — standing (a mouse-turn in place), or mid-air
// (a jump-turn: no heartbeats stream while FALLING, yet observers must see the turn — the sniff
// shows the real client's mid-air SET_FACING with `(Forward, Falling)` and the jump tail).
// Rate-limited so a continuous turn doesn't flood the wire.
if !sent
&& (wire_flags == 0 || falling)
&& (facing - player.last_sent_facing).abs() > FACING_EPSILON
&& now - player.last_heartbeat >= FACING_INTERVAL
{
send_move!(MoveKind::SetFacing);
}
if sent {
player.last_heartbeat = now;
player.last_sent_facing = facing;
}
// The change detector's reference is the **previous frame's** facing, not the last one we reported:
// the reference compares the new facing against the unit's live facing cell, which its setter
// updates on every change whether or not a packet went out. So the turn-axis frames above, which
// deliberately send nothing, still leave no catch-up packet behind when the key releases.
player.last_facing = facing;
player.move_flags = wire_flags;
}
@@ -288,7 +347,7 @@ pub(super) fn park_mover(sender: &Sender<ClientCommand>, player: &mut Player) {
transport: None, // flags cleared → no transport tail written
});
player.move_flags = 0;
player.last_sent_facing = facing;
player.last_facing = facing;
}
#[cfg(test)]
@@ -312,10 +371,13 @@ mod tests {
&mut player,
move_flags::FORWARD,
0.0,
false,
false,
false,
0,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 0,
},
0.0,
&[],
None,
@@ -349,12 +411,12 @@ mod tests {
stream_self_movement(
&tx,
&mut player,
0, // grounded again: no FALLING/FALLINGFAR on the land packet itself
0,
// grounded again: no FALLING/FALLINGFAR on the land packet itself
0.0,
false,
true, // landed
false,
1700, // the snapshot: ~1.7 s of fall (> the 1229 ms damage gate)
ArcEdges { jumped: false, air_nudged: false, landed: true, started_falling: // landed
false, fall_time: 1700 },
// the snapshot: ~1.7 s of fall (> the 1229 ms damage gate)
0.0,
&[],
None,
@@ -390,10 +452,13 @@ mod tests {
&mut player,
move_flags::FALLING,
0.0,
false,
false,
false,
300,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 300,
},
0.2,
&[],
None,
@@ -410,10 +475,13 @@ mod tests {
&mut player,
0,
0.0,
false,
true,
false,
800,
ArcEdges {
jumped: false,
air_nudged: false,
landed: true,
started_falling: false,
fall_time: 800,
},
0.8,
&[],
None,
@@ -430,11 +498,87 @@ mod tests {
);
}
#[test]
fn the_standstill_air_nudge_is_the_one_press_that_breaks_the_airborne_silence() {
// Decision 0627. The airborne silence is the reference's *deferral* (`StartMove 0x7c6ae0`
// latches a mid-air press instead of flipping the direction bit) and the deferral has one
// byte-verified exclusion: "unless nothing is currently moving". That case really flips the
// bit, so the broadcaster really sends — and it must, because it is the only case where the
// press changed our motion, and the observer is integrating an arc that says xy_speed = 0.
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player {
move_flags: move_flags::FALLING, // the standing jump: airborne, no direction
// Post-nudge: the mover re-seeded it this frame (Bevy −Z = WoW +X, so |xy| = 2.5).
horiz_vel: bevy::prelude::Vec3::new(0.0, 0.0, -2.5),
..Default::default()
};
stream_self_movement(
&tx,
&mut player,
move_flags::FORWARD | move_flags::FALLING,
0.0,
ArcEdges {
jumped: false,
air_nudged: true,
landed: false,
started_falling: false,
fall_time: 300,
},
0.3,
&[],
None,
);
let ClientCommand::Move {
kind, flags, jump, ..
} = rx.try_recv().expect("the nudge broadcasts its transition")
else {
panic!("expected a Move command");
};
assert_eq!(kind, MoveKind::StartForward);
assert_ne!(flags & move_flags::FALLING, 0, "it rides the arc");
let tail = jump.expect("an airborne packet carries the ballistic tail");
assert!(
(tail.xy_speed - 2.5).abs() < 1.0e-3,
"the tail carries the RE-SEEDED horizontal — the whole point: an observer whose \
arc says xy_speed = 0 learns the mover started moving, got {}",
tail.xy_speed
);
assert!(rx.try_recv().is_err(), "one packet, not a burst");
// The other side of the same rule: a press that did NOT nudge (a jump taken with momentum —
// the reference defers it into an inert latch) stays silent, exactly as the sniff shows.
let mut moving = Player {
move_flags: move_flags::FORWARD | move_flags::FALLING,
..Default::default()
};
stream_self_movement(
&tx,
&mut moving,
move_flags::FORWARD | move_flags::STRAFE_LEFT | move_flags::FALLING,
0.0,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 300,
},
0.3,
&[],
None,
);
assert!(
rx.try_recv().is_err(),
"a deferred mid-air press sends nothing"
);
}
#[test]
fn airborne_turn_transitions_and_facing_still_stream() {
// The two things that DO go out mid-air (sniff-verified): the turn axis (turning works
// airborne — START_TURN_RIGHT/STOP_TURN with Falling set) and SET_FACING for a mouse
// jump-turn (no heartbeats stream while FALLING, so facing needs its own carrier).
// jump-turn — the capture's `8193 (Forward, Falling)` SET_FACINGs, which carry **no** turn
// bit. No heartbeats stream while FALLING, so the facing needs its own carrier.
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player {
move_flags: move_flags::FORWARD | move_flags::FALLING,
@@ -445,10 +589,13 @@ mod tests {
&mut player,
move_flags::FORWARD | move_flags::TURN_LEFT | move_flags::FALLING,
0.0,
false,
false,
false,
200,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 200,
},
0.2,
&[],
None,
@@ -460,28 +607,203 @@ mod tests {
};
assert_eq!(kind, MoveKind::StartTurnLeft);
assert_ne!(flags & move_flags::FALLING, 0, "the packet rides the arc");
// Mid-air mouse-turn, flags unchanged, heartbeat interval long past: the periodic
// heartbeat stays suppressed while FALLING and the facing streams via SET_FACING instead.
assert!(rx.try_recv().is_err(), "the turn axis carries the facing");
// Mid-air mouse-turn with the turn key released: the periodic heartbeat stays suppressed
// while FALLING, and the facing streams via SET_FACING once the turn flag is gone.
player.face_yaw = 1.0;
stream_self_movement(
&tx,
&mut player,
move_flags::FORWARD | move_flags::TURN_LEFT | move_flags::FALLING,
move_flags::FORWARD | move_flags::FALLING,
0.0,
false,
false,
false,
900,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 900,
},
1.5,
&[],
None,
);
let ClientCommand::Move { kind, .. } = rx.try_recv().expect("the mid-air facing packet")
let kinds: Vec<_> = rx
.try_iter()
.map(|c| match c {
ClientCommand::Move { kind, .. } => kind,
_ => panic!("expected Move commands"),
})
.collect();
assert_eq!(
kinds,
vec![MoveKind::StopTurn, MoveKind::SetFacing],
"the turn closes, then the facing reports — no heartbeat while FALLING"
);
}
#[test]
fn facing_streams_while_running() {
// **The regression this law exists for** (decision 0617). A mouse-turn while running used to
// put nothing on the wire until the next ~500 ms heartbeat, so observers dead-reckoned us in a
// stale direction for half a second and then watched us snap. The reference streams SET_FACING
// *while moving* — 116 of the capture's 179 carry a direction bit — so every frame the facing
// moves, one packet goes out carrying the live direction flags.
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player {
move_flags: move_flags::FORWARD, // already running: no transition this frame
..Default::default()
};
player.face_yaw = 0.7;
stream_self_movement(
&tx,
&mut player,
move_flags::FORWARD,
0.0,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 0,
},
0.05,
&[],
None,
);
let ClientCommand::Move { kind, flags, .. } = rx
.try_recv()
.expect("a mouse-turn while running reports its facing")
else {
panic!("expected a Move command");
};
assert_eq!(kind, MoveKind::SetFacing, "SET_FACING, not a heartbeat");
assert!(rx.try_recv().is_err());
assert_eq!(kind, MoveKind::SetFacing);
assert_eq!(
flags,
move_flags::FORWARD,
"the facing report carries the live direction flags"
);
assert!(
rx.try_recv().is_err(),
"exactly one packet per changed frame"
);
// A frame that didn't move the mouse sends nothing: the detector is exact equality, not a
// rate limit — and the heartbeat deadline (0.5 s) has not come round.
stream_self_movement(
&tx,
&mut player,
move_flags::FORWARD,
0.0,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 0,
},
0.06,
&[],
None,
);
assert!(rx.try_recv().is_err(), "an unchanged facing is silent");
}
#[test]
fn the_turn_axis_carries_its_own_facing() {
// Not one SET_FACING in the capture carries TURN_LEFT/TURN_RIGHT: a keyboard turn is fully
// described by its flag (observers rotate at the turn rate while it's set), so the facing it
// sweeps needs no packet of its own — and, crucially, the suppressed frames leave no catch-up
// packet behind, because the change detector tracks the previous frame's facing regardless.
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
..Default::default()
};
for (i, yaw) in [0.3_f32, 0.6, 0.9].into_iter().enumerate() {
player.face_yaw = yaw;
stream_self_movement(
&tx,
&mut player,
move_flags::FORWARD | move_flags::TURN_RIGHT,
0.0,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 0,
},
0.05 * (i as f32 + 1.0),
&[],
None,
);
assert!(
rx.try_recv().is_err(),
"the turn axis is silent (frame {i})"
);
}
// Release the turn key with the facing unchanged since the last swept frame: the STOP_TURN
// goes out and nothing follows it.
stream_self_movement(
&tx,
&mut player,
move_flags::FORWARD,
0.0,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 0,
},
0.2,
&[],
None,
);
let ClientCommand::Move { kind, .. } = rx.try_recv().expect("the STOP_TURN") else {
panic!("expected a Move command");
};
assert_eq!(kind, MoveKind::StopTurn);
assert!(
rx.try_recv().is_err(),
"no catch-up SET_FACING after a suppressed turn"
);
}
#[test]
fn a_transition_does_not_swallow_the_facing_report() {
// The capture repeatedly shows both in one millisecond (`+4971` SET_FACING & START_STRAFE_
// RIGHT). They are two independent emitters in the reference, so a frame that both turns and
// changes axis puts both on the wire.
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player {
move_flags: move_flags::FORWARD,
..Default::default()
};
player.face_yaw = 1.2;
stream_self_movement(
&tx,
&mut player,
move_flags::FORWARD | move_flags::STRAFE_RIGHT,
0.0,
ArcEdges {
jumped: false,
air_nudged: false,
landed: false,
started_falling: false,
fall_time: 0,
},
0.05,
&[],
None,
);
let kinds: Vec<_> = rx
.try_iter()
.map(|c| match c {
ClientCommand::Move { kind, .. } => kind,
_ => panic!("expected Move commands"),
})
.collect();
assert_eq!(kinds, vec![MoveKind::StartStrafeRight, MoveKind::SetFacing]);
}
#[test]
+183 -71
View File
@@ -148,78 +148,30 @@ pub(super) fn step(
air_nudged = true;
}
// The step-up (decision 0209): ATOMIC — a steep face in the way triggers rise →
// advance-this-frame's-travel-at-the-raised-height → settle onto a walkable floor, all
// committed inside this one frame, or nothing happens and the plain slide runs. There is
// no in-between state to be seen wedged or bouncing in (the 0191 ride dwelled mid-face;
// every stuck/bounce report of the step-up era was that dwelling). Grazing a face nets
// back onto the same floor (reads as sliding); a square push onto a low step lands on its
// top; anything taller than [`STEP_UP_HEIGHT`] never commits.
let mut stepped = None;
if !held && moving && grounded && !jumped {
stepped = step_up(&cast, center, dir.normalize_or_zero(), speed * dt);
}
// Held: zero velocity (no move). Grounded: move horizontally only (no gravity-slide).
// Jumping/airborne: gravity carries the arc.
let velocity = if held {
Vec3::ZERO
} else if grounded && !jumped {
player.horiz_vel
} else {
player.horiz_vel + Vec3::Y * player.vel_y
};
let pre_move = center;
if let Some(landed) = stepped {
// The committed maneuver IS this frame's motion — already settled on a walkable floor,
// so the slide and the snap below are skipped.
center = landed;
} else {
let out = ms.move_and_slide(
capsule,
center,
Quat::IDENTITY,
velocity,
time.delta(),
&MoveAndSlideConfig::default(),
&filter,
|hit| {
if grounded && !jumped {
if let Some(ride) = walkable_ride_velocity(**hit.normal, *hit.velocity) {
*hit.velocity = ride;
return MoveAndSlideHitResponse::Accept;
}
}
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
if let Ok(wall) = Dir::new(wall) {
*hit.normal = wall;
}
}
MoveAndSlideHitResponse::Accept
},
);
center = out.position;
}
// Snap onto the surface (grounded, not jumping) so we follow downhill slopes + steps down —
// the client's step-vs-fall election (`0x6367b0`, wow-re `step-vs-fall-election.md`): the
// probe reaches [`STEP_SLOPE_RATIO`]·travel + [`STEP_SNAP_SLACK`] + the unit's collision
// height (`0x617430` = `[unit+0xb8]`, our [`CAPSULE_HEIGHT`]; the election's `0x4000000`-
// gated extension — decision 0182) and snaps only onto a *walkable* floor (≤50°, the
// election's own `cos50°` = [`GROUND_COS`]). A deeper or steeper floor is NOT absorbed: no
// snap, the next frame's ground probe misses, and the gap becomes a fall (the client's
// `StartFalling(0)` election) — a short ledge drop reads as a quick, continuous, steep
// descent, which is what the director's eye confirmed against the reference (decision 0190;
// 0189's instant absorbed step read as a teleport and was reverted).
let mut snap_probe = None; // (reach, probe outcome) — feeds the WOW_MOVE_TRACE line below
if grounded && !jumped && stepped.is_none() {
let d = center - pre_move;
let reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK + CAPSULE_HEIGHT;
let hit = probe_down(center, reach);
snap_probe = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y))));
if let Some(h) = hit.filter(|h| h.normal1.y >= GROUND_COS) {
center.y -= h.distance;
ground_entity = Some(h.entity);
// The grounded walk is the SHARED resolve ([`grounded_step`]) — step-up, slide, election
// snap — the same code every remote mover's dead-reckon runs. Held and airborne/jumping
// frames keep their own slide here: no step-up, no snap, and gravity in the velocity.
let (mut climb, mut snap_probe) = (None, None);
if !held && grounded && !jumped {
let g = grounded_step(ms, capsule, &filter, center, player.horiz_vel, time.delta());
center = g.center;
climb = g.climb;
snap_probe = g.snap;
if let Some(e) = g.ground {
ground_entity = Some(e);
}
} else {
// Held: zero velocity (no move) — `held` already zeroed both terms, but say it outright.
// Jumping/airborne: gravity carries the arc.
let velocity = if held {
Vec3::ZERO
} else {
player.horiz_vel + Vec3::Y * player.vel_y
};
// The airborne slide is the OTHER shared resolve ([`airborne_step`]) — the same code a
// remote mover's arc runs, so a jump meets our walls whoever is jumping (decision 0627).
center = airborne_step(ms, capsule, &filter, center, velocity, time.delta());
}
// Wedge-rest detection (decisions 0211/0212): airborne, already falling fast, yet the
// descent achieved is a sliver of what gravity intended — [`WEDGE_STILL_FRAMES`] in a row
@@ -264,7 +216,7 @@ pub(super) fn step(
on_walkable,
vel_y: player.vel_y,
snap: snap_probe,
climb: stepped.map(|landed| landed.y - pre_move.y),
climb,
});
Outcome {
@@ -280,6 +232,166 @@ pub(super) fn step(
}
}
/// What one grounded walk step resolved against the world came out as ([`grounded_step`]).
pub(crate) struct GroundedStep {
/// The resolved capsule centre.
pub(crate) center: Vec3,
/// The collider of the walkable floor the election snap settled onto, when it ran and hit one.
/// `None` means "keep whatever the caller already believed" — a step-up commit and a missed
/// snap both leave the support unchanged.
pub(crate) ground: Option<Entity>,
/// The atomic step-up's committed height gain (yd), when the maneuver ran.
pub(crate) climb: Option<f32>,
/// The election snap's `(probe reach, what it found)` — trace fodder, `None` when the step-up
/// took the frame instead. The inner pair is `(hit distance, hit normal.y)`.
pub(crate) snap: Option<(f32, Option<(f32, f32)>)>,
}
/// **One grounded walk step, resolved against the world** — step-up → slide → election snap, from
/// a capsule centre and this frame's horizontal velocity. The single place a walking body meets the
/// terrain, and deliberately so: the reference drives **every** mover through one controller (0059's
/// byte trail — `0x616620` integrates any mover; the local-player GUID compare at `0x6166a9` gates
/// only a timing budget; the grounded fork zeroes the vertical and commits through the swept world
/// query `0x633840` + the WALK resolver `0x6367b0`, which reads Z off the surface). So does benilla:
/// the local controller ([`step`]) calls this for its grounded frames, and every **remote** mover's
/// dead-reckon calls it for its extrapolated step ([`crate::net::motion::remote`]).
///
/// Why a remote needs it at all: dead-reckoning between packets is *our invention*, and an invention
/// that ignores the world walks a watched player into a hillside (they sink in, then the next packet
/// pops them out) and leaves their height wherever the last packet put it while the ground under them
/// rises or falls (they sink or float, and the height arrives as a 2 Hz snap). Both are one defect —
/// the extrapolator never touched the world — and both are gone the moment the step is resolved
/// through here.
///
/// Airborne and swimming frames are **not** this function's: a jump is a ballistic arc and a
/// swimmer's Z is its depth, exactly as the reference's grounded fork excludes both.
pub(crate) fn grounded_step(
ms: &MoveAndSlide<'_, '_>,
capsule: &Collider,
filter: &SpatialQueryFilter,
center: Vec3,
horiz_vel: Vec3,
dt: std::time::Duration,
) -> GroundedStep {
let cast = |from: Vec3, disp: Vec3| {
ms.cast_move(capsule, from, Quat::IDENTITY, disp, SKIN_WIDTH, filter)
};
let speed = horiz_vel.length();
// The step-up (decision 0209): ATOMIC — a steep face in the way triggers rise →
// advance-this-frame's-travel-at-the-raised-height → settle onto a walkable floor, all
// committed inside this one frame, or nothing happens and the plain slide runs. There is
// no in-between state to be seen wedged or bouncing in (the 0191 ride dwelled mid-face;
// every stuck/bounce report of the step-up era was that dwelling). Grazing a face nets
// back onto the same floor (reads as sliding); a square push onto a low step lands on its
// top; anything taller than [`STEP_UP_HEIGHT`] never commits.
let stepped = if speed > 1.0e-6 {
step_up(&cast, center, horiz_vel / speed, speed * dt.as_secs_f32())
} else {
None
};
if let Some(landed) = stepped {
// The committed maneuver IS this frame's motion — already settled on a walkable floor,
// so the slide and the snap below are skipped.
return GroundedStep {
center: landed,
ground: None,
climb: Some(landed.y - center.y),
snap: None,
};
}
let out = ms.move_and_slide(
capsule,
center,
Quat::IDENTITY,
horiz_vel,
dt,
&MoveAndSlideConfig::default(),
filter,
|hit| {
if let Some(ride) = walkable_ride_velocity(**hit.normal, *hit.velocity) {
*hit.velocity = ride;
return MoveAndSlideHitResponse::Accept;
}
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
if let Ok(wall) = Dir::new(wall) {
*hit.normal = wall;
}
}
MoveAndSlideHitResponse::Accept
},
);
let mut slid = out.position;
// Snap onto the surface so we follow downhill slopes + steps down — the client's step-vs-fall
// election (`0x6367b0`, wow-re `step-vs-fall-election.md`): the probe reaches
// [`STEP_SLOPE_RATIO`]·travel + [`STEP_SNAP_SLACK`] + the unit's collision height (`0x617430` =
// `[unit+0xb8]`, our [`CAPSULE_HEIGHT`]; the election's `0x4000000`-gated extension — decision
// 0182) and snaps only onto a *walkable* floor (≤50°, the election's own `cos50°` =
// [`GROUND_COS`]). A deeper or steeper floor is NOT absorbed: no snap, the next frame's ground
// probe misses, and the gap becomes a fall (the client's `StartFalling(0)` election) — a short
// ledge drop reads as a quick, continuous, steep descent, which is what the director's eye
// confirmed against the reference (decision 0190; 0189's instant absorbed step read as a
// teleport and was reverted).
//
// Standing still the reach is slack + the collision height, which is what re-grounds an
// *idle* body every frame: the small float a raw wire Z leaves a watched player standing on
// our terrain is taken out here, the same way [`crate::net::motion::ground_clamp_creatures`]
// takes it out of an idle NPC.
let d = slid - center;
let reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK + CAPSULE_HEIGHT;
let hit = cast(slid, Vec3::NEG_Y * reach);
let snap = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y))));
let mut ground = None;
if let Some(h) = hit.filter(|h| h.normal1.y >= GROUND_COS) {
slid.y -= h.distance;
ground = Some(h.entity);
}
GroundedStep {
center: slid,
ground,
climb: None,
snap,
}
}
/// **One airborne step, resolved against the world** — the arc's slide and nothing else. No
/// step-up and no election snap: the arc owns its own height (gravity carries it; the landing is
/// next frame's ground probe to call), so the only thing the world may do here is *stop* it. Steep
/// faces get [`steep_wall_plane`]'s treatment, exactly as they do on the ground.
///
/// The airborne twin of [`grounded_step`], and shared for the same reason (0059's one controller,
/// every mover): the local controller ([`step`]) calls it for its held/airborne frames, and a
/// **remote** mover's ballistic dead-reckon calls it for the arc it invents between packets
/// ([`crate::net::motion::remote`]). Without that, a watched player who jumps into a building is
/// drawn *inside* it for the length of the jump and pops back out on the landing packet — the
/// airborne half of the very defect 0626 fixed on the ground (decision 0627).
pub(crate) fn airborne_step(
ms: &MoveAndSlide<'_, '_>,
capsule: &Collider,
filter: &SpatialQueryFilter,
center: Vec3,
velocity: Vec3,
dt: std::time::Duration,
) -> Vec3 {
ms.move_and_slide(
capsule,
center,
Quat::IDENTITY,
velocity,
dt,
&MoveAndSlideConfig::default(),
filter,
|hit| {
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
if let Ok(wall) = Dir::new(wall) {
*hit.normal = wall;
}
}
MoveAndSlideHitResponse::Accept
},
)
.position
}
/// The even-speed ramp ride: a walkable slope never slows or deflects the grounded walk. The
/// real client's walk step is two-dimensional — the resolver takes speed·dt as a *horizontal*
/// distance and a normalized 2D direction, and Z follows purely through the snap/step machinery
+87
View File
@@ -0,0 +1,87 @@
//! `WOW_PROBE_LOOK` — the scripted **mouse-turn**, the one player action the probe harness could not
//! synthesize (decision 0621).
//!
//! `WOW_PROBE_KEY` presses keys, which is enough for the transition opcodes; but since decision 0617
//! the busiest thing on our wire by far is the facing stream a *mouse* turn produces (~one packet per
//! frame). A defect that only shows up under that density — and the runaway hunt looks like exactly
//! that — could not be reproduced headlessly at all: an agent had to ask the director to drive, every
//! iteration. That is the tooling gap this closes.
//!
//! Format: `WOW_PROBE_LOOK="<deg_per_sec>@<start_s>:<duration_s>[;…]"`, e.g. `"90@20:6"` — turn the
//! avatar's aim 90°/s for six seconds starting twenty seconds in. Negative rates turn the other way.
//! It writes [`Player::face_yaw`] directly rather than faking `AccumulatedMouseMotion` + a held right
//! button: the camera's look session gates on the OS cursor being inside the viewport
//! (`cursor_in_viewport`), which is unreliable for an unfocused probe window, and the camera plumbing
//! is not what is under test. From `face_yaw` onward this is the identical path a real mouse-turn
//! takes — the same value `stream_self_movement` diffs to decide on a `MSG_MOVE_SET_FACING`.
use bevy::prelude::*;
use super::Player;
/// One scripted turn: `rate` (rad/s), when it starts, and how long it runs (seconds, in-world clock).
struct Turn {
rate: f32,
at: f32,
until: f32,
}
#[derive(Resource)]
pub(super) struct ProbeLook {
turns: Vec<Turn>,
}
/// Parse the env script. Absent or unparseable entries yield no turns (with a warning), so the probe
/// is inert unless asked for.
pub(super) fn from_env() -> Option<ProbeLook> {
let spec = std::env::var("WOW_PROBE_LOOK").ok()?;
let turns: Vec<Turn> = spec
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| {
let (rate, rest) = s.split_once('@')?;
let (at, dur) = rest.split_once(':')?;
match (
rate.trim().parse::<f32>(),
at.trim().parse::<f32>(),
dur.trim().parse::<f32>(),
) {
(Ok(rate), Ok(at), Ok(dur)) => Some(Turn {
rate: rate.to_radians(),
at,
until: at + dur,
}),
_ => {
warn!("probe-look: unparseable turn {s:?} (want e.g. 90@20:6) — skipped");
None
}
}
})
.collect();
(!turns.is_empty()).then_some(ProbeLook { turns })
}
/// Rotate the aim for every turn whose window covers this frame. Runs in `WorldStage::Input`
/// **before** the controller, so the frame that sees the new `face_yaw` is the frame that streams it.
pub(super) fn drive_probe_look(
probe: Res<ProbeLook>,
time: Res<Time>,
mut player: ResMut<Player>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
) {
if self_player.is_empty() {
return; // in-world only, like the other probes
}
let now = time.elapsed_secs();
let dt = time.delta_secs();
let rate: f32 = probe
.turns
.iter()
.filter(|t| now >= t.at && now < t.until)
.map(|t| t.rate)
.sum();
if rate != 0.0 {
player.face_yaw += rate * dt;
}
}
+16 -6
View File
@@ -133,10 +133,18 @@ pub(super) const SETTLE_TIMEOUT: f32 = 6.0;
/// glimpsed through a building whose collider hasn't streamed in yet.
pub(super) const SETTLE_REACH: f32 = 1.0;
/// The player's collision capsule, built once at startup and swept by avian's `MoveAndSlide` each
/// frame. Its origin is the capsule centre; the player's `pos` is its feet (centre − half-height·Y).
/// The player body's collision capsule, built once at startup and swept by avian's `MoveAndSlide`
/// each frame. Its origin is the capsule centre; the player's `pos` is its feet (centre −
/// half-height·Y).
///
/// **Every player body, not only ours.** A remote mover's dead-reckon sweeps this same capsule
/// against the same colliders ([`super::mover::grounded_step`]) — the reference drives non-local
/// units through the *same* movement controller as the local player (decision 0059's byte trail:
/// `0x616620` integrates any mover; the local-player GUID compare gates only a timing budget). The
/// reference reads each unit's own collision height (`[unit+0xb8]`); we use the one player capsule
/// for every player body, which the 1.12 races are close enough to share.
#[derive(Resource)]
pub(super) struct PlayerCapsule(pub(super) Collider);
pub(crate) struct PlayerCapsule(pub(crate) Collider);
/// The avatar run-speed fallback + dev override. `value` is `$WOW_MOVE_SPEED` when set
/// (`env_override` — the absolute dev knob, backpedal scaled by [`RUN_BACK_RATIO`] under it), else
@@ -187,9 +195,11 @@ pub(crate) struct Player {
/// [`crate::net::move_flags`]). Diffed against this frame's flags to emit a `MSG_MOVE_*` per
/// movement-axis transition — the way the real client announces its movement.
pub(super) move_flags: u32,
/// The facing (WoW orientation) we last sent. While standing and mouse-turning, a change beyond a
/// small threshold streams a `MSG_MOVE_SET_FACING` (rate-limited), so others see us turn in place.
pub(super) last_sent_facing: f32,
/// The facing (WoW orientation) as of **last frame** — the reference's facing-change detector
/// (`0x617170`, exact equality against the unit's live facing cell). Any change off the turn axis
/// streams a `MSG_MOVE_SET_FACING` that frame, moving or standing, so observers see us aim
/// (decision 0617). Updated every frame whether or not a packet went out.
pub(super) last_facing: f32,
/// The stand state we last volunteered (`CMSG_STANDSTATECHANGE`) whose echo into our
/// `UNIT_FIELD_BYTES_1` hasn't landed yet — the local commit (the client's `SetStandState`
/// `0x6127b0` applies immediately *and* sends; decision 0080c). `None` = at the echoed value.
+109 -25
View File
@@ -23,10 +23,10 @@
//! *Interim*: the reference's interactability predicate (`CGUnit::CanInteract 0x606880`) isn't
//! fully derived; we approximate it as "has service flags and isn't attack-worthy (reaction ≥
//! neutral)". Attackability approximates the PvP/attack matrix as "reaction rank ≤ neutral" —
//! the same approximation the ring's player branch documents. The questgiver bit skips the
//! client's quest-status gate (`0x5df490` — Speak only with an actual quest to show), and the
//! auto-loot modifier-key split (Pickup vs LootAll, `0x41f8f0`) waits on auto-loot existing at
//! all — until then the base Pickup is the only mode the loot leg can honestly show.
//! the same approximation the ring's player branch documents. The auto-loot modifier-key split
//! (Pickup vs LootAll, `0x41f8f0`) waits on auto-loot existing at all — until then the base Pickup
//! is the only mode the loot leg can honestly show. (The questgiver bit's own quest-status gate,
//! `0x5df490`, used to be listed here as unmodelled; it is modelled now — [`questgiver_has_quest`].)
use bevy::prelude::*;
@@ -295,16 +295,42 @@ fn go_cursor_kind(type_id: i32, lock_cursor: Option<CursorKind>) -> CursorKind {
}
}
/// The QUESTGIVER leg's own gate — the bit alone is not enough. The ladder calls `0x5df490(unit)`,
/// which is `NPC_FLAGS bit 1` **AND** the cached quest status `[unit+0xcb8] ∉ {0, 1}` (wow-re
/// `ui/scratch/cursor-system.md` §3, byte-verified row; `object-layer/scratch/questgiver-marker.md`
/// independently pins `+0xcb8` as the `SMSG_QUESTGIVER_STATUS` cache, written by `0x607440` and with
/// only three writers repo-wide). So NONE(0) and UNAVAILABLE(1) do **not** make a unit talkable;
/// every other status does.
///
/// This is what keeps a questgiver-flagged NPC with nothing to offer from being clickable at all —
/// and it is load-bearing far beyond the cursor. Melika Isenstrider (vmangos entry 6778) is flagged
/// QUESTGIVER, carries no other service bit, and has zero rows in `creature_questrelation`: without
/// this gate she classifies Speak, we send `CMSG_GOSSIP_HELLO`, and vmangos answers the resulting
/// `DEFAULT_GOSSIP_MESSAGE` text query with eight literal `"Greetings $N"` blocks
/// (`QueryHandler.cpp:210-217`) — an empty gossip frame carrying a placeholder greeting, on an NPC
/// the reference never opens anything for. That was the whole of the reported "the client invents
/// 'Greetings NAME'" bug: the text was genuine, the *asking* was ours.
///
/// `None` (no status ever sent) reads as no quest: the server sends the status unprompted for every
/// questgiver in range, so its absence means the unit isn't offering us one.
fn questgiver_has_quest(quest_status: Option<u32>) -> bool {
use benilla_protocol::messages::dialog_status::{NONE, UNAVAILABLE};
!matches!(quest_status, None | Some(NONE) | Some(UNAVAILABLE))
}
/// The per-bit service ladder (`0x482336..0x4824e3`, statically unrolled — every row byte-verified
/// in the RE note's §3 table): lowest set bit wins. `None` = no *consulted* bit set — the unit
/// falls through to the attack/clear leg (this is where repair-only units land: bit 14 is never
/// tested in the binary).
fn service_cursor(service: u32) -> Option<CursorKind> {
///
/// `quest_status` is the unit's last `SMSG_QUESTGIVER_STATUS` (`None` = never sent), which gates the
/// QUESTGIVER leg — see [`questgiver_has_quest`].
fn service_cursor(service: u32, quest_status: Option<u32>) -> Option<CursorKind> {
use npc_flags::*;
if service & GOSSIP != 0 {
Some(CursorKind::Speak)
} else if service & QUESTGIVER != 0 {
// The client additionally gates this on quest status (`0x5df490`); not yet modeled.
// Bits 0 and 1 both land on Speak, and bit 0 is tested first, so the two rows fold into one
// condition without changing a single outcome — the same folding the SPIRITHEALER/SPIRITGUIDE
// and PETITIONER/TABARDDESIGNER/BATTLEMASTER rows already use. Only bit 1 carries a gate.
if service & GOSSIP != 0 || (service & QUESTGIVER != 0 && questgiver_has_quest(quest_status)) {
Some(CursorKind::Speak)
} else if service & VENDOR != 0 {
Some(CursorKind::Pickup)
@@ -347,6 +373,9 @@ pub(super) fn classify_cursor(
go_templates: Res<crate::go_templates::GameObjectTemplates>,
locks: Option<Res<crate::go_templates::Locks>>,
lock_types: Option<Res<crate::go_templates::LockTypes>>,
// The QUESTGIVER leg's gate reads the per-guid `SMSG_QUESTGIVER_STATUS` store — see
// [`questgiver_has_quest`].
quest: Res<crate::ui_quest::QuestGiver>,
) {
// A **highlightable** GameObject shows its **data-driven** cursor (wow-re cursor-system §4): a
// mailbox's Mail, a plaque's Inspect, a vein's Mine / herb's GatherHerbs / picked lock's PickLock
@@ -414,7 +443,8 @@ pub(super) fn classify_cursor(
// Interactable NPC (interim CanInteract): a consulted service bit + not attack-worthy.
// A repair-only unit yields None here and falls through, exactly like the binary.
if !is_player && rank >= 3 {
if let Some(kind) = service_cursor(store.0.unit_npc_flags()) {
let status = hovered.guid.and_then(|g| quest.status(g));
if let Some(kind) = service_cursor(store.0.unit_npc_flags(), status) {
return Some((kind, dist_sq > SERVICE_RANGE_SQ));
}
}
@@ -439,6 +469,7 @@ pub(super) fn classify_cursor(
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::dialog_status;
#[test]
fn stems_name_the_shipped_blps() {
@@ -497,27 +528,80 @@ mod tests {
#[test]
fn service_ladder_matches_the_unrolled_binary() {
use npc_flags::*;
// A quest to offer, so the QUESTGIVER row behaves like the rest of the ladder here; the
// gate itself is `questgiver_flag_alone_is_not_talkable`.
let has = Some(dialog_status::AVAILABLE);
// The rows the RE table pins per byte address.
assert_eq!(service_cursor(GOSSIP), Some(CursorKind::Speak));
assert_eq!(service_cursor(QUESTGIVER), Some(CursorKind::Speak));
assert_eq!(service_cursor(VENDOR), Some(CursorKind::Pickup));
assert_eq!(service_cursor(FLIGHTMASTER), Some(CursorKind::Taxi));
assert_eq!(service_cursor(TRAINER), Some(CursorKind::Trainer));
assert_eq!(service_cursor(SPIRITHEALER), Some(CursorKind::Speak));
assert_eq!(service_cursor(INNKEEPER), Some(CursorKind::Interact));
assert_eq!(service_cursor(BANKER), Some(CursorKind::Buy));
assert_eq!(service_cursor(BATTLEMASTER), Some(CursorKind::Speak));
assert_eq!(service_cursor(AUCTIONEER), Some(CursorKind::Buy));
assert_eq!(service_cursor(STABLEMASTER), Some(CursorKind::Speak));
assert_eq!(service_cursor(GOSSIP, None), Some(CursorKind::Speak));
assert_eq!(service_cursor(QUESTGIVER, has), Some(CursorKind::Speak));
assert_eq!(service_cursor(VENDOR, None), Some(CursorKind::Pickup));
assert_eq!(service_cursor(FLIGHTMASTER, None), Some(CursorKind::Taxi));
assert_eq!(service_cursor(TRAINER, None), Some(CursorKind::Trainer));
assert_eq!(service_cursor(SPIRITHEALER, None), Some(CursorKind::Speak));
assert_eq!(service_cursor(INNKEEPER, None), Some(CursorKind::Interact));
assert_eq!(service_cursor(BANKER, None), Some(CursorKind::Buy));
assert_eq!(service_cursor(BATTLEMASTER, None), Some(CursorKind::Speak));
assert_eq!(service_cursor(AUCTIONEER, None), Some(CursorKind::Buy));
assert_eq!(service_cursor(STABLEMASTER, None), Some(CursorKind::Speak));
// Lowest bit wins: a gossiping vendor speaks; an innkeeper-banker interacts.
assert_eq!(service_cursor(GOSSIP | VENDOR), Some(CursorKind::Speak));
assert_eq!(
service_cursor(INNKEEPER | BANKER),
service_cursor(GOSSIP | VENDOR, None),
Some(CursorKind::Speak)
);
assert_eq!(
service_cursor(INNKEEPER | BANKER, None),
Some(CursorKind::Interact)
);
// REPAIR (0x4000) is never consulted — repair-only falls to the attack/clear leg.
assert_eq!(service_cursor(0x4000), None);
assert_eq!(service_cursor(0), None);
assert_eq!(service_cursor(0x4000, None), None);
assert_eq!(service_cursor(0, None), None);
}
/// The QUESTGIVER leg's `0x5df490` gate: the bit alone never makes a unit talkable. This is the
/// "client invents 'Greetings NAME'" bug at its root — a questgiver-flagged NPC with nothing to
/// offer must fall out of the ladder entirely, so we never send `CMSG_GOSSIP_HELLO` and never
/// open the empty gossip frame the server would answer with a placeholder greeting.
#[test]
fn questgiver_flag_alone_is_not_talkable() {
use npc_flags::*;
// Melika Isenstrider's exact shape: QUESTGIVER, no other service bit, nothing on offer.
for status in [
None,
Some(dialog_status::NONE),
Some(dialog_status::UNAVAILABLE),
] {
assert_eq!(
service_cursor(QUESTGIVER, status),
None,
"status {status:?} must not classify Speak"
);
}
// Every other status is a quest worth talking about — `[unit+0xcb8] ∉ {0, 1}`.
for status in [
dialog_status::CHAT,
dialog_status::INCOMPLETE,
dialog_status::REWARD_REP,
dialog_status::AVAILABLE,
dialog_status::REWARD_OLD,
dialog_status::REWARD2,
] {
assert_eq!(
service_cursor(QUESTGIVER, Some(status)),
Some(CursorKind::Speak),
"status {status} must classify Speak"
);
}
// The gate is the QUESTGIVER leg's alone: a quest-less unit that also gossips still speaks
// (bit 0 is tested first and carries no gate), and a quest-less vendor still shows Pickup
// rather than falling out of the ladder.
assert_eq!(
service_cursor(GOSSIP | QUESTGIVER, None),
Some(CursorKind::Speak)
);
assert_eq!(
service_cursor(QUESTGIVER | VENDOR, None),
Some(CursorKind::Pickup)
);
}
#[test]
+1 -1
View File
@@ -24,7 +24,7 @@
use benilla_formats::SpellDisplay;
/// Wire reason → its `SPELL_FAILED_*` GlobalStrings key (the `0x6e23e0` table, byte-exact).
const CAST_FAIL_KEYS: [&str; 146] = [
pub(super) const CAST_FAIL_KEYS: [&str; 146] = [
"SPELL_FAILED_AFFECTING_COMBAT", // 0x00
"SPELL_FAILED_ALREADY_AT_FULL_HEALTH", // 0x01
"SPELL_FAILED_ALREADY_AT_FULL_POWER", // 0x02
+24
View File
@@ -345,6 +345,7 @@ fn feed_actions(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
commands: Res<NetCommands>,
mut memory: Local<FeedMemory>,
) {
@@ -370,6 +371,29 @@ fn feed_actions(
.filter_map(|(spell_id, reason)| {
let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
// 0x19/0x1a/0x1b EQUIPPED_ITEM_CLASS* — the other argument-formatted family whose
// `%s` benilla models (`0x6e1db7`, the arm that resolves an item class/subclass name
// through `0x6e2380`): "Must have a **Wand** equipped", the SINGULAR DisplayName,
// where the spell tooltip's own requirement line takes the verbose plural. Purely a
// DBC read, so no query/redisplay round trip. A multi-bit mask resolves too — through
// ItemSubClassMask.dbc's group name, else the FIRST matching subclass (law
// §3-EQUIPITEM; the tooltip's twin joins instead).
if let (0x19..=0x1b, Some(d), Some(subs)) = (reason, d, sub_classes.as_deref()) {
if let Some(name) = (d.equipped_item_class >= 0)
.then(|| {
subs.0.requirement_display_name(
d.equipped_item_class as u32,
d.equipped_item_subclass_mask,
)
})
.flatten()
{
let key = cast_fail::CAST_FAIL_KEYS[reason as usize];
return get(key)
.filter(|s| !s.is_empty())
.map(|t| t.replace("%s", &name));
}
}
if reason == 0x78 || reason == 0x5c {
let d = d?;
let failing = if reason == 0x78 {
+33 -22
View File
@@ -51,6 +51,36 @@ pub(crate) struct UsableCtx<'a> {
pub(crate) now: Instant,
}
/// Leg 4's own test (`0x6e40e0`), shared with the spell tooltip's requirement line: does some
/// WORN item match `EquippedItemClass` + `EquippedItemSubClassMask`? `true` when the spell asks
/// for nothing (`class < 0`). An equipped item whose template hasn't streamed yet counts as a
/// match — never grey (and never red) on missing data, the catalog-absent convention.
pub(crate) fn equipped_item_fits(
d: &SpellDisplay,
store: &ObjectStore,
items: &mut Items,
commands: &NetCommands,
) -> bool {
if d.equipped_item_class < 0 {
return true;
}
let class = d.equipped_item_class as u32;
(0..19).any(|slot| {
let Some(guid) = store.0.player_inv_slot(slot).filter(|&g| g != 0) else {
return false;
};
let Some(entry) = items.object(guid).and_then(|o| o.object_entry()) else {
return false;
};
let Some(t) = items.template(entry, guid, commands) else {
return true; // unresolved template: benefit of the doubt
};
t.class == class
&& (d.equipped_item_subclass_mask == 0
|| d.equipped_item_subclass_mask & (1 << t.subclass) != 0)
})
}
/// The walk. Returns `(usable, not_enough_mana)` — the `IsUsableAction` pair.
pub(crate) fn spell_usable(
spell_id: u32,
@@ -79,28 +109,9 @@ pub(crate) fn spell_usable(
return (false, false);
}
}
// Leg 4 (`0x6e40e0`): some worn item must match the class + subclass mask. An equipped
// item whose template hasn't streamed yet counts as a match — never grey on missing data
// (the catalog-absent convention).
if d.equipped_item_class >= 0 {
let class = d.equipped_item_class as u32;
let fits = (0..19).any(|slot| {
let Some(guid) = ctx.store.0.player_inv_slot(slot).filter(|&g| g != 0) else {
return false;
};
let Some(entry) = items.object(guid).and_then(|o| o.object_entry()) else {
return false;
};
let Some(t) = items.template(entry, guid, commands) else {
return true; // unresolved template: benefit of the doubt
};
t.class == class
&& (d.equipped_item_subclass_mask == 0
|| d.equipped_item_subclass_mask & (1 << t.subclass) != 0)
});
if !fits {
return (false, false);
}
// Leg 4 (`0x6e40e0`): some worn item must match the class + subclass mask.
if !equipped_item_fits(d, ctx.store, items, commands) {
return (false, false);
}
// Leg 6 (`0x612480`): the shapeshift-form gate — the form's stance flag from
// SpellShapeshiftForm.dbc decides whether it counts as "shapeshifted".
+8 -2
View File
@@ -290,7 +290,10 @@ fn feed_auras(
name: display.map(|d| d.name.clone()),
icon: display.and_then(|d| d.icon.clone()),
count: c.stacks,
debuff_type: display.and_then(|d| d.debuff_type()).map(str::to_string),
debuff_type: display
.zip(catalog)
.and_then(|(d, cat)| cat.dispel_name(d))
.map(str::to_string),
duration,
expiration_time,
helpful: c.slot < UNIT_AURA_POSITIVE_SLOTS,
@@ -366,7 +369,10 @@ fn feed_auras(
name: display.map(|d| d.name.clone()),
icon: display.and_then(|d| d.icon.clone()),
count: a.stacks,
debuff_type: display.and_then(|d| d.debuff_type()).map(str::to_string),
debuff_type: display
.zip(catalog)
.and_then(|(d, cat)| cat.dispel_name(d))
.map(str::to_string),
// No duration for any unit but yourself — the 1.12 wire carries none
// (byte-verified, 0257 B6); the reference's target frame shows no timers.
duration: 0.0,
+91 -21
View File
@@ -13,7 +13,7 @@
use std::collections::HashMap;
use benilla_protocol::messages::GossipOption;
use benilla_protocol::messages::{select_greeting, GossipOption, NpcTextBlock};
use bevy::prelude::*;
use benilla_ui::script::{GossipMenu, GossipOptionView, GossipQuestRow, ScriptValue, UiScript};
@@ -26,16 +26,23 @@ use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
/// The open gossip menu, filled by the net bridge ([`crate::net`]) and read by [`feed_gossip`]. The
/// ask-once `greetings` cache keyed by `text_id` (mirroring [`crate::items::Items`]'s template cache)
/// means a revisit to the same NPC never re-queries the greeting. Cleared on `SMSG_GOSSIP_COMPLETE`,
/// a client-side close, and disconnect; the greeting cache survives (NPC text is static per id).
/// ask-once `records` cache keyed by `text_id` (mirroring [`crate::items::Items`]'s template cache)
/// means a revisit to the same NPC never re-queries the text. Cleared on `SMSG_GOSSIP_COMPLETE`,
/// a client-side close, and disconnect; the record cache survives (NPC text is static per id).
///
/// The cache holds the **record**, not a greeting. Which of its 8 blocks greets you is drawn per
/// menu-open from the NPC's gender and a die roll (`benilla_protocol::messages::select_greeting`),
/// exactly as the reference re-draws it in its gossip handler — so the same `text_id` can greet you
/// differently on the next visit, and two NPCs sharing a `text_id` can differ by gender. Caching a
/// resolved string, as this did, froze the first draw for the whole session.
#[derive(Resource, Default)]
pub(crate) struct GossipState {
/// The NPC whose menu is open; `None` = no menu open.
pub(crate) npc: Option<u64>,
/// The open menu's `NpcText` id (drives the greeting query / cache).
/// The open menu's `NpcText` id (drives the text query / cache).
pub(crate) text_id: u32,
/// The resolved greeting (`SMSG_NPC_TEXT_UPDATE`), or `None` while its query is in flight.
/// The greeting drawn for THIS menu-open, or `None` while its query is in flight (or when the
/// record names no line for this NPC — the reference's "Missing gossip text!" path).
pub(crate) greeting: Option<String>,
/// The selectable option rows (wire `GossipOption`: `index` echoed on select, `icon`, `coded`,
/// `message`).
@@ -44,21 +51,39 @@ pub(crate) struct GossipState {
/// window lists them above the options; a click sends `CMSG_QUESTGIVER_QUERY_QUEST` /
/// `_COMPLETE_QUEST` (decision 0088).
pub(crate) quests: Vec<(u32, u32, String)>,
/// Ask-once greeting cache keyed by `text_id`.
greetings: HashMap<u32, String>,
/// Ask-once NPC-text record cache keyed by `text_id` — the 8 undrawn blocks.
records: HashMap<u32, Vec<NpcTextBlock>>,
}
impl GossipState {
/// The cached greeting for `text_id`, if `CMSG_NPC_TEXT_QUERY` was already answered for it.
pub(crate) fn cached_greeting(&self, text_id: u32) -> Option<String> {
self.greetings.get(&text_id).cloned()
/// The cached record for `text_id`, if `CMSG_NPC_TEXT_QUERY` was already answered for it.
pub(crate) fn cached_record(&self, text_id: u32) -> Option<&[NpcTextBlock]> {
self.records.get(&text_id).map(Vec::as_slice)
}
/// Record a greeting answer (`SMSG_NPC_TEXT_UPDATE`) into the ask-once cache.
pub(crate) fn remember_greeting(&mut self, text_id: u32, greeting: String) {
self.greetings.insert(text_id, greeting);
/// Record an NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) into the ask-once cache.
pub(crate) fn remember_record(&mut self, text_id: u32, blocks: Vec<NpcTextBlock>) {
self.records.insert(text_id, blocks);
}
/// Draw this menu-open's greeting out of `text_id`'s cached record for an NPC of `npc_gender`
/// — `None` if the record hasn't arrived yet, or names no line for that gender.
pub(crate) fn draw_greeting(&self, text_id: u32, npc_gender: u8) -> Option<String> {
let blocks = self.cached_record(text_id)?;
select_greeting(blocks, npc_gender, greeting_roll()).map(str::to_string)
}
}
/// The reference's uniform float in `[1.0, 2.0)` for the greeting draw: it stuffs PRNG bits straight
/// into a mantissa (`(rand & 0x7fffff) | 0x3f800000`), which is what makes the range `[1, 2)` rather
/// than the `[0, 1)` you would expect. We build the same shape from our own source — the *stream*
/// can't match (it is the client's own PRNG, seeded per run) but the distribution is what the law
/// needs.
fn greeting_roll() -> f32 {
f32::from_bits((rand::random::<u32>() & 0x7f_ffff) | 0x3f80_0000)
}
impl GossipState {
/// Close the open menu (`SMSG_GOSSIP_COMPLETE` / a client-side close). Keeps the greeting cache.
pub(crate) fn clear(&mut self) {
self.npc = None;
@@ -163,6 +188,7 @@ fn feed_gossip(
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
quest_log: Res<QuestLog>,
states: Res<crate::world_state::WorldStates>,
mut last: Local<Option<GossipMenu>>,
mut last_name: Local<Option<String>>,
mut last_npc: Local<Option<u64>>,
@@ -171,10 +197,16 @@ fn feed_gossip(
return;
};
let mut fresh = snapshot(&state, &quest_log);
// Expand the greeting's chat-text macros ($N/$B/$G) client-side, as the real client does.
// Expand the greeting's chat-text macros ($N/$B/$G/$<n>w) client-side, as the real client does.
if let Some(greeting) = fresh.as_mut().and_then(|m| m.greeting.as_mut()) {
let (name, gender) = crate::npc_text::player_identity(&self_q, &mut names, &commands);
*greeting = crate::npc_text::substitute(greeting, &name, gender);
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
*greeting = crate::npc_text::substitute(
greeting,
&crate::npc_text::MacroContext {
subject: player.as_ref(),
states: &states,
},
);
}
// The gossip NPC's name resolves through the NameCache (ask-once — the merchant feed's pattern),
// delivered as arg1 of GOSSIP_SHOW; `None`/empty while the query is in flight, and the diff below
@@ -307,12 +339,50 @@ mod tests {
}
#[test]
fn ask_once_greeting_cache_survives_clear() {
fn ask_once_text_cache_survives_clear() {
let mut state = GossipState::default();
state.remember_greeting(50, "Greetings $N".into());
state.remember_record(
50,
vec![NpcTextBlock {
probability: 0.0,
male: "Greetings $N".into(),
female: String::new(),
}],
);
state.clear();
// The open menu is gone but the cached greeting is still servable (a revisit won't re-query).
assert_eq!(state.cached_greeting(50).as_deref(), Some("Greetings $N"));
// The open menu is gone but the cached RECORD is still servable (a revisit won't re-query
// — it re-draws from these blocks).
assert!(state.cached_record(50).is_some());
assert_eq!(
state.draw_greeting(50, 0).as_deref(),
Some("Greetings $N"),
"the all-zero record draws block 0 on every roll"
);
assert!(state.npc.is_none());
}
/// The record is cached, the GREETING is not: a female NPC and a male NPC sharing one `text_id`
/// must read their own columns. Caching a resolved string (as this did) froze whichever one
/// asked first for the rest of the session.
#[test]
fn one_cached_record_greets_each_gender_from_its_own_column() {
let mut state = GossipState::default();
state.remember_record(
77,
vec![NpcTextBlock {
probability: 0.0,
male: "Well met, friend.".into(),
female: "Well met, sister.".into(),
}],
);
assert_eq!(
state.draw_greeting(77, 0).as_deref(),
Some("Well met, friend.")
);
assert_eq!(
state.draw_greeting(77, 1).as_deref(),
Some("Well met, sister.")
);
assert_eq!(state.draw_greeting(78, 0), None, "record not yet arrived");
}
}
+38 -8
View File
@@ -198,9 +198,9 @@ fn snapshot(
icons: Option<&ItemDisplays>,
commands: &NetCommands,
quest_log: &QuestLog,
player: (&str, u8),
macros: &crate::npc_text::MacroContext,
) -> Option<QuestState> {
let sub = |t: &str| crate::npc_text::substitute(t, player.0, player.1);
let sub = |t: &str| crate::npc_text::substitute(t, macros);
Some(match giver.view.as_ref()? {
QuestView::Greeting(l) => {
let mut active_titles = Vec::new();
@@ -274,6 +274,7 @@ fn feed_quest(
commands: Res<NetCommands>,
mut names: ResMut<NameCache>,
quest_log: Res<QuestLog>,
states: Res<crate::world_state::WorldStates>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
mut last: Local<Option<QuestState>>,
mut last_name: Local<Option<String>>,
@@ -287,15 +288,17 @@ fn feed_quest(
if giver.take_completed_fanfare() {
script.queue_sound_kit("QUESTCOMPLETED");
}
let (player_name, player_gender) =
crate::npc_text::player_identity(&self_q, &mut names, &commands);
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let fresh = snapshot(
&giver,
&mut items,
icons.as_deref(),
&commands,
&quest_log,
(&player_name, player_gender),
&crate::npc_text::MacroContext {
subject: player.as_ref(),
states: &states,
},
);
let npc_name = giver
.npc
@@ -476,8 +479,24 @@ mod tests {
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let quest_log = QuestLog::default();
let snap =
snapshot(&giver, &mut items, None, &commands, &quest_log, ("Tri", 0)).expect("open");
let player = crate::npc_text::Subject {
name: "Tri".into(),
race: 1,
class: 1,
gender: 0,
};
let snap = snapshot(
&giver,
&mut items,
None,
&commands,
&quest_log,
&crate::npc_text::MacroContext {
subject: Some(&player),
states: &crate::world_state::WorldStates::default(),
},
)
.expect("open");
assert_eq!(snap.panel, QuestPanel::Detail);
assert_eq!(snap.title, "A Threat Within");
// The wire text's $N substituted through the shared expander (crate::npc_text).
@@ -512,7 +531,18 @@ mod tests {
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let quest_log = QuestLog::default();
let snap = snapshot(&giver, &mut items, None, &commands, &quest_log, ("", 0)).unwrap();
let snap = snapshot(
&giver,
&mut items,
None,
&commands,
&quest_log,
&crate::npc_text::MacroContext {
subject: None,
states: &crate::world_state::WorldStates::default(),
},
)
.unwrap();
assert_eq!(snap.panel, QuestPanel::Progress);
assert_eq!(snap.required.len(), 1);
assert_eq!(snap.required_money, 500);
+14 -16
View File
@@ -322,7 +322,7 @@ fn build_detail(
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
player: (&str, u8),
macros: &crate::npc_text::MacroContext,
) -> QuestLogDetail {
let (required_money, reward_money) = money_split(template.money);
let rewards = template
@@ -339,10 +339,10 @@ fn build_detail(
.collect();
QuestLogDetail {
// The wire delivers quest text with its chat macros ($N/$B/$G) un-expanded; the client
// substitutes (crate::npc_text — the 0109 look fix's shared mechanism).
description: crate::npc_text::substitute(&template.details, player.0, player.1),
objectives_text: crate::npc_text::substitute(&template.objectives_text, player.0, player.1),
// The wire delivers quest text with its chat macros ($N/$B/$G/$<n>w) un-expanded; the
// client substitutes (crate::npc_text — the 0109 look fix's shared mechanism).
description: crate::npc_text::substitute(&template.details, macros),
objectives_text: crate::npc_text::substitute(&template.objectives_text, macros),
required_money,
reward_money,
choices,
@@ -396,6 +396,7 @@ fn feed_quest_log(
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
header_names: Option<Res<QuestHeaderNamesRes>>,
states: Res<crate::world_state::WorldStates>,
mut last: Local<QuestLogState>,
mut prior_quest_ids: Local<Option<HashSet<u32>>>,
) {
@@ -526,8 +527,11 @@ fn feed_quest_log(
quest_log.entry_slots = entry_slots;
quest_log.header_keys = header_keys;
let (player_name, player_gender) =
crate::npc_text::player_identity(&self_q, &mut names, &commands);
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let macros = crate::npc_text::MacroContext {
subject: player.as_ref(),
states: &states,
};
let sel = script.quest_log_selection() as usize;
let detail = sel
.checked_sub(1)
@@ -535,15 +539,9 @@ fn feed_quest_log(
.filter(|e| !e.is_header)
.map(|e| e.quest_id)
.and_then(|quest_id| {
quest_log.template(quest_id, &commands).map(|t| {
build_detail(
t,
&mut items,
icons.as_deref(),
&commands,
(&player_name, player_gender),
)
})
quest_log
.template(quest_id, &commands)
.map(|t| build_detail(t, &mut items, icons.as_deref(), &commands, &macros))
});
let fresh = QuestLogState {
+364 -26
View File
@@ -39,23 +39,41 @@ impl Plugin for UiTooltipPlugin {
}
}
/// Everything a view needs beyond the spell catalogs — the player-dependent halves of the line
/// law: the worn set (law §3.6's equipped-item test), the bags (§3.8's reagent possession, and
/// the item-name cache the reagent names come from), the current form, and the bind point `$z`
/// substitutes against.
struct ViewCtx<'a> {
home_area: Option<&'a str>,
form: u8,
store: Option<&'a ObjectStore>,
items: &'a mut Items,
commands: &'a NetCommands,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
}
/// Build one spell's tooltip view (decision 0274 P2) — the verified spell line law's inputs,
/// every string resolved here where the catalogs live: the cost cell (power-typed; rage prints
/// wire-cost ÷ 10; "Next melee" for on-next-swing attributes), the range cell ("N yd range",
/// "N-M yd range" when the row's min is nonzero — the law's `"%d-%d"` fork; "Melee Range" for
/// the melee family — INTERIM text, the proper source is SpellRange.dbc's own display-name
/// column), the cast cell ("Instant"/"Instant cast"/"N sec cast"; None = passive, which omits
/// the whole line), the cooldown cell (`max(RecoveryTime, CategoryRecoveryTime)` — law §3.4),
/// the required-form line (law §3.6: the `Stances` mask's form names, met/unmet against `form`),
/// and the $-substituted description/aura-description (byte-exact formulas,
/// column), the cast cell ("Instant"/"Instant cast"/"N sec cast"; None = the law's passive gate,
/// which omits the whole line), the cooldown cell (`max(RecoveryTime, CategoryRecoveryTime)` —
/// law §3.4), the required-item and required-form lines (law §3.6), the reagents line (law
/// §3.8), and the $-substituted description/aura-description (byte-exact formulas,
/// `benilla_formats::substitute`).
///
/// Line TEXT is composed in English here, as every cell in this builder is; the reference reads
/// its own GlobalStrings templates ("Reagents: ", "Requires %s"). That is this builder's standing
/// INTERIM shape, not a new debt introduced by these lines.
fn spell_tooltip_view(
spell_id: u32,
spells: &Spells,
home_area: Option<&str>,
form: u8,
vctx: &mut ViewCtx,
) -> Option<benilla_ui::script::SpellTooltipView> {
let d = spells.catalog.get(spell_id)?;
let home_area = vctx.home_area;
let form = vctx.form;
let ctx = benilla_formats::TokenContext {
durations: &spells.durations,
radii: &spells.radii,
@@ -90,7 +108,9 @@ fn spell_tooltip_view(
None
}
});
let cast_time = if d.passive {
// Law §3.4's own gate — wider than the spellbook's `passive`: a TRADE_SKILL or ATTACK
// Effect[0] omits the line too ([`SpellDisplay::tooltip_omits_cast_line`]).
let cast_time = if d.tooltip_omits_cast_line() {
None
} else {
let base = spells
@@ -132,15 +152,72 @@ fn spell_tooltip_view(
})
.flatten();
let form_met = form != 0 && d.stances & (1u32 << (u32::from(form) - 1)) != 0;
// The equipped-item-class half of law §3.6 — "Requires Wands" on the wand Shoot, and "Requires
// Melee Weapon" on Parry. A mask with several bits set is NOT a reason to print nothing (what we
// used to do): the reference names the whole mask through ItemSubClassMask.dbc first, and only
// falls back to comma-joining the individual subclasses. Law §3-EQUIPITEM, in the catalog.
// §3-EQUIPITEM's three entry gates, all of which skip the line: Targets bit 0x10 set, class < 0,
// or an empty mask.
let requires_item = (d.targets & TARGET_ITEM == 0 && d.equipped_item_class >= 0)
.then(|| {
vctx.sub_classes?
.requirement_name(d.equipped_item_class as u32, d.equipped_item_subclass_mask)
})
.flatten()
.map(|name| format!("Requires {name}"));
// The chance-to-X line (law line 10, §3-CHANCE): `Effect[0]` picks which of the player's four
// avoidance/crit percentages to print, and — except for ATTACK, which bypasses the gate — the
// spell must be passive. The percentages are already percents on the wire.
let chance = chance_line(d, vctx.store);
let item_met = vctx.store.is_none_or(|s| {
crate::ui_action::usable::equipped_item_fits(d, s, vctx.items, vctx.commands)
});
// Reagents (law §3.8): the named slots, `count > 1` suffixed, a slot the player is short of
// wrapped in the builder's inline red. A reagent whose item template hasn't streamed yet is
// simply absent from this snapshot — `feed_spell_tooltips` re-pushes when it lands, which is
// our shape of the ref's own query-then-redisplay callback.
let reagents = {
let mut parts: Vec<String> = Vec::new();
for (entry, count) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
let Some(name) = vctx
.items
.template(entry, 0, vctx.commands)
.map(|t| t.name.clone())
else {
continue;
};
let text = if count > 1 {
format!("{name} ({count})")
} else {
name
};
let short = vctx
.store
.is_some_and(|s| crate::ui_items::count_of(&s.0, vctx.items, entry) < count);
parts.push(if short {
format!("|cffff2020{text}|r")
} else {
text
});
}
(!parts.is_empty()).then(|| format!("Reagents: {}", parts.join(", ")))
};
Some(benilla_ui::script::SpellTooltipView {
name: d.name.clone(),
rank: d.rank.clone(),
// The aura variant's right column (law §3-BUFF) — gated inside the catalog by
// SpellDispelType.dbc's own `[+0x28]`, so Stealth-class auras hand back None.
dispel_type: spells.catalog.dispel_name(d).map(str::to_string),
cost,
range,
cast_time,
cooldown,
requires_item,
item_met,
requires_form,
form_met,
chance,
reagents,
description: d
.description
.as_deref()
@@ -154,6 +231,36 @@ fn spell_tooltip_view(
})
}
/// `Targets` bit `0x10` — set ⇒ the equipped-item requirement line is skipped whatever the class
/// and mask say (§3-EQUIPITEM's first gate; the bit test is verified, the `TARGET_FLAG_ITEM` name
/// wow-re flags as inferred).
const TARGET_ITEM: u32 = 0x10;
/// `SPELL_EFFECT_DODGE` / `_PARRY` / `_BLOCK` / `_ATTACK` — the four `Effect[0]` values that select
/// a chance-to-X line (law §3-CHANCE's jump table).
const EFFECT_DODGE: u32 = 20;
const EFFECT_PARRY: u32 = 22;
const EFFECT_BLOCK: u32 = 23;
const EFFECT_ATTACK: u32 = 78;
/// The chance-to-X line (law line 10 / §3-CHANCE) — `None` when the spell names none of the four
/// effects, when the gate rejects it, or before the player's descriptor has streamed.
///
/// The predicate is the reference's, and its asymmetry is the point: **ATTACK bypasses the passive
/// gate** while dodge/parry/block require it. That is why Attack — whose `Attributes` carry `0x10`,
/// not the passive `0x40` — still shows a crit line, which is exactly the missing line reported.
fn chance_line(d: &benilla_formats::SpellDisplay, store: Option<&ObjectStore>) -> Option<String> {
let (label, percentage) = match d.effect_1 {
EFFECT_ATTACK => ("crit", store?.0.player_crit_percentage()?),
EFFECT_DODGE if d.passive => ("dodge", store?.0.player_dodge_percentage()?),
EFFECT_PARRY if d.passive => ("parry", store?.0.player_parry_percentage()?),
EFFECT_BLOCK if d.passive => ("block", store?.0.player_block_percentage()?),
_ => return None,
};
// The reference's `%.2f%%` lives in its GlobalStrings, not the binary — this is the same shape.
Some(format!("{percentage:.2}% chance to {label}"))
}
/// The `%.3g`-style terse seconds (1.5 → "1.5", 2.0 → "2") — the SPELL_CAST_TIME/RECAST shape.
fn trim_secs(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
@@ -163,6 +270,27 @@ fn trim_secs(v: f64) -> String {
}
}
/// The push loop's change detectors — everything a view SNAPSHOTS at build time. A change to any
/// of them means every pushed view is stale, so the loop re-pushes the lot (the reference simply
/// re-runs its builder on every hover, so a stale snapshot is a benilla-only failure mode).
#[derive(Default)]
struct SpellFeedMemory {
/// Spell ids whose view has been pushed at least once.
pushed: std::collections::HashSet<u32>,
/// The bind point `$z` substitutes against.
home: Option<String>,
/// The current shapeshift form (law §3.6's stance line, white/red).
form: Option<u8>,
/// The 19 worn-slot guids (law §3.6's item line, white/red).
worn: Option<[u64; 19]>,
/// The player's block/dodge/parry/crit percentages, as raw bit patterns so the diff needs no
/// float comparison (law line 10's printed value — it moves with gear, buffs and talents).
avoidance: Option<[u32; 4]>,
/// Per reagent entry currently on show: `(owned count, item name resolved)` — law §3.8's
/// inline red plus the ask-once template landing.
reagents: std::collections::BTreeMap<u32, (u32, bool)>,
}
/// The spell-tooltip store's push half: every spell the UI can HOVER is owed its view at
/// arrival — the known book (spellbook/bar), the class's talent rank spells (`SetTalent`'s
/// display + next-rank reads), and the live aura spells (`SetPlayerBuff`) — so a first hover
@@ -180,9 +308,10 @@ fn feed_spell_tooltips(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
mut pushed: Local<std::collections::HashSet<u32>>,
mut last_home: Local<Option<String>>,
mut last_form: Local<Option<u8>>,
mut items: ResMut<Items>,
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
commands: Res<NetCommands>,
mut memory: Local<SpellFeedMemory>,
) {
let Some(mut script) = script else {
return;
@@ -197,7 +326,7 @@ fn feed_spell_tooltips(
.spells
.iter()
.copied()
.filter(|s| !pushed.contains(s)),
.filter(|s| !memory.pushed.contains(s)),
);
}
// The talent window's hoverables: every rank spell of the class's pages (a talent tooltip
@@ -212,14 +341,14 @@ fn feed_spell_tooltips(
t.ranks
.iter()
.copied()
.filter(|s| *s != 0 && !pushed.contains(s)),
.filter(|s| *s != 0 && !memory.pushed.contains(s)),
);
}
}
}
// The buff bar's hoverables: the live aura spells, at arrival.
if let Some(auras) = auras.as_deref() {
wanted.extend(auras.spell_ids().filter(|s| !pushed.contains(s)));
wanted.extend(auras.spell_ids().filter(|s| !memory.pushed.contains(s)));
}
// The minimap tracking icon's hover (SetTrackingSpell): the tracking aura never enters the
// display cache above (the rebuild's tracking-effect exclusion, `ui_aura`), so pre-feed from
@@ -231,7 +360,7 @@ fn feed_spell_tooltips(
.0
.unit_auras()
.map(|a| a.spell_id)
.filter(|s| !pushed.contains(s)),
.filter(|s| !memory.pushed.contains(s)),
);
}
// The target frame's aura rows (SetUnitBuff/SetUnitDebuff): the target's live aura spells,
@@ -242,7 +371,7 @@ fn feed_spell_tooltips(
.0
.unit_auras()
.map(|a| a.spell_id)
.filter(|s| !pushed.contains(s)),
.filter(|s| !memory.pushed.contains(s)),
);
}
let home_area: Option<String> = home_bind
@@ -251,9 +380,9 @@ fn feed_spell_tooltips(
.and_then(|id| area_names.as_deref()?.0.resolve(id as i32))
.map(str::to_string);
// A bind-point change re-substitutes every pushed view ($z — Astral Recall's shape).
if *last_home != home_area {
*last_home = home_area.clone();
wanted.extend(pushed.drain());
if memory.home != home_area {
memory.home = home_area.clone();
wanted.extend(memory.pushed.drain());
}
// A stance/form change re-pushes too: the required-form line's white/red tracks the CURRENT
// form (law §3.6), and the views are static snapshots until re-pushed.
@@ -261,14 +390,77 @@ fn feed_spell_tooltips(
.single()
.map(|s| s.0.unit_shapeshift_form())
.unwrap_or(0);
if *last_form != Some(form) {
*last_form = Some(form);
wanted.extend(pushed.drain());
if memory.form != Some(form) {
memory.form = Some(form);
wanted.extend(memory.pushed.drain());
}
let self_store = self_q.single().ok();
// …and so do the two BAG-dependent halves, which the views likewise snapshot: the worn set
// (law §3.6's item line flips white/red on a weapon swap) and the owned counts of the
// reagents currently on show (law §3.8's inline red, plus the item names themselves landing
// from the ask-once template cache — the ref's query-then-redisplay callback in our shape).
// Both signatures are tiny: 19 slot guids, and one count per DISTINCT reagent in play.
let worn =
self_store.map(|s| std::array::from_fn(|i| s.0.player_inv_slot(i as u8).unwrap_or(0)));
if memory.worn != worn {
memory.worn = worn;
wanted.extend(memory.pushed.drain());
}
// …and so does the chance-to-X line's percentage (law line 10), which the views snapshot the
// same way: a weapon swap, a buff or a talent moves it.
let avoidance = self_store.map(|s| {
[
s.0.player_block_percentage(),
s.0.player_dodge_percentage(),
s.0.player_parry_percentage(),
s.0.player_crit_percentage(),
]
.map(|v| v.unwrap_or(0.0).to_bits())
});
if memory.avoidance != avoidance {
memory.avoidance = avoidance;
wanted.extend(memory.pushed.drain());
}
let watched: Vec<u32> = memory.reagents.keys().copied().collect();
let reagent_state: std::collections::BTreeMap<u32, (u32, bool)> = watched
.into_iter()
.map(|entry| {
let named = items.template(entry, 0, &commands).is_some();
let owned = self_store.map_or(0, |s| crate::ui_items::count_of(&s.0, &items, entry));
(entry, (owned, named))
})
.collect();
if memory.reagents != reagent_state {
memory.reagents = reagent_state;
wanted.extend(memory.pushed.drain());
}
let mut vctx = ViewCtx {
home_area: home_area.as_deref(),
form,
store: self_store,
items: &mut items,
commands: &commands,
sub_classes: sub_classes.as_deref().map(|c| &c.0),
};
for id in wanted {
if let Some(view) = spell_tooltip_view(id, spells, home_area.as_deref(), form) {
if let Some(view) = spell_tooltip_view(id, spells, &mut vctx) {
// Register this spell's reagents in the watch set, seeded with the state the view was
// just built against — so the next recompute re-pushes on a REAL change only.
if let Some(d) = spells.catalog.get(id) {
for (entry, _) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
if let std::collections::btree_map::Entry::Vacant(slot) =
memory.reagents.entry(entry)
{
let named = vctx.items.template(entry, 0, vctx.commands).is_some();
let owned = vctx
.store
.map_or(0, |s| crate::ui_items::count_of(&s.0, vctx.items, entry));
slot.insert((owned, named));
}
}
}
script.set_spell_tooltip(id, view);
pushed.insert(id);
memory.pushed.insert(id);
}
}
}
@@ -440,6 +632,57 @@ mod tests {
use super::*;
use crate::ui_action::Spells;
/// A view context with no player state — the DBC-only half of the builder (the shape the
/// pre-0616 test used). `sub_classes` is threaded in by the caller when the case needs it.
struct TestCtx {
items: Items,
commands: NetCommands,
_rx: crossbeam_channel::Receiver<crate::net::ClientCommand>,
}
impl TestCtx {
fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
Self {
items: Items::default(),
commands: NetCommands(tx),
_rx: rx,
}
}
fn ctx<'a>(
&'a mut self,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
) -> ViewCtx<'a> {
self.ctx_for(form, sub_classes, None)
}
fn ctx_for<'a>(
&'a mut self,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
store: Option<&'a ObjectStore>,
) -> ViewCtx<'a> {
ViewCtx {
home_area: None,
form,
store,
items: &mut self.items,
commands: &self.commands,
sub_classes,
}
}
}
/// A player descriptor with nothing worn and nothing in the bags — the "owns none of it"
/// pole of both possession tests.
fn empty_player() -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
22u16, 100u32,
)]))
}
/// The full spell-tooltip view off the REAL 5875 data — Fireball rank 1 (133) end to end:
/// the pinned columns (description 138, cast index 18→1500 ms, duration 30), the token
/// engine's byte formulas, and the view's verified cell shapes. Skips without client data.
@@ -462,7 +705,8 @@ mod tests {
.expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let v = spell_tooltip_view(133, &spells, None, 0).expect("Fireball view");
let mut t = TestCtx::new();
let v = spell_tooltip_view(133, &spells, &mut t.ctx(0, None)).expect("Fireball view");
assert_eq!(v.name, "Fireball");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost.as_deref(), Some("30 Mana"));
@@ -492,7 +736,7 @@ mod tests {
// Charge rank 1 (100) — the director's reference shot, end to end: the dual-bound range
// row (SpellRange 95 = {8, 25}), the CATEGORY-column cooldown (recoveryTime 0 /
// categoryRecoveryTime 15000), and the Stances-mask form line (0x10000 → form 17).
let v = spell_tooltip_view(100, &spells, None, 0).expect("Charge view");
let v = spell_tooltip_view(100, &spells, &mut t.ctx(0, None)).expect("Charge view");
assert_eq!(v.name, "Charge");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost, None, "Charge costs nothing (it generates rage)");
@@ -505,7 +749,101 @@ mod tests {
v.description,
"Charge an enemy, generate 9 rage, and stun it for 1 sec. Cannot be used in combat."
);
let v = spell_tooltip_view(100, &spells, None, 17).expect("Charge view");
let v = spell_tooltip_view(100, &spells, &mut t.ctx(17, None)).expect("Charge view");
assert!(v.form_met, "form 17 = Battle Stance satisfies the mask");
}
/// The three lines the 2026-07-25 reference captures pinned (decision 0620), each against the
/// REAL 5875 data. Skips without client data.
#[test]
fn the_pinned_c6_lines_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain)
.expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
.expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain)
.expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let subs = benilla_formats::load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc");
let mut t = TestCtx::new();
let store = empty_player();
// 1 · The wand Shoot (5019, class 2 / submask bit 19) — "Requires Wands", red with no
// wand worn. The same row feeds the cast-fail line's SINGULAR "Wand" (see `cast_fail`).
assert_eq!(subs.name(2, 19), Some("Wands"), "the verbose plural");
assert_eq!(subs.display_name(2, 19), Some("Wand"), "the singular");
let v = spell_tooltip_view(5019, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
.expect("Shoot view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Wands"));
assert!(!v.item_met, "nothing worn satisfies class 2 / bit 19 → red");
// A multi-bit mask is named by ItemSubClassMask.dbc, not skipped (law §3-EQUIPITEM — we
// printed nothing here until `0x6e2380` was carved): Parry's 0x2a5f3 is exactly the eleven
// melee subclasses, which that table names in one word.
let parry = spells.catalog.get(3127).expect("Parry 3127");
assert!(parry.equipped_item_subclass_mask.count_ones() > 1);
let v = spell_tooltip_view(3127, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
.expect("Parry view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Melee Weapon"));
// 2 · Attack (6603) — `Effect[0] == 78` omits the cast|cooldown line WHOLE, even though
// `Attributes & 0x40` is clear. Before the §3.4 gate widened, this read "Instant".
let d = spells.catalog.get(6603).expect("Attack 6603");
assert_eq!(d.effect_1, 78, "SPELL_EFFECT_ATTACK");
assert!(!d.passive, "6603 carries Attributes 0x10, not 0x40");
let v = spell_tooltip_view(6603, &spells, &mut t.ctx(0, None)).expect("Attack view");
assert_eq!(v.cast_time, None, "the law's Effect[0] gate");
// …and the chance line the same Effect[0] selects (law line 10 / §3-CHANCE). ATTACK
// BYPASSES the passive gate, which is the whole reason a non-passive Attack shows a crit
// line at all. No descriptor = no line; the percentages are already percents on the wire.
assert_eq!(v.chance, None, "no player streamed yet");
let rated = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22u16, 100u32),
(1109u16, 2.62f32.to_bits()), // PLAYER_CRIT_PERCENTAGE
(1107u16, 5.5f32.to_bits()), // PLAYER_DODGE_PERCENTAGE
]));
let v = spell_tooltip_view(6603, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Attack view");
assert_eq!(v.chance.as_deref(), Some("2.62% chance to crit"));
// Dodge (81) is passive and reads its own field.
let dodge = spells.catalog.get(81).expect("Dodge 81");
assert_eq!(dodge.effect_1, 20, "SPELL_EFFECT_DODGE");
assert!(dodge.passive, "81 carries Attributes 0x40");
let v = spell_tooltip_view(81, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Dodge view");
assert_eq!(v.chance.as_deref(), Some("5.50% chance to dodge"));
// A spell naming none of the four effects has no line at all.
let v = spell_tooltip_view(133, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Fireball view");
assert_eq!(v.chance, None);
// 3 · Slow Fall (130) — "Reagents: Light Feather", inline-red while unowned (no store =
// owns nothing). The name rides the ask-once item cache, seeded here as the server would.
let d = spells.catalog.get(130).expect("Slow Fall 130");
assert_eq!(d.reagents[0], (17056, 1), "Light Feather ×1");
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Slow Fall view");
assert_eq!(
v.reagents, None,
"the template hasn't landed: the line waits rather than printing an id"
);
t.items
.insert_template(17056, Some(crate::items::test_template("Light Feather")));
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Slow Fall view");
assert_eq!(
v.reagents.as_deref(),
Some("Reagents: |cffff2020Light Feather|r"),
"count 1 prints no (N); unowned wraps in the builder's inline red"
);
}
}
+4 -3
View File
@@ -242,8 +242,9 @@ fn fire_unit_combat(
/// The 1.12 race id → (localized display, `raceFile` token) — `UnitRace`'s two returns. The file
/// token is the client's internal name (undead = `"Scourge"`, the space dropped from `"NightElf"`),
/// the same vocabulary the 2D portrait stand-in files use (`portrait::temporary_portrait`).
fn race_names(race: u8) -> Option<(&'static str, &'static str)> {
/// the same vocabulary the 2D portrait stand-in files use (`portrait::temporary_portrait`). The
/// display column is also what `$R`/`$r` expand to ([`crate::npc_text`]) — one table, both readers.
pub(crate) fn race_names(race: u8) -> Option<(&'static str, &'static str)> {
Some(match race {
1 => ("Human", "Human"),
2 => ("Orc", "Orc"),
@@ -260,7 +261,7 @@ fn race_names(race: u8) -> Option<(&'static str, &'static str)> {
/// The 1.12 class id → (localized display, `classFileName`) — `UnitClass`'s two returns. The file
/// name is uppercase (the ref's `strupper(classFileName)` tooltip lookups index GlobalStrings keys
/// like `WARRIOR_STRENGTH_TOOLTIP` directly with it).
fn class_names(class: u8) -> Option<(&'static str, &'static str)> {
pub(crate) fn class_names(class: u8) -> Option<(&'static str, &'static str)> {
Some(match class {
1 => ("Warrior", "WARRIOR"),
2 => ("Paladin", "PALADIN"),
+88
View File
@@ -0,0 +1,88 @@
//! The world-state table — the server's key/value scoreboard, fed by `SMSG_INIT_WORLD_STATES` and
//! `SMSG_UPDATE_WORLD_STATE` ([`benilla_protocol::messages::world_state`] for both wires).
//!
//! Its one reader today is the NPC-text expander's `$<n>w` / `$<n>e` tokens
//! ([`crate::npc_text`]): the reference renders a hit as `%d` and a **miss as `"0"`**, so every
//! such token printed `"0"` until this landed — faithful while the table was empty, wrong the
//! moment a zone sent real states. A battleground/PvP scoreboard is the other consumer, later.
//!
//! Shape is the reference's, not a convenience: it keeps an open hash keyed by the **raw wire
//! dword** (`[0xb71ec8]`, bucket `mask & key`, value at node `+0x18`, miss → `0` at `0x4c5867`).
//! Raw matters because `$<n>e` reads the table at the *negated* key — `$2077e` looks up
//! `0xFFFFF7E3`, which only resolves if nothing re-interpreted the id on the way in.
//!
//! **One thing is deliberately not modelled, because it is not known.** The reference hands
//! `SMSG_INIT_WORLD_STATES`' two leading dwords (map, zone) to `0x4c5650` before the pair loop
//! runs, and wow-re has not carved that function — so whether an init *clears* the table first is
//! unrecorded. We do not clear: with no evidence either way, inventing a reset is the speculative
//! act, and vmangos re-sends its full default set on every zone change (`Player.cpp:8267-8270`),
//! which makes a stale key from a previous zone hard to reach in practice. If `0x4c5650` turns out
//! to reset, this is a one-line change in [`WorldStates::write`]'s caller.
use std::collections::HashMap;
use bevy::prelude::*;
/// The live world-state table. Keys and values are raw wire dwords; see the module doc.
#[derive(Resource, Default)]
pub(crate) struct WorldStates {
values: HashMap<u32, u32>,
}
impl WorldStates {
/// The reference's getter `0x4c5810`, including its miss leg: an absent key reads **0**, which
/// is why an un-received world state renders `"0"` rather than blank or the literal token.
///
/// Returned as `i32` because that is the width the one caller formats — the reference prints
/// the dword through `"%d"`, so a value with the top bit set shows as negative.
pub(crate) fn get(&self, key: u32) -> i32 {
self.values.get(&key).copied().unwrap_or(0) as i32
}
/// Apply a run of `(id, value)` writes — the reference's setter `0x4c5870`, which both opcodes
/// funnel into. Init's trailing `(0, 0)` terminator is written like any other pair, exactly as
/// the reference's count loop does; it is data, not a sentinel.
pub(crate) fn write(&mut self, states: &[(u32, u32)]) {
self.values.extend(states.iter().copied());
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The getter's miss leg and the `%d` width: an unknown key is 0 (not blank), a known one is
/// its value, and the top bit reads through as a negative — the three things `$<n>w` depends on.
#[test]
fn a_miss_reads_zero_and_a_value_keeps_its_sign() {
let mut table = WorldStates::default();
assert_eq!(table.get(2077), 0, "empty table — the reference's `\"0\"`");
table.write(&[(2077, 7), (2264, 0xFFFF_FFFF)]);
assert_eq!(table.get(2077), 7);
assert_eq!(table.get(2264), -1, "printed through `%d`");
assert_eq!(table.get(9999), 0, "still a miss");
}
/// `$<n>e`'s negated key is a *different* entry, not the same one — the whole reason ids stay
/// raw dwords. Writing id 2077 must not make `$2077e` (key `0xFFFFF7E3`) resolve.
#[test]
fn the_negated_key_is_a_separate_entry() {
let mut table = WorldStates::default();
table.write(&[(2077, 7)]);
assert_eq!(table.get(2077u32.wrapping_neg()), 0);
table.write(&[(2077u32.wrapping_neg(), 42)]);
assert_eq!(table.get(2077u32.wrapping_neg()), 42);
assert_eq!(table.get(2077), 7, "the positive key is untouched");
}
/// A later write to a live key replaces it (`SMSG_UPDATE_WORLD_STATE`'s whole job), and an
/// init's terminator lands as the ordinary entry it is.
#[test]
fn a_write_replaces_and_the_terminator_is_just_data() {
let mut table = WorldStates::default();
table.write(&[(2264, 1), (0, 0)]);
table.write(&[(2264, 5)]);
assert_eq!(table.get(2264), 5);
assert_eq!(table.get(0), 0);
}
}