diff --git a/Cargo.lock b/Cargo.lock index 38e67fb3..e1b708c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/README.md b/README.md index c9f1327e..fce90e1b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index ed52d28f..a3bcf99e 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -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| { + 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!(""), (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(), diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index d5b54c3a..20a4b592 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -150,6 +150,16 @@ enum Command { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, + /// 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, + }, /// 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) diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 1c167a07..f6b4eca5 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -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 = 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 = 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 = 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(""); + *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::>() + .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::>() + .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 diff --git a/crates/benilla-formats/src/itemsubclass.rs b/crates/benilla-formats/src/itemsubclass.rs index fdf46f44..afaaf7d3 100644 --- a/crates/benilla-formats/src/itemsubclass.rs +++ b/crates/benilla-formats/src/itemsubclass.rs @@ -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 { + 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::>() + .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 { + 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 + '_ { + 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 { 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 { ); // 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] diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index f5dc604c..e3ac95e1 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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::{ diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 1f8ed511..37e3e4e1 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -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. +} diff --git a/crates/benilla-formats/src/spells/dispel_types.rs b/crates/benilla-formats/src/spells/dispel_types.rs new file mode 100644 index 00000000..42d15291 --- /dev/null +++ b/crates/benilla-formats/src/spells/dispel_types.rs @@ -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, +} + +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 { + 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"); + } +} diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index baff4c2a..18165ffd 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -53,7 +53,8 @@ pub struct SpellDisplay { pub open_lock_type: Option, /// `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 diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 1acec1ad..1b2605cf 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -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, learned_spell: HashMap, + 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) -> 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 + '_ { @@ -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 { 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 { Ok(SpellCatalog { spells, learned_spell, + dispel_types, }) } diff --git a/crates/benilla-m2/src/skin.rs b/crates/benilla-m2/src/skin.rs index 0e9ad376..0778c6c7 100644 --- a/crates/benilla-m2/src/skin.rs +++ b/crates/benilla-m2/src/skin.rs @@ -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)?, diff --git a/crates/benilla-protocol/src/bin/benilla-world/main.rs b/crates/benilla-protocol/src/bin/benilla-world/main.rs index 22fdcf16..61bae220 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/main.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/main.rs @@ -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 `$w`/`$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). diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs index 4a60f509..ac9b605f 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs new file mode 100644 index 00000000..ce6ce052 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs @@ -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 ` 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=; -- both rows +//! cargo run -p benilla-protocol --bin benilla-world -- probeN pprobeN --worldstate --seconds 16 +//! UPDATE realmd.account_access SET gmlevel=3 WHERE 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, + hopped: bool, + inits: Vec, + /// 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(()) + } +} diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index ee8a1897..b325e828 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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, + }, /// 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 }, @@ -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 }, + /// 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), + /// 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, + }, /// 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. diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index f5d7386b..805b34c4 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -16,6 +16,10 @@ fn v3(v: Vector3d) -> [f32; 3] { pub fn decode(packet: ServerPacket) -> Vec { 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 { 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 { 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 { 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. diff --git a/crates/benilla-protocol/src/guid.rs b/crates/benilla-protocol/src/guid.rs index a4ee94b3..0f838c57 100644 --- a/crates/benilla-protocol/src/guid.rs +++ b/crates/benilla-protocol/src/guid.rs @@ -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 { + 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 { 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. diff --git a/crates/benilla-protocol/src/messages/client.rs b/crates/benilla-protocol/src/messages/client.rs index ad05ab71..a0c00d95 100644 --- a/crates/benilla-protocol/src/messages/client.rs +++ b/crates/benilla-protocol/src/messages/client.rs @@ -265,6 +265,17 @@ pub fn creature_query(entry: u32, guid: u64) -> Vec { 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 { + 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 { let mut body = Vec::with_capacity(28); diff --git a/crates/benilla-protocol/src/messages/gossip.rs b/crates/benilla-protocol/src/messages/gossip.rs index 33035e03..8ed6fe52 100644 --- a/crates/benilla-protocol/src/messages/gossip.rs +++ b/crates/benilla-protocol/src/messages/gossip.rs @@ -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)> { 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 } diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 4cfcaa26..ae47beef 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 1082ff62..70fc1599 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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 `$w`/`$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 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 439573d7..8347fca6 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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, }, + /// `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, + }, /// `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, }, + /// `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, }, /// `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, }, + /// `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}"), } } diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index e9eabb39..8ec76608 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -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 io::Result> { + 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 { 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 { } 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 { } } } + // `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 { opcode::SMSG_TRADE_STATUS_EXTENDED => ServerPacket::TradeStatusExtended { state: Box::new(trade::read_trade_status_extended(&mut r)?), }, + // The world-state table — the `$w`/`$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. diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index e25cab51..5bed7fae 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index ee2d5356..bb920735 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -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 { + self.get_f32(FIELD_PLAYER_BLOCK_PERCENTAGE) + } + /// `PLAYER_DODGE_PERCENTAGE` — see [`Self::player_block_percentage`]. + pub fn player_dodge_percentage(&self) -> Option { + self.get_f32(FIELD_PLAYER_DODGE_PERCENTAGE) + } + /// `PLAYER_PARRY_PERCENTAGE` — see [`Self::player_block_percentage`]. + pub fn player_parry_percentage(&self) -> Option { + 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 { + 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 { diff --git a/crates/benilla-protocol/src/messages/world_state.rs b/crates/benilla-protocol/src/messages/world_state.rs new file mode 100644 index 00000000..eadb2f42 --- /dev/null +++ b/crates/benilla-protocol/src/messages/world_state.rs @@ -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 `$w`/`$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 +//! `$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 { + 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) + ); + } +} diff --git a/crates/benilla-protocol/src/world/mod.rs b/crates/benilla-protocol/src/world/mod.rs index 6d7316c5..0d8347f4 100644 --- a/crates/benilla-protocol/src/world/mod.rs +++ b/crates/benilla-protocol/src/world/mod.rs @@ -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}")) } diff --git a/crates/benilla-protocol/src/world/reader.rs b/crates/benilla-protocol/src/world/reader.rs index a396dcda..5b194f3f 100644 --- a/crates/benilla-protocol/src/world/reader.rs +++ b/crates/benilla-protocol/src/world/reader.rs @@ -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. diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index 9f6e9c38..1b105e4b 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -63,6 +63,19 @@ impl WorldSession { session_key: [u8; SESSION_KEY_LENGTH], ) -> Result { 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). diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index a2531d33..38bc3711 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -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). diff --git a/crates/benilla-protocol/tests/gossip_vendor.rs b/crates/benilla-protocol/tests/gossip_vendor.rs index 74ee6ede..9b3a5780 100644 --- a/crates/benilla-protocol/tests/gossip_vendor.rs +++ b/crates/benilla-protocol/tests/gossip_vendor.rs @@ -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] diff --git a/crates/benilla-protocol/tests/movement.rs b/crates/benilla-protocol/tests/movement.rs index 0b49a3bd..251be20a 100644 --- a/crates/benilla-protocol/tests/movement.rs +++ b/crates/benilla-protocol/tests/movement.rs @@ -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()); +} diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 01824f1b..7e111a41 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -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`) 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, - externals: BTreeMap, +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, + /// Is `input[h]` a frame to solve this round? + is_frame: Vec, + /// Per-handle rect: externals as seeded, frames as they resolve. `None` = not (yet) known. + rect: Vec>, + /// Kahn in-degree, and the reverse edges (each inner `Vec` is cleared, never freed). + indeg: Vec, + dependents: Vec>, + /// Handles whose slots were written this round — `begin` resets exactly these. + touched: Vec, + /// Is `h` already in `touched`? + marked: Vec, + /// The frames to solve, ascending (the `BTreeMap` iteration order this replaces). + live: Vec, + /// Kahn scratch + output. + queue: VecDeque, + emitted: Vec, + order: Vec, + cycle: Vec, + unresolvable: Vec, } -/// 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, - /// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an - /// unresolvable/missing target). May overlap [`Resolution::cycle`]. - pub unresolvable: BTreeSet, - /// 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, -} - -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 { - 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 { - &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 { - &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 = self.frames.keys().map(|&h| (h, 0)).collect(); - let mut dependents: BTreeMap> = 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 = indeg - .iter() - .filter(|&(_, &d)| d == 0) - .map(|(&h, _)| h) - .collect(); - let mut order: Vec = 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 = self.externals.clone(); - let mut unresolvable: BTreeSet = BTreeSet::new(); - - let resolve_one = |h: Handle, rects: &mut BTreeMap| -> 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 = order.iter().copied().collect(); - let cycle: BTreeSet = 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, -} - -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 { + self.rect.get(h as usize).copied().flatten() } - /// Remove a frame and invalidate the cache. - pub fn remove_frame(&mut self, handle: Handle) -> Option { - 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)) + ); } } diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index c9d5cf4b..0559b5c0 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -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 = 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 = 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 = 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)> = 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 { diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index ed641aaf..6f05a52a 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, /// The last [`UiScript::resolve`] result: each resolvable frame's rect. Empty until `resolve`. pub(crate) resolved: HashMap, + /// 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, + /// 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, diff --git a/crates/benilla-ui/src/script/tests/layout_gate.rs b/crates/benilla-ui/src/script/tests/layout_gate.rs new file mode 100644 index 00000000..3182ad05 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/layout_gate.rs @@ -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::() + .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::("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" + ); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index b70e13cc..56358505 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -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; diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index 1697880b..86d26af5 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -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()); } diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index 86804316..fca0354b 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -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, + /// "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, /// "35 Mana" / "20 Rage" / "Next melee" — the cost cell. pub cost: Option, /// "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, + /// "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, + /// 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, /// 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, + /// "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, /// 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, 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| { diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index de45591e..dc40df9f 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -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) // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla/Cargo.toml b/crates/benilla/Cargo.toml index 6f3d879f..0ab2bbfb 100644 --- a/crates/benilla/Cargo.toml +++ b/crates/benilla/Cargo.toml @@ -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 / diff --git a/crates/benilla/assets/ui/SkillFrame.xml b/crates/benilla/assets/ui/SkillFrame.xml index f9af0e89..68ac353e 100644 --- a/crates/benilla/assets/ui/SkillFrame.xml +++ b/crates/benilla/assets/ui/SkillFrame.xml @@ -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 diff --git a/crates/benilla/src/dbg_trace.rs b/crates/benilla/src/dbg_trace.rs index 44a1a807..88ae28da 100644 --- a/crates/benilla/src/dbg_trace.rs +++ b/crates/benilla/src/dbg_trace.rs @@ -1,8 +1,10 @@ //! The shared debug-trace sink behind `WOW_MOVE_TRACE=`: 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=` 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>> = OnceLock::new(); fn sink() -> Option<&'static Mutex> { 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(), diff --git a/crates/benilla/src/entities/display.rs b/crates/benilla/src/entities/display.rs index ea2b2185..7aa7bff8 100644 --- a/crates/benilla/src/entities/display.rs +++ b/crates/benilla/src/entities/display.rs @@ -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 diff --git a/crates/benilla/src/items.rs b/crates/benilla/src/items.rs index 9f2886a1..766f4b85 100644 --- a/crates/benilla/src/items.rs +++ b/crates/benilla/src/items.rs @@ -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) { @@ -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(); diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index c169bef5..5a5a2dbe 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -121,6 +121,7 @@ mod wdl; mod weather; mod wmo_portal; mod world_map; +mod world_state; use assets::AssetPlugin; use avian3d::prelude::*; diff --git a/crates/benilla/src/names.rs b/crates/benilla/src/names.rs index 5e824590..49439e21 100644 --- a/crates/benilla/src/names.rs +++ b/crates/benilla/src/names.rs @@ -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>, + /// 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, pending_players: HashSet, pending_creatures: HashSet, + pending_pets: HashSet, } /// 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) { 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(); diff --git a/crates/benilla/src/net.rs b/crates/benilla/src/net.rs index cf398593..35fd08e4 100644 --- a/crates/benilla/src/net.rs +++ b/crates/benilla/src/net.rs @@ -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::() .init_resource::() - .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -71,6 +70,7 @@ impl Plugin for NetPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -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. diff --git a/crates/benilla/src/net/apply.rs b/crates/benilla/src/net/apply.rs index eed58f18..660c611c 100644 --- a/crates/benilla/src/net/apply.rs +++ b/crates/benilla/src/net/apply.rs @@ -115,8 +115,9 @@ pub(super) fn apply_net_updates( ResMut, // 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 `$w` tokens read, grouped to stay under Bevy's 16-SystemParam ceiling (this + // tuple's 16th and last slot). ( ResMut, ResMut, @@ -124,6 +125,7 @@ pub(super) fn apply_net_updates( ResMut, ResMut, ResMut, + ResMut, ), ), // 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, Query<&crate::transport::Transport>, - // The relayed-move clock map (decision 0601): server MovementInfo time → client - // fire-time, for the scheduled remote apply. - ResMut, + // 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>, ), ) { 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 diff --git a/crates/benilla/src/net/apply/npc.rs b/crates/benilla/src/net/apply/npc.rs index 7d2b1660..ee9fa252 100644 --- a/crates/benilla/src/net/apply/npc.rs +++ b/crates/benilla/src/net/apply/npc.rs @@ -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, 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, + 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); } } diff --git a/crates/benilla/src/net/apply/objects.rs b/crates/benilla/src/net/apply/objects.rs index 254f899b..48f1d284 100644 --- a/crates/benilla/src/net/apply/objects.rs +++ b/crates/benilla/src/net/apply/objects.rs @@ -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, ) { - 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::(); 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); } } diff --git a/crates/benilla/src/net/io.rs b/crates/benilla/src/net/io.rs index cbfd6329..11cdbd0b 100644 --- a/crates/benilla/src/net/io.rs +++ b/crates/benilla/src/net/io.rs @@ -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) { + 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; diff --git a/crates/benilla/src/net/motion.rs b/crates/benilla/src/net/motion.rs index 9ce6995c..34cb84b8 100644 --- a/crates/benilla/src/net/motion.rs +++ b/crates/benilla/src/net/motion.rs @@ -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, }; diff --git a/crates/benilla/src/net/motion/relay.rs b/crates/benilla/src/net/motion/relay.rs new file mode 100644 index 00000000..6c5005b5 --- /dev/null +++ b/crates/benilla/src/net/motion/relay.rs @@ -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, + pub(crate) transport: Option, + /// `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 + } +} diff --git a/crates/benilla/src/net/motion/remote.rs b/crates/benilla/src/net/motion/remote.rs index 8a6d9824..a1d4ed53 100644 --- a/crates/benilla/src/net/motion/remote.rs +++ b/crates/benilla/src/net/motion/remote.rs @@ -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, + /// 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, - pub(crate) transport: Option, - /// `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, - /// 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 = 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, ) { @@ -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