other players walk the world, and NPCs speak your name
- Other players walk the world. A watched player's motion between server updates now runs through the same grounded movement resolve as your own character — same capsule, same colliders, same step-up and ground election — so they follow terrain every frame instead of every packet, and stop at a wall instead of walking into it and being snapped back. Their jumps are swept against the world too, and a standstill jump steered mid-air reports that steer, the one mid-air press the reference client broadcasts rather than defers. - The move relay rebuilt: relayed moves ride a per-unit chain on real time, a due move never jumps ahead of its unit's queue, and SMSG_COMPRESSED_MOVES — the batched carrier the server sends under load — is decoded rather than skipped. Our own send path now matches the reference client's: facing streams while moving, Nagle is off so each packet is one write, and the strafe axis nets so left and right can never ride the same packet. - NPC and quest text speaks the whole $-macro grammar, carved from the reference bytes — your name, class and race, the gendered forms, and the server's live world states — and the same substitution feeds gossip, quest text, the quest log and unit frames. Greetings are a weighted draw, rolled per NPC per open, and a quest giver with no quest to give is no longer clickable, where it used to open a dialog holding nothing but a placeholder greeting. - Spell tooltips finish the reference's line law: cast time and cooldown behind the real passive gate, the required tool or form, the reagents block, the chance-to-X line, a multi-bit equipped-item requirement named properly instead of by raw bits, and a buff's dispel class. - The UI layout engine got a dense, reusable anchor solver, and a change gate that skips the resolve entirely on frames where no layout input moved — in practice, almost every idle frame. Pets are named by their pet number rather than a creature-template entry, so the client asks the query the server can actually answer. And the asset extractor grew a stray-geometry census for tracking down geometry that renders where it should not. This snapshot replaces the six dated `sync` commits that stood here before it, whose subjects said nothing but the date. If you cloned before now: `git fetch && git reset --hard origin/main`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -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]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# benilla
|
||||
|
||||
[](https://discord.gg/8r5kUgu9ts)
|
||||
[](https://discord.gg/wJSJx467G4)
|
||||
[](https://www.youtube.com/playlist?list=PLdCnpZNKxyb8)
|
||||
[](#license)
|
||||
|
||||
@@ -58,8 +58,12 @@ Credentials go in at the login screen, or set `WOW_USER` / `WOW_PASS` to skip it
|
||||
|
||||
benilla is a solo project and isn't taking pull requests for now. Bug reports and questions are
|
||||
welcome — open an issue, or bring them to Discord. Comments throughout the code cite numbered
|
||||
decision records from a private development log; the citations won't resolve here, but the
|
||||
reasoning they carry is kept in the comments themselves.
|
||||
decision records and method notes from a private development log; the citations won't resolve
|
||||
here, but the reasoning they carry is kept in the comments themselves.
|
||||
|
||||
Development happens in that private tree, and this repo is its published export: **each commit here
|
||||
is one squashed snapshot** of everything that landed since the last one, titled with what changed —
|
||||
not an atomic change, which is why they're large and why there are few of them.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
@@ -570,7 +570,11 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dump an M2's render batches as the renderer sees them.
|
||||
/// Dump an M2's render batches as the renderer sees them, preceded by the model-level material
|
||||
/// state the **static visibility cull** reads (wow-re `m2-alpha-combine-cull`: a batch is skipped
|
||||
/// when `colorAlpha · transparencyWeight ≤ 0`). Batches this dump *lists* are ones that survived
|
||||
/// that cull — when one of them turns out to be a stray primitive in game, these tables are where
|
||||
/// the answer has to be, so they print together.
|
||||
pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> {
|
||||
let name = normalize(internal_path);
|
||||
let data = chain
|
||||
@@ -579,6 +583,54 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> {
|
||||
let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or("");
|
||||
let subs = benilla_formats::parse_m2_render_submeshes(&data, dir, &[])
|
||||
.with_context(|| format!("parsing M2 render submeshes '{name}'"))?;
|
||||
if let Ok(format) = benilla_m2::parse_m2(&mut std::io::Cursor::new(data.as_slice())) {
|
||||
let m = format.model();
|
||||
let track = |t: &benilla_m2::M2ScalarTrack| match (t.keys.len(), t.constant()) {
|
||||
(0, _) => "keyless".to_string(),
|
||||
(_, Some(v)) => format!("const {v:.3}"),
|
||||
(n, None) => format!("{n} keys {:.3}..{:.3}", t.keys[0].1, t.keys[n - 1].1),
|
||||
};
|
||||
let join = |v: Vec<String>| {
|
||||
if v.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
v.join(", ")
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"materials: {}",
|
||||
join(
|
||||
m.materials
|
||||
.iter()
|
||||
.map(|mt| format!(
|
||||
"flags 0x{:02x}/blend {}",
|
||||
mt.flags.bits(),
|
||||
mt.blend_mode.bits()
|
||||
))
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
println!(
|
||||
"color alpha: {} transparency: {} transLookup: {:?}",
|
||||
join(m.color_alpha_tracks.iter().map(track).collect()),
|
||||
join(m.transparency_tracks.iter().map(track).collect()),
|
||||
m.transparency_lookup,
|
||||
);
|
||||
if let Ok(skin) = m.parse_embedded_skin(&data, 0) {
|
||||
println!(
|
||||
"skin batches: {}",
|
||||
join(
|
||||
skin.batches()
|
||||
.iter()
|
||||
.map(|b| format!(
|
||||
"flags 0x{:02x}/shader 0x{:02x}/texCount {}",
|
||||
b.flags, b.shader_id, b.texture_count
|
||||
))
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("{} render batch(es)", subs.len());
|
||||
for (i, s) in subs.iter().enumerate() {
|
||||
let mut flags = Vec::new();
|
||||
@@ -613,8 +665,32 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> {
|
||||
(None, Some(slot)) => format!("<char:{slot:?}>"),
|
||||
(None, None) => "NONE".into(),
|
||||
};
|
||||
// Model-space extent + centre: a batch that renders as a stray primitive is explained by
|
||||
// where and how big it actually is (a degenerate zero-area batch is a different bug from a
|
||||
// real card the reference hides some other way).
|
||||
let ext = |axis: usize| -> (f32, f32) {
|
||||
s.positions
|
||||
.iter()
|
||||
.fold((f32::MAX, f32::MIN), |(lo, hi), p| {
|
||||
(lo.min(p[axis]), hi.max(p[axis]))
|
||||
})
|
||||
};
|
||||
let span = if s.positions.is_empty() {
|
||||
"empty".to_string()
|
||||
} else {
|
||||
let (x, y, z) = (ext(0), ext(1), ext(2));
|
||||
format!(
|
||||
"span {:.2}x{:.2}x{:.2} @ ({:.2}, {:.2}, {:.2})",
|
||||
x.1 - x.0,
|
||||
y.1 - y.0,
|
||||
z.1 - z.0,
|
||||
(x.0 + x.1) / 2.0,
|
||||
(y.0 + y.1) / 2.0,
|
||||
(z.0 + z.1) / 2.0,
|
||||
)
|
||||
};
|
||||
println!(
|
||||
" batch {i}: geoset {:>4} {:?} {} verts [{}] tex {}",
|
||||
" batch {i}: geoset {:>4} {:?} {} verts {span} [{}] tex {}",
|
||||
s.geoset_id,
|
||||
s.blend,
|
||||
s.positions.len(),
|
||||
|
||||
@@ -150,6 +150,16 @@ enum Command {
|
||||
/// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted.
|
||||
prefix: Option<String>,
|
||||
},
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and census the geometry a
|
||||
/// **non-character** spawn draws that the reference may not: MULTI-GEOSET models (more than
|
||||
/// one `skinSectionId` — only the character compositor selects among them, so every other
|
||||
/// spawn path draws all of them), UNTEX batches (no embedded texture and no character runtime
|
||||
/// slot) and TINY batches (at most 2 faces). The population instrument behind the stray
|
||||
/// untextured-primitive reports; `m2batch` then explains a single model in full.
|
||||
Geosetscan {
|
||||
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted.
|
||||
prefix: Option<String>,
|
||||
},
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and census every particle-emitter
|
||||
/// FEATURE the corpus authors — shapes, head/tail, blend modes (incl. the folded Mod/Mod2x),
|
||||
/// every file-flag bit, spin signs, twinkle gates, atlas tiling, spline emitters, and the
|
||||
@@ -352,6 +362,7 @@ fn main() -> Result<()> {
|
||||
Command::Blendscan { prefix } => scan::blendscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Bbscan { prefix } => scan::bbscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Geosetscan { prefix } => scan::geosetscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?,
|
||||
Command::Partscan { mask, prefix } => {
|
||||
let mask = parse_u32_maybe_hex(&mask)
|
||||
|
||||
@@ -248,6 +248,110 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sweep every `.m2` (under `prefix`, if given) and census the **geometry a non-character model
|
||||
/// draws that the reference may not** — the population instrument behind the bug channel's
|
||||
/// "stray untextured primitive" family. Three independent signals per model, all read through the
|
||||
/// renderer's own batch resolution (`m2batch`'s), so the report can't drift from the mechanism:
|
||||
///
|
||||
/// - **MULTI-GEOSET** — more than one distinct `skinSectionId`. The character compositor selects
|
||||
/// among these; every other spawn path draws **all** of them, so this is exactly the population
|
||||
/// an unfiltered creature/doodad/effect draw over-renders (`Creature\Banshee\Banshee.m2` is the
|
||||
/// pinned case: `0`×17 + `402`×9).
|
||||
/// - **UNTEX** — batches with no texture *and* no runtime slot that fills one: neither a character
|
||||
/// composite slot ([`benilla_formats::RenderSubmesh::char_slot`]) nor a creature skin variation
|
||||
/// ([`benilla_formats::RenderSubmesh::skin_slot`], filled at spawn from `CreatureDisplayInfo`).
|
||||
/// Both fills are ordinary, so counting them would drown the signal — 324 of 420 `Creature\`
|
||||
/// models carry a skin slot. What is left is geometry nothing can texture.
|
||||
/// - **TINY** — batches of at most 2 faces: the literal single-triangle/quad primitives.
|
||||
///
|
||||
/// A model is listed when it trips any of the three. `m2batch` then explains one model in full.
|
||||
pub fn geosetscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\"));
|
||||
let names: Vec<String> = chain
|
||||
.list()
|
||||
.context("listing chain contents")?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.filter(|n| {
|
||||
let l = n.to_ascii_lowercase();
|
||||
l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p))
|
||||
})
|
||||
.collect();
|
||||
let (mut scanned, mut hits) = (0u32, 0u32);
|
||||
let (mut multi_models, mut untex_models, mut tiny_models) = (0u32, 0u32, 0u32);
|
||||
// Top-level directory → multi-geoset model count, so the report says *where* the population
|
||||
// lives (Creature/, Spells/, World/…) rather than only how big it is.
|
||||
let mut by_dir: BTreeMap<String, u32> = BTreeMap::new();
|
||||
for name in names {
|
||||
let Ok(bytes) = chain.read_file(&name) else {
|
||||
continue;
|
||||
};
|
||||
scanned += 1;
|
||||
let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or("");
|
||||
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else {
|
||||
continue;
|
||||
};
|
||||
let mut geosets: BTreeMap<u16, u32> = BTreeMap::new();
|
||||
let (mut untex, mut tiny) = (0u32, 0u32);
|
||||
for s in &subs {
|
||||
*geosets.entry(s.geoset_id).or_default() += 1;
|
||||
if s.texture.is_none() && s.char_slot.is_none() && s.skin_slot.is_none() {
|
||||
untex += 1;
|
||||
}
|
||||
if !s.indices.is_empty() && s.indices.len() <= 6 {
|
||||
tiny += 1;
|
||||
}
|
||||
}
|
||||
let multi = geosets.len() > 1;
|
||||
if !multi && untex == 0 && tiny == 0 {
|
||||
continue;
|
||||
}
|
||||
hits += 1;
|
||||
if multi {
|
||||
multi_models += 1;
|
||||
let top = name.split_once('\\').map(|(d, _)| d).unwrap_or("<root>");
|
||||
*by_dir.entry(top.to_ascii_lowercase()).or_default() += 1;
|
||||
}
|
||||
if untex > 0 {
|
||||
untex_models += 1;
|
||||
}
|
||||
if tiny > 0 {
|
||||
tiny_models += 1;
|
||||
}
|
||||
let hist = geosets
|
||||
.iter()
|
||||
.map(|(id, n)| format!("{id}×{n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut marks = Vec::new();
|
||||
if multi {
|
||||
marks.push(format!("MULTI-GEOSET({})", geosets.len()));
|
||||
}
|
||||
if untex > 0 {
|
||||
marks.push(format!("UNTEX({untex})"));
|
||||
}
|
||||
if tiny > 0 {
|
||||
marks.push(format!("TINY({tiny})"));
|
||||
}
|
||||
println!(
|
||||
"{:>3} batches [{hist}] {} {name}",
|
||||
subs.len(),
|
||||
marks.join(" ")
|
||||
);
|
||||
}
|
||||
let dirs = by_dir
|
||||
.iter()
|
||||
.map(|(d, n)| format!("{d}={n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
eprintln!(
|
||||
"{scanned} models scanned, {hits} listed — {multi_models} MULTI-GEOSET, \
|
||||
{untex_models} with UNTEX batches, {tiny_models} with TINY batches; \
|
||||
multi-geoset by top dir: [{dirs}]"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sweep every `.m2` (under `prefix`, if given) and report models that author flat ground-plane
|
||||
/// render geometry — the population instrument for the class of spell effects that lie in the
|
||||
/// model-space XY plane at z≈0 (WoW axes, Z up) and get buried by sloped terrain (Battle Shout's
|
||||
|
||||
@@ -23,6 +23,10 @@ use crate::dbc::{i32_at, parse, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const ITEM_SUB_CLASS: &str = "DBFilesClient\\ItemSubClass.dbc";
|
||||
/// The group-name table read *before* [`ITEM_SUB_CLASS`] whenever a whole subclass **mask** needs a
|
||||
/// name — see [`ItemSubClassCatalog::requirement_name`]. 11 fields (verified by loading the shipped
|
||||
/// file): `ClassID@0`, `Mask@1`, `Name_Lang@2..9`, `NameFlags@10`.
|
||||
const ITEM_SUB_CLASS_MASK: &str = "DBFilesClient\\ItemSubClassMask.dbc";
|
||||
|
||||
/// One row's tooltip-relevant fields.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -44,6 +48,21 @@ pub struct ItemSubClassCatalog {
|
||||
/// when non-empty, else **DisplayName** (`+0x28`, column 10). "One-Handed Swords" over
|
||||
/// "Sword"; plain "Cloth" where no verbose form exists.
|
||||
names: HashMap<(u32, u32), String>,
|
||||
/// **DisplayName** alone (column 10) — the SINGULAR spelling. The two are not
|
||||
/// interchangeable, and the reference picks between them by call site: the cast-fail line
|
||||
/// `SPELL_FAILED_EQUIPPED_ITEM_CLASS` reads "Must have a **Wand** equipped" where the spell
|
||||
/// tooltip's requirement line reads "Requires **Wands**" — both pinned by reference captures
|
||||
/// of the same spell (Shoot 5019: class 2, submask bit 19).
|
||||
display_names: HashMap<(u32, u32), String>,
|
||||
/// Every `(class, subclass)` key in **file order** — the order the reference walks the rows in,
|
||||
/// which decides both the comma-join's order and which row counts as "first"
|
||||
/// ([`Self::requirement_name`] / [`Self::requirement_display_name`]).
|
||||
order: Vec<(u32, u32)>,
|
||||
/// `ItemSubClassMask.dbc`: `(classId, mask, name)` — the group names that stand in for a whole
|
||||
/// mask ("Melee Weapon" for the eleven melee weapon subclasses). Only 3 rows ship. Folded in
|
||||
/// here rather than given its own catalog because it is stage 1 of one lookup, not a second
|
||||
/// vocabulary.
|
||||
mask_groups: Vec<(u32, u32, String)>,
|
||||
}
|
||||
|
||||
impl ItemSubClassCatalog {
|
||||
@@ -63,6 +82,73 @@ impl ItemSubClassCatalog {
|
||||
self.names.get(&(class, subclass)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The SINGULAR subclass name (DisplayName only — [`Self::display_names`]); `None` for an
|
||||
/// unknown key or an empty column.
|
||||
pub fn display_name(&self, class: u32, subclass: u32) -> Option<&str> {
|
||||
self.display_names
|
||||
.get(&(class, subclass))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// What a spell's equipped-item requirement is *called*, for `(class, subclass_mask)` — the
|
||||
/// **plural/verbose** spelling the spell tooltip prints ("Requires Wands", "Requires Melee
|
||||
/// Weapon"). `None` when nothing names it.
|
||||
///
|
||||
/// The reference's law is two-stage (wow-re `tooltip-content-law.md` §3-EQUIPITEM, carved from
|
||||
/// `0x6e2380` and its two call sites), and both stages matter for a mask with several bits set:
|
||||
///
|
||||
/// 1. `ItemSubClassMask.dbc` on **exact whole-mask equality** — not "any bit", the entire mask.
|
||||
/// Three rows ship: `{2, 0x2a5f3, "Melee Weapon"}`, `{4, 0x60, "Shield"}`,
|
||||
/// `{2, 0x4000c, "Ranged Weapon"}`. This is why Parry (`0x2a5f3` = eleven melee subclasses)
|
||||
/// prints one group name rather than eleven weapon types.
|
||||
/// 2. Otherwise every subclass the mask names, comma-joined in file order, each preferring
|
||||
/// VerboseName with a DisplayName fallback ([`Self::name`]).
|
||||
///
|
||||
/// Only when stage 2 also finds nothing is the line absent. Note `0x6e2380` reads **only**
|
||||
/// ItemSubClass.dbc — there is no ItemClass.dbc fallback on either path.
|
||||
pub fn requirement_name(&self, class: u32, mask: u32) -> Option<String> {
|
||||
self.mask_group(class, mask)
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
let joined = self
|
||||
.masked(class, mask)
|
||||
.filter_map(|key| self.names.get(&key))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
(!joined.is_empty()).then_some(joined)
|
||||
})
|
||||
}
|
||||
|
||||
/// The **singular** spelling of the same requirement — what the `SPELL_FAILED_EQUIPPED_ITEM_CLASS`
|
||||
/// cast-fail line says ("Must have a Wand equipped"). Same stage 1, but stage 2 takes only the
|
||||
/// FIRST matching subclass and its DisplayName: never a join, never VerboseName.
|
||||
pub fn requirement_display_name(&self, class: u32, mask: u32) -> Option<String> {
|
||||
self.mask_group(class, mask)
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
self.masked(class, mask)
|
||||
.find_map(|key| self.display_names.get(&key))
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Stage 1: the `ItemSubClassMask.dbc` group name for an EXACT `(class, mask)` pair.
|
||||
fn mask_group(&self, class: u32, mask: u32) -> Option<&str> {
|
||||
self.mask_groups
|
||||
.iter()
|
||||
.find(|(c, m, _)| *c == class && *m == mask)
|
||||
.map(|(_, _, name)| name.as_str())
|
||||
}
|
||||
|
||||
/// The catalog's keys for `class` whose subclass bit is set in `mask`, in file order.
|
||||
fn masked(&self, class: u32, mask: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
|
||||
self.order
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(move |&(c, sub)| c == class && sub < 32 && mask & (1 << sub) != 0)
|
||||
}
|
||||
|
||||
/// Whether the type name is suppressed (displayFlags bit 0).
|
||||
pub fn hides_name(&self, class: u32, subclass: u32) -> bool {
|
||||
self.rows
|
||||
@@ -115,18 +201,52 @@ fn item_sub_class_schema() -> Schema {
|
||||
s
|
||||
}
|
||||
|
||||
/// Load ItemSubClass.dbc from the patch chain.
|
||||
fn item_sub_class_mask_schema() -> Schema {
|
||||
let mut s = Schema::new("ItemSubClassMask");
|
||||
s.add_field(SchemaField::new("ClassID", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new("Mask", FieldType::UInt32));
|
||||
for i in 0..8 {
|
||||
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
|
||||
}
|
||||
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
|
||||
s
|
||||
}
|
||||
|
||||
/// Load ItemSubClass.dbc — plus the ItemSubClassMask.dbc group names, which are stage 1 of the same
|
||||
/// lookup ([`ItemSubClassCatalog::requirement_name`]) — from the patch chain.
|
||||
pub fn load_item_sub_classes(chain: &mut Chain) -> Result<ItemSubClassCatalog> {
|
||||
let bytes = chain
|
||||
.read_file(ITEM_SUB_CLASS)
|
||||
.with_context(|| format!("reading {ITEM_SUB_CLASS}"))?;
|
||||
let rs = parse(&bytes, item_sub_class_schema(), "ItemSubClass")?;
|
||||
let mask_bytes = chain
|
||||
.read_file(ITEM_SUB_CLASS_MASK)
|
||||
.with_context(|| format!("reading {ITEM_SUB_CLASS_MASK}"))?;
|
||||
let mask_rs = parse(
|
||||
&mask_bytes,
|
||||
item_sub_class_mask_schema(),
|
||||
"ItemSubClassMask",
|
||||
)?;
|
||||
let mut mask_groups = Vec::with_capacity(mask_rs.records().len());
|
||||
for r in mask_rs.records() {
|
||||
let (Some(class), Some(mask), Some(name)) = (
|
||||
u32_at(r, 0),
|
||||
u32_at(r, 1),
|
||||
crate::dbc::str_at(&mask_rs, r, 2).filter(|n| !n.is_empty()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
mask_groups.push((class, mask, name));
|
||||
}
|
||||
let mut rows = HashMap::with_capacity(rs.records().len());
|
||||
let mut names = HashMap::with_capacity(rs.records().len());
|
||||
let mut display_names = HashMap::with_capacity(rs.records().len());
|
||||
let mut order = Vec::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let (Some(class), Some(subclass)) = (u32_at(r, 0), u32_at(r, 1)) else {
|
||||
continue;
|
||||
};
|
||||
order.push((class, subclass));
|
||||
rows.insert(
|
||||
(class, subclass),
|
||||
ItemSubClassInfo {
|
||||
@@ -137,14 +257,24 @@ pub fn load_item_sub_classes(chain: &mut Chain) -> Result<ItemSubClassCatalog> {
|
||||
);
|
||||
// VerboseName enUS (col 19) first, DisplayName enUS (col 10) fallback — the struct doc's
|
||||
// byte law. Empty both → no name row (the header renders blank, faithfully unlikely).
|
||||
let display = crate::dbc::str_at(&rs, r, 10).filter(|n| !n.is_empty());
|
||||
let name = crate::dbc::str_at(&rs, r, 19)
|
||||
.filter(|n| !n.is_empty())
|
||||
.or_else(|| crate::dbc::str_at(&rs, r, 10).filter(|n| !n.is_empty()));
|
||||
.or_else(|| display.clone());
|
||||
if let Some(name) = name {
|
||||
names.insert((class, subclass), name);
|
||||
}
|
||||
if let Some(display) = display {
|
||||
display_names.insert((class, subclass), display);
|
||||
}
|
||||
}
|
||||
Ok(ItemSubClassCatalog { rows, names })
|
||||
Ok(ItemSubClassCatalog {
|
||||
rows,
|
||||
names,
|
||||
display_names,
|
||||
order,
|
||||
mask_groups,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -173,6 +303,59 @@ mod tests {
|
||||
assert_eq!(cat.name(99, 0), None);
|
||||
}
|
||||
|
||||
/// The two-stage equipped-item requirement law (§3-EQUIPITEM) against the real 5875 DBCs —
|
||||
/// including the multi-bit case we used to give up on. Skips without client data.
|
||||
#[test]
|
||||
fn real_requirement_names_take_the_group_before_the_join() {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_item_sub_classes(&mut chain).expect("load the two DBCs");
|
||||
|
||||
// Stage 1 — the whole mask has a name of its own. All three shipped rows.
|
||||
assert_eq!(
|
||||
cat.requirement_name(2, 0x0002_a5f3).as_deref(),
|
||||
Some("Melee Weapon"),
|
||||
"Parry's eleven melee subclasses are ONE group name, not a join"
|
||||
);
|
||||
assert_eq!(cat.requirement_name(4, 0x60).as_deref(), Some("Shield"));
|
||||
assert_eq!(
|
||||
cat.requirement_name(2, 0x0004_000c).as_deref(),
|
||||
Some("Ranged Weapon")
|
||||
);
|
||||
// The group name is the same string in the singular arm — the stages share stage 1.
|
||||
assert_eq!(
|
||||
cat.requirement_display_name(2, 0x0002_a5f3).as_deref(),
|
||||
Some("Melee Weapon")
|
||||
);
|
||||
|
||||
// Stage 2, one bit — the two spellings diverge (Shoot 5019: class 2, bit 19).
|
||||
assert_eq!(cat.requirement_name(2, 1 << 19).as_deref(), Some("Wands"));
|
||||
assert_eq!(
|
||||
cat.requirement_display_name(2, 1 << 19).as_deref(),
|
||||
Some("Wand")
|
||||
);
|
||||
|
||||
// Stage 2, several bits with no group row — joined for the tooltip, first-only for the
|
||||
// cast-fail line. Daggers (15) + fist weapons (13) is not a shipped group.
|
||||
let mask = (1 << 13) | (1 << 15);
|
||||
assert_eq!(
|
||||
cat.requirement_name(2, mask).as_deref(),
|
||||
Some("Fist Weapons, Daggers")
|
||||
);
|
||||
assert_eq!(
|
||||
cat.requirement_display_name(2, mask).as_deref(),
|
||||
Some("Fist Weapon")
|
||||
);
|
||||
|
||||
// Nothing to name.
|
||||
assert_eq!(cat.requirement_name(2, 0), None);
|
||||
assert_eq!(cat.requirement_name(99, 1), None);
|
||||
}
|
||||
|
||||
/// Data-gated on the real 5875 DBC. Prints the live prereq/postreq and displayFlags rows so
|
||||
/// a schema slip is visible, and pins the known shape. Skips without client data.
|
||||
#[test]
|
||||
|
||||
@@ -106,13 +106,13 @@ mod wmo_area;
|
||||
pub use wmo_area::{load_wmo_area_catalog, WmoArea, WmoAreaCatalog};
|
||||
mod spells;
|
||||
pub use spells::{
|
||||
load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_durations,
|
||||
load_spell_radii, load_spell_ranges, substitute, ShapeshiftForm, SpellCastTime,
|
||||
SpellCastTimeCatalog, SpellCatalog, SpellDisplay, SpellDuration, SpellDurationCatalog,
|
||||
SpellRadius, SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext,
|
||||
ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, SPELL_EFFECT_CREATE_ITEM,
|
||||
SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_SKINNING,
|
||||
SPELL_EFFECT_TRADE_SKILL,
|
||||
load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types,
|
||||
load_spell_durations, load_spell_radii, load_spell_ranges, substitute, ShapeshiftForm,
|
||||
SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay,
|
||||
SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
|
||||
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
|
||||
ATTR_ONLY_STEALTHED, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM,
|
||||
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
|
||||
};
|
||||
mod skill_lines;
|
||||
pub use skill_lines::{
|
||||
|
||||
@@ -659,3 +659,33 @@ fn real_crafting_columns_read_created_item_and_focus() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two tooltip-law reads added for the 2026-07-25 spellbook reports (decision 0620), on pure
|
||||
/// data — no client install needed.
|
||||
#[test]
|
||||
fn the_tooltip_gates_read_effect_and_mask() {
|
||||
// §3.4: the cast|cooldown line goes on the ATTRIBUTE bit or on Effect[0] ∈ {47, 78}.
|
||||
let plain = SpellDisplay::default();
|
||||
assert!(!plain.tooltip_omits_cast_line());
|
||||
let attribute_passive = SpellDisplay {
|
||||
passive: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(attribute_passive.tooltip_omits_cast_line());
|
||||
// 6603 "Attack"'s shape: Effect[0] = 78, attributes 0x10 (NOT the passive bit).
|
||||
let auto_attack = SpellDisplay {
|
||||
effect_1: 78,
|
||||
attributes: 0x10,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!auto_attack.passive, "the attribute bit is clear");
|
||||
assert!(auto_attack.tooltip_omits_cast_line(), "the Effect[0] leg");
|
||||
let trade_skill = SpellDisplay {
|
||||
effect_1: 47,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(trade_skill.tooltip_omits_cast_line());
|
||||
|
||||
// §3-EQUIPITEM's naming rule moved to `ItemSubClassCatalog::requirement_name`, where the
|
||||
// vocabulary it reads lives — see `itemsubclass::tests` for its coverage against the real DBCs.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! `SpellDispelType.dbc` — the name of a spell's dispel class, and the flag that decides whether
|
||||
//! the class is *named at all*.
|
||||
//!
|
||||
//! Two consumers read exactly this table: the **aura tooltip's right column** ("Magic" on Ice
|
||||
//! Armor — wow-re `tooltip-content-law.md` §3-BUFF, the aura builder `0x52f880`), and the
|
||||
//! `debuffType` return of `UnitAura`/`UnitDebuff`, which FrameXML's `DebuffTypeColor` keys the
|
||||
//! debuff border tint on. Both take the name from this row; neither hard-codes it.
|
||||
//!
|
||||
//! **The gate is `[+0x28]`, not the id.** A row is named only when its `+0x28` field is nonzero —
|
||||
//! byte-verified in wow-re, and confirmed here on the shipped file: the 11 records are `{0 "",
|
||||
//! 1 Magic, 2 Curse, 3 Disease, 4 Poison, 5 Stealth, 6 Invisibility, 7 All(M+C+D+P), 8 "Special -
|
||||
//! npc only", 9 Frenzy, 10 ZG Trinkets}` and `[+0x28]` reads **1 for ids 1–4 and 0 for every
|
||||
//! other row**. So Stealth and Invisibility have names in the file yet print nothing — the flag,
|
||||
//! not the string, is what withholds them.
|
||||
//!
|
||||
//! Record layout (11 records × 12 fields × 4 B = 0x30 stride, read off the extracted file):
|
||||
//! `ID@0`, `Name_Lang@1..8` (enUS at 1; every other locale is empty on 5875), `NameFlags@9`
|
||||
//! (`0x007F007E`, `0x003F007E` on the last two rows), the gate at `@10` (= `+0x28`), and `@11` —
|
||||
//! a second string column holding the *same* string as the name, populated on exactly the same
|
||||
//! four rows. Whatever `@11` is for, it is not what the tooltip reads; `@10` is.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::Chain;
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{parse, str_at, u32_at};
|
||||
|
||||
/// The named dispel classes, by `Spell.dbc` `Dispel` id — a row whose `[+0x28]` gate is 0 is
|
||||
/// **absent**, so a lookup miss is exactly "this class is not named" (see the module doc).
|
||||
#[derive(Default)]
|
||||
pub struct SpellDispelTypes {
|
||||
names: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl SpellDispelTypes {
|
||||
/// The dispel class's name ("Magic"/"Curse"/"Disease"/"Poison" on 5875), or `None` when the
|
||||
/// spell has no dispel class (`dispel == 0`) or its class is one the gate withholds.
|
||||
pub fn name(&self, dispel: u32) -> Option<&str> {
|
||||
self.names.get(&dispel).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.names.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.names.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load `SpellDispelType.dbc` off the patch chain, keeping only the rows the `[+0x28]` gate names.
|
||||
pub fn load_spell_dispel_types(chain: &mut Chain) -> Result<SpellDispelTypes> {
|
||||
let bytes = chain
|
||||
.read_file("DBFilesClient\\SpellDispelType.dbc")
|
||||
.context("reading SpellDispelType.dbc")?;
|
||||
let mut schema = Schema::new("SpellDispelType");
|
||||
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
for i in 0..8 {
|
||||
schema.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
|
||||
}
|
||||
schema.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
|
||||
schema.add_field(SchemaField::new("Named", FieldType::UInt32));
|
||||
schema.add_field(SchemaField::new("Unknown11", FieldType::String));
|
||||
let set = parse(&bytes, schema, "SpellDispelType.dbc")?;
|
||||
let mut names = HashMap::new();
|
||||
for r in set.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
// The gate first: a 0 here withholds the name however good the string is (Stealth, id 5).
|
||||
if u32_at(r, 10).unwrap_or(0) == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = str_at(&set, r, 1).filter(|n| !n.is_empty()) {
|
||||
names.insert(id, name);
|
||||
}
|
||||
}
|
||||
Ok(SpellDispelTypes { names })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The whole shipped table, and the gate that is the point of it: ids 1–4 name themselves,
|
||||
/// and Stealth/Invisibility/Frenzy — which DO carry strings in the file — do not. Skips
|
||||
/// without client data.
|
||||
#[test]
|
||||
fn real_dispel_types_name_only_what_the_gate_allows() {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let types = load_spell_dispel_types(&mut chain).expect("load SpellDispelType");
|
||||
assert_eq!(types.name(1), Some("Magic"));
|
||||
assert_eq!(types.name(2), Some("Curse"));
|
||||
assert_eq!(types.name(3), Some("Disease"));
|
||||
assert_eq!(types.name(4), Some("Poison"));
|
||||
// Named in the file, withheld by the gate — the whole reason we read `[+0x28]`.
|
||||
assert_eq!(types.name(5), None, "Stealth");
|
||||
assert_eq!(types.name(6), None, "Invisibility");
|
||||
assert_eq!(types.name(9), None, "Frenzy");
|
||||
assert_eq!(types.name(0), None, "no dispel class");
|
||||
assert_eq!(types.len(), 4, "exactly the four the gate allows");
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,8 @@ pub struct SpellDisplay {
|
||||
pub open_lock_type: Option<u32>,
|
||||
/// `Dispel` (column 4, `SpellRec+0x10`) — the `SpellDispelType.dbc` id. The reference client
|
||||
/// reads exactly this field for `GetPlayerBuffDispelType` / `UnitDebuff`'s third return
|
||||
/// (byte-verified, decision 0257). Read it through [`Self::debuff_type`].
|
||||
/// (byte-verified, decision 0257). Name it through [`crate::SpellCatalog::dispel_name`] — the
|
||||
/// id alone does not say whether the class is named (`SpellDispelType.dbc`'s `[+0x28]` gate).
|
||||
pub dispel: u32,
|
||||
/// `Category` (column 2, [`COL_CATEGORY`]) — the shared-cooldown category; `0` = none.
|
||||
pub category: u32,
|
||||
@@ -249,27 +250,12 @@ impl Default for SpellDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `SpellDispelType.dbc` ids the debuff border tints by (vmangos `DispelType`,
|
||||
/// `SpellDefines.h:679-692`). The reference's `DebuffTypeColor` table keys on exactly these four
|
||||
/// names and colors everything else — including `0` (none) and the untinted 5–10 (stealth,
|
||||
/// invisibility, enrage, …) — with its `"none"` entry.
|
||||
const DISPEL_NAMES: [(u32, &str); 4] = [(1, "Magic"), (2, "Curse"), (3, "Disease"), (4, "Poison")];
|
||||
// The dispel-class NAME (the aura tooltip's right column, and the `debuffType` the debuff border
|
||||
// tints by) is not derivable from [`SpellDisplay::dispel`] alone: `SpellDispelType.dbc` carries
|
||||
// both the string and the `[+0x28]` gate that decides whether a class is named at all. It lives on
|
||||
// the catalog that loads that file — [`crate::SpellCatalog::dispel_name`].
|
||||
|
||||
impl SpellDisplay {
|
||||
/// The dispel-class name a debuff border tints by (`"Magic"`/`"Curse"`/`"Disease"`/`"Poison"`),
|
||||
/// or `None` for an undispellable class — the `debuffType` return of `UnitAura`/`UnitDebuff`,
|
||||
/// whose `nil` the reference maps to `DebuffTypeColor["none"]`.
|
||||
///
|
||||
/// The real client resolves the name through `SpellDispelType.dbc`; we map the four ids the UI
|
||||
/// actually distinguishes, which are stable and English in that table's enUS column. Nothing
|
||||
/// reads the other ids.
|
||||
pub fn debuff_type(&self) -> Option<&'static str> {
|
||||
DISPEL_NAMES
|
||||
.iter()
|
||||
.find(|(id, _)| *id == self.dispel)
|
||||
.map(|(_, name)| *name)
|
||||
}
|
||||
|
||||
/// The client's ranged-stance gate, verbatim: `AttributesEx2 & 0x20` (auto-repeat: Auto Shot,
|
||||
/// wand Shoot) **or** `Attributes & 0x2` (uses the ranged slot: every Shoot variant, Throw).
|
||||
/// Byte-verified in wow-re (`SpellRec+0x20&0x20 || +0x18&0x2` — the test every ranged trigger
|
||||
@@ -352,6 +338,32 @@ impl SpellDisplay {
|
||||
self.effect_1 == SPELL_EFFECT_ATTACK
|
||||
}
|
||||
|
||||
/// The spell **tooltip's** passive gate (wow-re `tooltip-content-law.md` §3.4): the
|
||||
/// CastTime|Cooldown line is skipped whole for `Attributes & 0x40` **or**
|
||||
/// `Effect[0] ∈ {0x2f, 0x4e}` — `SPELL_EFFECT_TRADE_SKILL` (the profession book entries) and
|
||||
/// `SPELL_EFFECT_ATTACK` (6603 "Attack"). Wider than [`Self::passive`], which is the
|
||||
/// spellbook's own gray-and-refuse gate and must keep reading the attribute alone.
|
||||
///
|
||||
/// The law's note writes the field as "iconID `[+0xf4]`"; the offset is Effect[0]
|
||||
/// (`0xf4/4 == 61` — the same `[SpellRec+0xf4]` the byte-verified auto-attack resolvers
|
||||
/// compare against `0x4e`). Confirmed on the shipped data: 6603 "Attack" carries
|
||||
/// `Effect[0] = 78` with `Attributes = 0x10`, and the reference's spellbook hover shows it
|
||||
/// with NO cast-time line.
|
||||
pub fn tooltip_omits_cast_line(&self) -> bool {
|
||||
self.passive
|
||||
|| matches!(
|
||||
self.effect_1,
|
||||
SPELL_EFFECT_TRADE_SKILL | SPELL_EFFECT_ATTACK
|
||||
)
|
||||
}
|
||||
|
||||
// The equipped-item requirement's NAME used to be decided here, by a
|
||||
// `single_equipped_subclass` that answered only for a one-bit mask — a placeholder standing in
|
||||
// for the uncarved `0x6e2380`. `0x6e2380` is carved now, and it reads only ItemSubClass.dbc,
|
||||
// behind an ItemSubClassMask.dbc group lookup that names a wide mask in one word. The whole
|
||||
// rule (both spellings, both call sites) lives with the vocabulary it reads:
|
||||
// [`crate::ItemSubClassCatalog::requirement_name`].
|
||||
|
||||
/// The **ranged** icon-substitution gate — `Attributes & 0x2` AND `AttributesEx2 & 0x20`,
|
||||
/// both bits (decision 0231's deferred ranged case; wow-re `attack-icon-substitution.md` §5,
|
||||
/// byte-verified: the paired tests at `0x4b3f99`/`0x4b3f9f` and `0x4e5a2e`/`0x4e5a34` gate the
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
//! simulator (we render and speak the protocol; the server simulates).
|
||||
|
||||
mod cast_times;
|
||||
mod dispel_types;
|
||||
mod display;
|
||||
mod duration;
|
||||
mod forms;
|
||||
@@ -120,6 +121,7 @@ mod ranges;
|
||||
mod tokens;
|
||||
|
||||
pub use cast_times::{load_spell_cast_times, SpellCastTime, SpellCastTimeCatalog};
|
||||
pub use dispel_types::{load_spell_dispel_types, SpellDispelTypes};
|
||||
pub use display::SpellDisplay;
|
||||
pub use duration::{load_spell_durations, SpellDuration, SpellDurationCatalog};
|
||||
pub use forms::{load_shapeshift_forms, ShapeshiftForm};
|
||||
@@ -430,15 +432,18 @@ pub const SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY: u32 = 54;
|
||||
pub struct SpellCatalog {
|
||||
spells: HashMap<u32, SpellDisplay>,
|
||||
learned_spell: HashMap<u32, u32>,
|
||||
dispel_types: SpellDispelTypes,
|
||||
}
|
||||
|
||||
impl SpellCatalog {
|
||||
/// Build a catalog from an explicit display map — for tests and synthetic fixtures. The live
|
||||
/// path is [`load_spell_catalog`]. Carries no learn-spell map (synthetic ids teach nothing).
|
||||
/// path is [`load_spell_catalog`]. Carries no learn-spell map (synthetic ids teach nothing) and
|
||||
/// no dispel table, so [`Self::dispel_name`] answers `None` for everything.
|
||||
pub fn from_displays(spells: HashMap<u32, SpellDisplay>) -> Self {
|
||||
Self {
|
||||
spells,
|
||||
learned_spell: HashMap::new(),
|
||||
dispel_types: SpellDispelTypes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +451,13 @@ impl SpellCatalog {
|
||||
self.spells.get(&id)
|
||||
}
|
||||
|
||||
/// The name of a spell's dispel class — the aura tooltip's right column and the `debuffType`
|
||||
/// the debuff border tints by. `None` when the spell has no class or the class is one
|
||||
/// `SpellDispelType.dbc`'s `[+0x28]` gate withholds (see [`dispel_types`]).
|
||||
pub fn dispel_name(&self, display: &SpellDisplay) -> Option<&str> {
|
||||
self.dispel_types.name(display.dispel)
|
||||
}
|
||||
|
||||
/// Every loaded spell `(id, display)`, unordered — the corpus instruments' walk
|
||||
/// (`benilla-extract partcensus` attributes effect models back to the spells that play them).
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u32, &SpellDisplay)> + '_ {
|
||||
@@ -502,6 +514,7 @@ fn spell_schema() -> Schema {
|
||||
/// Load the joined spell display catalog off the patch chain.
|
||||
pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
let icons = crate::dbc::load_spell_icon_map(chain)?;
|
||||
let dispel_types = load_spell_dispel_types(chain)?;
|
||||
|
||||
let spell_bytes = chain.read_file(SPELL).context("reading Spell.dbc")?;
|
||||
let spells_set = parse(&spell_bytes, spell_schema(), "Spell.dbc")?;
|
||||
@@ -634,6 +647,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
Ok(SpellCatalog {
|
||||
spells,
|
||||
learned_spell,
|
||||
dispel_types,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ pub struct SkinSection {
|
||||
|
||||
/// One skin draw batch (section + material + texture-combo references).
|
||||
pub struct SkinBatch {
|
||||
/// Batch flags (texUnit `+0x00`) — kept raw, unclassified. Read by the diagnostic dumps while
|
||||
/// we carve which bits the reference's batch path actually reads; the renderer ignores them.
|
||||
pub flags: u16,
|
||||
/// Shader / render-order selector (texUnit `+0x02`), same status as [`Self::flags`].
|
||||
pub shader_id: u16,
|
||||
pub skin_section_index: u16,
|
||||
pub texture_combo_index: u16,
|
||||
pub material_index: u16,
|
||||
@@ -118,6 +123,8 @@ impl M2Model {
|
||||
for i in 0..n_bat {
|
||||
let u = bytes.bytes_at(o_bat + i * 24, 24).ok_or(Error::Truncated)?;
|
||||
batches.push(SkinBatch {
|
||||
flags: u.u16_at(0).ok_or(Error::Truncated)?,
|
||||
shader_id: u.u16_at(2).ok_or(Error::Truncated)?,
|
||||
skin_section_index: u.u16_at(4).ok_or(Error::Truncated)?,
|
||||
material_index: u.u16_at(0x0a).ok_or(Error::Truncated)?,
|
||||
texture_combo_index: u.u16_at(0x10).ok_or(Error::Truncated)?,
|
||||
|
||||
@@ -23,7 +23,7 @@ use clap::Parser;
|
||||
|
||||
use probes::{
|
||||
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, Loot, Probe, QueryNames, Quest,
|
||||
QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot, Vendor,
|
||||
QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot, Vendor, WorldState,
|
||||
};
|
||||
use world::{DeathArc, Tracked, World};
|
||||
|
||||
@@ -167,6 +167,15 @@ struct Cli {
|
||||
#[arg(long)]
|
||||
giverstatus: bool,
|
||||
|
||||
/// Live-verify the world-state table's two wires (`SMSG_INIT_WORLD_STATES` /
|
||||
/// `SMSG_UPDATE_WORLD_STATE` — what the NPC-text `$<n>w`/`$<n>e` tokens read): teleport to
|
||||
/// Elwynn, hop to Stormwind to force a zone-change init, and `.debug send worldstate` one
|
||||
/// synthetic pair to require back. Prints every state received. Needs a **SEC_DEVELOPER**
|
||||
/// account: `.debug send worldstate` is gmlevel 5, one above the 3 the slot-keyed probe
|
||||
/// accounts carry (decision 0450), so the update leg needs a temporary grant.
|
||||
#[arg(long)]
|
||||
worldstate: bool,
|
||||
|
||||
/// Live-verify the Charge wire (warrior Charge rank 1, spell 100): GM `.learn 100`, teleport to
|
||||
/// open ground near the Northshire kobold camp ([`CHARGE_TP`]), pick a creature at charge range
|
||||
/// (8–25 yd), `CMSG_SET_SELECTION` + `CMSG_CAST_SPELL` 100 at it — then require an
|
||||
@@ -348,6 +357,9 @@ fn main() -> Result<()> {
|
||||
if cli.spirit {
|
||||
probes.push(Box::new(Spirit::default()));
|
||||
}
|
||||
if cli.worldstate {
|
||||
probes.push(Box::new(WorldState::default()));
|
||||
}
|
||||
|
||||
// Pre-stream staging: the DeathArc first (its `.revive` + teleport lead --spirit's `.repairitems`),
|
||||
// then every probe's stage in registry order, then the `--say` lines (after all staging, as today).
|
||||
|
||||
@@ -75,6 +75,7 @@ mod spirit;
|
||||
mod swap_pack_slots;
|
||||
mod use_pack_slot;
|
||||
mod vendor;
|
||||
mod worldstate;
|
||||
|
||||
pub(crate) use attack::Attack;
|
||||
pub(crate) use aura::Aura;
|
||||
@@ -92,3 +93,4 @@ pub(crate) use spirit::Spirit;
|
||||
pub(crate) use swap_pack_slots::SwapPackSlots;
|
||||
pub(crate) use use_pack_slot::UsePackSlot;
|
||||
pub(crate) use vendor::Vendor;
|
||||
pub(crate) use worldstate::WorldState;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `--worldstate`: the world-state table's two wires, end to end against the live server.
|
||||
//!
|
||||
//! Both are worth a live run for different reasons. `SMSG_UPDATE_WORLD_STATE` is *deterministic* —
|
||||
//! `.debug send worldstate <id> <value>` makes the server send one pair we chose, so the probe can
|
||||
//! require that exact pair back and a wrong reader shows up as a mismatch rather than a plausible
|
||||
//! number. `SMSG_INIT_WORLD_STATES` is the one with real parse risk (two leading dwords, a `u16`
|
||||
//! count, a run of pairs, a `(0,0)` terminator that *is* counted) and it can't be requested — so
|
||||
//! the probe forces one by teleporting across a zone boundary, which is what makes vmangos re-send
|
||||
//! it (`Player::UpdateZone` → `SendInitWorldStates`, `Player.cpp:6660`).
|
||||
//!
|
||||
//! **The update leg needs a temporary grant.** `.debug send worldstate` sits in
|
||||
//! `debugSendCommandTable` at **SEC_DEVELOPER = 5** (vmangos `Chat.cpp:323`) — one above the
|
||||
//! gmlevel 3 the slot-keyed probe accounts carry (decision 0450). Without it the server answers
|
||||
//! *"This command is not available to you."* in chat and sends no packet, and the probe fails on
|
||||
//! the update leg while the init leg still passes.
|
||||
//!
|
||||
//! The effective level lives in **`realmd.account_access`**, not `realmd.account.gmlevel` —
|
||||
//! mangosd reads the former (rows per `RealmID`, plus a `-1` catch-all) and ignores the latter for
|
||||
//! command gating. Grant, run, revert, with the account offline:
|
||||
//!
|
||||
//! ```text
|
||||
//! UPDATE realmd.account_access SET gmlevel=5 WHERE id=<account id>; -- both rows
|
||||
//! cargo run -p benilla-protocol --bin benilla-world -- probeN pprobeN --worldstate --seconds 16
|
||||
//! UPDATE realmd.account_access SET gmlevel=3 WHERE id=<account id>; -- put it straight back
|
||||
//! ```
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use benilla_protocol::SessionEvent;
|
||||
|
||||
use crate::probes::{Ctx, Probe};
|
||||
|
||||
/// Northshire (zone 9, Elwynn Forest) — the same spot the quest probes stage on.
|
||||
const ELWYNN_TP: &str = ".go xyz -8902.59 -162.606 82.0223";
|
||||
/// Stormwind's Trade District (zone 1519) — a different zone on the same map, so the hop from
|
||||
/// [`ELWYNN_TP`] is a genuine `UpdateZone` and forces a fresh `SMSG_INIT_WORLD_STATES`.
|
||||
const STORMWIND_TP: &str = ".go xyz -8913.0 554.0 93.8";
|
||||
/// How long to sit in Elwynn before hopping, so the first zone's init has landed and the second is
|
||||
/// unambiguously a *second* packet.
|
||||
const HOP_AFTER: Duration = Duration::from_secs(4);
|
||||
|
||||
/// A synthetic `(id, value)` no real content uses, so a match proves our reader and not a
|
||||
/// coincidence with a live Silithus/AQ counter.
|
||||
const PROBE_ID: u32 = 0xBEEF;
|
||||
const PROBE_VALUE: u32 = 1_234_567;
|
||||
|
||||
/// One received `SMSG_INIT_WORLD_STATES`: the packet's `(map, zone)` scope and its pair run
|
||||
/// verbatim — terminator included, which [`WorldState::verify`] asserts on.
|
||||
struct Init {
|
||||
map: u32,
|
||||
zone: u32,
|
||||
states: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WorldState {
|
||||
staged_at: Option<Instant>,
|
||||
hopped: bool,
|
||||
inits: Vec<Init>,
|
||||
/// Every `SMSG_UPDATE_WORLD_STATE` pair.
|
||||
updates: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl Probe for WorldState {
|
||||
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
|
||||
cx.session.send_chat(ELWYNN_TP)?;
|
||||
self.staged_at = Some(Instant::now());
|
||||
println!("worldstate: teleported to Elwynn (zone 9) {ELWYNN_TP}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
|
||||
if self.hopped || self.staged_at.is_none_or(|t| t.elapsed() < HOP_AFTER) {
|
||||
return Ok(());
|
||||
}
|
||||
self.hopped = true;
|
||||
// The zone hop (forces an init) and the one deterministic pair, back to back.
|
||||
cx.session.send_chat(STORMWIND_TP)?;
|
||||
cx.session
|
||||
.send_chat(&format!(".debug send worldstate {PROBE_ID} {PROBE_VALUE}"))?;
|
||||
println!(
|
||||
"worldstate: hopped to Stormwind (zone 1519) and sent .debug send worldstate {PROBE_ID} {PROBE_VALUE}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_event(&mut self, ev: &SessionEvent, _cx: &mut Ctx) -> Result<()> {
|
||||
if let SessionEvent::WorldStates { scope, states } = ev {
|
||||
match *scope {
|
||||
Some((map, zone)) => self.inits.push(Init {
|
||||
map,
|
||||
zone,
|
||||
states: states.clone(),
|
||||
}),
|
||||
None => self.updates.extend(states.iter().copied()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify(&mut self, _cx: &mut Ctx) -> Result<()> {
|
||||
println!("\n--- world states ---");
|
||||
for init in &self.inits {
|
||||
println!(
|
||||
"SMSG_INIT_WORLD_STATES map {} zone {} {} pair(s)",
|
||||
init.map,
|
||||
init.zone,
|
||||
init.states.len()
|
||||
);
|
||||
for (id, value) in &init.states {
|
||||
println!(" {id:>6} = {:<12} (raw {value:#010x})", *value as i32);
|
||||
}
|
||||
}
|
||||
for (id, value) in &self.updates {
|
||||
println!(
|
||||
"SMSG_UPDATE_WORLD_STATE {id} = {} (raw {value:#010x})",
|
||||
*value as i32
|
||||
);
|
||||
}
|
||||
|
||||
if self.inits.is_empty() {
|
||||
bail!("no SMSG_INIT_WORLD_STATES arrived — the zone hop should have forced one");
|
||||
}
|
||||
// The terminator is counted by the server and read by us: a well-formed init ends on (0,0).
|
||||
// Assert it rather than assume, since dropping it would be the natural "helpful" bug.
|
||||
for init in &self.inits {
|
||||
if init.states.last() != Some(&(0, 0)) {
|
||||
bail!(
|
||||
"init for map {} zone {} did not end on the (0,0) terminator: {:?}",
|
||||
init.map,
|
||||
init.zone,
|
||||
init.states.last()
|
||||
);
|
||||
}
|
||||
}
|
||||
if !self.updates.contains(&(PROBE_ID, PROBE_VALUE)) {
|
||||
bail!(
|
||||
"no SMSG_UPDATE_WORLD_STATE carrying our ({PROBE_ID}, {PROBE_VALUE}) — got {:?}. \
|
||||
`.debug send worldstate` is gmlevel 5 (SEC_DEVELOPER); a lower account is silently refused",
|
||||
self.updates
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"\nworldstate: OK — {} init packet(s), and the ({PROBE_ID}, {PROBE_VALUE}) round trip landed",
|
||||
self.inits.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -194,10 +194,11 @@ pub enum SessionEvent {
|
||||
/// is set (`0.0` otherwise): the wire tail exists so observers can integrate a swimmer's
|
||||
/// vertical between packets (the client's swim velocity basis adds pitch, `0x7c5880`).
|
||||
pitch: f32,
|
||||
/// The `MovementInfo` time word — vmangos's own ms clock stamped at receipt (`stime`), one
|
||||
/// coherent server clock across all movers. The reference schedules a remote's apply from
|
||||
/// this (fire-time = mapped time + clamped skew, wow-re `remote-apply-timing.md`); the app
|
||||
/// mirrors that in `RelayClock` (decision 0601).
|
||||
/// The `MovementInfo` time word — vmangos's own ms clock stamped at receipt (`stime`
|
||||
/// = `WorldTimer::getMSTime()` in `MovementInfo::Read`, relayed verbatim by `Write`), one
|
||||
/// coherent server clock across all movers. The reference schedules a remote's apply off the
|
||||
/// *deltas* between consecutive stamps — replay paced by the sender's own cadence, wow-re
|
||||
/// `remote-apply-timing.md`; the app mirrors that per unit (decisions 0601/0615).
|
||||
time: u32,
|
||||
/// True for `MSG_MOVE_HEARTBEAT` — the periodic mid-move pulse. The reference's reconcile
|
||||
/// lerp is armed only for NON-heartbeat events (`0x619090` excludes tag `0x26`); a
|
||||
@@ -310,6 +311,13 @@ pub enum SessionEvent {
|
||||
gender: u32,
|
||||
class: u32,
|
||||
},
|
||||
/// A pet's display name (`SMSG_PET_NAME_QUERY_RESPONSE`), keyed by **pet number** — the third
|
||||
/// naming path, alongside [`Self::PlayerName`] and [`Self::CreatureName`], and the only one a
|
||||
/// pet can use: its guid carries a pet number where a creature carries its template entry
|
||||
/// ([`crate::guid::pet_number`]), so a creature query for it can only miss. The name is the
|
||||
/// pet's own — a hunter pet's custom name, or the creature name for anything summoned. There is
|
||||
/// no miss shape: the server stays silent when the pet is gone or the number disagrees.
|
||||
PetName { pet_number: u32, name: String },
|
||||
/// A creature template's display name (`SMSG_CREATURE_QUERY_RESPONSE`). Keyed by template
|
||||
/// `entry` (shared by every spawn of that template), not by spawn guid. `name` is `None` when
|
||||
/// the server flagged the entry unknown; `subname` is the tooltip line ("Stable Master", …).
|
||||
@@ -606,10 +614,15 @@ pub enum SessionEvent {
|
||||
QuestGiverRefused { quest_id: u32, reason: u32 },
|
||||
/// The gossip window closes (`SMSG_GOSSIP_COMPLETE`) — no menu is open server-side any more.
|
||||
GossipComplete,
|
||||
/// The greeting text for a gossip menu (`SMSG_NPC_TEXT_UPDATE`, answering our
|
||||
/// `CMSG_NPC_TEXT_QUERY`): the highest-probability text block, already resolved male-with-
|
||||
/// female-fallback. `$N` (the player name) is a client-substituted token still in the string.
|
||||
NpcGreeting { text_id: u32, greeting: String },
|
||||
/// The greeting record for a gossip menu (`SMSG_NPC_TEXT_UPDATE`, answering our
|
||||
/// `CMSG_NPC_TEXT_QUERY`) — all 8 blocks, **undrawn**: which line greets you depends on the
|
||||
/// NPC's gender and a die roll, so the app draws it when the frame opens
|
||||
/// (`messages::gossip::select_greeting`). `$N` and friends are client-substituted tokens still
|
||||
/// in the strings.
|
||||
NpcGreeting {
|
||||
text_id: u32,
|
||||
blocks: Vec<crate::messages::NpcTextBlock>,
|
||||
},
|
||||
/// A vendor's stock (`SMSG_LIST_INVENTORY`, answering our `CMSG_LIST_INVENTORY`): entry, icon,
|
||||
/// price, and remaining stock per row (`current_count == 0xFFFF_FFFF` = unlimited).
|
||||
VendorInventory { vendor: u64, items: Vec<VendorItem> },
|
||||
@@ -887,12 +900,29 @@ pub enum SessionEvent {
|
||||
/// partner's, per [`TradeStatusExtended::their_window`]); pushed whenever that side changes.
|
||||
/// Boxed (~460 bytes) so it doesn't bloat every `SessionEvent`.
|
||||
TradeStatusExtended { state: Box<TradeStatusExtended> },
|
||||
/// The world-state table changed. `SMSG_INIT_WORLD_STATES` carries the whole table for a zone
|
||||
/// (`states` is the wire run verbatim, terminator pair included); `SMSG_UPDATE_WORLD_STATE`
|
||||
/// arrives as a one-entry `states` with `scope: None`, so both wires land on one event and the
|
||||
/// app has a single write path — the reference likewise funnels both into the one setter.
|
||||
/// `scope` is the init packet's `(map, zone)` dwords; nothing consumes them yet.
|
||||
WorldStates {
|
||||
scope: Option<(u32, u32)>,
|
||||
states: Vec<(u32, u32)>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The result of polling the reader for the next packet's events.
|
||||
pub enum Poll {
|
||||
/// A packet decoded into these events (possibly empty for an unmodelled opcode).
|
||||
Events(Vec<SessionEvent>),
|
||||
/// A packet decoded into these events. `events` is **empty** for an opcode we parse but do not
|
||||
/// model — a third outcome, distinct from both "decoded into something" and [`Poll::Skipped`],
|
||||
/// and until decision 0624 it was invisible from outside: no event, no skip, and the inbound
|
||||
/// census counted it like any other packet. A relayed move landing in an unmodelled opcode was
|
||||
/// therefore indistinguishable from one that never arrived. `opcode` rides along so the net
|
||||
/// thread can name what actually came off the wire.
|
||||
Events {
|
||||
opcode: u16,
|
||||
events: Vec<SessionEvent>,
|
||||
},
|
||||
/// An unparseable packet was skipped — kept the stream aligned, not an error. Carries the
|
||||
/// opcode (for the app's dropped-packet tally) and a short description (opcode + error + a hex
|
||||
/// preview) so the net thread can log *which* packet was dropped.
|
||||
|
||||
@@ -16,6 +16,10 @@ fn v3(v: Vector3d) -> [f32; 3] {
|
||||
pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
match packet {
|
||||
ServerPacket::UpdateObject { objects } => decode_objects(objects),
|
||||
// A batch is only a transport: unwrap it in order and decode each move exactly as if it had
|
||||
// arrived on its own (decision 0624). Order matters — the replay queue in
|
||||
// `net::motion` reconstructs a mover's arc from packet order.
|
||||
ServerPacket::CompressedMoves { packets } => packets.into_iter().flat_map(decode).collect(),
|
||||
ServerPacket::CharEnum { characters } => vec![SessionEvent::CharacterList {
|
||||
characters,
|
||||
realm: None,
|
||||
@@ -251,8 +255,8 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
vec![SessionEvent::QuestGiverRefused { quest_id, reason }]
|
||||
}
|
||||
ServerPacket::GossipComplete => vec![SessionEvent::GossipComplete],
|
||||
ServerPacket::NpcText { text_id, greeting } => {
|
||||
vec![SessionEvent::NpcGreeting { text_id, greeting }]
|
||||
ServerPacket::NpcText { text_id, blocks } => {
|
||||
vec![SessionEvent::NpcGreeting { text_id, blocks }]
|
||||
}
|
||||
ServerPacket::VendorList { vendor, items } => {
|
||||
vec![SessionEvent::VendorInventory { vendor, items }]
|
||||
@@ -536,6 +540,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
gender,
|
||||
class,
|
||||
}],
|
||||
ServerPacket::PetNameQueryResponse { pet_number, name } => {
|
||||
vec![SessionEvent::PetName { pet_number, name }]
|
||||
}
|
||||
ServerPacket::CreatureQueryResponse { entry, info } => {
|
||||
let (name, subname, creature_type, rank, type_flags, civilian, racial_leader) =
|
||||
match info {
|
||||
@@ -687,6 +694,14 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
ServerPacket::TradeStatusExtended { state } => {
|
||||
vec![SessionEvent::TradeStatusExtended { state }]
|
||||
}
|
||||
ServerPacket::InitWorldStates(w) => vec![SessionEvent::WorldStates {
|
||||
scope: Some((w.map, w.zone)),
|
||||
states: w.states,
|
||||
}],
|
||||
ServerPacket::UpdateWorldState { id, value } => vec![SessionEvent::WorldStates {
|
||||
scope: None,
|
||||
states: vec![(id, value)],
|
||||
}],
|
||||
// An opcode with NO parse arm at all — surface it so the app can tally the coverage gap
|
||||
// (the debug panel's dropped-opcode instrument); the silent fall-through hid whole wire
|
||||
// families. Parsed-but-unmodelled packets (the `_` below) stay deliberate no-ops.
|
||||
|
||||
@@ -34,11 +34,31 @@ pub fn is_player(guid: u64) -> bool {
|
||||
guid != 0 && high(guid) == HIGH_PLAYER
|
||||
}
|
||||
|
||||
/// A creature or pet guid (`IsCreatureOrPet`) — the family whose template entry rides in the guid.
|
||||
/// A creature or pet guid (`IsCreatureOrPet`) — both are `TYPEID_UNIT` to every consumer. They are
|
||||
/// **not** interchangeable for naming: only the creature half carries a template entry (see
|
||||
/// [`entry`] and [`pet_number`]).
|
||||
pub fn is_creature_or_pet(guid: u64) -> bool {
|
||||
matches!(high(guid), HIGH_UNIT | HIGH_PET)
|
||||
}
|
||||
|
||||
/// A pet guid (`IsPet`) — a summoned or charmed unit, whose guid's middle field is a **pet number**
|
||||
/// and not a creature template entry. See [`pet_number`].
|
||||
pub fn is_pet(guid: u64) -> bool {
|
||||
high(guid) == HIGH_PET
|
||||
}
|
||||
|
||||
/// A pet guid's **pet number** — the per-pet id `CMSG_PET_NAME_QUERY` is keyed by, and the value the
|
||||
/// server matches against `CharmInfo::GetPetNumber()` before it will answer at all.
|
||||
///
|
||||
/// It occupies the same guid bits 24–47 that a creature's template entry does, because vmangos
|
||||
/// composes a pet with `Object::_Create(guidlow, petNumber, HIGHGUID_PET)` (`Objects/Pet.cpp:2250`,
|
||||
/// from `Pet::Create(guidlow, pos, cinfo, petNumber)`): the shared `_Create`'s `entry` parameter is
|
||||
/// fed the pet number, **not** `cinfo->entry`. That is why [`entry`] answers `None` for this family —
|
||||
/// the slot is occupied, just not by a template id, and a `CMSG_CREATURE_QUERY` for it can only miss.
|
||||
pub fn pet_number(guid: u64) -> Option<u32> {
|
||||
is_pet(guid).then_some(((guid >> 24) & 0xFF_FFFF) as u32)
|
||||
}
|
||||
|
||||
/// A (non-zero) item or container guid (`IsItem` — containers included). Items carry **no** entry
|
||||
/// in the guid; the entry lives in the descriptor (`OBJECT_FIELD_ENTRY`).
|
||||
pub fn is_item(guid: u64) -> bool {
|
||||
@@ -55,9 +75,9 @@ pub fn is_transport(guid: u64) -> bool {
|
||||
/// The embedded template entry, for the guid families that carry one. Two different layouts, both
|
||||
/// VERIFIED against vmangos:
|
||||
///
|
||||
/// - Creatures/pets/GameObjects/**elevators** (`HIGH_UNIT`/`HIGH_PET`/`HIGH_GAMEOBJECT`/
|
||||
/// - Creatures/GameObjects/**elevators** (`HIGH_UNIT`/`HIGH_GAMEOBJECT`/
|
||||
/// `HIGH_TRANSPORT`) use the standard `[high:16][entry:24][counter:24]` layout — vmangos's
|
||||
/// class-internal `HasEntry` switch returns `true` for all four (`ObjectGuid.h:223-240`), and an
|
||||
/// class-internal `HasEntry` switch returns `true` for these (`ObjectGuid.h:223-240`), and an
|
||||
/// elevator composes exactly like an ordinary GameObject: `GameObject::Create` calls
|
||||
/// `Object::_Create(guidlow, goinfo->id, HIGHGUID_TRANSPORT)` for a `GAMEOBJECT_TYPE_TRANSPORT`
|
||||
/// template (`Objects/GameObject.cpp:207`, reached via `ElevatorTransport::Create` →
|
||||
@@ -77,13 +97,13 @@ pub fn is_transport(guid: u64) -> bool {
|
||||
/// id — none exist today, but the 32-bit mask is what vmangos actually does, so that's what we do.
|
||||
///
|
||||
/// `None` for players/items/corpses/dynamic objects (no entry part, and not composed like a transport
|
||||
/// either).
|
||||
/// either) — and `None` for **pets**, whose entry-shaped slot holds a pet number instead
|
||||
/// ([`pet_number`]): `HasEntry` is `true` for the family, but what vmangos stores there is not a
|
||||
/// template id, so answering with it would hand every caller a creature entry that cannot resolve.
|
||||
pub fn entry(guid: u64) -> Option<u32> {
|
||||
match high(guid) {
|
||||
HIGH_MO_TRANSPORT => Some((guid & 0xFFFF_FFFF) as u32),
|
||||
HIGH_UNIT | HIGH_PET | HIGH_GAMEOBJECT | HIGH_TRANSPORT => {
|
||||
Some(((guid >> 24) & 0xFF_FFFF) as u32)
|
||||
}
|
||||
HIGH_UNIT | HIGH_GAMEOBJECT | HIGH_TRANSPORT => Some(((guid >> 24) & 0xFF_FFFF) as u32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -137,10 +157,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn entry_masks_to_24_bits() {
|
||||
let g = compose(HIGH_PET, 0xFF_FFFF, 0xFF_FFFF);
|
||||
let g = compose(HIGH_UNIT, 0xFF_FFFF, 0xFF_FFFF);
|
||||
assert_eq!(entry(g), Some(0xFF_FFFF));
|
||||
}
|
||||
|
||||
/// A pet's entry-shaped slot is its PET NUMBER — `Pet::Create` feeds `_Create`'s `entry`
|
||||
/// parameter `petNumber`, not `cinfo->entry` (`Objects/Pet.cpp:2250`). Asking
|
||||
/// `CMSG_CREATURE_QUERY` for it is what left NPC-summoned pets nameless, so `entry` must refuse.
|
||||
#[test]
|
||||
fn a_pet_carries_a_pet_number_not_a_template_entry() {
|
||||
let g = compose(HIGH_PET, 137, 4242);
|
||||
assert!(is_pet(g));
|
||||
assert!(is_creature_or_pet(g));
|
||||
assert_eq!(pet_number(g), Some(137));
|
||||
assert_eq!(entry(g), None, "a pet's slot is not a template entry");
|
||||
// ...and only a pet answers `pet_number`.
|
||||
assert_eq!(pet_number(compose(HIGH_UNIT, 137, 4242)), None);
|
||||
}
|
||||
|
||||
/// An elevator (`HIGHGUID_TRANSPORT`, 0xF120) composes exactly like an ordinary GameObject —
|
||||
/// `Object::_Create(guidlow, goinfo->id, HIGHGUID_TRANSPORT)` (`GameObject.cpp:207`): the DB spawn's
|
||||
/// own low guid in `counter`, the `gameobject_template` entry in the standard entry slot.
|
||||
|
||||
@@ -265,6 +265,17 @@ pub fn creature_query(entry: u32, guid: u64) -> Vec<u8> {
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of a `CMSG_PET_NAME_QUERY`: the pet's **number** then its full guid (VERIFIED vmangos
|
||||
/// `QueryPetName::ReadFromWorldPacket`, `Server/Packets/Pet.cpp:3-7`). Both values come out of the
|
||||
/// pet's own guid ([`crate::guid::pet_number`]); the server answers only when the number agrees with
|
||||
/// the live pet's `CharmInfo` (`PetHandler.cpp:190-192`), and stays silent otherwise.
|
||||
pub fn pet_name_query(pet_number: u32, guid: u64) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(12);
|
||||
body.extend_from_slice(&pet_number.to_le_bytes());
|
||||
body.extend_from_slice(&guid.to_le_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of a movement message (`MSG_MOVE_*`) — a `MovementInfo`.
|
||||
pub fn movement(info: &MovementInfo) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(28);
|
||||
|
||||
@@ -98,33 +98,105 @@ pub(super) fn read_gossip_message(
|
||||
Ok((npc_guid, text_id, options, quests))
|
||||
}
|
||||
|
||||
/// One of the 8 greeting variants in an `SMSG_NPC_TEXT_UPDATE` record. Both gender columns are kept:
|
||||
/// which one is read is decided **per NPC**, not per block — see [`select_greeting`].
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct NpcTextBlock {
|
||||
/// The block's draw weight. Not a rank — the reference sums these and draws (see
|
||||
/// [`select_greeting`]); vmangos's own fallback record sets all eight to `0.0`.
|
||||
pub probability: f32,
|
||||
/// `text0` — shown when the NPC is male, or genderless.
|
||||
pub male: String,
|
||||
/// `text1` — shown when the NPC is female.
|
||||
pub female: String,
|
||||
}
|
||||
|
||||
impl NpcTextBlock {
|
||||
/// This block's text in the gender column [`select_greeting`] chose for the NPC.
|
||||
fn column(&self, female: bool) -> &str {
|
||||
if female {
|
||||
&self.female
|
||||
} else {
|
||||
&self.male
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of greeting blocks in an `SMSG_NPC_TEXT_UPDATE` record — always exactly 8.
|
||||
pub const NPC_TEXT_BLOCKS: usize = 8;
|
||||
|
||||
/// Read `SMSG_NPC_TEXT_UPDATE` (vmangos `GossipDef.cpp:298-369`): `u32 textID` then always exactly 8
|
||||
/// blocks of `{f32 probability, cstr text0 (male), cstr text1 (female), u32 languageId, 3x(u32
|
||||
/// emoteDelay, u32 emoteId)}`. The client only needs one line for the greeting: the highest-
|
||||
/// probability block's `text0`, falling back to `text1` when `text0` is empty (vmangos's own default
|
||||
/// row, when a text is missing entirely, is literally `"Greetings $N"`). Returns
|
||||
/// `(text_id, greeting)`; the emote/language tails and the losing blocks are parsed for alignment
|
||||
/// and dropped.
|
||||
pub(super) fn read_npc_text_update(r: &mut &[u8]) -> io::Result<(u32, String)> {
|
||||
/// emoteDelay, u32 emoteId)}`. Returns `(text_id, blocks)` — **all** of them, undecided.
|
||||
///
|
||||
/// This layer used to pick the greeting here (highest probability, with a per-block fall back to the
|
||||
/// other gender column). Both halves of that were wrong, and neither could be fixed here: the real
|
||||
/// choice needs the NPC and a die roll, which the wire layer has neither of. It belongs at the
|
||||
/// moment the frame opens, where the reference does it — [`select_greeting`], called from the app.
|
||||
///
|
||||
/// The language and emote tails are still parsed for alignment and dropped. Language is the input to
|
||||
/// the reference's garble pass (`0x49b560`), which is inert at `lang == 0`; we do not garble at all,
|
||||
/// so a non-zero language would read plainly here. No such record is known in 1.12 data.
|
||||
pub(super) fn read_npc_text_update(r: &mut &[u8]) -> io::Result<(u32, Vec<NpcTextBlock>)> {
|
||||
let text_id = read_u32_le(r)?;
|
||||
let mut best: Option<(f32, String)> = None;
|
||||
for _ in 0..8 {
|
||||
let mut blocks = Vec::with_capacity(NPC_TEXT_BLOCKS);
|
||||
for _ in 0..NPC_TEXT_BLOCKS {
|
||||
let probability = read_f32_le(r)?;
|
||||
let text0 = read_cstring(r)?;
|
||||
let text1 = read_cstring(r)?;
|
||||
let male = read_cstring(r)?;
|
||||
let female = read_cstring(r)?;
|
||||
let _language_id = read_u32_le(r)?;
|
||||
for _ in 0..3 {
|
||||
let _emote_delay = read_u32_le(r)?;
|
||||
let _emote_id = read_u32_le(r)?;
|
||||
}
|
||||
let text = if text0.is_empty() { text1 } else { text0 };
|
||||
let is_new_best = match &best {
|
||||
Some((best_probability, _)) => probability > *best_probability,
|
||||
None => true,
|
||||
};
|
||||
if is_new_best {
|
||||
best = Some((probability, text));
|
||||
blocks.push(NpcTextBlock {
|
||||
probability,
|
||||
male,
|
||||
female,
|
||||
});
|
||||
}
|
||||
Ok((text_id, blocks))
|
||||
}
|
||||
|
||||
/// Pick the greeting line the way the reference does (`0x4e2010`, wow-re
|
||||
/// `system/ui/scratch/gossip-npctext-law.md`) — a **weighted random draw**, not the highest weight:
|
||||
///
|
||||
/// ```text
|
||||
/// sum = Σ probability over blocks whose CHOSEN-COLUMN text is non-empty
|
||||
/// thr = (2.0 - roll) * sum ∈ (0, sum] for roll ∈ [1.0, 2.0)
|
||||
/// walk the same blocks in order, acc += probability, take the first where thr <= acc
|
||||
/// ```
|
||||
///
|
||||
/// Three details are load-bearing, and each is the opposite of what we assumed:
|
||||
///
|
||||
/// - **The column is chosen once, from the NPC's own gender** (`UNIT_FIELD_BYTES_0` byte 2, tested
|
||||
/// `== 1` for female — so genderless `2` reads as male), before any block is examined. It is never
|
||||
/// chosen per block, never from the emptiness of a string, and never from the *player*, whom the
|
||||
/// reference resolves only as an existence check and never dereferences for data.
|
||||
/// - **There is no fallback to the other column.** A block whose chosen-column text is empty is
|
||||
/// excluded from the draw outright; if that empties the record, the reference takes its error path
|
||||
/// and shows no greeting at all, rather than reading the other gender's line.
|
||||
/// - **The predicate is `<=`, not `<`** (the emitted jcc is `jne` on `test ah,0x41`, so the equal
|
||||
/// case selects). That is what makes an all-`0.0` record — vmangos's fallback, and much of the
|
||||
/// 1.12 table — deterministically select block 0 instead of nothing.
|
||||
///
|
||||
/// `roll` is the reference's uniform float in `[1.0, 2.0)` (it builds one by stuffing PRNG bits into
|
||||
/// a mantissa: `(rand & 0x7fffff) | 0x3f800000`); passing it in keeps this pure and lets tests pin
|
||||
/// each branch. Accumulation is `f64` deliberately: the reference's accumulators live on the x87
|
||||
/// stack at 53-bit precision and round to `f32` exactly once, at the end.
|
||||
///
|
||||
/// `None` = the record names no greeting for this NPC (the reference's `"Missing gossip text!"`).
|
||||
pub fn select_greeting(blocks: &[NpcTextBlock], npc_gender: u8, roll: f32) -> Option<&str> {
|
||||
let female = npc_gender == 1;
|
||||
let drawn = || blocks.iter().filter(|b| !b.column(female).is_empty());
|
||||
let sum: f64 = drawn().map(|b| b.probability as f64).sum();
|
||||
let threshold = (2.0 - roll as f64) * sum;
|
||||
let mut acc = 0.0f64;
|
||||
for block in drawn() {
|
||||
acc += block.probability as f64;
|
||||
if threshold <= acc {
|
||||
return Some(block.column(female));
|
||||
}
|
||||
}
|
||||
Ok((text_id, best.map(|(_, text)| text).unwrap_or_default()))
|
||||
None
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ mod trade;
|
||||
mod trainer;
|
||||
mod update_object;
|
||||
mod vendor;
|
||||
mod world_state;
|
||||
pub use bank::{
|
||||
autobank_item, autostore_bank_item, bank_slot_result, banker_activate, buy_bank_slot,
|
||||
};
|
||||
@@ -59,14 +60,18 @@ pub use client::{
|
||||
channel_set_owner, channel_unban, channel_unmoderator, channel_unmute, char_create,
|
||||
creature_query, force_speed_ack, full_guid, join_channel, leave_channel, messagechat,
|
||||
messagechat_channel, messagechat_kind, messagechat_whisper, move_flag_ack, move_spline_done,
|
||||
movement, ping, played_time, random_roll, stand_state_change, teleport_ack, text_emote,
|
||||
movement, pet_name_query, ping, played_time, random_roll, stand_state_change, teleport_ack,
|
||||
text_emote,
|
||||
};
|
||||
pub use death::{
|
||||
reclaim_corpse, resurrect_response, spirit_healer_activate, CorpseLocation,
|
||||
ResurrectRequestBody,
|
||||
};
|
||||
pub use gameobject::{gameobj_use, gameobject_query, GameObjectQueryInfo};
|
||||
pub use gossip::{gossip_hello, gossip_select_option, npc_text_query, GossipOption, QuestOption};
|
||||
pub use gossip::{
|
||||
gossip_hello, gossip_select_option, npc_text_query, select_greeting, GossipOption,
|
||||
NpcTextBlock, QuestOption, NPC_TEXT_BLOCKS,
|
||||
};
|
||||
pub use group::{
|
||||
group_accept, group_assistant_leader, group_change_sub_group, group_decline, group_disband,
|
||||
group_invite, group_raid_convert, group_set_leader, group_swap_sub_group, group_uninvite,
|
||||
@@ -135,6 +140,7 @@ pub use vendor::{
|
||||
buy_item, buy_result, buyback_item, list_inventory, repair_item, sell_item, sell_result,
|
||||
VendorItem,
|
||||
};
|
||||
pub use world_state::InitWorldStates;
|
||||
|
||||
/// `SMSG_AUTH_RESPONSE` AuthOk result.
|
||||
pub const AUTH_OK: u8 = 0x0C;
|
||||
|
||||
@@ -11,6 +11,12 @@ pub const CMSG_CHAR_DELETE: u16 = 0x0038;
|
||||
pub const SMSG_CHAR_DELETE: u16 = 0x003C;
|
||||
pub const SMSG_NAME_QUERY_RESPONSE: u16 = 0x0051; // 81
|
||||
pub const SMSG_CREATURE_QUERY_RESPONSE: u16 = 0x0061; // 97
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h:86`: 83. Answers `CMSG_PET_NAME_QUERY` — the only query that
|
||||
/// can name a pet, whose guid carries no template entry ([`crate::guid::pet_number`]). Body: `u32
|
||||
/// petNumber`, cstring name, `u32 nameTimestamp` (`Server/Packets/Pet.cpp:79-84`). The server simply
|
||||
/// does not reply when the guid is not a live pet bearing that number (`PetHandler.cpp:190-192`), so
|
||||
/// there is no "unknown" answer shape to model.
|
||||
pub const SMSG_PET_NAME_QUERY_RESPONSE: u16 = 0x0053; // 83
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 95 (decision 0236). Answers `CMSG_GAMEOBJECT_QUERY`; body
|
||||
/// (`GameObjectQueryInfo`) in [`super::gameobject`].
|
||||
pub const SMSG_GAMEOBJECT_QUERY_RESPONSE: u16 = 0x005F; // 95
|
||||
@@ -49,6 +55,12 @@ pub const SMSG_AUTH_RESPONSE: u16 = 0x01EE;
|
||||
/// a server at the handshake rather than entering a 30-second kick/reconnect cycle.
|
||||
pub const SMSG_WARDEN_DATA: u16 = 0x02E6;
|
||||
pub const SMSG_COMPRESSED_UPDATE_OBJECT: u16 = 0x01F6;
|
||||
/// A zlib envelope holding a **batch of whole movement packets** (763, VERIFIED vmangos
|
||||
/// `Opcodes_1_12_1.h`). Not an edge case: vmangos moves a session onto this carrier the moment it
|
||||
/// has pushed 300 movement packets to it inside ten seconds (`Compression.Movement.Count`), which
|
||||
/// one nearby player moving at frame cadence reaches in about five. See
|
||||
/// [`super::ServerPacket::CompressedMoves`].
|
||||
pub const SMSG_COMPRESSED_MOVES: u16 = 0x02FB;
|
||||
pub const SMSG_LOGIN_VERIFY_WORLD: u16 = 0x0236;
|
||||
// The server-pushed sound trio (1.12.1 values VERIFIED vmangos `Opcodes_1_12_1.h`:
|
||||
// 631/632/722).
|
||||
@@ -187,6 +199,9 @@ pub const CMSG_PLAYER_LOGIN: u16 = 0x003D;
|
||||
pub const CMSG_LOGOUT_REQUEST: u16 = 0x004B;
|
||||
pub const CMSG_NAME_QUERY: u16 = 0x0050; // 80
|
||||
pub const CMSG_CREATURE_QUERY: u16 = 0x0060; // 96
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h:85`: 82. Body in [`super::client::pet_name_query`]. Answered
|
||||
/// by [`SMSG_PET_NAME_QUERY_RESPONSE`].
|
||||
pub const CMSG_PET_NAME_QUERY: u16 = 0x0052; // 82
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 94 (decision 0236). Body in
|
||||
/// [`super::gameobject::gameobject_query`] — the ask-once GO template lookup, identical shape to
|
||||
/// `CMSG_CREATURE_QUERY`.
|
||||
@@ -608,3 +623,11 @@ pub const MSG_QUERY_NEXT_MAIL_TIME: u16 = 0x0284; // 644
|
||||
/// One `u32` (always 0) — sent when a mail *arrives* (instant for text-only, on the delivery
|
||||
/// timer's expiry otherwise).
|
||||
pub const SMSG_RECEIVED_MAIL: u16 = 0x0285; // 645
|
||||
|
||||
// The world-state table (VERIFIED both ways: vmangos `Opcodes_1_12_1.h` 706-707, and wow-re's read
|
||||
// of the reference's own handler `0x48f690`, whose registration at `0x48f515-0x48f52f` selects these
|
||||
// two arms as the ONLY writers of the table setter `0x4c5870`). Bodies in
|
||||
// [`super::world_state`]. Backs the NPC-text `$<n>w`/`$<n>e` tokens today; the BG/PvP scoreboard
|
||||
// later.
|
||||
pub const SMSG_INIT_WORLD_STATES: u16 = 0x02C2; // 706
|
||||
pub const SMSG_UPDATE_WORLD_STATE: u16 = 0x02C3; // 707
|
||||
|
||||
@@ -7,9 +7,9 @@ use crate::wire::Vector3d;
|
||||
use super::{
|
||||
ActionButton, AttackerState, CastOutcome, ChannelNotify, Character, ChatMessage,
|
||||
CorpseLocation, DamageShield, EnvironmentalDamageLog, GameObjectQueryInfo, GossipOption,
|
||||
GroupLootInfo, GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
|
||||
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, Object,
|
||||
PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList,
|
||||
GroupLootInfo, GroupMemberEntry, InitWorldStates, ItemInfo, ItemPushResult, JumpInfo,
|
||||
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
|
||||
Object, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList,
|
||||
QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody,
|
||||
SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog,
|
||||
SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell,
|
||||
@@ -75,6 +75,19 @@ pub enum ServerPacket {
|
||||
UpdateObject {
|
||||
objects: Vec<Object>,
|
||||
},
|
||||
/// `SMSG_COMPRESSED_MOVES` — a **batch** of whole movement packets in one zlib envelope, each
|
||||
/// already parsed into its own [`ServerPacket`] (decision 0624).
|
||||
///
|
||||
/// vmangos flips a session onto this container once it has been sent
|
||||
/// `Compression.Movement.Count` (300) movement packets inside a ten-second window
|
||||
/// (`WorldSession::SendMovementPacket`), and flips back when the rate drops — so it is not an
|
||||
/// exotic case but the **normal** carrier for any nearby player moving at frame cadence, which
|
||||
/// since decision 0617 is what our own sender produces. Body: `u32` uncompressed size, then a
|
||||
/// deflate stream of `[u8 size][u16 opcode][body]` records where `size` counts the opcode's two
|
||||
/// bytes (`MovementData::AddPacket`).
|
||||
CompressedMoves {
|
||||
packets: Vec<ServerPacket>,
|
||||
},
|
||||
/// `SMSG_DESTROY_OBJECT` — one object ceased to exist server-side (a corpse decaying ahead of
|
||||
/// respawn, a despawn), body a plain `u64` guid (vmangos `DestroyObject::AppendBodyTo`). Distinct
|
||||
/// from the update-object `OutOfRange` block (*we* left its range; the real client keeps those
|
||||
@@ -222,6 +235,15 @@ pub enum ServerPacket {
|
||||
/// `Some` on a hit; `None` when the server flagged the entry unknown.
|
||||
info: Option<CreatureQueryInfo>,
|
||||
},
|
||||
/// `SMSG_PET_NAME_QUERY_RESPONSE` — a pet's display name, answering `CMSG_PET_NAME_QUERY`.
|
||||
/// Keyed by **pet number**, not a template entry (a pet guid carries none — see
|
||||
/// [`crate::guid::pet_number`]); that is also how the real client's own pet-name cache is keyed.
|
||||
/// The `nameTimestamp` tail is parsed for alignment and dropped: it exists to age out that
|
||||
/// on-disk cache, which we have no equivalent of.
|
||||
PetNameQueryResponse {
|
||||
pet_number: u32,
|
||||
name: String,
|
||||
},
|
||||
/// `SMSG_GAMEOBJECT_QUERY_RESPONSE` — a GameObject template's type/display/name/`data[24]` head,
|
||||
/// answering `CMSG_GAMEOBJECT_QUERY` (decision 0236's ask-once template lookup, the GO twin of
|
||||
/// [`Self::CreatureQueryResponse`]). `data[24]` is the raw type-specific tail (e.g. a chest's
|
||||
@@ -566,12 +588,13 @@ pub enum ServerPacket {
|
||||
},
|
||||
/// `SMSG_GOSSIP_COMPLETE` — the gossip window closes (vmangos `Npc.cpp:90`); an empty body.
|
||||
GossipComplete,
|
||||
/// `SMSG_NPC_TEXT_UPDATE` — answers `CMSG_NPC_TEXT_QUERY`: always 8 probability-weighted text
|
||||
/// blocks (vmangos `GossipDef.cpp:298-369`); `greeting` is the highest-probability block's male
|
||||
/// text with a female-text fallback (see [`super::gossip::read_npc_text_update`]).
|
||||
/// `SMSG_NPC_TEXT_UPDATE` — answers `CMSG_NPC_TEXT_QUERY`: always 8 weighted text blocks
|
||||
/// (vmangos `GossipDef.cpp:298-369`), carried here **undecided**. Which line greets you needs
|
||||
/// the NPC's gender and a die roll, so it is drawn when the frame opens, not when the packet
|
||||
/// lands — [`super::gossip::select_greeting`].
|
||||
NpcText {
|
||||
text_id: u32,
|
||||
greeting: String,
|
||||
blocks: Vec<super::gossip::NpcTextBlock>,
|
||||
},
|
||||
/// `SMSG_LIST_INVENTORY` — a vendor's stock, answering `CMSG_LIST_INVENTORY` (vmangos
|
||||
/// `ItemHandler.cpp:741-810`). Empty stock sends `count = 0` plus a trailing error byte the
|
||||
@@ -919,6 +942,14 @@ pub enum ServerPacket {
|
||||
TradeStatusExtended {
|
||||
state: Box<TradeStatusExtended>,
|
||||
},
|
||||
/// `SMSG_INIT_WORLD_STATES` — the whole world-state table for a zone, pushed on login and on
|
||||
/// every zone change (layout in [`super::world_state::read_init_world_states`]).
|
||||
InitWorldStates(InitWorldStates),
|
||||
/// `SMSG_UPDATE_WORLD_STATE` — one `(id, value)` write into that table.
|
||||
UpdateWorldState {
|
||||
id: u32,
|
||||
value: u32,
|
||||
},
|
||||
Other {
|
||||
opcode: u16,
|
||||
},
|
||||
@@ -934,6 +965,7 @@ impl ServerPacket {
|
||||
ServerPacket::CharCreate { .. } => "SMSG_CHAR_CREATE".into(),
|
||||
ServerPacket::CharDelete { .. } => "SMSG_CHAR_DELETE".into(),
|
||||
ServerPacket::UpdateObject { .. } => "SMSG_UPDATE_OBJECT".into(),
|
||||
ServerPacket::CompressedMoves { .. } => "SMSG_COMPRESSED_MOVES".into(),
|
||||
ServerPacket::DestroyObject { .. } => "SMSG_DESTROY_OBJECT".into(),
|
||||
ServerPacket::TriggerCinematic { .. } => "SMSG_TRIGGER_CINEMATIC".into(),
|
||||
ServerPacket::MonsterMove { .. } => "SMSG_MONSTER_MOVE".into(),
|
||||
@@ -950,6 +982,7 @@ impl ServerPacket {
|
||||
ServerPacket::SetFactionStanding { .. } => "SMSG_SET_FACTION_STANDING".into(),
|
||||
ServerPacket::NameQueryResponse { .. } => "SMSG_NAME_QUERY_RESPONSE".into(),
|
||||
ServerPacket::CreatureQueryResponse { .. } => "SMSG_CREATURE_QUERY_RESPONSE".into(),
|
||||
ServerPacket::PetNameQueryResponse { .. } => "SMSG_PET_NAME_QUERY_RESPONSE".into(),
|
||||
ServerPacket::GameObjectQueryResponse { .. } => "SMSG_GAMEOBJECT_QUERY_RESPONSE".into(),
|
||||
ServerPacket::PlaySound { .. } => "SMSG_PLAY_SOUND".into(),
|
||||
ServerPacket::PlayMusic { .. } => "SMSG_PLAY_MUSIC".into(),
|
||||
@@ -1088,6 +1121,8 @@ impl ServerPacket {
|
||||
ServerPacket::NextMailTime { .. } => "MSG_QUERY_NEXT_MAIL_TIME".into(),
|
||||
ServerPacket::TradeStatus { .. } => "SMSG_TRADE_STATUS".into(),
|
||||
ServerPacket::TradeStatusExtended { .. } => "SMSG_TRADE_STATUS_EXTENDED".into(),
|
||||
ServerPacket::InitWorldStates(_) => "SMSG_INIT_WORLD_STATES".into(),
|
||||
ServerPacket::UpdateWorldState { .. } => "SMSG_UPDATE_WORLD_STATE".into(),
|
||||
ServerPacket::Other { opcode } => format!("opcode {opcode:#06x}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
use std::io::{self, Read};
|
||||
|
||||
use crate::wire::{
|
||||
read_cstring, read_f32_le, read_packed_guid, read_u32_le, read_u64_le, read_u8, Vector3d,
|
||||
read_cstring, read_f32_le, read_packed_guid, read_u16_le, read_u32_le, read_u64_le, read_u8,
|
||||
Vector3d,
|
||||
};
|
||||
|
||||
use super::{
|
||||
bank, channel, chat, death, gameobject, gossip, group, items, loot, mail, monster_move,
|
||||
movement, opcode, quest, spells, taxi, trade, trainer, update_object, vendor, Character,
|
||||
CreatureQueryInfo, ServerPacket, SpeedKind,
|
||||
movement, opcode, quest, spells, taxi, trade, trainer, update_object, vendor, world_state,
|
||||
Character, CreatureQueryInfo, ServerPacket, SpeedKind,
|
||||
};
|
||||
|
||||
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
|
||||
@@ -27,6 +28,65 @@ fn read_force_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result<ServerPack
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an `SMSG_COMPRESSED_MOVES` body: `[u32 uncompressed size][deflate stream]`, the stream a
|
||||
/// run of `[u8 size][u16 opcode][body]` records where `size` counts the opcode's own two bytes
|
||||
/// (VERIFIED vmangos `MovementData::AddPacket`/`BuildPacket`). Each record is a whole movement
|
||||
/// packet, so it goes straight back through [`parse_server`] — the batch is a *transport*, not a
|
||||
/// message shape, and nothing downstream needs to know a move arrived batched.
|
||||
///
|
||||
/// A record that fails to parse fails the **whole** batch (surfacing as one `Poll::Skipped` naming
|
||||
/// the inner opcode) rather than being dropped quietly. Losing a batch is worse than losing a
|
||||
/// packet, but silence here is what cost days: an unhandled inner opcode has to be loud. Only the
|
||||
/// `MSG_MOVE_*` relays vmangos routes through `ObjectViewersMovementDeliverer` can appear, and we
|
||||
/// model all of them (`the_batch_carries_every_relayed_move_opcode`).
|
||||
fn read_compressed_moves(r: &mut impl Read) -> io::Result<Vec<ServerPacket>> {
|
||||
let uncompressed = read_u32_le(r)? as usize;
|
||||
let mut buf = Vec::with_capacity(uncompressed.min(64 * 1024));
|
||||
flate2::read::ZlibDecoder::new(r).read_to_end(&mut buf)?;
|
||||
let mut rest = buf.as_slice();
|
||||
let mut packets = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
let size = read_u8(&mut rest)? as usize;
|
||||
let opcode = read_u16_le(&mut rest)?;
|
||||
// `size` spans opcode + body; anything under two bytes cannot even hold the opcode we
|
||||
// just read, so the stream is misframed and every later record would be garbage.
|
||||
let body_len = size.checked_sub(2).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("compressed-moves record size {size} < 2"),
|
||||
)
|
||||
})?;
|
||||
if rest.len() < body_len {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
format!(
|
||||
"compressed-moves record {opcode:#06x} wants {body_len}B, {}B left",
|
||||
rest.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
let (body, tail) = rest.split_at(body_len);
|
||||
rest = tail;
|
||||
// A batch inside a batch would recurse without bound; vmangos never nests one.
|
||||
if opcode == opcode::SMSG_COMPRESSED_MOVES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"compressed-moves nested inside compressed-moves",
|
||||
));
|
||||
}
|
||||
packets.push(parse_server(opcode, body).map_err(|e| {
|
||||
io::Error::new(
|
||||
e.kind(),
|
||||
format!(
|
||||
"compressed-moves record {opcode:#06x} ({}): {e}",
|
||||
super::opcode_name(opcode).unwrap_or("?")
|
||||
),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
Ok(packets)
|
||||
}
|
||||
|
||||
/// Read one `SMSG_SPLINE_SET_*_SPEED` body — `[packed guid][f32 speed]` (VERIFIED vmangos
|
||||
/// `MovementPacketSender::SendSpeedChangeToAll` + the mid-spline observer branch, the 5875
|
||||
/// `> 1_8_4` layout). Observer-only: no counter, no ack (decision 0441).
|
||||
@@ -133,6 +193,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
objects: update_object::read_update_object(&mut dr)?,
|
||||
}
|
||||
}
|
||||
opcode::SMSG_COMPRESSED_MOVES => ServerPacket::CompressedMoves {
|
||||
packets: read_compressed_moves(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_MONSTER_MOVE => monster_move::read_monster_move(&mut r)?,
|
||||
opcode::MSG_MOVE_TELEPORT_ACK => {
|
||||
let guid = read_packed_guid(&mut r)?;
|
||||
@@ -478,8 +541,8 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
}
|
||||
opcode::SMSG_GOSSIP_COMPLETE => ServerPacket::GossipComplete,
|
||||
opcode::SMSG_NPC_TEXT_UPDATE => {
|
||||
let (text_id, greeting) = gossip::read_npc_text_update(&mut r)?;
|
||||
ServerPacket::NpcText { text_id, greeting }
|
||||
let (text_id, blocks) = gossip::read_npc_text_update(&mut r)?;
|
||||
ServerPacket::NpcText { text_id, blocks }
|
||||
}
|
||||
opcode::SMSG_LIST_INVENTORY => {
|
||||
let (vendor, items) = vendor::read_list_inventory(&mut r)?;
|
||||
@@ -688,6 +751,15 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `u32 petNumber`, cstring name, `u32 nameTimestamp` (VERIFIED vmangos
|
||||
// `PetNameQueryResponse::AppendBodyTo`, `Server/Packets/Pet.cpp:79-84`). The timestamp ages
|
||||
// out the reference's on-disk pet-name cache; we keep none, so it is alignment-only.
|
||||
opcode::SMSG_PET_NAME_QUERY_RESPONSE => {
|
||||
let pet_number = read_u32_le(&mut r)?;
|
||||
let name = read_cstring(&mut r)?;
|
||||
let _name_timestamp = read_u32_le(&mut r)?;
|
||||
ServerPacket::PetNameQueryResponse { pet_number, name }
|
||||
}
|
||||
opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE => {
|
||||
let (entry, info) = gameobject::read_gameobject_query_response(&mut r)?;
|
||||
ServerPacket::GameObjectQueryResponse { entry, info }
|
||||
@@ -851,6 +923,15 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
opcode::SMSG_TRADE_STATUS_EXTENDED => ServerPacket::TradeStatusExtended {
|
||||
state: Box::new(trade::read_trade_status_extended(&mut r)?),
|
||||
},
|
||||
// The world-state table — the `$<n>w`/`$<n>e` NPC-text tokens' source (and a battleground
|
||||
// scoreboard's, later). Both opcodes write the one table; see [`super::world_state`].
|
||||
opcode::SMSG_INIT_WORLD_STATES => {
|
||||
ServerPacket::InitWorldStates(world_state::read_init_world_states(&mut r)?)
|
||||
}
|
||||
opcode::SMSG_UPDATE_WORLD_STATE => {
|
||||
let (id, value) = world_state::read_update_world_state(&mut r)?;
|
||||
ServerPacket::UpdateWorldState { id, value }
|
||||
}
|
||||
// A relayed player movement packet: `[packed mover guid][MovementInfo]`, same opcode the mover
|
||||
// sent. This is how other players' walking/turning/strafing reaches us (creatures use
|
||||
// SMSG_MONSTER_MOVE instead). Surface the pose + live move-flags; the app extrapolates between.
|
||||
|
||||
@@ -283,6 +283,13 @@ const FIELD_PLAYER_NEXT_LEVEL_XP: u16 = 717;
|
||||
// UNIT_END(0xBC = 188) + 0x39B(923) = 1111. Cross-checked against COINAGE: 0x3DC − 0x39B = 65 =
|
||||
// the 64 bitset slots + PLAYER_FIELD_WATCHED_FACTION_INDEX between them (1111 + 64 = 1175, then
|
||||
// 1176 ✓); the stale hex comment reads 0x451 — the usual 6-low drift.
|
||||
// The four avoidance/crit percentages the spell tooltip's chance-to-X line reads (law §3-CHANCE),
|
||||
// consecutive and immediately below EXPLORED_ZONES_1: UNIT_END(188) + 0x396..0x399. FLOAT, and the
|
||||
// header agrees for once. Anchored on EXPLORED_ZONES_1 = UNIT_END + 0x39B = 1111 just below.
|
||||
const FIELD_PLAYER_BLOCK_PERCENTAGE: u16 = 1106;
|
||||
const FIELD_PLAYER_DODGE_PERCENTAGE: u16 = 1107;
|
||||
const FIELD_PLAYER_PARRY_PERCENTAGE: u16 = 1108;
|
||||
const FIELD_PLAYER_CRIT_PERCENTAGE: u16 = 1109;
|
||||
const FIELD_PLAYER_EXPLORED_ZONES_1: u16 = 1111;
|
||||
/// The bitset's slot count (`Size: 64` in the server enum).
|
||||
pub const PLAYER_EXPLORED_ZONES_SLOTS: u16 = 64;
|
||||
|
||||
@@ -176,6 +176,25 @@ impl ObjectFields {
|
||||
}
|
||||
self.get_u32(FIELD_PLAYER_EXPLORED_ZONES_1 + i).unwrap_or(0)
|
||||
}
|
||||
/// `PLAYER_BLOCK_PERCENTAGE` — our chance to block, already a percent (2.62 = "2.62%"). The
|
||||
/// spell tooltip's chance-to-X line (law §3-CHANCE) reads exactly this and its three
|
||||
/// neighbours; the character sheet reads them too. `None` for a non-player unit.
|
||||
pub fn player_block_percentage(&self) -> Option<f32> {
|
||||
self.get_f32(FIELD_PLAYER_BLOCK_PERCENTAGE)
|
||||
}
|
||||
/// `PLAYER_DODGE_PERCENTAGE` — see [`Self::player_block_percentage`].
|
||||
pub fn player_dodge_percentage(&self) -> Option<f32> {
|
||||
self.get_f32(FIELD_PLAYER_DODGE_PERCENTAGE)
|
||||
}
|
||||
/// `PLAYER_PARRY_PERCENTAGE` — see [`Self::player_block_percentage`].
|
||||
pub fn player_parry_percentage(&self) -> Option<f32> {
|
||||
self.get_f32(FIELD_PLAYER_PARRY_PERCENTAGE)
|
||||
}
|
||||
/// `PLAYER_CRIT_PERCENTAGE` — melee crit; see [`Self::player_block_percentage`]. (The ranged
|
||||
/// twin sits one slot above and has no consumer yet.)
|
||||
pub fn player_crit_percentage(&self) -> Option<f32> {
|
||||
self.get_f32(FIELD_PLAYER_CRIT_PERCENTAGE)
|
||||
}
|
||||
/// `PLAYER_FIELD_POSSTAT0 + i` — stat `i`'s **positive** buff delta (float despite the header's
|
||||
/// "Type: INT" tag; see the field's doc comment). `i >= 5` reads `None`.
|
||||
pub fn player_posstat(&self, i: u8) -> Option<f32> {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! World states — the server's key/value scoreboard table (`SMSG_INIT_WORLD_STATES` `0x2C2`,
|
||||
//! `SMSG_UPDATE_WORLD_STATE` `0x2C3`). It is what the NPC-text `$<n>w`/`$<n>e` tokens read, and it
|
||||
//! is what a battleground scoreboard is built from; benilla uses the first today.
|
||||
//!
|
||||
//! Both wires are settled from two independent sources that agree:
|
||||
//!
|
||||
//! - **The reference binary** (wow-re `system/ui/scratch/questtext-macro-expander.md` §3): the one
|
||||
//! handler `0x48f690` holds the only two callers of the table setter `0x4c5870`, registered
|
||||
//! against exactly these two opcodes at `0x48f515-0x48f52f`. `0x2C2` is *two leading dwords*
|
||||
//! (passed to the unexamined `0x4c5650`), a `u16` count, then `count × (id:u32, value:u32)`;
|
||||
//! `0x2C3` is a single such pair.
|
||||
//! - **vmangos's writers** (`Player::SendInitWorldStates`, `Player.cpp:8219-8293`, and
|
||||
//! `WorldPackets::Misc::UpdateWorldState::AppendBodyTo`, `Misc.cpp:685-694`): `u32 mapId`,
|
||||
//! `u32 zoneId` (the zone dword is `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`, so 1.12.1
|
||||
//! gets it), the `u16` count back-patched at the end, then the pairs; and `u32 field, u32 value`
|
||||
//! respectively (also build-gated — a pre-1.8.4 client would read `u16`s).
|
||||
//!
|
||||
//! **The count includes a `(0, 0)` terminator.** vmangos starts `count = 1` and appends
|
||||
//! `uint32(0) << uint32(0)` last, *"to prevent repeating audio bug"* — so the pair run is read
|
||||
//! verbatim, terminator and all, exactly as the reference's count loop does. There is no
|
||||
//! terminator special case on either side and we add none: id 0 simply lands in the table as 0.
|
||||
//!
|
||||
//! Ids and values are **raw dwords** on the wire and stay raw here. That matters: the reference's
|
||||
//! `$<n>e` token reads the table at the *negated* key, so a text asking for `$2077e` looks up
|
||||
//! `0xFFFFF7E3` — a key only reachable if nothing has re-interpreted the field on the way in.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::wire::{read_u16_le, read_u32_le};
|
||||
|
||||
/// `SMSG_INIT_WORLD_STATES` — the whole table for a zone, sent on login and on every zone change
|
||||
/// (vmangos `Player::UpdateZone` → `SendInitWorldStates`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct InitWorldStates {
|
||||
/// The map the states are scoped to. The reference hands this and `zone` to `0x4c5650` and
|
||||
/// nothing else; what that call does with them is **not recorded** (see [`read_init_world_states`]).
|
||||
pub map: u32,
|
||||
pub zone: u32,
|
||||
/// `(id, value)` verbatim, including the trailing `(0, 0)` terminator.
|
||||
pub states: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Read `SMSG_INIT_WORLD_STATES`: `u32 map, u32 zone, u16 count`, then `count` `(id, value)` pairs.
|
||||
///
|
||||
/// The count is trusted only as far as the body allows — a truncated run stops at the short read
|
||||
/// like every other reader here, rather than pre-allocating on a hostile length.
|
||||
pub(in crate::messages) fn read_init_world_states(r: &mut &[u8]) -> io::Result<InitWorldStates> {
|
||||
let map = read_u32_le(r)?;
|
||||
let zone = read_u32_le(r)?;
|
||||
let count = read_u16_le(r)?;
|
||||
let mut states = Vec::new();
|
||||
for _ in 0..count {
|
||||
states.push((read_u32_le(r)?, read_u32_le(r)?));
|
||||
}
|
||||
Ok(InitWorldStates { map, zone, states })
|
||||
}
|
||||
|
||||
/// Read `SMSG_UPDATE_WORLD_STATE`: one `(id, value)` dword pair.
|
||||
pub(in crate::messages) fn read_update_world_state(r: &mut &[u8]) -> io::Result<(u32, u32)> {
|
||||
Ok((read_u32_le(r)?, read_u32_le(r)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A vmangos-shaped `SMSG_INIT_WORLD_STATES`: map, zone, a count that *includes* the trailing
|
||||
/// `(0, 0)` terminator, then the pairs. The terminator is data, not a sentinel we strip.
|
||||
#[test]
|
||||
fn init_reads_every_pair_including_the_terminator() {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // map 0 (Eastern Kingdoms)
|
||||
body.extend_from_slice(&1519u32.to_le_bytes()); // zone (Stormwind)
|
||||
body.extend_from_slice(&3u16.to_le_bytes()); // 2 real pairs + the terminator
|
||||
for (id, value) in [(2264u32, 7u32), (2263u32, 0u32), (0, 0)] {
|
||||
body.extend_from_slice(&id.to_le_bytes());
|
||||
body.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let got = read_init_world_states(&mut body.as_slice()).unwrap();
|
||||
assert_eq!(got.map, 0);
|
||||
assert_eq!(got.zone, 1519);
|
||||
assert_eq!(got.states, vec![(2264, 7), (2263, 0), (0, 0)]);
|
||||
}
|
||||
|
||||
/// A count longer than the body is a short read, not a panic or a giant allocation.
|
||||
#[test]
|
||||
fn init_truncated_run_errors() {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0u32.to_le_bytes());
|
||||
body.extend_from_slice(&0u32.to_le_bytes());
|
||||
body.extend_from_slice(&u16::MAX.to_le_bytes());
|
||||
body.extend_from_slice(&1u32.to_le_bytes());
|
||||
assert!(read_init_world_states(&mut body.as_slice()).is_err());
|
||||
}
|
||||
|
||||
/// The update wire is the bare pair, and both halves stay raw dwords — a value with the top bit
|
||||
/// set survives as-is, so the `%d` render downstream is the only place a sign appears.
|
||||
#[test]
|
||||
fn update_reads_a_raw_dword_pair() {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&2264u32.to_le_bytes());
|
||||
body.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
assert_eq!(
|
||||
read_update_world_state(&mut body.as_slice()).unwrap(),
|
||||
(2264, 0xFFFF_FFFF)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ impl WorldReader {
|
||||
return Err(anyhow!("world stream closed: {e}"));
|
||||
}
|
||||
match messages::parse_server(opcode, &body) {
|
||||
Ok(packet) => Ok(crate::Poll::Events(crate::decode(packet))),
|
||||
Ok(packet) => Ok(crate::Poll::Events {
|
||||
opcode,
|
||||
events: crate::decode(packet),
|
||||
}),
|
||||
// Include the raw body (capped) so an unparseable packet can be decoded by hand — a parse
|
||||
// bug is otherwise invisible past "failed to fill whole buffer". The opcode rides
|
||||
// separately so the net thread can feed the app's dropped-packet tally.
|
||||
|
||||
@@ -63,6 +63,19 @@ impl WorldSession {
|
||||
session_key: [u8; SESSION_KEY_LENGTH],
|
||||
) -> Result<Self> {
|
||||
let mut stream = TcpStream::connect(addr).context("connecting to world server")?;
|
||||
// **Nagle off** (decision 0617). VERIFIED in the reference client: `0x5bca60` calls
|
||||
// `setsockopt(s, 6 /* IPPROTO_TCP */, 1 /* TCP_NODELAY */, &1, 4)` unconditionally on its game
|
||||
// socket (WSOCK32 ordinal 21, IAT slot `0x7ff6f8`; a sibling helper at `0x43dda0` toggles the
|
||||
// same option from a bool). Rust leaves Nagle *on*, which is exactly wrong for this protocol:
|
||||
// every packet we send is a sub-MSS write on a latency-critical stream, so the kernel holds
|
||||
// each one until the server ACKs the last — coalescing our movement stream into delayed-ACK-
|
||||
// sized clumps. The de-jitter chain on the observing client then sizes its buffer to that
|
||||
// self-inflicted lateness (decision 0615). Fatal on failure: the option cannot fail on a
|
||||
// healthy connected socket, so an error here means the next handshake write is doomed anyway —
|
||||
// better one named failure than a session that silently plays at delayed-ACK cadence.
|
||||
stream
|
||||
.set_nodelay(true)
|
||||
.context("disabling Nagle (TCP_NODELAY) on the world socket")?;
|
||||
// Handshake reads wait for one specific reply each — silence is failure, so bound them
|
||||
// (decision 0065). A server that stays connected but never answers must not wedge the net
|
||||
// thread forever. Cleared in `into_split` for the streaming phase (a quiet world is legal).
|
||||
|
||||
@@ -460,6 +460,17 @@ impl WorldWriter {
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask for a pet's name (`CMSG_PET_NAME_QUERY`: pet number + guid). A pet's guid holds a pet
|
||||
/// number where a creature's holds its template entry ([`crate::guid::pet_number`]), so
|
||||
/// [`Self::creature_query`] cannot name one — this is the only query that can. Answered by
|
||||
/// `SMSG_PET_NAME_QUERY_RESPONSE`, or by silence if the pet is gone.
|
||||
pub fn pet_name_query(&mut self, pet_number: u32, guid: u64) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_PET_NAME_QUERY,
|
||||
&messages::pet_name_query(pet_number, guid),
|
||||
)
|
||||
}
|
||||
|
||||
/// Cast a spell (`CMSG_CAST_SPELL`): `target: None` = a self/implicit cast, `Some(guid)` = an
|
||||
/// explicit unit target (body in [`messages::cast_spell`]). The server answers
|
||||
/// `SMSG_CAST_RESULT` (and `SMSG_SPELL_START`/`GO` on success, unmodelled yet).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -436,3 +436,58 @@ fn observer_speed_legs_parse_golden() {
|
||||
other => panic!("expected MountSpecial(player 4), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_COMPRESSED_MOVES` — the batch carrier vmangos switches a session onto once it has been
|
||||
/// sent 300 movement packets in ten seconds (decision 0624). The fixture is built exactly as
|
||||
/// `MovementData::AddPacket` + `BuildPacket` do: `[u32 uncompressed size][zlib(records)]`, each
|
||||
/// record `[u8 size][u16 opcode][body]` with `size` counting the opcode's two bytes. The two
|
||||
/// records carry the same relay bodies pinned by `movement_relay_decodes_to_unit_move` above, so
|
||||
/// what this adds is the *framing* — that a batched move decodes identically to a loose one.
|
||||
#[test]
|
||||
fn compressed_moves_unwraps_to_the_same_events_as_loose_relays() {
|
||||
let body = hx("42000000780153d8cac0b88a91818181859989f1ec75ee63a6752c877ff22f77626058600f146650b805540062602a38005600002f2610d9");
|
||||
let events =
|
||||
decode(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &body).unwrap());
|
||||
|
||||
// Both records, in wire order, as ordinary moves — order is load-bearing: the remote-replay
|
||||
// queue reconstructs a mover's arc from it (decision 0618).
|
||||
match events.as_slice() {
|
||||
[SessionEvent::UnitMove {
|
||||
guid: 0xAA,
|
||||
flags: 0x1,
|
||||
..
|
||||
}, SessionEvent::UnitMove {
|
||||
guid: 0xAA,
|
||||
flags: 0x0,
|
||||
..
|
||||
}] => {}
|
||||
other => panic!(
|
||||
"expected the moving then the idle relay for mover 0xAA, in that order, got {other:?}"
|
||||
),
|
||||
}
|
||||
|
||||
// A batched move must be byte-identical to the same move arriving loose.
|
||||
let loose = decode(
|
||||
messages::parse_server(
|
||||
messages::opcode::MSG_MOVE_START_FORWARD,
|
||||
&hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000"),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(format!("{:?}", events[0]), format!("{:?}", loose[0]));
|
||||
}
|
||||
|
||||
/// A misframed batch must fail **loudly** (one `Poll::Skipped` naming the inner opcode), never
|
||||
/// silently drop the moves it carries — the exact silence that made the runaway unattributable for
|
||||
/// three rounds of fixes (decision 0624).
|
||||
#[test]
|
||||
fn a_misframed_batch_errors_rather_than_dropping_moves() {
|
||||
// A HEARTBEAT record claiming a 40-byte body with only 4 bytes left in the stream.
|
||||
let truncated = hx("070000007801d37ac700020006c10119");
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &truncated).is_err());
|
||||
|
||||
// A record whose size (1) cannot even hold the opcode that follows it — misframing, not an
|
||||
// empty-body packet, and it must not underflow into a huge body length.
|
||||
let undersized = hx("030000007801637cc7000001e200f0");
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &undersized).is_err());
|
||||
}
|
||||
|
||||
+367
-226
@@ -30,7 +30,7 @@
|
||||
//! left`, `height = top − bottom`. Coordinates are screen pixels throughout; `scale` (layoutScale =
|
||||
//! effective scale, `propagation.md`) scales only the per-anchor offsets and the `width/height` span.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Constants (objdump-VERIFIED in layout.md "Constants")
|
||||
@@ -48,7 +48,7 @@ pub const SIZE_EPS: f64 = 2.384_185_791_015_625e-7;
|
||||
|
||||
/// Recompute change threshold ε — `_DAT_0080c5c8` = `0x3727c5ac` ≈ 9.96e-6 (`layout.md`; applied in
|
||||
/// `recompute 0x768d20`): an edge must move at least this far vs the cached rect for a re-store +
|
||||
/// `ApplyRect`. Benilla owns its own caching (see [`LayoutCache`]); this const records the client's
|
||||
/// `ApplyRect`. Benilla owns its own caching (the caller's dirty gate); this const records the client's
|
||||
/// documented threshold for fidelity work that needs it.
|
||||
pub const RESOLVE_EPS: f64 = f32::from_bits(0x3727_c5ac) as f64;
|
||||
|
||||
@@ -729,206 +729,246 @@ pub fn resolve_rect_edges(
|
||||
// detecting cycles. This is benilla's orchestration, not client fidelity math: the client resolves
|
||||
// lazily-on-read + per-frame-batched via a dependency-ordered deferred queue (layout.md "Resolve /
|
||||
// dirty / cache flow"); we express the same dependency ordering as an explicit topological pass.
|
||||
//
|
||||
// ## Why a dense, reusable solver rather than a fresh map-backed graph
|
||||
//
|
||||
// The caller re-solves the whole widget set every round of its fixpoint, and the fixpoint runs
|
||||
// every frame. A map-backed graph rebuilt per round paid, per round: one `LayoutInput` clone
|
||||
// (heap `Vec<Anchor>`) per frame, a `BTreeMap` insert per node, and a `BTreeMap` probe per anchor
|
||||
// target during the solve. Measured on the shipped default UI (1881 frames + 5478 regions,
|
||||
// release): 1.36 ms per resolve, of which ~0.25 ms was graph construction and ~0.64 ms the solve
|
||||
// itself — paid on a completely quiet frame.
|
||||
//
|
||||
// [`Handle`]s are the caller's own dense id space (benilla's `Model::next_id`, monotonic from 1
|
||||
// with frames and regions sharing the counter), so every per-node map collapses to an array index
|
||||
// and every buffer can live across calls. `begin` clears only the slots the previous round
|
||||
// touched, so a round costs O(nodes), never O(id space). Iteration order is kept **ascending by
|
||||
// handle** — identical to the `BTreeMap` it replaces — because cycle members are resolved
|
||||
// best-effort in that order and the result depends on it.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A frame-anchor graph: frames keyed by [`Handle`], plus *external* pre-resolved rects (the screen
|
||||
/// root — the client's `CSimpleTop`, whose rect is the physical screen — and any fixed targets).
|
||||
/// A reusable dependency solver over a set of anchored frames plus *external* pre-resolved rects
|
||||
/// (the screen root — the client's `CSimpleTop`, whose rect is the physical screen — and any fixed
|
||||
/// targets, such as already-resolved regions).
|
||||
///
|
||||
/// One solver is kept alive across frames so its buffers are reused. A round is:
|
||||
/// [`begin`](Self::begin) → [`set_external`](Self::set_external) /
|
||||
/// [`set_frame`](Self::set_frame) → [`solve`](Self::solve) → [`rect`](Self::rect).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LayoutGraph {
|
||||
frames: BTreeMap<Handle, LayoutInput>,
|
||||
externals: BTreeMap<Handle, Rect>,
|
||||
pub struct LayoutSolver {
|
||||
/// Per-handle layout input. Persistent storage: [`set_frame`](Self::set_frame) overwrites in
|
||||
/// place so the anchors `Vec` keeps its allocation across rounds.
|
||||
input: Vec<LayoutInput>,
|
||||
/// Is `input[h]` a frame to solve this round?
|
||||
is_frame: Vec<bool>,
|
||||
/// Per-handle rect: externals as seeded, frames as they resolve. `None` = not (yet) known.
|
||||
rect: Vec<Option<Rect>>,
|
||||
/// Kahn in-degree, and the reverse edges (each inner `Vec` is cleared, never freed).
|
||||
indeg: Vec<u32>,
|
||||
dependents: Vec<Vec<Handle>>,
|
||||
/// Handles whose slots were written this round — `begin` resets exactly these.
|
||||
touched: Vec<Handle>,
|
||||
/// Is `h` already in `touched`?
|
||||
marked: Vec<bool>,
|
||||
/// The frames to solve, ascending (the `BTreeMap` iteration order this replaces).
|
||||
live: Vec<Handle>,
|
||||
/// Kahn scratch + output.
|
||||
queue: VecDeque<Handle>,
|
||||
emitted: Vec<bool>,
|
||||
order: Vec<Handle>,
|
||||
cycle: Vec<Handle>,
|
||||
unresolvable: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// The result of resolving a [`LayoutGraph`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Resolution {
|
||||
/// Every known rect: the externals echoed back, plus each resolvable frame's resolved rect.
|
||||
pub rects: BTreeMap<Handle, Rect>,
|
||||
/// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an
|
||||
/// unresolvable/missing target). May overlap [`Resolution::cycle`].
|
||||
pub unresolvable: BTreeSet<Handle>,
|
||||
/// Frames on (or behind) an anchor dependency cycle — no valid topological position. Resolved
|
||||
/// best-effort against whatever rects were available; a cycle member that could not resolve
|
||||
/// also appears in `unresolvable`.
|
||||
pub cycle: BTreeSet<Handle>,
|
||||
}
|
||||
|
||||
impl LayoutGraph {
|
||||
/// A new empty graph.
|
||||
pub fn new() -> LayoutGraph {
|
||||
LayoutGraph::default()
|
||||
impl LayoutSolver {
|
||||
/// A new, empty solver.
|
||||
pub fn new() -> LayoutSolver {
|
||||
LayoutSolver::default()
|
||||
}
|
||||
|
||||
/// Insert/replace a frame at `handle`.
|
||||
pub fn set_frame(&mut self, handle: Handle, frame: LayoutInput) {
|
||||
self.frames.insert(handle, frame);
|
||||
/// Grow the per-handle arrays to cover `h`.
|
||||
fn ensure(&mut self, h: Handle) {
|
||||
let need = h as usize + 1;
|
||||
if self.input.len() < need {
|
||||
self.input.resize_with(need, LayoutInput::default);
|
||||
self.is_frame.resize(need, false);
|
||||
self.rect.resize(need, None);
|
||||
self.indeg.resize(need, 0);
|
||||
self.dependents.resize_with(need, Vec::new);
|
||||
self.marked.resize(need, false);
|
||||
self.emitted.resize(need, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a frame; returns it if present.
|
||||
pub fn remove_frame(&mut self, handle: Handle) -> Option<LayoutInput> {
|
||||
self.frames.remove(&handle)
|
||||
/// Mark `h` as written this round (so [`begin`](Self::begin) will reset its slot).
|
||||
fn touch(&mut self, h: Handle) {
|
||||
let i = h as usize;
|
||||
if !self.marked[i] {
|
||||
self.marked[i] = true;
|
||||
self.touched.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an external (pre-resolved) rect — typically the screen root that top-level frames
|
||||
/// anchor to.
|
||||
pub fn set_external(&mut self, handle: Handle, rect: Rect) {
|
||||
self.externals.insert(handle, rect);
|
||||
/// Start a round: reset every slot the previous round touched (O(nodes), not O(id space)).
|
||||
pub fn begin(&mut self) {
|
||||
for &h in &self.touched {
|
||||
let i = h as usize;
|
||||
self.is_frame[i] = false;
|
||||
self.rect[i] = None;
|
||||
self.indeg[i] = 0;
|
||||
self.dependents[i].clear();
|
||||
self.marked[i] = false;
|
||||
self.emitted[i] = false;
|
||||
}
|
||||
self.touched.clear();
|
||||
self.live.clear();
|
||||
self.queue.clear();
|
||||
self.order.clear();
|
||||
self.cycle.clear();
|
||||
self.unresolvable.clear();
|
||||
}
|
||||
|
||||
/// The frames map.
|
||||
pub fn frames(&self) -> &BTreeMap<Handle, LayoutInput> {
|
||||
&self.frames
|
||||
/// Seed a pre-resolved rect at `h` — an anchor target this solve does not compute (the screen
|
||||
/// root, an already-resolved region). Also used to publish a rect mid-sweep so later nodes in
|
||||
/// the same pass see it.
|
||||
pub fn set_external(&mut self, h: Handle, rect: Rect) {
|
||||
self.ensure(h);
|
||||
self.touch(h);
|
||||
self.rect[h as usize] = Some(rect);
|
||||
}
|
||||
|
||||
/// The externals map.
|
||||
pub fn externals(&self) -> &BTreeMap<Handle, Rect> {
|
||||
&self.externals
|
||||
/// Register a frame to solve, copying `src` into the solver's persistent slot (the anchors
|
||||
/// `Vec` is refilled in place, so a steady round allocates nothing).
|
||||
pub fn set_frame(&mut self, h: Handle, src: &LayoutInput) {
|
||||
self.ensure(h);
|
||||
self.touch(h);
|
||||
let dst = &mut self.input[h as usize];
|
||||
dst.anchors.clear();
|
||||
dst.anchors.extend_from_slice(&src.anchors);
|
||||
Self::copy_fields(dst, src);
|
||||
self.is_frame[h as usize] = true;
|
||||
self.live.push(h);
|
||||
}
|
||||
|
||||
/// Resolve every frame in dependency order (each anchor target before its dependents),
|
||||
/// detecting cycles. Cycles are reported and everything resolvable is still resolved.
|
||||
pub fn resolve(&self) -> Resolution {
|
||||
// Kahn's algorithm over frame→frame anchor dependencies. A dependency on an external or an
|
||||
// unknown handle does not constrain ordering (its rect is fixed / missing, not produced by
|
||||
// this pass); only frame→frame edges do. A self-anchor counts as an edge, so a
|
||||
// self-referential frame is never emitted ⇒ reported as a cycle.
|
||||
let mut indeg: BTreeMap<Handle, usize> = self.frames.keys().map(|&h| (h, 0)).collect();
|
||||
let mut dependents: BTreeMap<Handle, Vec<Handle>> = BTreeMap::new();
|
||||
for (&h, frame) in &self.frames {
|
||||
let mut seen = BTreeSet::new();
|
||||
for a in &frame.anchors {
|
||||
let d = a.relative_to;
|
||||
if self.frames.contains_key(&d) && seen.insert(d) {
|
||||
*indeg.get_mut(&h).expect("frame in indeg") += 1;
|
||||
dependents.entry(d).or_default().push(h);
|
||||
/// [`set_frame`](Self::set_frame) with `src`'s anchors replaced by exactly `anchor` — the
|
||||
/// ScrollFrame child override (decision 0112), which otherwise cost a full input clone plus a
|
||||
/// one-element `Vec` per scroll child per round.
|
||||
pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) {
|
||||
self.ensure(h);
|
||||
self.touch(h);
|
||||
let dst = &mut self.input[h as usize];
|
||||
dst.anchors.clear();
|
||||
dst.anchors.push(anchor);
|
||||
Self::copy_fields(dst, src);
|
||||
self.is_frame[h as usize] = true;
|
||||
self.live.push(h);
|
||||
}
|
||||
|
||||
/// The non-anchor fields, copied verbatim.
|
||||
fn copy_fields(dst: &mut LayoutInput, src: &LayoutInput) {
|
||||
dst.width = src.width;
|
||||
dst.height = src.height;
|
||||
dst.scale = src.scale;
|
||||
dst.clamp = src.clamp;
|
||||
dst.extent_x = src.extent_x;
|
||||
dst.extent_y = src.extent_y;
|
||||
}
|
||||
|
||||
/// Resolve every registered frame in dependency order (each anchor target before its
|
||||
/// dependents), detecting cycles. Cycle members are still resolved best-effort, ascending by
|
||||
/// handle, against whatever rects were available — matching the map-backed driver this
|
||||
/// replaces.
|
||||
pub fn solve(&mut self) {
|
||||
self.live.sort_unstable();
|
||||
|
||||
// Frame→frame anchor edges. A dependency on an external or an unknown handle does not
|
||||
// constrain ordering (its rect is fixed / missing, not produced by this pass); only
|
||||
// frame→frame edges do. A self-anchor counts as an edge, so a self-referential frame is
|
||||
// never emitted ⇒ reported as a cycle. Duplicate targets count once (an anchor slot set is
|
||||
// at most 9, so the dedupe is a linear scan, not a set).
|
||||
for idx in 0..self.live.len() {
|
||||
let h = self.live[idx];
|
||||
let mut seen: [Handle; 9] = [0; 9];
|
||||
let mut n_seen = 0usize;
|
||||
for a_idx in 0..self.input[h as usize].anchors.len() {
|
||||
let d = self.input[h as usize].anchors[a_idx].relative_to;
|
||||
if !self.is_frame.get(d as usize).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if seen[..n_seen].contains(&d) {
|
||||
continue;
|
||||
}
|
||||
if n_seen < seen.len() {
|
||||
seen[n_seen] = d;
|
||||
n_seen += 1;
|
||||
}
|
||||
self.indeg[h as usize] += 1;
|
||||
self.dependents[d as usize].push(h);
|
||||
}
|
||||
}
|
||||
|
||||
for &h in &self.live {
|
||||
if self.indeg[h as usize] == 0 {
|
||||
self.queue.push_back(h);
|
||||
}
|
||||
}
|
||||
while let Some(h) = self.queue.pop_front() {
|
||||
self.order.push(h);
|
||||
self.emitted[h as usize] = true;
|
||||
for i in 0..self.dependents[h as usize].len() {
|
||||
let dep = self.dependents[h as usize][i];
|
||||
let e = &mut self.indeg[dep as usize];
|
||||
*e -= 1;
|
||||
if *e == 0 {
|
||||
self.queue.push_back(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut queue: VecDeque<Handle> = indeg
|
||||
.iter()
|
||||
.filter(|&(_, &d)| d == 0)
|
||||
.map(|(&h, _)| h)
|
||||
.collect();
|
||||
let mut order: Vec<Handle> = Vec::with_capacity(self.frames.len());
|
||||
while let Some(h) = queue.pop_front() {
|
||||
order.push(h);
|
||||
if let Some(deps) = dependents.get(&h) {
|
||||
for &dep in deps {
|
||||
let e = indeg.get_mut(&dep).expect("dependent in indeg");
|
||||
*e -= 1;
|
||||
if *e == 0 {
|
||||
queue.push_back(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..self.order.len() {
|
||||
let h = self.order[i];
|
||||
self.resolve_one(h);
|
||||
}
|
||||
|
||||
// rects accumulates externals + resolved frames; it is the target-rect lookup.
|
||||
let mut rects: BTreeMap<Handle, Rect> = self.externals.clone();
|
||||
let mut unresolvable: BTreeSet<Handle> = BTreeSet::new();
|
||||
|
||||
let resolve_one = |h: Handle, rects: &mut BTreeMap<Handle, Rect>| -> bool {
|
||||
let frame = &self.frames[&h];
|
||||
match resolve_rect(frame, |t| rects.get(&t).copied()) {
|
||||
ResolveOutcome::Resolved(r) => {
|
||||
rects.insert(h, r);
|
||||
true
|
||||
}
|
||||
ResolveOutcome::Unresolvable => false,
|
||||
// Frames never emitted are on (or behind) a cycle; resolve them best-effort, ascending.
|
||||
for idx in 0..self.live.len() {
|
||||
let h = self.live[idx];
|
||||
if !self.emitted[h as usize] {
|
||||
self.cycle.push(h);
|
||||
self.resolve_one(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one frame against the rects known so far, recording failure.
|
||||
fn resolve_one(&mut self, h: Handle) {
|
||||
let outcome = {
|
||||
let input = &self.input[h as usize];
|
||||
let rects = &self.rect;
|
||||
resolve_rect(input, |t| rects.get(t as usize).copied().flatten())
|
||||
};
|
||||
|
||||
for &h in &order {
|
||||
if !resolve_one(h, &mut rects) {
|
||||
unresolvable.insert(h);
|
||||
}
|
||||
}
|
||||
|
||||
// Frames never emitted are on (or behind) a cycle; resolve them best-effort.
|
||||
let emitted: BTreeSet<Handle> = order.iter().copied().collect();
|
||||
let cycle: BTreeSet<Handle> = self
|
||||
.frames
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|h| !emitted.contains(h))
|
||||
.collect();
|
||||
for &h in &cycle {
|
||||
if !resolve_one(h, &mut rects) {
|
||||
unresolvable.insert(h);
|
||||
}
|
||||
}
|
||||
|
||||
Resolution {
|
||||
rects,
|
||||
unresolvable,
|
||||
cycle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Dirty-tracking wrapper — a thin recompute-on-demand cache over LayoutGraph. The math above is the
|
||||
// fidelity surface; this caching strategy is benilla's own (the client uses a lazy-on-read +
|
||||
// per-frame-batched deferred queue — layout.md — which we may later mirror more closely).
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A [`LayoutGraph`] with a memoized [`Resolution`]. Any mutation marks the whole cache dirty; the
|
||||
/// next [`LayoutCache::resolve`] recomputes. (Per-frame incremental invalidation — dirtying only a
|
||||
/// changed frame and its transitive dependents — is a future optimization; correctness lives in the
|
||||
/// resolver, not the cache.)
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LayoutCache {
|
||||
graph: LayoutGraph,
|
||||
cached: Option<Resolution>,
|
||||
}
|
||||
|
||||
impl LayoutCache {
|
||||
/// Wrap a graph (starts dirty).
|
||||
pub fn new(graph: LayoutGraph) -> LayoutCache {
|
||||
LayoutCache {
|
||||
graph,
|
||||
cached: None,
|
||||
match outcome {
|
||||
ResolveOutcome::Resolved(r) => self.rect[h as usize] = Some(r),
|
||||
ResolveOutcome::Unresolvable => self.unresolvable.push(h),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert/replace a frame and invalidate the cache.
|
||||
pub fn set_frame(&mut self, handle: Handle, frame: LayoutInput) {
|
||||
self.graph.set_frame(handle, frame);
|
||||
self.cached = None;
|
||||
/// The rect known at `h`: an external as seeded, or a frame's resolved rect. `None` if the
|
||||
/// handle is unknown or its frame was under-constrained.
|
||||
#[inline]
|
||||
pub fn rect(&self, h: Handle) -> Option<Rect> {
|
||||
self.rect.get(h as usize).copied().flatten()
|
||||
}
|
||||
|
||||
/// Remove a frame and invalidate the cache.
|
||||
pub fn remove_frame(&mut self, handle: Handle) -> Option<LayoutInput> {
|
||||
let removed = self.graph.remove_frame(handle);
|
||||
if removed.is_some() {
|
||||
self.cached = None;
|
||||
}
|
||||
removed
|
||||
/// Frames on (or behind) an anchor dependency cycle — no valid topological position. Ascending.
|
||||
pub fn cycle(&self) -> &[Handle] {
|
||||
&self.cycle
|
||||
}
|
||||
|
||||
/// Set an external rect and invalidate the cache.
|
||||
pub fn set_external(&mut self, handle: Handle, rect: Rect) {
|
||||
self.graph.set_external(handle, rect);
|
||||
self.cached = None;
|
||||
}
|
||||
|
||||
/// Whether the next [`LayoutCache::resolve`] will recompute.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.cached.is_none()
|
||||
}
|
||||
|
||||
/// The underlying graph.
|
||||
pub fn graph(&self) -> &LayoutGraph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
/// The resolved layout, recomputing if dirty.
|
||||
pub fn resolve(&mut self) -> &Resolution {
|
||||
if self.cached.is_none() {
|
||||
self.cached = Some(self.graph.resolve());
|
||||
}
|
||||
self.cached.as_ref().expect("just populated")
|
||||
/// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an
|
||||
/// unresolvable/missing target). May overlap [`cycle`](Self::cycle).
|
||||
pub fn unresolvable(&self) -> &[Handle] {
|
||||
&self.unresolvable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1166,23 +1206,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chained_frames_via_graph() {
|
||||
fn chained_frames_via_solver() {
|
||||
const A: Handle = 1;
|
||||
const B: Handle = 2;
|
||||
let mut g = LayoutGraph::new();
|
||||
g.set_external(SCREEN, screen_rect());
|
||||
// Insert B before A to prove topological ordering, not insertion ordering.
|
||||
g.set_frame(
|
||||
let mut s = LayoutSolver::new();
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
// Register B before A to prove topological ordering, not registration ordering.
|
||||
s.set_frame(
|
||||
B,
|
||||
LayoutInput::sized(
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
|
||||
20.0,
|
||||
20.0,
|
||||
),
|
||||
);
|
||||
g.set_frame(
|
||||
s.set_frame(
|
||||
A,
|
||||
LayoutInput::sized(
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(
|
||||
Point::TopLeft,
|
||||
SCREEN,
|
||||
@@ -1194,17 +1235,17 @@ mod tests {
|
||||
50.0,
|
||||
),
|
||||
);
|
||||
let res = g.resolve();
|
||||
assert!(res.cycle.is_empty());
|
||||
assert!(res.unresolvable.is_empty());
|
||||
s.solve();
|
||||
assert!(s.cycle().is_empty());
|
||||
assert!(s.unresolvable().is_empty());
|
||||
// A: left 100, top 500, right 150, bottom 450
|
||||
assert_eq!(
|
||||
bits(res.rects[&A]),
|
||||
bits(s.rect(A).expect("A resolved")),
|
||||
bits(Rect::new(450.0, 100.0, 500.0, 150.0))
|
||||
);
|
||||
// B hangs off A's bottom-right corner (150, 450), size 20
|
||||
assert_eq!(
|
||||
bits(res.rects[&B]),
|
||||
bits(s.rect(B).expect("B resolved")),
|
||||
bits(Rect::new(430.0, 150.0, 450.0, 170.0))
|
||||
);
|
||||
}
|
||||
@@ -1241,67 +1282,70 @@ mod tests {
|
||||
fn dependent_of_unresolvable_is_unresolvable() {
|
||||
const A: Handle = 1; // no anchors -> unresolvable
|
||||
const B: Handle = 2; // anchored to A
|
||||
let mut g = LayoutGraph::new();
|
||||
g.set_external(SCREEN, screen_rect());
|
||||
g.set_frame(A, LayoutInput::sized(vec![], 100.0, 40.0));
|
||||
g.set_frame(
|
||||
let mut s = LayoutSolver::new();
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame(A, &LayoutInput::sized(vec![], 100.0, 40.0));
|
||||
s.set_frame(
|
||||
B,
|
||||
LayoutInput::sized(
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(Point::TopLeft, A, Point::TopLeft, 0.0, 0.0)],
|
||||
20.0,
|
||||
20.0,
|
||||
),
|
||||
);
|
||||
let res = g.resolve();
|
||||
assert!(res.unresolvable.contains(&A));
|
||||
assert!(res.unresolvable.contains(&B));
|
||||
assert!(!res.rects.contains_key(&A));
|
||||
assert!(!res.rects.contains_key(&B));
|
||||
s.solve();
|
||||
assert!(s.unresolvable().contains(&A));
|
||||
assert!(s.unresolvable().contains(&B));
|
||||
assert!(s.rect(A).is_none());
|
||||
assert!(s.rect(B).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_detected() {
|
||||
const A: Handle = 1;
|
||||
const B: Handle = 2;
|
||||
let mut g = LayoutGraph::new();
|
||||
g.set_frame(
|
||||
let mut s = LayoutSolver::new();
|
||||
s.begin();
|
||||
s.set_frame(
|
||||
A,
|
||||
LayoutInput::sized(
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(Point::TopLeft, B, Point::TopLeft, 0.0, 0.0)],
|
||||
20.0,
|
||||
20.0,
|
||||
),
|
||||
);
|
||||
g.set_frame(
|
||||
s.set_frame(
|
||||
B,
|
||||
LayoutInput::sized(
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(Point::TopLeft, A, Point::TopLeft, 0.0, 0.0)],
|
||||
20.0,
|
||||
20.0,
|
||||
),
|
||||
);
|
||||
let res = g.resolve();
|
||||
assert!(res.cycle.contains(&A));
|
||||
assert!(res.cycle.contains(&B));
|
||||
s.solve();
|
||||
assert!(s.cycle().contains(&A));
|
||||
assert!(s.cycle().contains(&B));
|
||||
// neither can resolve (each depends on the other's rect, unavailable)
|
||||
assert!(res.unresolvable.contains(&A));
|
||||
assert!(res.unresolvable.contains(&B));
|
||||
assert!(s.unresolvable().contains(&A));
|
||||
assert!(s.unresolvable().contains(&B));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_anchor_is_a_cycle() {
|
||||
const A: Handle = 1;
|
||||
let mut g = LayoutGraph::new();
|
||||
g.set_frame(
|
||||
let mut s = LayoutSolver::new();
|
||||
s.begin();
|
||||
s.set_frame(
|
||||
A,
|
||||
LayoutInput::sized(
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
|
||||
20.0,
|
||||
20.0,
|
||||
),
|
||||
);
|
||||
let res = g.resolve();
|
||||
assert!(res.cycle.contains(&A));
|
||||
s.solve();
|
||||
assert!(s.cycle().contains(&A));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1315,13 +1359,22 @@ mod tests {
|
||||
assert!(size_changed(r, big));
|
||||
}
|
||||
|
||||
/// The solver's buffers live across rounds, so `begin` must leave NO trace of the previous
|
||||
/// one — a stale rect, in-degree, dependent edge, or `is_frame` flag would silently corrupt
|
||||
/// the next solve. Round 2 registers a *disjoint* frame set that anchors to a handle round 1
|
||||
/// used, and must see it as unknown (not round 1's rect).
|
||||
#[test]
|
||||
fn cache_recomputes_on_dirty() {
|
||||
let mut g = LayoutGraph::new();
|
||||
g.set_external(SCREEN, screen_rect());
|
||||
g.set_frame(
|
||||
1,
|
||||
LayoutInput::sized(
|
||||
fn begin_leaves_no_state_from_the_previous_round() {
|
||||
const A: Handle = 1;
|
||||
const B: Handle = 2;
|
||||
let mut s = LayoutSolver::new();
|
||||
|
||||
// Round 1: A anchored to the screen, B hanging off A.
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame(
|
||||
A,
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(
|
||||
Point::TopLeft,
|
||||
SCREEN,
|
||||
@@ -1333,27 +1386,115 @@ mod tests {
|
||||
40.0,
|
||||
),
|
||||
);
|
||||
let mut cache = LayoutCache::new(g);
|
||||
assert!(cache.is_dirty());
|
||||
let first = cache.resolve().rects[&1];
|
||||
assert!(!cache.is_dirty());
|
||||
// mutate -> dirty -> recompute reflects the change
|
||||
cache.set_frame(
|
||||
1,
|
||||
LayoutInput::sized(
|
||||
s.set_frame(
|
||||
B,
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
|
||||
20.0,
|
||||
20.0,
|
||||
),
|
||||
);
|
||||
s.solve();
|
||||
let a_first = s.rect(A).expect("A resolved in round 1");
|
||||
assert!(s.rect(B).is_some());
|
||||
|
||||
// Round 2: ONLY B, still anchored to A — but A is no longer registered and no external
|
||||
// seeds it, so B must be unresolvable rather than reusing round 1's stale A rect.
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame(
|
||||
B,
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
|
||||
20.0,
|
||||
20.0,
|
||||
),
|
||||
);
|
||||
s.solve();
|
||||
assert!(
|
||||
s.rect(A).is_none(),
|
||||
"round 1's A rect must not survive begin"
|
||||
);
|
||||
assert!(
|
||||
s.rect(B).is_none(),
|
||||
"B must not resolve against a stale target"
|
||||
);
|
||||
assert!(s.unresolvable().contains(&B));
|
||||
assert!(s.cycle().is_empty(), "cycle list must be cleared too");
|
||||
|
||||
// Round 3: the original pair again — identical results to round 1 (no accumulated
|
||||
// in-degree or dependent edges from the rounds between).
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame(
|
||||
A,
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(
|
||||
Point::TopLeft,
|
||||
SCREEN,
|
||||
Point::TopLeft,
|
||||
50.0,
|
||||
0.0,
|
||||
0.0,
|
||||
)],
|
||||
100.0,
|
||||
40.0,
|
||||
),
|
||||
);
|
||||
assert!(cache.is_dirty());
|
||||
let second = cache.resolve().rects[&1];
|
||||
assert_eq!(second.left - first.left, 50.0);
|
||||
s.set_frame(
|
||||
B,
|
||||
&LayoutInput::sized(
|
||||
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
|
||||
20.0,
|
||||
20.0,
|
||||
),
|
||||
);
|
||||
s.solve();
|
||||
assert_eq!(
|
||||
bits(s.rect(A).expect("A resolved in round 3")),
|
||||
bits(a_first)
|
||||
);
|
||||
assert!(s.unresolvable().is_empty());
|
||||
}
|
||||
|
||||
/// A frame registered with the ScrollFrame override keeps its size/scale but drops its own
|
||||
/// anchors for exactly the override (decision 0112) — and the slot's anchor `Vec` is reused,
|
||||
/// so the previous round's anchors must not leak through.
|
||||
#[test]
|
||||
fn set_frame_anchored_replaces_only_the_anchors() {
|
||||
const A: Handle = 1;
|
||||
let mut s = LayoutSolver::new();
|
||||
let input = LayoutInput::sized(
|
||||
vec![
|
||||
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 0.0, 0.0),
|
||||
Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, 0.0, 0.0),
|
||||
],
|
||||
100.0,
|
||||
40.0,
|
||||
);
|
||||
|
||||
// Plain: both anchors pin all four edges, so the size is ignored.
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame(A, &input);
|
||||
s.solve();
|
||||
assert_eq!(
|
||||
bits(s.rect(A).expect("resolved")),
|
||||
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
|
||||
);
|
||||
|
||||
// Overridden: the two authored anchors are gone, replaced by one TOPLEFT pin at (10, -5),
|
||||
// so the explicit 100x40 size now decides the other two edges.
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame_anchored(
|
||||
A,
|
||||
&input,
|
||||
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0),
|
||||
);
|
||||
s.solve();
|
||||
assert_eq!(
|
||||
bits(s.rect(A).expect("resolved")),
|
||||
bits(Rect::new(555.0, 10.0, 595.0, 110.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::layout::{self, Anchor, LayoutGraph, LayoutInput, Point, Rect};
|
||||
use crate::layout::{self, Anchor, LayoutInput, Point, Rect};
|
||||
use crate::order::ZTarget;
|
||||
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
|
||||
|
||||
@@ -10,6 +10,104 @@ use super::{
|
||||
Outline, QuadContent, UiScript, SCREEN,
|
||||
};
|
||||
|
||||
/// A 128-bit rolling fingerprint of everything [`UiScript::resolve_layout`] *reads*, so a resolve
|
||||
/// whose inputs are byte-identical to the last one can be skipped outright.
|
||||
///
|
||||
/// ## Why a fingerprint and not a dirty flag
|
||||
///
|
||||
/// A dirty flag has to be *set* at every mutation site, and the resolve's read set is spread over
|
||||
/// `layout_inputs`, the arena (scale, clamp, ScrollFrame child + offset, frame/region liveness),
|
||||
/// `region_data` (anchors, size, measured), and `screen`. Missing one site is a silently stale
|
||||
/// layout — the worst failure mode this module has, and one no test reliably catches because the
|
||||
/// staleness only shows on the frame the missed setter fires. A fingerprint has no sites to miss:
|
||||
/// it is computed FROM the read set, so any change to any of it changes the value by construction.
|
||||
/// It also absorbs *idempotent* writes for free — the GameTooltip pre-pass rewrites tooltip sizes
|
||||
/// and anchor offsets every single call, which would pin a mutation-tracked flag permanently dirty.
|
||||
///
|
||||
/// ## Why 128 bits
|
||||
///
|
||||
/// A collision here is not self-healing: the skip keeps the stored value, so a colliding change
|
||||
/// would stay unresolved until something *else* moved. Two independent lanes put that beyond
|
||||
/// physical concern (~2^-128 per frame) at the cost of one extra multiply per item.
|
||||
///
|
||||
/// ## Order sensitivity is safe
|
||||
///
|
||||
/// The accumulator is order-dependent, and it is fed by walking `HashMap`s whose iteration order
|
||||
/// is arbitrary. That order is nevertheless *stable for an unmutated map*, so an unchanged model
|
||||
/// reproduces the value; a mutated map may reorder and merely re-resolves. The failure mode is a
|
||||
/// false DIRTY (a wasted resolve), never a false CLEAN (a stale rect).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct InputFingerprint(u64, u64);
|
||||
|
||||
impl Default for InputFingerprint {
|
||||
fn default() -> Self {
|
||||
// Distinct non-zero bases (FNV-1a's, and a 64-bit mix constant) so leading zero-valued
|
||||
// items still move both lanes.
|
||||
InputFingerprint(0xcbf2_9ce4_8422_2325, 0x9e37_79b9_7f4a_7c15)
|
||||
}
|
||||
}
|
||||
|
||||
impl InputFingerprint {
|
||||
#[inline]
|
||||
fn feed(&mut self, v: u64) {
|
||||
self.0 = (self.0 ^ v).wrapping_mul(0x0000_0100_0000_01b3);
|
||||
self.1 = (self.1 ^ v.rotate_left(29)).wrapping_mul(0xff51_afd7_ed55_8ccd);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn f32(&mut self, v: f32) {
|
||||
// Bit pattern, not value: `resolve` is bit-exact float math, so -0.0 and NaN payloads are
|
||||
// distinctions that matter here even where `==` would call them equal (or unequal).
|
||||
self.feed(u64::from(v.to_bits()));
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn anchors(&mut self, anchors: &[Anchor]) {
|
||||
self.feed(anchors.len() as u64);
|
||||
for a in anchors {
|
||||
self.feed(u64::from(a.point.id()));
|
||||
self.feed(u64::from(a.relative_to));
|
||||
self.feed(u64::from(a.relative_point.id()));
|
||||
self.f32(a.x_off);
|
||||
self.f32(a.y_off);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn input(&mut self, i: &LayoutInput) {
|
||||
self.anchors(&i.anchors);
|
||||
self.f32(i.width);
|
||||
self.f32(i.height);
|
||||
self.f32(i.scale);
|
||||
self.feed(u64::from(i.clamp));
|
||||
self.f32(i.extent_x);
|
||||
self.f32(i.extent_y);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rect(&mut self, r: Rect) {
|
||||
self.f32(r.bottom);
|
||||
self.f32(r.left);
|
||||
self.f32(r.top);
|
||||
self.f32(r.right);
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_LAYOUT_VERIFY=1` — the change gate's self-check (decision 0022's instruments-first
|
||||
/// posture). When set, a resolve the gate wants to SKIP runs in full anyway and asserts it
|
||||
/// reproduced the previous rects exactly; a divergence means the fingerprint's read set is
|
||||
/// incomplete, and it fails loudly at the resolve that proves it rather than silently rendering a
|
||||
/// stale frame days later.
|
||||
///
|
||||
/// This is what makes the gate's central claim — "the fingerprint covers everything the rounds
|
||||
/// read" — machine-checked instead of argued: run the workspace suite under it and every UI test
|
||||
/// (the shipped windows, driven through real FrameXML + Lua) becomes a probe for a missed input.
|
||||
/// Read once; when off it costs a relaxed atomic load per resolve.
|
||||
fn layout_verify_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
impl UiScript {
|
||||
/// [`UiScript::resolve`]'s body, taking `&mut Model` directly rather than `&mut self` — so a
|
||||
/// Lua binding holding only a `Model` borrow (via `lua.app_data_mut`, no `&Lua`-wrapping
|
||||
@@ -34,21 +132,26 @@ impl UiScript {
|
||||
region_data,
|
||||
region_resolved,
|
||||
frame_to_id,
|
||||
id_to_frame,
|
||||
id_to_region,
|
||||
region_to_id,
|
||||
screen,
|
||||
warnings,
|
||||
solver,
|
||||
layout_fingerprint,
|
||||
layout_solves,
|
||||
..
|
||||
} = model;
|
||||
|
||||
// One id per live frame, and its layout input with scale synced from the arena.
|
||||
let mut ids: HashMap<FrameHandle, u32> = HashMap::new();
|
||||
// One id per live frame, and its layout input with scale synced from the arena. A flat
|
||||
// `Vec`, not a map: this list is walked (never probed) by every round below, and it is
|
||||
// rebuilt on each call anyway — a `HashMap` bought nothing and cost 1881 hashes per call.
|
||||
let mut ids: Vec<(FrameHandle, u32)> = Vec::with_capacity(frame_to_id.len());
|
||||
for (&h, &id) in frame_to_id.iter() {
|
||||
if arena.frame(h).is_some() {
|
||||
ids.insert(h, id);
|
||||
ids.push((h, id));
|
||||
}
|
||||
}
|
||||
for &h in ids.keys() {
|
||||
for &(h, _) in &ids {
|
||||
let (scale, clamp) = arena
|
||||
.frame(h)
|
||||
.map(|f| (f.effective_scale, f.clamped_to_screen))
|
||||
@@ -76,7 +179,7 @@ impl UiScript {
|
||||
// computed. The child's own width/height (from its own `LayoutInput`) stay whatever they are;
|
||||
// only its anchors are replaced.
|
||||
let mut scroll_child_anchor: HashMap<FrameHandle, Anchor> = HashMap::new();
|
||||
for (&h, &id) in ids.iter() {
|
||||
for &(h, id) in &ids {
|
||||
let Some(frame) = arena.frame(h) else {
|
||||
continue;
|
||||
};
|
||||
@@ -122,38 +225,140 @@ impl UiScript {
|
||||
arena.region(*rh).is_some()
|
||||
&& region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
|
||||
});
|
||||
let round_cap = ids.len() + region_data.len() + 2;
|
||||
// The per-frame solve plan, resolved ONCE per call rather than per round: each live
|
||||
// frame's id, a borrow of its (already scale/clamp-synced) layout input, and its
|
||||
// ScrollFrame anchor override if it is a scroll child. The round loop below then walks a
|
||||
// flat slice — no map probes, no input clones.
|
||||
let plan: Vec<(FrameHandle, u32, &LayoutInput, Option<Anchor>)> = ids
|
||||
.iter()
|
||||
.filter_map(|&(h, id)| {
|
||||
layout_inputs
|
||||
.get(&h)
|
||||
.map(|input| (h, id, input, scroll_child_anchor.get(&h).copied()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── The change gate ───────────────────────────────────────────────────────────────────
|
||||
// Fingerprint exactly what the rounds below read, and skip them outright when nothing has
|
||||
// moved since the last converged resolve. Measured on the shipped default UI: the layout
|
||||
// inputs are byte-identical on 34 of 35 idle frames and 39 of 41 with three windows open,
|
||||
// because almost all per-frame UI traffic (StatusBar values, cooldown sweeps, chat fades,
|
||||
// colour changes) is read by EXTRACT, not by the anchor solve.
|
||||
//
|
||||
// The read set, in the order the rounds consume it:
|
||||
// * `screen` — the root external, and the clamp extents;
|
||||
// * `plan` — every live frame's id, its (already scale/clamp-
|
||||
// synced) `LayoutInput`, and its ScrollFrame override,
|
||||
// which together distil `frame_to_id`, frame liveness,
|
||||
// `effective_scale`, `clamped_to_screen`, and the
|
||||
// ScrollFrame child + vertical offset;
|
||||
// * `id_to_region` × `region_resolved` — the region rects seeded as externals;
|
||||
// * `region_data` — each region's anchors, explicit size, and measured
|
||||
// text extent;
|
||||
// * `arena.region(rh)` — liveness and owner (a dead region drops out of the
|
||||
// sweep; the owner supplies the fallback edges).
|
||||
// `resolved` / `region_resolved` are the previous pass's OUTPUT carried forward as the
|
||||
// 0294 seed; at convergence seed and output are equal, so re-running against an unchanged
|
||||
// input set is guaranteed to reproduce them — which is precisely what makes the skip safe.
|
||||
let mut fp = InputFingerprint::default();
|
||||
fp.rect(*screen);
|
||||
fp.feed(plan.len() as u64);
|
||||
for &(_, id, input, over) in &plan {
|
||||
fp.feed(u64::from(id));
|
||||
fp.input(input);
|
||||
match over {
|
||||
Some(a) => fp.anchors(std::slice::from_ref(&a)),
|
||||
None => fp.feed(u64::MAX),
|
||||
}
|
||||
}
|
||||
fp.feed(region_data.len() as u64);
|
||||
for (&rh, data) in region_data.iter() {
|
||||
fp.feed(rh.fingerprint_bits());
|
||||
fp.anchors(&data.anchors);
|
||||
match data.size {
|
||||
Some((w, h)) => {
|
||||
fp.f32(w);
|
||||
fp.f32(h);
|
||||
}
|
||||
None => fp.feed(u64::MAX),
|
||||
}
|
||||
match data.measured {
|
||||
Some(m) => {
|
||||
fp.f32(m.w);
|
||||
fp.f32(m.h);
|
||||
}
|
||||
None => fp.feed(u64::MAX),
|
||||
}
|
||||
// Liveness only. The sweep also reads the region's `owner` and `kind`, but both are
|
||||
// fixed at creation and can never change under a live handle; the owner's contribution
|
||||
// to the result — its resolved rect and its `effective_scale` — rides the frame half
|
||||
// (the scale is synced into `layout_inputs` by the preamble above, and the rect is the
|
||||
// frame pass's own output). A DESTROYED region does still matter: `WidgetArena::destroy`
|
||||
// drops the arena entry but leaves `region_data` behind, so membership alone would miss
|
||||
// it and the sweep's `continue` would go unnoticed.
|
||||
fp.feed(u64::from(arena.region(rh).is_some()));
|
||||
}
|
||||
for (&id, rh) in id_to_region.iter() {
|
||||
if let Some(r) = region_resolved.get(rh) {
|
||||
fp.feed(u64::from(id));
|
||||
fp.rect(*r);
|
||||
}
|
||||
}
|
||||
let gate_skips = *layout_fingerprint == Some(fp);
|
||||
if gate_skips && !layout_verify_enabled() {
|
||||
return;
|
||||
}
|
||||
// Under `WOW_LAYOUT_VERIFY=1` a skippable resolve runs anyway, against a copy of the rects
|
||||
// it is supposed to reproduce — see [`layout_verify_enabled`].
|
||||
let verify_against = gate_skips.then(|| (resolved.clone(), region_resolved.clone()));
|
||||
// Cleared, not stored, until the fixpoint actually CONVERGES: a run that exhausts the
|
||||
// round cap (a genuine anchor cycle) leaves its rects mid-flight, and the 0294 cross-frame
|
||||
// seed is what lets the next frame carry them further. Storing here would freeze a
|
||||
// pathological graph at whatever partial state it reached.
|
||||
*layout_fingerprint = None;
|
||||
// Counted on the gate's DECISION, not on the fixpoint running: under
|
||||
// `WOW_LAYOUT_VERIFY=1` a skipped resolve still runs below, and a counter that moved for
|
||||
// it would mean something different in the two modes — exactly the ambiguity the tests
|
||||
// asserting on it exist to remove.
|
||||
if !gate_skips {
|
||||
*layout_solves += 1;
|
||||
}
|
||||
|
||||
let round_cap = plan.len() + region_data.len() + 2;
|
||||
for round in 0..round_cap {
|
||||
let mut changed = false;
|
||||
|
||||
let mut graph = LayoutGraph::new();
|
||||
graph.set_external(SCREEN, *screen);
|
||||
// Seed the solver: the screen root, then every region rect settled so far (regions are
|
||||
// externals to the frame solve — the fixpoint is what closes the loop between them).
|
||||
// Frame ids and region ids come from one monotonic counter, so both live in the
|
||||
// solver's single dense rect array and every anchor-target lookup is an array index.
|
||||
solver.begin();
|
||||
solver.set_external(SCREEN, *screen);
|
||||
for (&id, rh) in id_to_region.iter() {
|
||||
if let Some(r) = region_resolved.get(rh) {
|
||||
graph.set_external(id, *r);
|
||||
solver.set_external(id, *r);
|
||||
}
|
||||
}
|
||||
for (&h, &id) in ids.iter() {
|
||||
if let Some(input) = layout_inputs.get(&h) {
|
||||
match scroll_child_anchor.get(&h) {
|
||||
Some(&over) => {
|
||||
let mut overridden = input.clone();
|
||||
overridden.anchors = vec![over];
|
||||
graph.set_frame(id, overridden);
|
||||
for &(_, id, input, over) in &plan {
|
||||
match over {
|
||||
Some(a) => solver.set_frame_anchored(id, input, a),
|
||||
None => solver.set_frame(id, input),
|
||||
}
|
||||
}
|
||||
solver.solve();
|
||||
for &(h, id, _, _) in &plan {
|
||||
match solver.rect(id) {
|
||||
Some(r) => {
|
||||
if resolved.get(&h) != Some(&r) {
|
||||
resolved.insert(h, r);
|
||||
changed = true;
|
||||
}
|
||||
None => graph.set_frame(id, input.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
let res = graph.resolve();
|
||||
for (&h, &id) in ids.iter() {
|
||||
if let Some(&r) = res.rects.get(&id) {
|
||||
if resolved.get(&h) != Some(&r) {
|
||||
resolved.insert(h, r);
|
||||
changed = true;
|
||||
None => {
|
||||
if resolved.remove(&h).is_some() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} else if resolved.remove(&h).is_some() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +370,10 @@ impl UiScript {
|
||||
// owner-fallback that stood in for this was the jutting-plates bug). One region sweep per
|
||||
// outer round: a not-yet-resolved chain target leaves this region on its owner-edge
|
||||
// fallback for now, the fixpoint re-sweeps until every link has settled.
|
||||
//
|
||||
// `scratch` is hoisted out of the loop so the per-region `LayoutInput` refills its
|
||||
// anchors `Vec` in place instead of allocating one per region per round.
|
||||
let mut scratch = LayoutInput::default();
|
||||
for (&rh, data) in region_data.iter() {
|
||||
if data.anchors.is_empty() {
|
||||
continue;
|
||||
@@ -193,19 +402,20 @@ impl UiScript {
|
||||
width = m.w;
|
||||
}
|
||||
}
|
||||
let input = LayoutInput {
|
||||
anchors: data.anchors.clone(),
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
..LayoutInput::default()
|
||||
};
|
||||
let edges = layout::resolve_rect_edges(&input, |id| {
|
||||
id_to_frame
|
||||
.get(&id)
|
||||
.and_then(|h| resolved.get(h))
|
||||
.or_else(|| id_to_region.get(&id).and_then(|h| region_resolved.get(h)))
|
||||
.copied()
|
||||
scratch.anchors.clear();
|
||||
scratch.anchors.extend_from_slice(&data.anchors);
|
||||
scratch.width = width;
|
||||
scratch.height = height;
|
||||
scratch.scale = scale;
|
||||
let edges = layout::resolve_rect_edges(&scratch, |id| {
|
||||
// SCREEN is a FRAME-pass external only. The map-pair lookup this replaces
|
||||
// consulted `id_to_frame` and `id_to_region`, and neither can hold the
|
||||
// reserved id 0 (`next_id` starts at 1) — so a region anchor to the screen
|
||||
// root has always fallen through to the owner-edge fallback below. Preserved
|
||||
// deliberately: the solver's dense array DOES carry the screen rect at 0, and
|
||||
// quietly starting to resolve it here would be a behaviour change riding in
|
||||
// on a performance fix.
|
||||
(id != SCREEN).then(|| solver.rect(id)).flatten()
|
||||
});
|
||||
// Unpinned edges: textures inherit the owner's edge (the v1 region model,
|
||||
// decision 0068). A FONTSTRING's implicit extent is its measured TEXT, and
|
||||
@@ -225,6 +435,12 @@ impl UiScript {
|
||||
let (bottom, top) = axis(edges[0], edges[2], owner_rect.bottom, owner_rect.top);
|
||||
let (left, right) = axis(edges[1], edges[3], owner_rect.left, owner_rect.right);
|
||||
let rect = Rect::new(bottom, left, top, right);
|
||||
// Publish into the solver as well as the model: a later region in THIS same sweep
|
||||
// that anchors to this one must see the fresh rect (the sweep has always worked
|
||||
// that way — it read the same `region_resolved` map it was writing).
|
||||
if let Some(&id) = region_to_id.get(&rh) {
|
||||
solver.set_external(id, rect);
|
||||
}
|
||||
if region_resolved.get(&rh) != Some(&rect) {
|
||||
region_resolved.insert(rh, rect);
|
||||
changed = true;
|
||||
@@ -232,6 +448,19 @@ impl UiScript {
|
||||
}
|
||||
|
||||
if !changed {
|
||||
if let Some((frames_before, regions_before)) = &verify_against {
|
||||
assert!(
|
||||
frames_before == resolved && regions_before == region_resolved,
|
||||
"WOW_LAYOUT_VERIFY: the change gate skipped a resolve that would have \
|
||||
MOVED something — the fingerprint's read set is incomplete. \
|
||||
frames {} -> {} changed, regions {} -> {} changed",
|
||||
frames_before.len(),
|
||||
resolved.len(),
|
||||
regions_before.len(),
|
||||
region_resolved.len(),
|
||||
);
|
||||
}
|
||||
*layout_fingerprint = Some(fp);
|
||||
return;
|
||||
}
|
||||
if round + 1 == round_cap {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::layout::{LayoutInput, Rect};
|
||||
use crate::layout::{LayoutInput, LayoutSolver, Rect};
|
||||
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
|
||||
|
||||
use super::{
|
||||
@@ -25,6 +25,22 @@ pub(crate) struct Model {
|
||||
pub(crate) layout_inputs: HashMap<FrameHandle, LayoutInput>,
|
||||
/// The last [`UiScript::resolve`] result: each resolvable frame's rect. Empty until `resolve`.
|
||||
pub(crate) resolved: HashMap<FrameHandle, Rect>,
|
||||
/// The anchor-graph solver, kept alive across resolves so its per-handle arrays and the
|
||||
/// per-frame anchor `Vec`s are reused — a steady round allocates nothing (see
|
||||
/// [`LayoutSolver`]). Scratch only: it holds no state between rounds that `begin` doesn't
|
||||
/// clear, and `resolved`/`region_resolved` remain the model's own answer.
|
||||
pub(crate) solver: LayoutSolver,
|
||||
/// The fingerprint of the input set the last CONVERGED resolve ran against — the change gate
|
||||
/// (see `script::layout::InputFingerprint`). `None` forces the next resolve to run: the
|
||||
/// initial state, and what a non-converging (cyclic) pass leaves behind so its progress can
|
||||
/// carry into the next frame.
|
||||
pub(crate) layout_fingerprint: Option<super::layout::InputFingerprint>,
|
||||
/// How many times the change gate has LET A RESOLVE THROUGH. `UiScript::resolve` is called
|
||||
/// unconditionally every frame, so the gap between call count and this is the gate's whole
|
||||
/// value; it is also what lets a test assert that a no-op resolve really was one, rather than
|
||||
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
|
||||
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
|
||||
pub(crate) layout_solves: u64,
|
||||
/// Hyperlink spans per frame — `(rect, link payload, full |H…|h markup)`, rects in the
|
||||
/// engine's y-up screen space. Fed by the app each frame after it rasterizes message lines
|
||||
/// ([`super::UiScript::set_link_spans`]); consumed by the pointer's release dispatch
|
||||
@@ -551,6 +567,9 @@ impl Model {
|
||||
Model {
|
||||
arena: WidgetArena::new(),
|
||||
layout_inputs: HashMap::new(),
|
||||
solver: LayoutSolver::new(),
|
||||
layout_fingerprint: None,
|
||||
layout_solves: 0,
|
||||
resolved: HashMap::new(),
|
||||
link_spans: HashMap::new(),
|
||||
chat_tab: false,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The layout change gate: a resolve whose inputs are byte-identical to the last converged one is
|
||||
//! skipped outright (`script::layout::InputFingerprint`).
|
||||
//!
|
||||
//! These assert on `Model::layout_solves` — the count of times the fixpoint actually ran — rather
|
||||
//! than on the rects, because rect equality alone cannot distinguish "the gate skipped" from "the
|
||||
//! gate re-solved and got the same answer". The value of the gate is precisely the difference.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{Model, UiScript};
|
||||
|
||||
/// How many times the fixpoint has run.
|
||||
fn solves(s: &UiScript) -> u64 {
|
||||
s.lua()
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.layout_solves
|
||||
}
|
||||
|
||||
/// A frame anchored to the screen, plus a child hanging off it — enough that a move has to
|
||||
/// propagate, so a wrongly-skipped resolve would be visible in the child too.
|
||||
fn setup(s: &UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
parent = CreateFrame("Frame", "Parent", nil)
|
||||
parent:SetWidth(100); parent:SetHeight(40)
|
||||
parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
|
||||
child = CreateFrame("Frame", "Child", parent)
|
||||
child:SetWidth(20); child:SetHeight(20)
|
||||
child:SetPoint("TOPLEFT", parent, "BOTTOMRIGHT", 0, 0)
|
||||
"#,
|
||||
)
|
||||
.expect("setup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_resolve_does_not_run_the_fixpoint() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
|
||||
s.resolve();
|
||||
let after_first = solves(&s);
|
||||
assert_eq!(after_first, 1, "the first resolve must run");
|
||||
|
||||
// Nothing touched in between: every further resolve is a no-op.
|
||||
for _ in 0..5 {
|
||||
s.resolve();
|
||||
}
|
||||
assert_eq!(
|
||||
solves(&s),
|
||||
after_first,
|
||||
"resolves with identical inputs must be skipped, not re-run"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_frame_reopens_the_gate_and_propagates() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
s.resolve();
|
||||
s.resolve(); // settle: the gate is now closed
|
||||
let before = solves(&s);
|
||||
|
||||
let child_left = |s: &UiScript| -> f32 { s.eval::<f32>("return Child:GetLeft()").unwrap() };
|
||||
let first = child_left(&s);
|
||||
|
||||
s.run("parent:SetPoint(\"TOPLEFT\", nil, \"TOPLEFT\", 60, -10)")
|
||||
.expect("move");
|
||||
s.resolve();
|
||||
assert_eq!(solves(&s), before + 1, "a SetPoint must reopen the gate");
|
||||
assert!(
|
||||
(child_left(&s) - (first + 50.0)).abs() < 0.001,
|
||||
"the move must propagate to the child: {} -> {}",
|
||||
first,
|
||||
child_left(&s)
|
||||
);
|
||||
|
||||
// And it closes again once the move has settled.
|
||||
let after = solves(&s);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
solves(&s),
|
||||
after,
|
||||
"the gate must close again after the move"
|
||||
);
|
||||
}
|
||||
|
||||
/// Resizing the window moves every top-level frame — the gate must never swallow it.
|
||||
#[test]
|
||||
fn a_screen_resize_reopens_the_gate() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
s.resolve();
|
||||
s.resolve();
|
||||
let before = solves(&s);
|
||||
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
solves(&s),
|
||||
before + 1,
|
||||
"a screen-size change must reopen the gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// A region's own `SetPoint` writes `region_data`, not `layout_inputs` — the half of the read set
|
||||
/// that is easiest to leave out of a change gate, and the one the region sweep consumes.
|
||||
#[test]
|
||||
fn moving_a_region_reopens_the_gate() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
s.run(
|
||||
r#"
|
||||
tex = parent:CreateTexture(nil, "ARTWORK")
|
||||
tex:SetWidth(10); tex:SetHeight(10)
|
||||
tex:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
|
||||
"#,
|
||||
)
|
||||
.expect("region");
|
||||
s.resolve();
|
||||
s.resolve();
|
||||
let before = solves(&s);
|
||||
|
||||
s.run("tex:SetPoint(\"TOPLEFT\", parent, \"TOPLEFT\", 25, 0)")
|
||||
.expect("move region");
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
solves(&s),
|
||||
before + 1,
|
||||
"a region SetPoint must reopen the gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hiding a frame does NOT move any rect (the client resolves hidden frames too — visibility is an
|
||||
/// extract-time filter), so the gate is right to stay closed. Pinned deliberately: it is the one
|
||||
/// place where "nothing to re-solve" is surprising, and a future reader tempted to dirty on
|
||||
/// show/hide should see that the current model does not need it.
|
||||
#[test]
|
||||
fn hiding_a_frame_does_not_reopen_the_gate() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
s.resolve();
|
||||
s.resolve();
|
||||
let before = solves(&s);
|
||||
|
||||
s.run("parent:Hide()").expect("hide");
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
solves(&s),
|
||||
before,
|
||||
"visibility does not move rects, so the gate stays closed"
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod end_to_end;
|
||||
mod events;
|
||||
mod frame_api;
|
||||
mod input;
|
||||
mod layout_gate;
|
||||
mod measure;
|
||||
mod minimap;
|
||||
mod object_model;
|
||||
|
||||
@@ -209,6 +209,129 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
if t.starts_with("Intellect increased") && *c == [1.0, 1.0, 1.0, 1.0])
|
||||
});
|
||||
assert!(white, "aura description is white, not gold");
|
||||
// …and the NAME is GOLD: the aura builder `0x52f880` writes it through the gold wrapper
|
||||
// `0x530380`, where the spell builder uses the plain `0x530270`. Pinned against the
|
||||
// reference's own buff hover (2026-07-25 report B53), which the white name did not match.
|
||||
let gold_name = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "Arcane Intellect" && (c[1] - 210.0 / 255.0).abs() < 1e-6)
|
||||
});
|
||||
assert!(gold_name, "aura name is gold, not white");
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// Law §3-BUFF's right column: the buff hover names the DISPEL CLASS where the spell hover would
|
||||
/// put a gray "Rank N" — "Magic" on Ice Armor, the half of B53 the gold name didn't cover — and it
|
||||
/// is gold, sharing the aura name's wrapper. A `rank` on the same view must NOT displace it.
|
||||
#[test]
|
||||
fn player_buff_hover_names_the_dispel_class_in_gold() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_spell_tooltip(
|
||||
168,
|
||||
SpellTooltipView {
|
||||
name: "Ice Armor".into(),
|
||||
rank: Some("Rank 1".into()), // the spell variant's column — never the aura's
|
||||
dispel_type: Some("Magic".into()),
|
||||
aura_description: "Encases the caster in a layer of ice.".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_auras(
|
||||
"player",
|
||||
Some(vec![AuraState {
|
||||
spell_id: 168,
|
||||
name: Some("Ice Armor".into()),
|
||||
helpful: true,
|
||||
..Default::default()
|
||||
}]),
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetPlayerBuff(1)
|
||||
assert(TTTextRight1:GetText() == "Magic",
|
||||
"the dispel class, got " .. tostring(TTTextRight1:GetText()))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let gold_dispel = s.extract().iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "Magic" && (c[1] - 210.0 / 255.0).abs() < 1e-6)
|
||||
});
|
||||
assert!(
|
||||
gold_dispel,
|
||||
"the dispel column is gold, not the rank's gray"
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// Law §3.6's equipped-item line and §3.8's reagents line — the two lines the 2026-07-25 reports
|
||||
/// found missing (B54, B56). Order: cast|cooldown → requires-item → requires-form → reagents →
|
||||
/// description, each requirement red while unmet.
|
||||
#[test]
|
||||
fn requirement_and_reagent_lines_render_in_law_order() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_spell_tooltip(
|
||||
5019,
|
||||
SpellTooltipView {
|
||||
name: "Shoot".into(),
|
||||
range: Some("30 yd range".into()),
|
||||
cast_time: Some("Instant".into()),
|
||||
requires_item: Some("Requires Wands".into()),
|
||||
item_met: false,
|
||||
reagents: Some("Reagents: |cffff2020Light Feather|r".into()),
|
||||
description: "Attack with an equipped wand.".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_spellbook(SpellBookState {
|
||||
tabs: Vec::new(),
|
||||
slots: vec![SpellSlotView {
|
||||
spell_id: 5019,
|
||||
name: "Shoot".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "SB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetSpell(1, "spell")
|
||||
assert(tt:NumLines() == 6, "name + range + cast + requires + reagents + desc, got " .. tt:NumLines())
|
||||
assert(TTTextLeft1:GetText() == "Shoot")
|
||||
assert(TTTextLeft2:GetText() == "30 yd range", "cost absent: range moves left")
|
||||
assert(TTTextLeft3:GetText() == "Instant")
|
||||
assert(TTTextLeft4:GetText() == "Requires Wands", "law §3.6 sits above the reagents")
|
||||
assert(TTTextLeft5:GetText():find("Light Feather") ~= nil, "law §3.8")
|
||||
assert(TTTextLeft6:GetText() == "Attack with an equipped wand.", "description last")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
let red = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "Requires Wands" && *c == [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0])
|
||||
});
|
||||
assert!(red, "an unmet equipped-item requirement is red");
|
||||
// The reagent's inline `|cffff2020` escape reaches the region VERBATIM: the engine paints
|
||||
// the line's BASE colour white and the app's text layer resolves the escape into colour runs
|
||||
// (`benilla::ui_text::markup`, covered by its own `color_runs_survive_the_wrap`). So what this
|
||||
// layer owns is that the escape is neither stripped nor pre-flattened.
|
||||
let verbatim = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "Reagents: |cffff2020Light Feather|r" && *c == [1.0, 1.0, 1.0, 1.0])
|
||||
});
|
||||
assert!(
|
||||
verbatim,
|
||||
"the reagents line reaches the region escape-intact, base white"
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
//! spell builder `0x52e610` and the aura builder `0x52f880` (wow-re
|
||||
//! `ui/scratch/tooltip-content-law.md`, the 0276 fold-back):
|
||||
//!
|
||||
//! - name (white) | rank (gray) — one double line;
|
||||
//! - name | rank (gray) — one double line. The name's colour is the BUILDER's: **white** from
|
||||
//! the spell builder (`0x530270`), **gold** from the aura builder (`0x530380`, the gold
|
||||
//! wrapper) — the same split `SetTrackingSpell` shows;
|
||||
//! - **Cost | Range** — ONE double line (either side may be absent);
|
||||
//! - **CastTime | Cooldown** — ONE double line; a passive spell simply omits it (there is NO
|
||||
//! "Passive" text in the 1.12 builder);
|
||||
//! - required tool / form — the equipped-item-class line then the stance line, each white when
|
||||
//! met and red when not;
|
||||
//! - reagents — inline red per missing item (the one builder that uses the `|cffff2020` escape;
|
||||
//! joins when a reagent feed exists);
|
||||
//! - description — gold, wrapped. An AURA's description is **white** (byte-verified difference),
|
||||
@@ -34,8 +38,13 @@ const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SpellTooltipView {
|
||||
pub name: String,
|
||||
/// "Rank N" — the gray right column of the name line.
|
||||
/// "Rank N" — the gray right column of the name line, on the SPELL variant only.
|
||||
pub rank: Option<String>,
|
||||
/// "Magic" / "Curse" / "Disease" / "Poison" — the AURA variant's right column (law §3-BUFF),
|
||||
/// the `SpellDispelType.dbc` name of the spell's dispel class gated by that table's `[+0x28]`
|
||||
/// flag, so an undispellable aura (Stealth) carries `None`. Rendered GOLD, like the aura name
|
||||
/// it shares its line with — not the spell variant's gray.
|
||||
pub dispel_type: Option<String>,
|
||||
/// "35 Mana" / "20 Rage" / "Next melee" — the cost cell.
|
||||
pub cost: Option<String>,
|
||||
/// "30 yd range" — the range cell.
|
||||
@@ -46,12 +55,28 @@ pub struct SpellTooltipView {
|
||||
/// "15 sec cooldown" — the cooldown cell: `max(RecoveryTime, CategoryRecoveryTime)` (the
|
||||
/// 0276 line law §3.4 — Charge's 15 s lives in the CATEGORY column).
|
||||
pub cooldown: Option<String>,
|
||||
/// "Requires Wands" — the equipped-item-class half of law §3.6, over
|
||||
/// `EquippedItemClass`/`EquippedItemSubClassMask`: white when [`Self::item_met`], red when
|
||||
/// not. Sits ABOVE [`Self::requires_form`] (the law's tool-then-form order).
|
||||
pub requires_item: Option<String>,
|
||||
/// Whether some WORN item satisfies the class + subclass mask (the app re-pushes views when
|
||||
/// the equipped set changes, so the color tracks live swaps).
|
||||
pub item_met: bool,
|
||||
/// "Requires Battle Stance" — the required-form line (law §3.6, `SPELL_REQUIRED_FORM` over
|
||||
/// the `Stances` mask): white when [`Self::form_met`], red when not.
|
||||
pub requires_form: Option<String>,
|
||||
/// Whether the player's CURRENT shapeshift form satisfies the mask (the app re-pushes views
|
||||
/// on a form change, so the color tracks live stance switches).
|
||||
pub form_met: bool,
|
||||
/// "Reagents: Light Feather" — law §3.8, `SPELL_REAGENTS` ("Reagents: ", no format slot) +
|
||||
/// the 8 reagent slots. Rendered WHITE and wrapped; a reagent the player is short of carries
|
||||
/// the builder's own **inline** `|cffff2020` escape (the spell builder is the one builder
|
||||
/// that colors mid-line), so the composed string arrives paint-ready from the app.
|
||||
pub reagents: Option<String>,
|
||||
/// "2.62% chance to dodge" — the law's line 10 (§3-CHANCE), selected by the spell's `Effect[0]`
|
||||
/// and rendered white and unwrapped, BELOW the reagents and ABOVE the description. The
|
||||
/// percentage is the player's own live avoidance/crit field, so the app re-pushes as it moves.
|
||||
pub chance: Option<String>,
|
||||
/// The $-substituted description — gold + wrapped for spells.
|
||||
pub description: String,
|
||||
/// The $-substituted AURA description (`Spell.dbc AuraDescription`) — the buff hover's
|
||||
@@ -118,18 +143,21 @@ fn render_spell(
|
||||
remaining: Option<String>,
|
||||
talent: Option<&TalentLines>,
|
||||
) -> mlua::Result<()> {
|
||||
// The rank column shows only when the CALLER asks (byte-verified: SetSpell passes
|
||||
// param6=0 — the spellbook hover never shows "Rank N"; SetAction passes 1).
|
||||
match v.rank.as_ref().filter(|_| show_rank) {
|
||||
Some(rank) => append_line(
|
||||
lua,
|
||||
this,
|
||||
(v.name.clone(), WHITE),
|
||||
Some((rank.clone(), GRAY)),
|
||||
false,
|
||||
)?,
|
||||
None => append_line(lua, this, (v.name.clone(), WHITE), None, false)?,
|
||||
}
|
||||
// The name colour splits by BUILDER, byte-verified: the spell builder `0x52e610` writes its
|
||||
// name line through `0x530270` (white), the aura builder `0x52f880` through `0x530380` — the
|
||||
// GOLD wrapper (wow-re tooltip-content-law §3 line 1 vs §3-BUFF). SetTrackingSpell's gold,
|
||||
// already pinned by the director's own A/B, is the same wrapper.
|
||||
let name_color = if aura { GOLD } else { WHITE };
|
||||
// The name line's RIGHT column splits by builder too. The spell builder's is the gray "Rank N",
|
||||
// and it shows only when the CALLER asks (byte-verified: SetSpell passes param6=0 — the
|
||||
// spellbook hover never shows "Rank N"; SetAction passes 1). The aura builder's is the dispel
|
||||
// class ("Magic" on Ice Armor — §3-BUFF), and it is GOLD, not gray: a buff never shows a rank.
|
||||
let right = if aura {
|
||||
v.dispel_type.clone().map(|t| (t, GOLD))
|
||||
} else {
|
||||
v.rank.clone().filter(|_| show_rank).map(|t| (t, GRAY))
|
||||
};
|
||||
append_line(lua, this, (v.name.clone(), name_color), right, false)?;
|
||||
// The talent head: "Rank r/m" (builder line 2, TOOLTIP_TALENT_RANK white) + the red
|
||||
// requirement lines while locked (position CONFIRMED, decision 0305 — TalentLines doc).
|
||||
if let Some(t) = talent {
|
||||
@@ -165,11 +193,25 @@ fn render_spell(
|
||||
None => append_line(lua, this, (ct.clone(), WHITE), None, false)?,
|
||||
}
|
||||
}
|
||||
// Required form (law §3.6, SPELL_REQUIRED_FORM): white when met, red when not.
|
||||
// Required tool / form (law §3.6): the equipped-item-class line first, then the stance
|
||||
// line. Each white when met, red when not.
|
||||
if let Some(req) = &v.requires_item {
|
||||
let color = if v.item_met { WHITE } else { RED };
|
||||
append_line(lua, this, (req.clone(), color), None, false)?;
|
||||
}
|
||||
if let Some(req) = &v.requires_form {
|
||||
let color = if v.form_met { WHITE } else { RED };
|
||||
append_line(lua, this, (req.clone(), color), None, false)?;
|
||||
}
|
||||
// Reagents (law §3.8): white + wrapped, the missing entries inline-red inside the text.
|
||||
if let Some(reagents) = &v.reagents {
|
||||
append_line(lua, this, (reagents.clone(), WHITE), None, true)?;
|
||||
}
|
||||
// Chance to dodge/parry/block/crit (law line 10, §3-CHANCE) — white, NOT wrapped, and it
|
||||
// sits here: below the reagents, above the description.
|
||||
if let Some(chance) = &v.chance {
|
||||
append_line(lua, this, (chance.clone(), WHITE), None, false)?;
|
||||
}
|
||||
}
|
||||
let desc = if aura && !v.aura_description.is_empty() {
|
||||
&v.aura_description
|
||||
@@ -363,12 +405,13 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
// GameTooltip:SetTrackingSpell() — the minimap tracking icon's hover. Its shape differs from
|
||||
// the buff-bar hover: the NAME line is GOLD, the (aura-)description white — pinned by the
|
||||
// director's reference A/B (2026-07-20: "Find Minerals" gold over white "Finding Minerals.",
|
||||
// vs SetPlayerBuff's white name). `0x532c50`'s body isn't carved; carve it in wow-re before
|
||||
// extending this shape. No duration-remaining line (only SetPlayerBuff appends one), and no
|
||||
// tracking active clears + hides, like the SetUnitBuff miss path.
|
||||
// GameTooltip:SetTrackingSpell() — the minimap tracking icon's hover: NAME gold over a white
|
||||
// (aura-)description, pinned by the director's reference A/B (2026-07-20: "Find Minerals"
|
||||
// gold over white "Finding Minerals."). That is just the AURA builder's shape — its name line
|
||||
// rides the gold wrapper `0x530380` too — so this is no longer a one-off; `0x532c50`'s body
|
||||
// still isn't carved, so carve it in wow-re before extending BEYOND that shape. No
|
||||
// duration-remaining line (only SetPlayerBuff appends one), and no tracking active clears +
|
||||
// hides, like the SetUnitBuff miss path.
|
||||
m.set(
|
||||
"SetTrackingSpell",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
|
||||
@@ -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)
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! The shared debug-trace sink behind `WOW_MOVE_TRACE=<path>`: one file, one clock, written by
|
||||
//! the player mover's frame lines ([`crate::player`]'s `move_trace`) and the anim driver's event
|
||||
//! lines ([`crate::creature_anim`]), so the two layers interleave on a common timeline and a feel
|
||||
//! report ("it snaps when I land") can be read across both. Costs one `OnceLock` read per call
|
||||
//! when the env var is unset.
|
||||
//! the player mover's frame lines ([`crate::player`]'s `move_trace`), its outbound wire lines
|
||||
//! (`snd`), the anim driver's event lines ([`crate::creature_anim`]), and the remote-replay lines
|
||||
//! (`rly`/`run`, [`crate::net::motion`]) — so the layers interleave on a common timeline and a feel
|
||||
//! report ("it snaps when I land") can be read across all of them. Each file opens with a
|
||||
//! `# t0=<unix epoch>` header so **two clients' traces align with each other**, which is what a
|
||||
//! sender-vs-observer question needs. Costs one `OnceLock` read per call when the env var is unset.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
@@ -19,9 +21,17 @@ static SINK: OnceLock<Option<Mutex<Sink>>> = OnceLock::new();
|
||||
fn sink() -> Option<&'static Mutex<Sink>> {
|
||||
SINK.get_or_init(|| {
|
||||
let path = std::env::var("WOW_MOVE_TRACE").ok()?;
|
||||
let out = File::create(&path)
|
||||
let mut out = File::create(&path)
|
||||
.map_err(|e| eprintln!("dbg-trace: cannot create {path}: {e}"))
|
||||
.ok()?;
|
||||
// The wall-clock epoch of this file's `t=0`, so **two traces can be read against each other**
|
||||
// — a sender's `snd` lines beside an observer's `rly`/`run` lines from a different process
|
||||
// (decision 0619). `t=` alone is per-process seconds and says nothing across clients; with
|
||||
// this header, wall time is `t0 + t`.
|
||||
let t0_wall = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0.0, |d| d.as_secs_f64());
|
||||
let _ = writeln!(out, "# t0={t0_wall:.3} (unix epoch seconds at t=0)");
|
||||
Some(Mutex::new(Sink {
|
||||
out,
|
||||
t0: Instant::now(),
|
||||
|
||||
@@ -62,8 +62,16 @@ pub(super) struct EntityPart {
|
||||
/// already bakes it for the un-swapped parts.
|
||||
pub(super) two_sided: bool,
|
||||
/// The part's `skinSectionId` (geoset / mesh-part ID). For a player body the per-entity attach
|
||||
/// filters parts by it (only the selected hair/facial/body geosets spawn); `0` and ignored for
|
||||
/// single-geoset models (creatures, GameObjects, WMO).
|
||||
/// filters parts by it (only the selected hair/facial/body geosets spawn); **unfiltered for
|
||||
/// everything else — which is correct, but NOT because those models have a single geoset.**
|
||||
///
|
||||
/// Plenty of them do not: 101 creature models author several (`Creature\Banshee` carries `0`
|
||||
/// and `402`). They still draw whole, because the reference's per-model visibility array is
|
||||
/// allocated **filled with `1`** — all visible — and the only writer is the character
|
||||
/// compositor, which a creature never reaches (VERIFIED, wow-re `models.md` §"M2 geoset
|
||||
/// visibility": the `CCharacterComponent` is created on exactly two guarded paths and a
|
||||
/// creature fails both). That array is indexed by **submesh ordinal**, not by this id — the
|
||||
/// client never decomposes a `group*100 + variant` id anywhere in its render band.
|
||||
pub(super) geoset_id: u16,
|
||||
/// The character runtime texture slot this part carries (M2 type 1 = body, type 6 = hair). For a
|
||||
/// player the per-entity attach swaps its material to one carrying that per-appearance texture
|
||||
|
||||
+61
-56
@@ -157,9 +157,70 @@ impl Items {
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal, VALID item template named `name` — the shared test seam for every module that
|
||||
/// needs a landed template (the sentinels that matter are `allowable_*` = −1 and `stackable` = 1).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_template(name: &str) -> ItemInfo {
|
||||
ItemInfo {
|
||||
class: 0,
|
||||
subclass: 0,
|
||||
name: name.into(),
|
||||
display_info_id: 1,
|
||||
quality: 1,
|
||||
flags: 0,
|
||||
buy_price: 0,
|
||||
sell_price: 0,
|
||||
inventory_type: 0,
|
||||
allowable_class: -1,
|
||||
allowable_race: -1,
|
||||
item_level: 0,
|
||||
required_level: 0,
|
||||
required_skill: 0,
|
||||
required_skill_rank: 0,
|
||||
required_spell: 0,
|
||||
required_honor_rank: 0,
|
||||
required_city_rank: 0,
|
||||
required_rep_faction: 0,
|
||||
required_rep_rank: 0,
|
||||
max_count: 0,
|
||||
stackable: 1,
|
||||
container_slots: 0,
|
||||
stats: Vec::new(),
|
||||
damages: Vec::new(),
|
||||
dmg_min: 0.0,
|
||||
dmg_max: 0.0,
|
||||
dmg_type: 0,
|
||||
armor: 0,
|
||||
resistances: [0; 6],
|
||||
delay_ms: 0,
|
||||
ammo_type: 0,
|
||||
ranged_mod_range: 0.0,
|
||||
spells: Vec::new(),
|
||||
use_spell: None,
|
||||
bonding: 0,
|
||||
description: String::new(),
|
||||
page_text: 0,
|
||||
language_id: 0,
|
||||
page_material: 0,
|
||||
start_quest: 0,
|
||||
lock_id: 0,
|
||||
material: 0,
|
||||
sheath: 0,
|
||||
random_property: 0,
|
||||
block: 0,
|
||||
item_set: 0,
|
||||
max_durability: 0,
|
||||
area: 0,
|
||||
map: 0,
|
||||
bag_family: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use super::test_template as info;
|
||||
use crossbeam_channel::TryRecvError;
|
||||
|
||||
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
@@ -167,62 +228,6 @@ mod tests {
|
||||
(NetCommands(tx), rx)
|
||||
}
|
||||
|
||||
fn info(name: &str) -> ItemInfo {
|
||||
ItemInfo {
|
||||
class: 0,
|
||||
subclass: 0,
|
||||
name: name.into(),
|
||||
display_info_id: 1,
|
||||
quality: 1,
|
||||
flags: 0,
|
||||
buy_price: 0,
|
||||
sell_price: 0,
|
||||
inventory_type: 0,
|
||||
allowable_class: -1,
|
||||
allowable_race: -1,
|
||||
item_level: 0,
|
||||
required_level: 0,
|
||||
required_skill: 0,
|
||||
required_skill_rank: 0,
|
||||
required_spell: 0,
|
||||
required_honor_rank: 0,
|
||||
required_city_rank: 0,
|
||||
required_rep_faction: 0,
|
||||
required_rep_rank: 0,
|
||||
max_count: 0,
|
||||
stackable: 1,
|
||||
container_slots: 0,
|
||||
stats: Vec::new(),
|
||||
damages: Vec::new(),
|
||||
dmg_min: 0.0,
|
||||
dmg_max: 0.0,
|
||||
dmg_type: 0,
|
||||
armor: 0,
|
||||
resistances: [0; 6],
|
||||
delay_ms: 0,
|
||||
ammo_type: 0,
|
||||
ranged_mod_range: 0.0,
|
||||
spells: Vec::new(),
|
||||
use_spell: None,
|
||||
bonding: 0,
|
||||
description: String::new(),
|
||||
page_text: 0,
|
||||
language_id: 0,
|
||||
page_material: 0,
|
||||
start_quest: 0,
|
||||
lock_id: 0,
|
||||
material: 0,
|
||||
sheath: 0,
|
||||
random_property: 0,
|
||||
block: 0,
|
||||
item_set: 0,
|
||||
max_durability: 0,
|
||||
area: 0,
|
||||
map: 0,
|
||||
bag_family: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_miss_queries_once_then_serves_the_answer() {
|
||||
let (cmds, rx) = commands();
|
||||
|
||||
@@ -121,6 +121,7 @@ mod wdl;
|
||||
mod weather;
|
||||
mod wmo_portal;
|
||||
mod world_map;
|
||||
mod world_state;
|
||||
|
||||
use assets::AssetPlugin;
|
||||
use avian3d::prelude::*;
|
||||
|
||||
@@ -32,8 +32,13 @@ pub(crate) struct NameCache {
|
||||
/// the `CreatureType.dbc` id the TAB-target critter filter reads; rank/civilian feed the
|
||||
/// unit tooltip's level-line word + CIVILIAN line (decision 0276).
|
||||
creatures: HashMap<u32, Option<CreatureRecord>>,
|
||||
/// Pet names by **pet number** — the third naming path (see [`Self::resolve`]'s pet branch).
|
||||
/// No negative entry exists: the server answers for a live pet or says nothing at all, so an
|
||||
/// unanswered ask stays unanswered, exactly as it does for the real client.
|
||||
pets: HashMap<u32, String>,
|
||||
pending_players: HashSet<u64>,
|
||||
pending_creatures: HashSet<u32>,
|
||||
pending_pets: HashSet<u32>,
|
||||
}
|
||||
|
||||
/// One cached creature template head (see [`NameCache::creatures`]).
|
||||
@@ -67,6 +72,11 @@ impl NameCache {
|
||||
return None;
|
||||
}
|
||||
self.players.get(&guid_val).and_then(|n| n.as_deref())
|
||||
} else if let Some(pet_number) = guid::pet_number(guid_val) {
|
||||
// A pet is a `TYPEID_UNIT` like any creature, but it carries a pet number where a
|
||||
// creature carries its template entry, so it has its own query. Asking the creature
|
||||
// query for that number is what left every summoned pet nameless.
|
||||
self.resolve_pet(pet_number, guid_val, commands)
|
||||
} else if guid::is_creature_or_pet(guid_val) {
|
||||
let entry = guid::entry(guid_val)?;
|
||||
self.resolve_creature(entry, guid_val, commands)
|
||||
@@ -75,6 +85,24 @@ impl NameCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The name for a live pet by its `pet_number` — the pet twin of [`Self::resolve_creature`],
|
||||
/// same ask-once discipline. Unlike the creature/player queries this one has **no negative
|
||||
/// answer**: vmangos returns early without a packet when the guid is not a live pet bearing that
|
||||
/// number (`PetHandler.cpp:190-192`), so an unanswered ask simply stays unresolved rather than
|
||||
/// caching a "server doesn't know" — and is re-asked after a reconnect like the others.
|
||||
fn resolve_pet(&mut self, pet_number: u32, guid: u64, commands: &NetCommands) -> Option<&str> {
|
||||
if !self.pets.contains_key(&pet_number) {
|
||||
if self.pending_pets.insert(pet_number) {
|
||||
debug!("names: asking pet name (pet {pet_number})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::PetNameQuery { pet_number, guid });
|
||||
}
|
||||
return None;
|
||||
}
|
||||
self.pets.get(&pet_number).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The name for a creature template `entry`, if known — the entry-keyed twin of
|
||||
/// [`Self::resolve`]'s creature branch (same cache, same ask-once discipline), for a caller that
|
||||
/// has no live spawn guid to decode an entry from: a quest objective names its kill target only
|
||||
@@ -107,6 +135,8 @@ impl NameCache {
|
||||
pub(crate) fn peek(&self, guid_val: u64) -> Option<&str> {
|
||||
if guid::is_player(guid_val) {
|
||||
self.players.get(&guid_val).and_then(|n| n.as_deref())
|
||||
} else if let Some(pet_number) = guid::pet_number(guid_val) {
|
||||
self.pets.get(&pet_number).map(String::as_str)
|
||||
} else if guid::is_creature_or_pet(guid_val) {
|
||||
self.creatures
|
||||
.get(&guid::entry(guid_val)?)
|
||||
@@ -124,6 +154,12 @@ impl NameCache {
|
||||
.insert(guid, (!name.is_empty()).then_some(name));
|
||||
}
|
||||
|
||||
/// Record a pet-name answer (`SMSG_PET_NAME_QUERY_RESPONSE`), keyed by pet number.
|
||||
pub(crate) fn insert_pet(&mut self, pet_number: u32, name: String) {
|
||||
self.pending_pets.remove(&pet_number);
|
||||
self.pets.insert(pet_number, name);
|
||||
}
|
||||
|
||||
/// Record a creature-name answer (`SMSG_CREATURE_QUERY_RESPONSE`); `None` = unknown entry.
|
||||
pub(crate) fn insert_creature(&mut self, entry: u32, record: Option<CreatureRecord>) {
|
||||
self.pending_creatures.remove(&entry);
|
||||
@@ -156,11 +192,15 @@ impl NameCache {
|
||||
.map(|r| r.creature_type)
|
||||
}
|
||||
|
||||
/// Forget the in-flight asks (a disconnect may have dropped them on the writer floor); the
|
||||
/// resolved names stay — they are stable across sessions.
|
||||
/// Forget the in-flight asks (a disconnect may have dropped them on the writer floor). Resolved
|
||||
/// player/creature names stay — those are identities, stable across sessions. Resolved **pet**
|
||||
/// names do not: a pet number names one live spawn rather than a template, and nothing that
|
||||
/// answered before the disconnect is still on the map after it.
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
self.pending_players.clear();
|
||||
self.pending_creatures.clear();
|
||||
self.pending_pets.clear();
|
||||
self.pets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +219,35 @@ mod tests {
|
||||
(NetCommands(tx), rx)
|
||||
}
|
||||
|
||||
/// A summoned pet resolves through `CMSG_PET_NAME_QUERY`, keyed by the pet number in its guid —
|
||||
/// NOT through the creature query, which would ask for a template entry that does not exist and
|
||||
/// leave the pet nameless (the reported bug: an NPC-summoned voidwalker with no name).
|
||||
#[test]
|
||||
fn a_pet_asks_the_pet_query_not_the_creature_query() {
|
||||
let (cmds, rx) = commands();
|
||||
let mut cache = NameCache::default();
|
||||
let voidwalker = compose(guid::HIGH_PET, 137, 9);
|
||||
|
||||
assert_eq!(cache.resolve(voidwalker, &cmds), None);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::PetNameQuery {
|
||||
pet_number: 137,
|
||||
..
|
||||
})
|
||||
));
|
||||
// Ask-once, like every other name.
|
||||
assert_eq!(cache.resolve(voidwalker, &cmds), None);
|
||||
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
|
||||
|
||||
cache.insert_pet(137, "Voidwalker".into());
|
||||
assert_eq!(cache.resolve(voidwalker, &cmds), Some("Voidwalker"));
|
||||
assert_eq!(cache.peek(voidwalker), Some("Voidwalker"));
|
||||
// Pet names are per-spawn, so a disconnect drops them.
|
||||
cache.clear_pending();
|
||||
assert_eq!(cache.peek(voidwalker), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creature_miss_queries_once_then_serves_the_answer() {
|
||||
let (cmds, rx) = commands();
|
||||
|
||||
@@ -26,7 +26,7 @@ use crossbeam_channel::{Receiver, Sender};
|
||||
use crate::schedule::WorldStage;
|
||||
|
||||
mod apply;
|
||||
mod io;
|
||||
pub(crate) mod io;
|
||||
mod motion;
|
||||
|
||||
use apply::{apply_net_updates, tag_self_player};
|
||||
@@ -60,7 +60,6 @@ impl Plugin for NetPlugin {
|
||||
.insert_resource(PingShared(handles.ping))
|
||||
.init_resource::<GuidIndex>()
|
||||
.init_resource::<SelfGuid>()
|
||||
.init_resource::<motion::RelayClock>()
|
||||
.init_resource::<PendingTransfer>()
|
||||
.init_resource::<NetStatus>()
|
||||
.init_resource::<DroppedOpcodes>()
|
||||
@@ -71,6 +70,7 @@ impl Plugin for NetPlugin {
|
||||
.init_resource::<crate::names::NameCache>()
|
||||
.init_resource::<crate::go_templates::GameObjectTemplates>()
|
||||
.init_resource::<crate::items::Items>()
|
||||
.init_resource::<crate::world_state::WorldStates>()
|
||||
.add_message::<TeleportMessage>()
|
||||
.add_message::<SpeedChangeMessage>()
|
||||
.add_message::<ServerSoundMessage>()
|
||||
@@ -481,6 +481,10 @@ pub(crate) enum ClientCommand {
|
||||
/// Ask a creature template's name (`CMSG_CREATURE_QUERY`, entry from the guid's bits 24–47);
|
||||
/// answered by a `CreatureName` event into the cache.
|
||||
CreatureQuery { entry: u32, guid: u64 },
|
||||
/// Ask a pet's name (`CMSG_PET_NAME_QUERY`, pet number from the guid's bits 24–47 — where a
|
||||
/// creature keeps its template entry); answered by a `PetName` event into the cache. A pet
|
||||
/// cannot be named by [`Self::CreatureQuery`]; see [`benilla_protocol::guid::pet_number`].
|
||||
PetNameQuery { pet_number: u32, guid: u64 },
|
||||
/// Ask an item template (`CMSG_ITEM_QUERY_SINGLE`; `guid` = the concrete item when the ask is
|
||||
/// about one, `0` for template-only); answered by an `ItemTemplate` event into the
|
||||
/// [`crate::items::Items`] cache. Sent by the cache's ask-once resolve, never directly.
|
||||
|
||||
@@ -115,8 +115,9 @@ pub(super) fn apply_net_updates(
|
||||
ResMut<crate::ui_party::GroupState>,
|
||||
// The taxi-map session (decision 0484 phase 1) + the mailbox session + its login-scoped
|
||||
// arrival countdown (decision 0544) + the player-trade session (decision 0592) + the bank
|
||||
// session and its purchase-refusal queue (decision 0604), grouped to stay under Bevy's
|
||||
// 16-SystemParam ceiling (this tuple's 16th and last slot).
|
||||
// session and its purchase-refusal queue (decision 0604) + the world-state table the
|
||||
// NPC-text `$<n>w` tokens read, grouped to stay under Bevy's 16-SystemParam ceiling (this
|
||||
// tuple's 16th and last slot).
|
||||
(
|
||||
ResMut<crate::ui_taxi::TaxiState>,
|
||||
ResMut<crate::ui_mail::MailOpen>,
|
||||
@@ -124,6 +125,7 @@ pub(super) fn apply_net_updates(
|
||||
ResMut<crate::ui_trade::TradeSession>,
|
||||
ResMut<crate::ui_bank::BankOpen>,
|
||||
ResMut<crate::ui_bank::BankErrors>,
|
||||
ResMut<crate::world_state::WorldStates>,
|
||||
),
|
||||
),
|
||||
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
|
||||
@@ -215,9 +217,11 @@ pub(super) fn apply_net_updates(
|
||||
// the purge; the TRANSFER_PENDING transport block routes NEW_WORLD's coordinates).
|
||||
ResMut<PendingTransfer>,
|
||||
Query<&crate::transport::Transport>,
|
||||
// The relayed-move clock map (decision 0601): server MovementInfo time → client
|
||||
// fire-time, for the scheduled remote apply.
|
||||
ResMut<crate::net::motion::RelayClock>,
|
||||
// The REAL-time clock the relayed-move replay runs on (decisions 0601/0615): a remote's
|
||||
// fire-time is stamped against this, and `drain_pending_moves`/`extrapolate_remote_units`
|
||||
// read the same clock. Virtual time's `max_delta` clamp falls behind real time under
|
||||
// occlusion throttling and would displace the whole replay schedule.
|
||||
Res<Time<bevy::time::Real>>,
|
||||
),
|
||||
) {
|
||||
let play_seq = &mut aura.4;
|
||||
@@ -244,6 +248,7 @@ pub(super) fn apply_net_updates(
|
||||
mut trade_session,
|
||||
mut bank_open,
|
||||
mut bank_errors,
|
||||
mut world_states,
|
||||
),
|
||||
) = caches;
|
||||
let (
|
||||
@@ -405,15 +410,14 @@ pub(super) fn apply_net_updates(
|
||||
jump,
|
||||
transport,
|
||||
} => {
|
||||
// The scheduled-replay law (decision 0601): map the packet's server timestamp to
|
||||
// a client fire-time; `unit_move` applies it now if due, else queues it on the
|
||||
// unit for `drain_pending_moves`.
|
||||
let now_ms = aura.1.elapsed_secs_f64() * 1000.0;
|
||||
let fire_ms = aura.8.fire_time(time, now_ms);
|
||||
// The scheduled-replay law (decisions 0601/0615): `unit_move` runs the mover's own
|
||||
// replay chain over this packet's wire stamp to get its client fire-time, then
|
||||
// applies it now if due, else queues it on the unit for `drain_pending_moves`.
|
||||
let now_ms = aura.8.elapsed_secs_f64() * 1000.0;
|
||||
objects::unit_move(
|
||||
guid,
|
||||
crate::net::motion::PendingMove {
|
||||
fire_ms,
|
||||
crate::net::motion::RelayMove {
|
||||
wire_ms: time,
|
||||
position,
|
||||
orientation,
|
||||
flags,
|
||||
@@ -528,6 +532,9 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::PlayerName { guid, name, .. } => {
|
||||
names.insert_player(guid, name);
|
||||
}
|
||||
SessionEvent::PetName { pet_number, name } => {
|
||||
names.insert_pet(pet_number, name);
|
||||
}
|
||||
SessionEvent::CreatureName {
|
||||
entry,
|
||||
name,
|
||||
@@ -663,6 +670,18 @@ pub(super) fn apply_net_updates(
|
||||
// per type, resolves the sender name ask-once, and AddMessages it into ChatFrame1.
|
||||
// System lines (type 0x0A) are GM dot-command feedback — now visible, not silent.
|
||||
debug!("net: chat [{:#04x}] {}", m.chat_type, m.text);
|
||||
// …and on the trace clock too (decision 0624). A GM dot-command is the only way to
|
||||
// ask the SERVER what it believes — `.gps` reads back the server-side position of a
|
||||
// mover whose packets may or may not be reaching it — and its answer is only usable
|
||||
// if it lands on the same timeline as the `snd`/`rly`/`run` lines it must be read
|
||||
// against. `debug!` timestamps are wall-clock in a different format; this is one
|
||||
// clock, one file.
|
||||
if crate::dbg_trace::enabled() {
|
||||
crate::dbg_trace::line(
|
||||
"sys",
|
||||
&format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")),
|
||||
);
|
||||
}
|
||||
chat_log.push_wire(m);
|
||||
}
|
||||
SessionEvent::ChannelNotify {
|
||||
@@ -1094,9 +1113,23 @@ pub(super) fn apply_net_updates(
|
||||
text_id,
|
||||
options,
|
||||
quests,
|
||||
} => npc::gossip_menu(npc, text_id, options, quests, &mut gossip, &net_commands),
|
||||
SessionEvent::NpcGreeting { text_id, greeting } => {
|
||||
npc::npc_greeting(text_id, greeting, &mut gossip)
|
||||
} => {
|
||||
let gender = npc_gender(npc, &index, &stores);
|
||||
npc::gossip_menu(
|
||||
npc,
|
||||
gender,
|
||||
text_id,
|
||||
options,
|
||||
quests,
|
||||
&mut gossip,
|
||||
&net_commands,
|
||||
);
|
||||
}
|
||||
SessionEvent::NpcGreeting { text_id, blocks } => {
|
||||
// The record answers a query we sent for the OPEN menu, so its NPC is the one whose
|
||||
// gender picks the column (decision 0081's ask-once flow).
|
||||
let gender = gossip.npc.map_or(0, |npc| npc_gender(npc, &index, &stores));
|
||||
npc::npc_greeting(text_id, gender, blocks, &mut gossip)
|
||||
}
|
||||
SessionEvent::GossipComplete => npc::gossip_complete(&mut gossip, &mut quest),
|
||||
// Questgiver panels (decision 0088): fill the `QuestGiver` the quest feed
|
||||
@@ -1276,6 +1309,19 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::TradeStatusExtended { state } => {
|
||||
trade::trade_status_extended(&state, &mut trade_session)
|
||||
}
|
||||
// The world-state table (`SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE`) — both
|
||||
// wires funnel into the one setter, as the reference's own handler does. An init does
|
||||
// NOT clear first: what its `(map, zone)` dwords drive is unrecorded, so we log the
|
||||
// scope rather than act on it (rationale on `crate::world_state`).
|
||||
SessionEvent::WorldStates { scope, states } => {
|
||||
if let Some((map, zone)) = scope {
|
||||
debug!(
|
||||
"world states: map {map} zone {zone}, {} entries",
|
||||
states.len()
|
||||
);
|
||||
}
|
||||
world_states.write(&states);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flush the staged descriptor seeds/deltas onto the entities born this drain (now spawned by the
|
||||
@@ -1287,6 +1333,19 @@ pub(super) fn apply_net_updates(
|
||||
}
|
||||
}
|
||||
|
||||
/// A streamed unit's gender (`UNIT_FIELD_BYTES_0` byte 2) by guid — the gossip greeting's column
|
||||
/// selector (wow-re `gossip-npctext-law.md`: tested `== 1` for female, so genderless `2` reads as
|
||||
/// male). `0` when the guid isn't streamed in or carries no descriptor yet, which is the same
|
||||
/// column the reference takes for a gossip target that isn't a unit at all.
|
||||
fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&mut ObjectStore>) -> u8 {
|
||||
index
|
||||
.0
|
||||
.get(&guid)
|
||||
.and_then(|&e| stores.get(e).ok())
|
||||
.and_then(|s| s.0.unit_gender())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Tag our own player's streamed entity with [`SelfPlayer`] once we know our guid — by matching the
|
||||
/// [`Guid`] component against [`SelfGuid`]. The renderer skips this entity (the controller owns our
|
||||
/// avatar); the controller reads its transform to take control. Done as its own pass (rather than at
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 0237), and the taxi-map session (decision 0484 phase 1). Each `pub(super)` fn here is exactly
|
||||
//! one arm's body; the match at the call site stays the dispatcher, one call per arm.
|
||||
|
||||
use benilla_protocol::messages::{GossipOption, TaxiMask, TrainerSpell, VendorItem};
|
||||
use benilla_protocol::messages::{GossipOption, NpcTextBlock, TaxiMask, TrainerSpell, VendorItem};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::ui_bank::{BankErrors, BankOpen};
|
||||
@@ -17,10 +17,16 @@ use super::super::{ClientCommand, NetCommands};
|
||||
|
||||
/// A gossip menu opened (`SMSG_GOSSIP_MESSAGE`): fill the [`GossipState`] the gossip feed
|
||||
/// (`crate::ui_gossip`) reads. On a menu opening, auto-send the ask-once `CMSG_NPC_TEXT_QUERY`
|
||||
/// for its greeting (served from the cache on a revisit); the greeting lands via
|
||||
/// [`npc_greeting`]; [`gossip_complete`] closes it.
|
||||
/// for its text record (served from the cache on a revisit); the record lands via [`npc_greeting`];
|
||||
/// [`gossip_complete`] closes it.
|
||||
///
|
||||
/// The greeting is **drawn here**, not at the packet — this is the reference's own moment for it
|
||||
/// (`0x4e2010`), and the draw needs both this NPC's gender and a fresh roll. `npc_gender` is
|
||||
/// `UNIT_FIELD_BYTES_0` byte 2 off the NPC's descriptor, defaulting to male when the unit isn't
|
||||
/// streamed in (which is also what the reference does for a non-unit gossip target).
|
||||
pub(super) fn gossip_menu(
|
||||
npc: u64,
|
||||
npc_gender: u8,
|
||||
text_id: u32,
|
||||
options: Vec<GossipOption>,
|
||||
quests: Vec<(u32, u32, String)>,
|
||||
@@ -36,9 +42,9 @@ pub(super) fn gossip_menu(
|
||||
gossip.text_id = text_id;
|
||||
gossip.options = options;
|
||||
gossip.quests = quests;
|
||||
match gossip.cached_greeting(text_id) {
|
||||
Some(greeting) => gossip.greeting = Some(greeting),
|
||||
None => {
|
||||
match gossip.cached_record(text_id).is_some() {
|
||||
true => gossip.greeting = gossip.draw_greeting(text_id, npc_gender),
|
||||
false => {
|
||||
gossip.greeting = None;
|
||||
let _ = net_commands
|
||||
.0
|
||||
@@ -47,12 +53,18 @@ pub(super) fn gossip_menu(
|
||||
}
|
||||
}
|
||||
|
||||
/// The greeting text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache, and only fill the OPEN
|
||||
/// menu's greeting (a late answer for a menu we already closed just seeds the cache).
|
||||
pub(super) fn npc_greeting(text_id: u32, greeting: String, gossip: &mut GossipState) {
|
||||
gossip.remember_greeting(text_id, greeting.clone());
|
||||
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache with the whole record, and draw
|
||||
/// the greeting only for the OPEN menu (a late answer for a menu we already closed just seeds the
|
||||
/// cache; the next open draws its own line).
|
||||
pub(super) fn npc_greeting(
|
||||
text_id: u32,
|
||||
npc_gender: u8,
|
||||
blocks: Vec<NpcTextBlock>,
|
||||
gossip: &mut GossipState,
|
||||
) {
|
||||
gossip.remember_record(text_id, blocks);
|
||||
if gossip.npc.is_some() && gossip.text_id == text_id {
|
||||
gossip.greeting = Some(greeting);
|
||||
gossip.greeting = gossip.draw_greeting(text_id, npc_gender);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,15 +191,15 @@ pub(super) fn object_move(
|
||||
|
||||
/// A relayed player movement packet (`MSG_MOVE_*`): the mover's authoritative pose + live move
|
||||
/// flags. The reference SCHEDULES a remote's apply (decision 0601, wow-re
|
||||
/// `remote-apply-timing.md`): the packet's server timestamp maps to a client fire-time
|
||||
/// ([`crate::net::motion::RelayClock`]); an already-due move applies now, a future one queues on
|
||||
/// the unit and fires in `drain_pending_moves` — the dead-reckon covering the mover's own
|
||||
/// timeline in between, which is what kills the arrival-jitter snap. `WOW_REMOTE_SNAP=1`
|
||||
/// restores raw apply-at-arrival for an A/B.
|
||||
/// `remote-apply-timing.md`): the mover's own replay chain gives the packet a client fire-time
|
||||
/// (decision 0615, [`crate::net::motion::RelayMove`] → `RelayChain::schedule`); an already-due move
|
||||
/// applies now, a future one queues on the unit and fires in `drain_pending_moves` — the dead-reckon
|
||||
/// covering the mover's own timeline in between, which is what kills the arrival-jitter snap.
|
||||
/// `WOW_REMOTE_SNAP=1` restores raw apply-at-arrival for an A/B.
|
||||
#[allow(clippy::too_many_arguments)] // the wire fields + the apply context, one per concern
|
||||
pub(super) fn unit_move(
|
||||
guid: u64,
|
||||
ev: crate::net::motion::PendingMove,
|
||||
mv: crate::net::motion::RelayMove,
|
||||
now_ms: f64,
|
||||
commands: &mut Commands,
|
||||
index: &GuidIndex,
|
||||
@@ -208,35 +208,69 @@ pub(super) fn unit_move(
|
||||
transforms: &mut Query<&mut Transform>,
|
||||
landings: &mut MessageWriter<crate::creature_anim::HardLanding>,
|
||||
) {
|
||||
use crate::net::motion::{apply_move, arrival_snap};
|
||||
use crate::net::motion::{apply_move, arrival_snap, trace_relay, PendingMove, RelayOutcome};
|
||||
// A relayed move belongs to another player; never let one drive our own avatar (the
|
||||
// controller owns it, and the server doesn't echo our moves back anyway).
|
||||
if self_guid.0 == Some(guid) {
|
||||
trace_relay(
|
||||
guid,
|
||||
&mv,
|
||||
&Default::default(),
|
||||
now_ms,
|
||||
0,
|
||||
RelayOutcome::SelfMover,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(&e) = index.0.get(&guid) {
|
||||
let Some(&e) = index.0.get(&guid) else {
|
||||
// No entity for this guid — the packet changes nothing. Traced rather than dropped in
|
||||
// silence: a mover that keeps running while these pile up is a streaming bug, not a replay
|
||||
// one, and the two look identical from the outside (decision 0619).
|
||||
trace_relay(
|
||||
guid,
|
||||
&mv,
|
||||
&Default::default(),
|
||||
now_ms,
|
||||
0,
|
||||
RelayOutcome::Unknown,
|
||||
);
|
||||
return;
|
||||
};
|
||||
{
|
||||
// The server is authoritative now, not any creature path — drop a stale spline.
|
||||
commands.entity(e).remove::<Spline>();
|
||||
if let Ok(mut rm) = remote_motion.get_mut(e) {
|
||||
if arrival_snap() || ev.fire_ms <= now_ms {
|
||||
apply_move(e, &ev, &mut rm, commands, landings);
|
||||
// The chain reads the mover's state as it stands BEFORE this move applies — the
|
||||
// reference times the packet off the live `[esi+0x40]`/`[esi+0x150]`.
|
||||
let (flags, queue_empty) = (rm.flags, rm.pending.is_empty());
|
||||
let fire_ms = rm.relay.schedule(mv.wire_ms, now_ms, flags, queue_empty);
|
||||
let at_arrival = arrival_snap() || rm.fires_at_arrival(fire_ms, now_ms);
|
||||
trace_relay(
|
||||
guid,
|
||||
&mv,
|
||||
&rm.relay,
|
||||
now_ms,
|
||||
rm.pending.len(),
|
||||
if at_arrival {
|
||||
RelayOutcome::Now
|
||||
} else {
|
||||
RelayOutcome::Queued
|
||||
},
|
||||
);
|
||||
if at_arrival {
|
||||
apply_move(e, &mv, &mut rm, now_ms, commands, landings);
|
||||
} else {
|
||||
// Fire-times stay ascending per unit (the wire is ordered; a clock-estimate
|
||||
// wobble could reorder them — clamp to the tail so the drain never stalls).
|
||||
let fire_ms = rm
|
||||
.pending
|
||||
.back()
|
||||
.map_or(ev.fire_ms, |b| b.fire_ms.max(ev.fire_ms));
|
||||
rm.pending
|
||||
.push_back(crate::net::motion::PendingMove { fire_ms, ..ev });
|
||||
// Fire-times are monotone per unit by construction (a chained fire never lands
|
||||
// before its predecessor — decision 0615), so the queue stays ordered.
|
||||
rm.pending.push_back(PendingMove { fire_ms, mv });
|
||||
}
|
||||
} else {
|
||||
// First move for this unit: apply immediately (scheduling starts with the next
|
||||
// packet) and place it this frame — the component insert is deferred, so the
|
||||
// extrapolator won't see it until next frame.
|
||||
// First move for this unit: apply immediately (the chain seeds on it — its own fire
|
||||
// is arrival — and paces from the next packet on) and place it this frame; the
|
||||
// component insert is deferred, so the extrapolator won't see it until next frame.
|
||||
let mut rm = RemoteMotion {
|
||||
wow_pos: ev.position,
|
||||
orientation: ev.orientation,
|
||||
wow_pos: mv.position,
|
||||
orientation: mv.orientation,
|
||||
flags: 0,
|
||||
pitch: 0.0,
|
||||
speed: 0.0,
|
||||
@@ -244,9 +278,15 @@ pub(super) fn unit_move(
|
||||
jump_xy_vel: [0.0; 2],
|
||||
fall_start_z: None,
|
||||
pending: std::collections::VecDeque::new(),
|
||||
relay: Default::default(),
|
||||
last_apply_ms: now_ms,
|
||||
last_apply_pos: mv.position,
|
||||
};
|
||||
apply_move(e, &ev, &mut rm, commands, landings);
|
||||
write_pose(transforms, e, ev.position, ev.orientation);
|
||||
let fire_ms = rm.relay.schedule(mv.wire_ms, now_ms, 0, true);
|
||||
debug_assert_eq!(fire_ms, now_ms, "a seeding packet fires at arrival");
|
||||
trace_relay(guid, &mv, &rm.relay, now_ms, 0, RelayOutcome::Seed);
|
||||
apply_move(e, &mv, &mut rm, now_ms, commands, landings);
|
||||
write_pose(transforms, e, mv.position, mv.orientation);
|
||||
commands.entity(e).insert(rm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,42 @@ use crossbeam_channel::{Receiver, Sender};
|
||||
|
||||
use super::{CharRequest, ChatKind, ClientCommand};
|
||||
|
||||
/// **The inbound census** — every packet the read thread pulls off the world socket, whatever its
|
||||
/// opcode, and the wall-clock of the most recent one (unix ms). Two numbers, one job: telling a
|
||||
/// *silent server* apart from a *dead socket* (decision 0621).
|
||||
///
|
||||
/// When a remote mover freezes into a dead-reckoned runaway, the client cannot otherwise say which
|
||||
/// of those it is looking at — and they are opposite bugs. If these keep climbing while the mover
|
||||
/// starves, the connection is healthy and the server simply stopped relaying that unit; if they
|
||||
/// freeze with it, the whole stream died without anyone noticing. Read by the runaway watch, which
|
||||
/// prints them on every line ([`crate::net::motion`]).
|
||||
pub(crate) static INBOUND_PACKETS: AtomicU64 = AtomicU64::new(0);
|
||||
static LAST_INBOUND_UNIX_MS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Count one packet off the wire. Called for every `poll()` return — parsed or skipped, since a
|
||||
/// packet we could not decode still proves the socket is alive.
|
||||
fn note_inbound() {
|
||||
INBOUND_PACKETS.fetch_add(1, Ordering::Relaxed);
|
||||
LAST_INBOUND_UNIX_MS.store(unix_ms(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Wall-clock unix milliseconds — the one clock two processes share (the trace header's `t0` is in
|
||||
/// the same units, so a mover's file and an observer's file line up).
|
||||
fn unix_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// `(packets seen, ms since the last one)` — `None` for the age until a first packet has landed.
|
||||
pub(crate) fn inbound_census() -> (u64, Option<u64>) {
|
||||
let last = LAST_INBOUND_UNIX_MS.load(Ordering::Relaxed);
|
||||
(
|
||||
INBOUND_PACKETS.load(Ordering::Relaxed),
|
||||
(last != 0).then(|| unix_ms().saturating_sub(last)),
|
||||
)
|
||||
}
|
||||
|
||||
/// One submitted login attempt (decision 0539): the credentials, plus the abandon generation at
|
||||
/// submit time — the thread discards the attempt at its next stage boundary if the shared counter
|
||||
/// has moved (a Cancel bumps it). A counter, not a flag: a flag cleared by the *next* submit would
|
||||
@@ -399,10 +435,28 @@ fn run(
|
||||
// aligned); a long run of consecutive skips means the stream desynced — guard against a busy spin.
|
||||
let (mut skip_run, mut skip_logged) = (0u32, 0u32);
|
||||
loop {
|
||||
match reader.poll()? {
|
||||
Poll::Events(evs) => {
|
||||
let polled = reader.poll()?;
|
||||
note_inbound(); // one packet off the wire, parsed or not — the census counts liveness
|
||||
match polled {
|
||||
Poll::Events { opcode, events } => {
|
||||
skip_run = 0;
|
||||
for ev in evs {
|
||||
// The full inbound opcode stream (tag `in`, decision 0624) — the last place a
|
||||
// packet could hide. `skip` covers what failed to parse and `rly` covers what
|
||||
// reached the mover replay; between them sits the packet that parsed into *no*
|
||||
// event, which no instrument could see. With this line every packet off the wire
|
||||
// is accounted for by name, so "the server stopped relaying" and "we dropped it on
|
||||
// the floor" are finally different pictures instead of the same silence.
|
||||
if crate::dbg_trace::enabled() {
|
||||
crate::dbg_trace::line(
|
||||
"in",
|
||||
&format!(
|
||||
"{opcode:#06x} {} ev={}",
|
||||
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?"),
|
||||
events.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
for ev in events {
|
||||
// A confirmed logout ends the cycle *after* the app hears about it.
|
||||
let logged_out = matches!(ev, SessionEvent::LoggedOut);
|
||||
// Receiver dropped → the app exited; end the thread cleanly.
|
||||
@@ -416,6 +470,14 @@ fn run(
|
||||
}
|
||||
Poll::Skipped { opcode, reason } => {
|
||||
skip_run += 1;
|
||||
// **Every** skip, uncapped, into the trace (tag `skip`, decision 0623). A packet that
|
||||
// arrives and fails to parse is indistinguishable, from outside, from one that never
|
||||
// arrived: the inbound census counts it either way, and no `rly` line is emitted
|
||||
// either way. That ambiguity is what made a starving remote mover unattributable —
|
||||
// so the skips get their own line, with the opcode that died.
|
||||
if crate::dbg_trace::enabled() {
|
||||
crate::dbg_trace::line("skip", &format!("opcode={opcode:#06x} {reason}"));
|
||||
}
|
||||
// Log which packet we dropped (opcode + message name), capped so it can't itself
|
||||
// flood — enough to capture a post-teleport burst of unparseable object updates.
|
||||
if skip_logged < 40 {
|
||||
@@ -492,6 +554,12 @@ fn writer_loop(
|
||||
recv(cmd_rx) -> cmd => {
|
||||
let Ok(cmd) = cmd else { return }; // all app senders dropped → app exit
|
||||
let Some(w) = writer.as_mut() else {
|
||||
// No live writer: the session is gone and this command evaporates. Traced
|
||||
// unconditionally — this is the state in which a client keeps *deciding* to send
|
||||
// movement (`snd` lines) that no one will ever receive (decision 0621).
|
||||
if crate::dbg_trace::enabled() {
|
||||
crate::dbg_trace::line("wire", "DROPPED — no live session");
|
||||
}
|
||||
if warned < SEND_WARN_CAP {
|
||||
bevy::log::warn!("net: dropping command — not connected");
|
||||
warned += 1;
|
||||
@@ -585,6 +653,9 @@ fn writer_loop(
|
||||
ClientCommand::PlayedTime => w.played_time(),
|
||||
ClientCommand::NameQuery { guid } => w.name_query(guid),
|
||||
ClientCommand::CreatureQuery { entry, guid } => w.creature_query(entry, guid),
|
||||
ClientCommand::PetNameQuery { pet_number, guid } => {
|
||||
w.pet_name_query(pet_number, guid)
|
||||
}
|
||||
ClientCommand::ItemQuery { entry, guid } => w.item_query(entry, guid),
|
||||
ClientCommand::UseItem { bag_index, slot } => w.use_item(bag_index, slot, 0),
|
||||
ClientCommand::AutoEquipItem { bag_index, slot } => {
|
||||
@@ -795,7 +866,16 @@ fn writer_loop(
|
||||
nodes,
|
||||
} => w.activate_taxi_express(guid, total_cost, &nodes),
|
||||
};
|
||||
// **What actually reached the socket** (tag `wire`, decision 0621). The controller's
|
||||
// `snd` line is written before the command is even queued, so it records a decision,
|
||||
// not a transmission — a client whose session died goes on producing `snd` lines into
|
||||
// a dead channel forever, which is exactly the ambiguity that cost us a hunt. Only
|
||||
// failures are traced: a silent `wire` log beside a busy `snd` log means every packet
|
||||
// went out.
|
||||
if let Err(e) = result {
|
||||
if crate::dbg_trace::enabled() {
|
||||
crate::dbg_trace::line("wire", &format!("SEND FAILED: {e:#}"));
|
||||
}
|
||||
if warned < SEND_WARN_CAP {
|
||||
bevy::log::warn!("net: send failed: {e:#}");
|
||||
warned += 1;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! **When** a remote unit's relayed move replays — the reference's per-unit replay chain, byte-traced
|
||||
//! (decision 0615; supersedes the invented estimator of 0601).
|
||||
//!
|
||||
//! A remote's inbound `MSG_MOVE_*` is not applied at arrival: it is given a client **fire-time** and
|
||||
//! waits in the unit's queue until the clock reaches it, the dead-reckon covering the mover's own
|
||||
//! timeline in between (decision 0601, wow-re `remote-apply-timing.md`). The law that picks that
|
||||
//! fire-time lives in `0x618c30` + its window helper `0x618b50`, and it is **per unit** — five cells
|
||||
//! on the unit's own CMovement, no manager cursor:
|
||||
//!
|
||||
//! | cell | here | meaning |
|
||||
//! |------|------|---------|
|
||||
//! | `+0xa8` | [`RelayChain::last_fire_ms`] | the fire-time the previous packet was given |
|
||||
//! | `+0xac` | [`RelayChain::last_wire_ms`] | the previous packet's wire stamp |
|
||||
//! | `+0xbc[32]`/`+0x13c` | [`RelayChain::ring`]/`ring_idx` | the lateness ring + its cursor |
|
||||
//! | `+0x140` | [`RelayChain::base_ms`] | the lateness base the ring is stored against |
|
||||
//!
|
||||
//! The law, in one line: **a moving unit replays on the sender's own cadence** — `fire = prev fire +
|
||||
//! (this wire stamp − prev wire stamp)`, so the spacing our client plays back is exactly the spacing
|
||||
//! the stamps carry, no matter how the packets clumped in flight. The buffer that absorbs the clumping
|
||||
//! is only ever re-sized while the mover is **idle with nothing queued**: there the chain re-bases by
|
||||
//! the worst lateness seen over the last 32 packets, which is a de-jitter buffer sized by measured
|
||||
//! jitter rather than by an estimate re-derived per packet. Either way the offset from arrival is held
|
||||
//! in `[−500, +1000] ms`.
|
||||
//!
|
||||
//! Byte index: `0x618c30` (chain: seed `@0x618c54`, wire delta `@0x618ca0-cc2`, skew out `@0x618ccf`,
|
||||
//! idle+empty gate `@0x618ce4`/`@0x618cf3`, re-base `@0x618cfc-d41`, clamps `@0x618d0d`/`@0x618d49`,
|
||||
//! fire store `@0x618dcc`, due test `@0x618dd2`) · `0x618b50` (the ring) · `0x615c30` (the drain).
|
||||
|
||||
use benilla_protocol::{JumpInfo, TransportPose};
|
||||
|
||||
/// One relayed move exactly as it came off the wire — the payload of
|
||||
/// [`benilla_protocol::SessionEvent::UnitMove`], before [`RelayChain`] decides *when* it applies.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RelayMove {
|
||||
/// The `MovementInfo` time word — vmangos's own ms clock stamped at **receipt**
|
||||
/// (`MovementInfo::Read`: `stime = WorldTimer::getMSTime()`, a live `steady_clock`; relayed
|
||||
/// verbatim by `Write`), so every mover's stamps share one coherent server clock. The chain paces
|
||||
/// replay by the deltas between consecutive stamps.
|
||||
pub(crate) wire_ms: u32,
|
||||
pub(crate) position: [f32; 3],
|
||||
pub(crate) orientation: f32,
|
||||
pub(crate) flags: u32,
|
||||
pub(crate) pitch: f32,
|
||||
pub(crate) fall_time: u32,
|
||||
pub(crate) jump: Option<JumpInfo>,
|
||||
pub(crate) transport: Option<TransportPose>,
|
||||
/// `MSG_MOVE_HEARTBEAT` — excluded from the pre-fire reconcile lerp (the reference's `0x619090`
|
||||
/// skips tag `0x26`); it applies as an outright snap.
|
||||
pub(crate) heartbeat: bool,
|
||||
}
|
||||
|
||||
/// One **scheduled** relayed move — the reference's queued move-event node (`0x617570`: fire-time at
|
||||
/// `node[+8]`, pose at `node[+0x10]`).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PendingMove {
|
||||
/// When to apply, on the real-time ms clock (the reference's `ev[+8]`).
|
||||
pub(crate) fire_ms: f64,
|
||||
/// What to apply.
|
||||
pub(crate) mv: RelayMove,
|
||||
}
|
||||
|
||||
/// The reference's skew clamp (`0x618c30 @0x618d0d` and `@0x618d49`: `lea eax,[skew+0x1f4]; cmp
|
||||
/// eax,0x5dc` saturating the biased value into `[0, 1500]`): a fire-time never lands more than 500 ms
|
||||
/// **before** its packet's arrival, nor more than 1000 ms after.
|
||||
const SKEW_MIN_MS: f64 = -500.0;
|
||||
const SKEW_MAX_MS: f64 = 1000.0;
|
||||
|
||||
/// The lateness window: 32 packets — `0x618b50`'s ring at `CMovement+0xbc` with its cursor at `+0x13c`
|
||||
/// masked `& 0x1f` (32 dwords is exactly `0x13c − 0xbc`).
|
||||
const LATENESS_WINDOW: usize = 32;
|
||||
|
||||
/// The "this mover is mid-motion" mask the re-base gates on — `0x618c30 @0x618ce4`'s
|
||||
/// `test [esi+0x40],0x20ff`: the four direction bits, both turn bits, both pitch bits (`0x40`/`0x80`),
|
||||
/// and `FALLING` (`0x2000`). While any is set the chain keeps the sender's pacing untouched; only a
|
||||
/// standing, un-queued mover re-sizes the buffer.
|
||||
const BUSY_MASK: u32 = 0x20ff;
|
||||
|
||||
/// A remote unit's replay chain — the per-unit `CMovement` timing cells (see the module header).
|
||||
/// Lives on [`super::RemoteMotion`]; one per mover, never shared (the pre-0615 estimator was a single
|
||||
/// app-wide resource, which is structurally wrong: two movers behind different network paths must
|
||||
/// carry different buffers).
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct RelayChain {
|
||||
/// Whether the chain has seen its first packet — the reference's `CMovement+0x40` bit 31, set
|
||||
/// alongside the first seed of the two cells below (`0x618c54-6a`).
|
||||
seeded: bool,
|
||||
/// `+0xa8` — the fire-time the previous packet was given (client ms). The whole chain hangs off
|
||||
/// this: the next fire is *this* plus the wire step.
|
||||
last_fire_ms: f64,
|
||||
/// `+0xac` — the previous packet's wire stamp. Advanced only by a **forward** step
|
||||
/// (`@0x618cb8`: `jle` skips the store), so a stale or duplicate stamp can't drag the chain back.
|
||||
last_wire_ms: u32,
|
||||
/// `+0xbc[32]` — the lateness ring: how late each of the last 32 packets ran against the chain,
|
||||
/// each stored **relative to [`Self::base_ms`] as it stood at that packet** (`0x618b50` writes
|
||||
/// `base + lateness`). That offset is what stops a spike already absorbed into the base from being
|
||||
/// charged a second time on the next re-base.
|
||||
ring: [f64; LATENESS_WINDOW],
|
||||
/// `+0x13c` — the ring write cursor, wrapped at 32.
|
||||
ring_idx: usize,
|
||||
/// `+0x140` — the lateness base the ring is stored against, i.e. the buffer the chain currently
|
||||
/// holds. Re-based to the window max whenever an idle mover with an empty queue takes a packet.
|
||||
base_ms: f64,
|
||||
}
|
||||
|
||||
impl RelayChain {
|
||||
/// Give this relayed move its client fire-time (`0x618c30`), advancing the chain.
|
||||
///
|
||||
/// `now_ms` is the packet's arrival on our real-time ms clock, `flags`/`queue_empty` the mover's
|
||||
/// state **before** this move applies (the reference reads `[esi+0x40]` and `[esi+0x150]` off the
|
||||
/// live CMovement at exactly this point). The caller applies the move at once if the returned
|
||||
/// fire-time is already due, else queues it for the drain — the reference's `mgr+0x128 ≥ fire`
|
||||
/// test (`@0x618dd2`), which for us is `fire ≤ now` because our arrival stamp *is* the frame clock
|
||||
/// the drain compares against.
|
||||
pub(crate) fn schedule(
|
||||
&mut self,
|
||||
wire_ms: u32,
|
||||
now_ms: f64,
|
||||
flags: u32,
|
||||
queue_empty: bool,
|
||||
) -> f64 {
|
||||
// First packet for this mover: anchor both cells on it, so the step below is zero and the
|
||||
// move fires at arrival (`@0x618c54`).
|
||||
if !self.seeded {
|
||||
self.seeded = true;
|
||||
self.last_wire_ms = wire_ms;
|
||||
self.last_fire_ms = now_ms;
|
||||
}
|
||||
// The sender's own step between packets. Wrapping, because the server's ms clock is a `u32`
|
||||
// (vmangos uptime ms — it wraps every 49.7 days); a non-forward step contributes nothing and
|
||||
// leaves the reference stamp alone (`@0x618cb6-c2`).
|
||||
let step = wire_ms.wrapping_sub(self.last_wire_ms) as i32;
|
||||
let wire_delta = if step > 0 {
|
||||
self.last_wire_ms = wire_ms;
|
||||
f64::from(step)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// How much the flight took beyond the chain's own pacing. `skew` is what the reference writes
|
||||
// out at `@0x618ccf`; note `now + skew == last_fire + wire_delta`, which IS the law: replay
|
||||
// paced by the sender's stamps.
|
||||
let arrival_delta = now_ms - self.last_fire_ms;
|
||||
let mut skew = wire_delta - arrival_delta;
|
||||
let window_max = self.record_lateness(arrival_delta - wire_delta);
|
||||
// The buffer is only ever re-sized on a standing mover with nothing queued (`@0x618ce4`,
|
||||
// `@0x618cf3`): re-base by the worst lateness the window still remembers, never letting the
|
||||
// chain step backwards past the previous fire (`@0x618d3b`).
|
||||
if flags & BUSY_MASK == 0 && queue_empty {
|
||||
skew = (skew + window_max - self.base_ms).clamp(SKEW_MIN_MS, SKEW_MAX_MS);
|
||||
if now_ms + skew < self.last_fire_ms {
|
||||
skew = self.last_fire_ms - now_ms;
|
||||
}
|
||||
self.base_ms = window_max;
|
||||
}
|
||||
let fire_ms = now_ms + skew.clamp(SKEW_MIN_MS, SKEW_MAX_MS);
|
||||
self.last_fire_ms = fire_ms;
|
||||
fire_ms
|
||||
}
|
||||
|
||||
/// Record this packet's lateness and return the window's worst (`0x618b50`): store
|
||||
/// `base + lateness` at the cursor, advance it mod 32, and return the max over all 32 slots (the
|
||||
/// entry just written included — the reference seeds its scan with it and walks the whole ring).
|
||||
/// An unfilled slot reads as the zero the reference's freshly-constructed CMovement holds.
|
||||
fn record_lateness(&mut self, lateness_ms: f64) -> f64 {
|
||||
self.ring[self.ring_idx] = self.base_ms + lateness_ms;
|
||||
self.ring_idx = (self.ring_idx + 1) % LATENESS_WINDOW;
|
||||
self.ring.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||
}
|
||||
|
||||
/// The scheduling lead this move got — `fire − arrival`, i.e. how long the dead-reckon has to
|
||||
/// cover before the pose lands. Negative means the move was already due and applies at arrival.
|
||||
/// Trace/diagnostic only ([`super::remote::trace_schedule`]).
|
||||
pub(crate) fn lead_ms(&self, now_ms: f64) -> f64 {
|
||||
self.last_fire_ms - now_ms
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,17 @@
|
||||
//! [`super`]'s motion model (decision 0053): flag-driven ground locomotion between the ~2 Hz
|
||||
//! heartbeats, and the jump as a locally-played ballistic event.
|
||||
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use avian3d::character_controller::move_and_slide::MoveAndSlide;
|
||||
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
|
||||
use benilla_protocol::{JumpInfo, MoveSpeeds};
|
||||
use bevy::prelude::*;
|
||||
use bevy::time::Real;
|
||||
|
||||
use crate::creature_anim::move_flags;
|
||||
use crate::player::{GRAVITY, TERMINAL_VELOCITY};
|
||||
|
||||
use super::super::{SelfPlayer, UnitSpeeds};
|
||||
use super::relay::{PendingMove, RelayChain, RelayMove};
|
||||
use super::{yaw_of, Spline};
|
||||
|
||||
/// The server-authoritative movement state of a *remote* mover (another player), set from each relayed
|
||||
@@ -62,80 +65,152 @@ pub(crate) struct RemoteMotion {
|
||||
/// clock reaches its [`PendingMove::fire_ms`]; until then the dead-reckon covers the mover's
|
||||
/// own timeline, so the residual at apply time is structurally small.
|
||||
pub(crate) pending: std::collections::VecDeque<PendingMove>,
|
||||
/// This mover's replay chain — the per-unit timing cells that pick each packet's fire-time
|
||||
/// (decision 0615; [`super::relay`]). Per unit, exactly as the reference holds them on the
|
||||
/// unit's own CMovement.
|
||||
pub(crate) relay: RelayChain,
|
||||
/// Real-time ms when a packet last applied to this mover (`0.0` until the first one), and the
|
||||
/// position it applied at. The dead-reckon's anchor — everything since is *our extrapolation*,
|
||||
/// not anything the server said. Read by the runaway watch in [`extrapolate_remote_units`].
|
||||
pub(crate) last_apply_ms: f64,
|
||||
pub(crate) last_apply_pos: [f32; 3],
|
||||
}
|
||||
|
||||
/// One scheduled relayed move — the payload of [`benilla_protocol::SessionEvent::UnitMove`] plus
|
||||
/// the client fire-time [`RelayClock`] mapped from its server timestamp. The reference's queued
|
||||
/// move-event node (`0x617570`: fire-time at `node[+8]`, pose at `node[+0x10]`).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PendingMove {
|
||||
/// When to apply, on [`Time::elapsed`]'s ms clock (the reference's `ev[+8]`).
|
||||
pub(crate) fire_ms: f64,
|
||||
pub(crate) position: [f32; 3],
|
||||
pub(crate) orientation: f32,
|
||||
pub(crate) flags: u32,
|
||||
pub(crate) pitch: f32,
|
||||
pub(crate) fall_time: u32,
|
||||
pub(crate) jump: Option<JumpInfo>,
|
||||
pub(crate) transport: Option<benilla_protocol::TransportPose>,
|
||||
/// `MSG_MOVE_HEARTBEAT` — excluded from the pre-fire reconcile lerp (the reference's
|
||||
/// `0x619090` skips tag `0x26`); it applies as an outright snap.
|
||||
pub(crate) heartbeat: bool,
|
||||
/// What [`crate::net::apply`]'s `unit_move` did with an inbound relayed move — the `out=` field of
|
||||
/// the `rly` trace. Every arrival gets a line, **including the ones we discard**: "the packet never
|
||||
/// showed up" and "the packet showed up and lost" are different bugs with the same symptom, and the
|
||||
/// trace has to tell them apart (decision 0619).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(in crate::net) enum RelayOutcome {
|
||||
/// Applied at arrival — it was due and the unit's queue was empty.
|
||||
Now,
|
||||
/// Scheduled: waiting in the unit's queue for its fire-time.
|
||||
Queued,
|
||||
/// The unit's first packet: seeds the chain and places it immediately.
|
||||
Seed,
|
||||
/// Ours. The controller owns our avatar; the server never echoes our own moves anyway.
|
||||
SelfMover,
|
||||
/// **No entity for this guid.** The mover isn't in our object index — so this packet, Stop or
|
||||
/// not, changes nothing. A stale mover that keeps running while these accumulate is a streaming
|
||||
/// bug, not a replay bug.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The relayed-move clock map (decision 0601): estimates the offset between the server's
|
||||
/// `MovementInfo` ms clock (vmangos stamps `stime` at receipt — one coherent clock for all movers)
|
||||
/// and our render clock, so each relayed move gets a client **fire-time** — the reference's
|
||||
/// scheduled replay (`ev[+8] = mapped time + clamped skew`, wow-re `remote-apply-timing.md`).
|
||||
/// The mapping is an EWMA of per-packet `(now − stime)` plus a jitter margin, so a typical packet
|
||||
/// arrives just *before* its fire-time and waits in the unit's queue; the exact reference cursor
|
||||
/// arithmetic (`mgr+0x128/+0x140`) is the un-traced residual on wow-re's board, so this estimator
|
||||
/// is ours — bounded by the reference's verified `[−500, +1000] ms` skew clamp.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct RelayClock {
|
||||
/// EWMA of `(now_ms − stime)` over recent relays; `None` until the first.
|
||||
mean: Option<f64>,
|
||||
/// EWMA of `|sample − mean|` — the jitter estimate feeding the scheduling margin.
|
||||
mad: f64,
|
||||
}
|
||||
|
||||
/// EWMA weight for the clock mean/jitter — ~16-packet memory (a few seconds of one mover's
|
||||
/// heartbeats), quick to settle at login, slow enough to ride out one outlier.
|
||||
const CLOCK_ALPHA: f64 = 1.0 / 16.0;
|
||||
/// A sample this far off the mean is a re-anchor (server restart, session change), not jitter.
|
||||
const CLOCK_REANCHOR_MS: f64 = 2000.0;
|
||||
/// Cap on the jitter-derived scheduling margin: never buffer more than this beyond the mean.
|
||||
const CLOCK_MARGIN_MAX_MS: f64 = 200.0;
|
||||
/// The reference's skew clamp ceiling: a fire-time never lands more than this past arrival
|
||||
/// (`0x618c30 @0x618d0d/49`: skew held in `[−500, +1000]` ms; the floor needs no mirror — an
|
||||
/// already-due fire-time applies at arrival either way).
|
||||
const FIRE_DEFER_MAX_MS: f64 = 1000.0;
|
||||
|
||||
impl RelayClock {
|
||||
/// Map a relayed move's server timestamp to a client fire-time (ms on [`Time::elapsed`]),
|
||||
/// updating the offset estimate with this packet's sample.
|
||||
pub(crate) fn fire_time(&mut self, stime: u32, now_ms: f64) -> f64 {
|
||||
let sample = now_ms - f64::from(stime);
|
||||
let mean = match self.mean {
|
||||
Some(m) if (sample - m).abs() < CLOCK_REANCHOR_MS => {
|
||||
let m = m + (sample - m) * CLOCK_ALPHA;
|
||||
self.mad += ((sample - m).abs() - self.mad) * CLOCK_ALPHA;
|
||||
m
|
||||
}
|
||||
_ => {
|
||||
self.mad = 0.0;
|
||||
sample
|
||||
}
|
||||
};
|
||||
self.mean = Some(mean);
|
||||
// Schedule at the mean-latency timeline plus a jitter margin, so most packets arrive
|
||||
// early and wait — the buffering that absorbs relay jitter — capped by the reference's
|
||||
// +1000 ms skew ceiling relative to arrival.
|
||||
let margin = (3.0 * self.mad).min(CLOCK_MARGIN_MAX_MS);
|
||||
(f64::from(stime) + mean + margin).min(now_ms + FIRE_DEFER_MAX_MS)
|
||||
impl RelayOutcome {
|
||||
fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Self::Now => "now",
|
||||
Self::Queued => "queued",
|
||||
Self::Seed => "seed",
|
||||
Self::SelfMover => "self",
|
||||
Self::Unknown => "UNKNOWN-GUID",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of the `WOW_MOVE_TRACE` sink per inbound relayed move (tag `rly`), so replay is
|
||||
/// **measured** rather than eyeballed (method §4): the mover, its wire stamp, the move-flags the
|
||||
/// packet carries, what we did with it, the lead the schedule gave it (`fire − arrival`, ms), and how
|
||||
/// deep the unit's queue is. Together with the sender's `snd` lines on the other client, a run's
|
||||
/// `rly` lines answer the only question that matters when a mover misbehaves: *did the packet arrive,
|
||||
/// and did it win?* Costs one `OnceLock` read per packet when the trace is off.
|
||||
pub(in crate::net) fn trace_relay(
|
||||
guid: u64,
|
||||
mv: &RelayMove,
|
||||
chain: &RelayChain,
|
||||
now_ms: f64,
|
||||
queued: usize,
|
||||
out: RelayOutcome,
|
||||
) {
|
||||
if !crate::dbg_trace::enabled() {
|
||||
return;
|
||||
}
|
||||
let lead = chain.lead_ms(now_ms);
|
||||
let kind = if mv.heartbeat { "hb" } else { "tr" };
|
||||
crate::dbg_trace::line(
|
||||
"rly",
|
||||
&format!(
|
||||
"guid={guid:#x} {kind} wire={} flags={:#x} out={} lead={lead:7.1} q={queued}",
|
||||
mv.wire_ms,
|
||||
mv.flags,
|
||||
out.tag()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// How long a mover may dead-reckon with **nothing queued** before the runaway watch starts
|
||||
/// reporting it (ms). A moving unit is normally fed every frame while it turns and at worst every
|
||||
/// 500 ms by the heartbeat (decision 0617), so two seconds of silence with a direction flag still
|
||||
/// live means we are inventing motion the server never described.
|
||||
const RUNAWAY_SILENCE_MS: f64 = 2000.0;
|
||||
|
||||
/// The **runaway watch** (tag `run`): one line per silent second per mover that is still moving under
|
||||
/// our own extrapolation with an empty queue — its flags, how long since anything applied, and how
|
||||
/// far we have carried it from the last position the server actually gave us.
|
||||
///
|
||||
/// This is the instrument the "he keeps running off into the distance" report needed: it timestamps
|
||||
/// the moment precisely, so the mover client's `snd` lines at that instant say whether the Stop was
|
||||
/// ever sent, and the observer's `rly` lines say whether it arrived. Nothing in the client — or in
|
||||
/// the reference — otherwise notices a mover that has travelled a hundred yards on dead reckoning
|
||||
/// alone. Reporting only; it never corrects the pose.
|
||||
fn trace_runaway(guid_hint: Entity, rm: &RemoteMotion, now_ms: f64, silent_s: u32) {
|
||||
let d = [
|
||||
rm.wow_pos[0] - rm.last_apply_pos[0],
|
||||
rm.wow_pos[1] - rm.last_apply_pos[1],
|
||||
];
|
||||
// The inbound census rides every line, because it is the discriminator: a starving mover with
|
||||
// packets still landing means the **server** stopped relaying that unit; a starving mover with
|
||||
// the whole census frozen means the **socket** died with nobody noticing (decision 0621).
|
||||
let (pkts, age) = crate::net::io::inbound_census();
|
||||
let age = age.map_or_else(|| "never".to_string(), |ms| format!("{ms}ms"));
|
||||
crate::dbg_trace::line(
|
||||
"run",
|
||||
&format!(
|
||||
"{guid_hint} RUNAWAY flags={:#x} silent={silent_s}s drift={:.1}yd since={:.0}ms pos=[{:.1},{:.1}] netpkts={pkts} lastpkt={age}",
|
||||
rm.flags,
|
||||
d[0].hypot(d[1]),
|
||||
now_ms - rm.last_apply_ms,
|
||||
rm.wow_pos[0],
|
||||
rm.wow_pos[1],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// How often the remote-pose watch samples a mover (ms) — see [`trace_remote`].
|
||||
const REMOTE_TRACE_MS: f64 = 500.0;
|
||||
|
||||
/// The **remote-pose watch** (tag `rem`): one line per mover per [`REMOTE_TRACE_MS`], reporting what
|
||||
/// the dead-reckon asked for and what the world gave back. Until decision 0626 nothing measured the
|
||||
/// *pose* of a watched player at all — `rly` measures the packets that arrive and `run` only fires
|
||||
/// when a mover starves — so "he sinks into the ground / into the wall and pops back out" had no
|
||||
/// instrument behind it and could only be argued from reading code. Two numbers close that:
|
||||
///
|
||||
/// - **`dz`** — the height the mover gained or lost **this frame**, *excluding* the packet applied
|
||||
/// this frame (the drain runs first, so the anchor already carries it). A grounded mover riding the
|
||||
/// surface moves in Z on every sloped frame; before 0626 the only thing that could move it was the
|
||||
/// pre-fire reconcile lerp, which skips heartbeats — so a mover running *straight* read `+0.000`
|
||||
/// sample after sample and took its height as a 2 Hz snap, which is the "delayed terrain snap"
|
||||
/// report. Read it against `age`: a nonzero `dz` at a large `age` is the ground, not the server.
|
||||
/// - **`held`** — how much of this frame's intended horizontal travel the world took away. Sustained
|
||||
/// nonzero is a mover being *held* against a wall by our colliders (right); a flat zero for a mover
|
||||
/// whose own client is stopped dead is the dead-reckon marching into the geometry (wrong), and it
|
||||
/// is the "sinks in and pops out again and again" report.
|
||||
///
|
||||
/// `age` (ms since a packet last applied) rides along so a sample is never read as server truth.
|
||||
fn trace_remote(e: Entity, rm: &RemoteMotion, held: f32, dz: f32, now_ms: f64) {
|
||||
crate::dbg_trace::line(
|
||||
"rem",
|
||||
&format!(
|
||||
"{e} flags={:#x} pos=[{:.2},{:.2},{:.2}] dz={dz:+.3} held={held:.3} age={:.0}ms",
|
||||
rm.flags,
|
||||
rm.wow_pos[0],
|
||||
rm.wow_pos[1],
|
||||
rm.wow_pos[2],
|
||||
now_ms - rm.last_apply_ms,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// `WOW_REMOTE_SNAP=1` — the A/B escape: apply every relayed move at arrival as a raw snap
|
||||
/// (pre-0601 behavior), bypassing the scheduled queue and the reconcile lerp.
|
||||
pub(in crate::net) fn arrival_snap() -> bool {
|
||||
@@ -143,6 +218,18 @@ pub(in crate::net) fn arrival_snap() -> bool {
|
||||
*SNAP.get_or_init(|| std::env::var_os("WOW_REMOTE_SNAP").is_some())
|
||||
}
|
||||
|
||||
/// `WOW_REMOTE_FLAT=1` — the other A/B escape (decision 0626): dead-reckon a remote mover **without
|
||||
/// the world**, the pre-0626 behaviour — height frozen at the last packet's Z, the step unswept.
|
||||
/// Restores both defects on demand (a watched player sinking into rising ground and floating over
|
||||
/// falling ground; a mover marching into the wall its own client is stopped at), which is what makes
|
||||
/// the fix measurable side by side rather than asserted — and what re-answers "is the ground resolve
|
||||
/// still doing anything?" for one env var instead of a bisect. Its twin above earns its keep the
|
||||
/// same way.
|
||||
fn flat_extrapolation() -> bool {
|
||||
static FLAT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*FLAT.get_or_init(|| std::env::var_os("WOW_REMOTE_FLAT").is_some())
|
||||
}
|
||||
|
||||
/// The reconcile-arm tolerance (squared yards): predicted-vs-event disagreement below this needs
|
||||
/// no correction. The reference's `0x80c744` = 7.716e-4 ≈ (0.0278 yd)², compared in 2D — Z joins
|
||||
/// only while SWIMMING (`0x619090`, wow-re `spec-driver-A.md`).
|
||||
@@ -210,8 +297,9 @@ pub(super) fn facing_lerp(orientation: f32, target: f32, dt: f32, remaining_s: f
|
||||
/// arrival path ([`crate::net::apply`]'s `unit_move`) and the queue drain ([`drain_pending_moves`]).
|
||||
pub(in crate::net) fn apply_move(
|
||||
e: Entity,
|
||||
ev: &PendingMove,
|
||||
ev: &RelayMove,
|
||||
rm: &mut RemoteMotion,
|
||||
now_ms: f64,
|
||||
commands: &mut Commands,
|
||||
landings: &mut MessageWriter<crate::creature_anim::HardLanding>,
|
||||
) {
|
||||
@@ -250,14 +338,26 @@ pub(in crate::net) fn apply_move(
|
||||
rm.pitch = ev.pitch;
|
||||
rm.vertical_velocity = vertical_velocity;
|
||||
rm.jump_xy_vel = jump_xy_vel;
|
||||
// The dead-reckon's anchor: when this mover was last *told* anything, and from where. Read only
|
||||
// by the runaway watch ([`trace_runaway`]) — a mover extrapolating far past its last packet is
|
||||
// the shape of every "he ran off into the distance" report.
|
||||
rm.last_apply_ms = now_ms;
|
||||
rm.last_apply_pos = ev.position;
|
||||
}
|
||||
|
||||
/// Fire every due queued move (the reference's drain `0x615c30`: bail while `now < ev[+8]`,
|
||||
/// dequeue + dispatch once due). Runs before [`extrapolate_remote_units`], which then advances
|
||||
/// the freshly-applied state and runs the pre-fire reconcile lerp against the next queued head.
|
||||
///
|
||||
/// **REAL time, deliberately** (decision 0615): this is a replay clock paced against the server's
|
||||
/// stamps, and the reference's is the OS wall clock (`0x42c010`, a QPC-derived ms counter). Bevy's
|
||||
/// virtual clock clamps every frame delta to `max_delta` (250 ms), so under macOS occlusion
|
||||
/// throttling (~1 fps for a backgrounded client — i.e. exactly the side-by-side A/B against the
|
||||
/// reference) it falls minutes behind real time and drags the whole replay schedule with it. Same
|
||||
/// lesson, same fix as the UI script clock (`ui_script/extract`).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(in crate::net) fn drain_pending_moves(
|
||||
time: Res<Time>,
|
||||
time: Res<Time<Real>>,
|
||||
mut commands: Commands,
|
||||
mut landings: MessageWriter<crate::creature_anim::HardLanding>,
|
||||
mut q: Query<(Entity, &mut RemoteMotion), (Without<Spline>, Without<SelfPlayer>)>,
|
||||
@@ -266,7 +366,7 @@ pub(in crate::net) fn drain_pending_moves(
|
||||
for (e, mut rm) in &mut q {
|
||||
while rm.pending.front().is_some_and(|ev| ev.fire_ms <= now_ms) {
|
||||
let ev = rm.pending.pop_front().expect("front checked");
|
||||
apply_move(e, &ev, &mut rm, &mut commands, &mut landings);
|
||||
apply_move(e, &ev.mv, &mut rm, now_ms, &mut commands, &mut landings);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,17 +374,49 @@ pub(in crate::net) fn drain_pending_moves(
|
||||
/// Integrate every remote mover's pose from its [`RemoteMotion`] each frame, so another player walks
|
||||
/// *smoothly* between the sparse relay packets instead of snapping at the ~2 Hz heartbeat rate. The
|
||||
/// horizontal velocity is derived from the live `moveFlags` in the mover's facing frame at its run /
|
||||
/// run-back / swim speed; the `TURN_*` flags rotate the facing at `turn_rate`. Z is left to the next
|
||||
/// packet's correction (the relay carries the authoritative height). A creature [`Spline`] (server-
|
||||
/// authored path) and our own avatar ([`SelfPlayer`]) are excluded — they have their own motion source.
|
||||
/// run-back / swim speed; the `TURN_*` flags rotate the facing at `turn_rate`. A creature [`Spline`]
|
||||
/// (server-authored path) and our own avatar ([`SelfPlayer`]) are excluded — they have their own
|
||||
/// motion source.
|
||||
///
|
||||
/// **The step is then resolved against the world** ([`crate::player::mover::grounded_step`], decision
|
||||
/// 0626) — the same swept capsule, colliders and step-vs-fall election our own avatar walks on,
|
||||
/// because the reference drives every mover through one controller (decision 0059). Height therefore
|
||||
/// comes from the ground under the mover **every frame**, and the dead-reckon cannot walk a watched
|
||||
/// player into geometry.
|
||||
///
|
||||
/// Without it the extrapolation ran in a vacuum, and the two symptoms that follow are the ones the
|
||||
/// director reported. **Height:** [`RemoteMotion::advance`] never moves a grounded mover's Z, so Z
|
||||
/// changed only where a *packet* put it — at the apply, or dragged there by the pre-fire reconcile
|
||||
/// lerp. The lerp skips heartbeats, and a mover running **straight** sends nothing else that carries
|
||||
/// position (0617's frame-cadence stream is `SET_FACING`, sent only while turning) — so a straight run
|
||||
/// gave a 2 Hz height staircase under a continuous XY, which sinks a watched player into ground that
|
||||
/// rises under them and floats them over ground that falls away, at the same rate. **Geometry:** a
|
||||
/// mover held against a wall by its own client keeps reporting `FORWARD` with an unchanged position —
|
||||
/// there is no "I am blocked" bit on the wire, and there needn't be, because the watching client is
|
||||
/// meant to be stopped by the same wall. Ours wasn't: it advanced into the geometry for the whole
|
||||
/// packet interval, and the next packet popped it back out.
|
||||
///
|
||||
/// This is the client's own dead-reckoning, in miniature: extrapolate from the last reported state,
|
||||
/// snap to the truth when the next packet lands. The pose lives canonically in WoW space on the
|
||||
/// component; the [`Transform`] is derived from it (translation + facing only — scale is preserved).
|
||||
///
|
||||
/// **REAL time, deliberately** — the same clock the replay schedule runs on
|
||||
/// ([`drain_pending_moves`]); a dead-reckon that advanced on a different (virtual, clamped) clock
|
||||
/// than the fire-times it converges toward would never land on them.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(in crate::net) fn extrapolate_remote_units(
|
||||
time: Res<Time>,
|
||||
time: Res<Time<Real>>,
|
||||
mut commands: Commands,
|
||||
// Avian's kinematic move-and-slide + the player-body capsule — the *same* pair the local
|
||||
// controller sweeps (decision 0626): one controller, every mover.
|
||||
move_and_slide: MoveAndSlide,
|
||||
capsule: Res<crate::player::PlayerCapsule>,
|
||||
// The runaway watch's per-mover throttle: the last whole silent second reported, so a stuck
|
||||
// mover writes one line a second rather than one a frame. Trace-only, hence a `Local` and not
|
||||
// state on the component.
|
||||
mut warned: Local<bevy::platform::collections::HashMap<Entity, u32>>,
|
||||
// The remote-pose watch's per-mover throttle: when each mover last wrote a `rem` line.
|
||||
mut sampled: Local<bevy::platform::collections::HashMap<Entity, f64>>,
|
||||
mut q: Query<
|
||||
(
|
||||
Entity,
|
||||
@@ -293,6 +425,7 @@ pub(in crate::net) fn extrapolate_remote_units(
|
||||
Option<&UnitSpeeds>,
|
||||
Option<&mut crate::creature_anim::BodyTwist>,
|
||||
Has<super::FacingStep>,
|
||||
Has<crate::transport::TransportRider>,
|
||||
),
|
||||
(Without<Spline>, Without<SelfPlayer>),
|
||||
>,
|
||||
@@ -300,9 +433,88 @@ pub(in crate::net) fn extrapolate_remote_units(
|
||||
use crate::creature_anim::{ease_strafe_yaw, strafe_body_offset, wrap_pi};
|
||||
let dt = time.delta_secs();
|
||||
let now_ms = time.elapsed_secs_f64() * 1000.0;
|
||||
for (e, mut t, mut rm, speeds, twist, latched) in &mut q {
|
||||
let filter = crate::collision::player_query_filter();
|
||||
for (e, mut t, mut rm, speeds, twist, latched, riding) in &mut q {
|
||||
// The runaway watch (trace-only): a mover still carrying a direction flag, with nothing
|
||||
// queued behind it and nothing applied for seconds, is running on our extrapolation alone.
|
||||
if crate::dbg_trace::enabled() {
|
||||
let silent = now_ms - rm.last_apply_ms;
|
||||
let moving = rm.flags & move_flags::ANY_MOVE != 0;
|
||||
if moving && rm.pending.is_empty() && silent > RUNAWAY_SILENCE_MS {
|
||||
let silent_s = (silent / 1000.0) as u32;
|
||||
if warned.insert(e, silent_s) != Some(silent_s) {
|
||||
trace_runaway(e, &rm, now_ms, silent_s);
|
||||
}
|
||||
} else {
|
||||
warned.remove(&e);
|
||||
}
|
||||
}
|
||||
let s = speeds.map_or_else(MoveSpeeds::default, |u| u.0);
|
||||
let prev = rm.wow_pos;
|
||||
let (mut pos, mut orientation, vertical_velocity, speed) = rm.advance(s, dt);
|
||||
// How much of the frame's intended horizontal travel the world took away — measured at the
|
||||
// resolve, before the reconcile lerp, so it is the *collision's* doing and not the
|
||||
// correction's. Stays 0 for a mover the resolve skips (swimming, on a boat).
|
||||
let mut held = 0.0f32;
|
||||
// **The step meets the ground** (decision 0626). What [`RemoteMotion::advance`] produced is
|
||||
// our *invention* — the mover's own client has not told us anything since the last packet —
|
||||
// and an invention that ignores the world is what a watched player sinking into a hillside
|
||||
// and popping back out actually is. Run it through the local controller's own grounded
|
||||
// resolve: swept capsule (so a mover held against a wall by its own collision is held
|
||||
// against ours too, instead of marching into it) and the step-vs-fall election (so height
|
||||
// comes from the surface every frame, instead of standing frozen at the last packet's Z
|
||||
// until the next one snaps it). The reference makes no distinction here — one controller
|
||||
// integrates and commits every mover (decision 0059).
|
||||
//
|
||||
// **An airborne arc resolves too — but only against walls** (decision 0627). A jump owns its
|
||||
// Z (the ballistic arc is the whole point), so it gets no election snap and no step-up; what
|
||||
// it does get is the same swept capsule, because a watched player who jumps into a building
|
||||
// has to be stopped by it. Unswept, our invented arc carried them *inside* the wall for the
|
||||
// length of the jump and the landing packet popped them back out — the airborne half of the
|
||||
// very same defect, closed by the local controller's own airborne slide
|
||||
// ([`crate::player::mover::airborne_step`]).
|
||||
//
|
||||
// Still excluded: a **swimmer** (the wire Z is its depth in the water volume, not a surface
|
||||
// to resolve against) and a **transport rider** (its pose is transport-local; `compose_riders`
|
||||
// re-anchors it through the boat's live matrix each frame, and a world-space resolve would
|
||||
// fight it).
|
||||
let airborne = rm.flags & move_flags::FALLING != 0;
|
||||
let afloat = rm.flags & move_flags::SWIMMING != 0;
|
||||
if !afloat && !riding && !flat_extrapolation() {
|
||||
let half_h = Vec3::Y * (crate::player::CAPSULE_HEIGHT * 0.5);
|
||||
let from = wow_to_bevy(rm.wow_pos) + half_h;
|
||||
// The frame's velocity by construction. Grounded, [`RemoteMotion::advance`] never moves
|
||||
// Z (the surface does, in the snap), so it is purely horizontal; airborne, the arc's
|
||||
// vertical rides along so the sweep sees the real displacement.
|
||||
let vel = if dt > 1.0e-6 {
|
||||
(wow_to_bevy(pos) + half_h - from) / dt
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
let resolved_center = if airborne {
|
||||
crate::player::mover::airborne_step(
|
||||
&move_and_slide,
|
||||
&capsule.0,
|
||||
&filter,
|
||||
from,
|
||||
vel,
|
||||
time.delta(),
|
||||
)
|
||||
} else {
|
||||
crate::player::mover::grounded_step(
|
||||
&move_and_slide,
|
||||
&capsule.0,
|
||||
&filter,
|
||||
from,
|
||||
vel,
|
||||
time.delta(),
|
||||
)
|
||||
.center
|
||||
};
|
||||
let resolved = bevy_to_wow(resolved_center - half_h);
|
||||
held = (pos[0] - resolved[0]).hypot(pos[1] - resolved[1]);
|
||||
pos = resolved;
|
||||
}
|
||||
// The pre-fire reconcile toward the queued head (decisions 0601/0602/0603):
|
||||
// - **Facing** interpolates toward a NON-heartbeat event's facing (the reference's
|
||||
// `0x618f80` ω armed by `0x619030`, integrated by `0x7c4f30` — its only smoothed
|
||||
@@ -320,12 +532,12 @@ pub(in crate::net) fn extrapolate_remote_units(
|
||||
// `0x619030` skips tag 0x26 exactly as the position arm `0x619090` does (wow-re
|
||||
// `remote-air-facing.md`, decision 0603) — so it applies as an outright snap at
|
||||
// fire; the smoothed facings are the transition/SET_FACING family's.
|
||||
if remaining_s > 0.0 && !ev.heartbeat {
|
||||
orientation = facing_lerp(orientation, ev.orientation, dt, remaining_s);
|
||||
if remaining_s > 0.0 && !ev.mv.heartbeat {
|
||||
orientation = facing_lerp(orientation, ev.mv.orientation, dt, remaining_s);
|
||||
// Predict from the pre-frame state to the fire-time (this frame's dt + what's left).
|
||||
let (predicted, ..) = rm.advance(s, dt + remaining_s);
|
||||
let swimming = ev.flags & move_flags::SWIMMING != 0;
|
||||
pos = reconcile_lerp(pos, predicted, ev.position, swimming, dt, remaining_s);
|
||||
let swimming = ev.mv.flags & move_flags::SWIMMING != 0;
|
||||
pos = reconcile_lerp(pos, predicted, ev.mv.position, swimming, dt, remaining_s);
|
||||
}
|
||||
}
|
||||
// The standing mouse-turn shuffle: a mouse-turning mover streams NO turn flag — only its
|
||||
@@ -346,7 +558,7 @@ pub(in crate::net) fn extrapolate_remote_units(
|
||||
| move_flags::SWIMMING)
|
||||
== 0;
|
||||
if grounded_still {
|
||||
wrap_pi(ev.orientation - orientation)
|
||||
wrap_pi(ev.mv.orientation - orientation)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
@@ -360,6 +572,15 @@ pub(in crate::net) fn extrapolate_remote_units(
|
||||
rm.orientation = orientation;
|
||||
rm.vertical_velocity = vertical_velocity;
|
||||
rm.speed = speed;
|
||||
// The remote-pose watch (trace-only): one sample per mover per half-second.
|
||||
if crate::dbg_trace::enabled()
|
||||
&& sampled
|
||||
.get(&e)
|
||||
.is_none_or(|t| now_ms - t >= REMOTE_TRACE_MS)
|
||||
{
|
||||
sampled.insert(e, now_ms);
|
||||
trace_remote(e, &rm, held, pos[2] - prev[2], now_ms);
|
||||
}
|
||||
t.translation = wow_to_bevy(pos);
|
||||
// The strafe body pose, same as our own avatar's (the client's display-facing blend): a
|
||||
// strafing remote player renders its body at `orientation ± 90°/45°`, eased in aim-relative
|
||||
@@ -440,6 +661,32 @@ pub(in crate::net) fn fall_arc_step(
|
||||
}
|
||||
|
||||
impl RemoteMotion {
|
||||
/// Does a freshly-scheduled move apply **at arrival**, or go on the unit's queue?
|
||||
///
|
||||
/// Due (`fire ≤ now`) **and the queue empty** (decision 0618). Fire-times are monotone per unit
|
||||
/// (0615), so a due arrival means every queued packet is due too — and [`drain_pending_moves`]
|
||||
/// runs *after* us in the same frame ([`crate::net`] chains apply → drain). Applying the arrival
|
||||
/// directly therefore writes the newest state and then lets the drain replay the older queued
|
||||
/// packets over the top of it. Last write wins, and the last write is stale: a Stop that races the
|
||||
/// tail of a burst is undone by the FORWARD packet queued in front of it, the mover then goes
|
||||
/// silent (a still player sends nothing at all), and the observer extrapolates a run that ended —
|
||||
/// off into the distance until some unrelated correction lands.
|
||||
///
|
||||
/// **The reference needs no such test, and tracing why is what makes this faithful rather than
|
||||
/// defensive.** Its due test (`@0x618dd2`: `ecx = [edx+0x128]; sub ecx,fire; jns apply`) compares
|
||||
/// against `mgr+0x128` — the movement manager's clock *cell*, stamped once per movement update
|
||||
/// (`0x616800`), not the live ms counter. Its drain (`0x615c30`) runs in that same update against
|
||||
/// that same stamped value. So by the time packets are processed, every event still queued has
|
||||
/// `fire > mgr+0x128`, and monotonicity puts a new packet's fire at or beyond those: a due arrival
|
||||
/// and a non-empty queue are mutually exclusive *by construction*. We read a live frame clock with
|
||||
/// the drain still ahead of us, which breaks that exclusivity; this term restores it.
|
||||
///
|
||||
/// Queuing instead costs nothing: the drain applies every due packet later in the same frame,
|
||||
/// this one included, in arrival order.
|
||||
pub(crate) fn fires_at_arrival(&self, fire_ms: f64, now_ms: f64) -> bool {
|
||||
self.pending.is_empty() && fire_ms <= now_ms
|
||||
}
|
||||
|
||||
/// Advance one frame of dead-reckoning: the new `(WoW position, facing, vertical speed, horizontal
|
||||
/// speed)` given the unit's `speeds` and `dt`. On the **ground**, integrates the velocity the current
|
||||
/// `flags` imply in the facing frame (forward/back/strafe summed, normalized, at the run / run-back /
|
||||
|
||||
@@ -10,9 +10,10 @@ use crate::creature_anim::move_flags;
|
||||
use crate::player::{GRAVITY, TERMINAL_VELOCITY};
|
||||
|
||||
use super::facing::{resolve_facing, turn_toward};
|
||||
use super::relay::RelayChain;
|
||||
use super::remote::{facing_lerp, fall_arc_step, jump_seed, reconcile_lerp};
|
||||
use super::spline::monster_move_spline;
|
||||
use super::{RelayClock, RemoteMotion, Spline};
|
||||
use super::{RemoteMotion, Spline};
|
||||
|
||||
fn speeds() -> MoveSpeeds {
|
||||
MoveSpeeds {
|
||||
@@ -57,6 +58,9 @@ fn motion(flags: u32, orientation: f32) -> RemoteMotion {
|
||||
vertical_velocity: 0.0,
|
||||
jump_xy_vel: [0.0, 0.0],
|
||||
fall_start_z: None,
|
||||
relay: Default::default(),
|
||||
last_apply_ms: 0.0,
|
||||
last_apply_pos: [0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,25 +431,151 @@ fn monster_move_without_a_travelable_path_clears_the_spline() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The relay clock maps a packet's server timestamp to a client fire-time on the mover's own
|
||||
/// timeline (decision 0601): the first packet anchors the offset (fire = arrival), a steady
|
||||
/// stream schedules by the learned mean, an early burst is capped at the reference's +1000 ms
|
||||
/// defer ceiling, and a wildly-off sample re-anchors instead of poisoning the mean.
|
||||
/// Flags that make the chain treat a mover as mid-motion (`0x20ff`'s FORWARD bit is enough).
|
||||
const MOVING: u32 = move_flags::FORWARD;
|
||||
|
||||
/// **The headline property** (decision 0615, the reference's `0x618c30`): while a mover is moving,
|
||||
/// replay is paced by the *sender's* stamps — `fire = prev fire + wire step` — so however the packets
|
||||
/// clumped in flight, they replay at the spacing the stamps carry. Here four 500 ms-apart stamps
|
||||
/// arrive at 0 / 520 / 1450 / 1460 ms (one late, then a two-packet burst) and still fire 500 ms apart.
|
||||
#[test]
|
||||
fn relay_clock_schedules_on_the_server_timeline() {
|
||||
let mut clock = RelayClock::default();
|
||||
// First sample anchors: offset 4000, zero jitter → fire exactly at arrival.
|
||||
assert_eq!(clock.fire_time(1000, 5000.0), 5000.0);
|
||||
// A steady stream (same offset) keeps firing at arrival — no invented delay.
|
||||
assert_eq!(clock.fire_time(1500, 5500.0), 5500.0);
|
||||
// An EARLY packet (offset 1500 ms under the mean, still in-family): the raw schedule
|
||||
// stime + mean lands ~1500 ms past arrival — clamped to the reference's +1000 ceiling.
|
||||
let fire = clock.fire_time(2000, 4500.0);
|
||||
assert_eq!(fire, 5500.0, "defer capped at arrival + 1000 ms");
|
||||
// A sample 6000 ms off the mean is a re-anchor (server restart), not jitter: the clock
|
||||
// resets to it and the next fire follows the NEW timeline at arrival.
|
||||
let fire = clock.fire_time(1000, 11_000.0);
|
||||
assert_eq!(fire, 11_000.0, "re-anchored — fire at arrival again");
|
||||
fn relay_chain_replays_on_the_senders_cadence() {
|
||||
let mut chain = RelayChain::default();
|
||||
let script = [(1000, 0.0), (1500, 520.0), (2000, 1450.0), (2500, 1460.0)];
|
||||
let fires: Vec<f64> = script
|
||||
.iter()
|
||||
.map(|&(wire, now)| chain.schedule(wire, now, MOVING, true))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fires,
|
||||
vec![0.0, 500.0, 1000.0, 1500.0],
|
||||
"the burst is de-clumped onto the sender's cadence"
|
||||
);
|
||||
}
|
||||
|
||||
/// The chain's two seeds and its guards: the first packet anchors both cells and fires at arrival; a
|
||||
/// stale/duplicate stamp contributes no step (`@0x618cb8`'s `jle`); and the server's `u32` ms clock
|
||||
/// wrapping mid-session is just another forward step.
|
||||
#[test]
|
||||
fn relay_chain_seeds_holds_stale_stamps_and_survives_the_clock_wrap() {
|
||||
let mut chain = RelayChain::default();
|
||||
assert_eq!(
|
||||
chain.schedule(7_000, 4_000.0, MOVING, true),
|
||||
4_000.0,
|
||||
"first packet: fire at arrival, whatever the server's clock reads"
|
||||
);
|
||||
// A re-sent / out-of-order stamp: no forward step, so the chain doesn't advance past the
|
||||
// previous fire (and the reference stamp is left alone — the next real step measures from it).
|
||||
assert_eq!(chain.schedule(7_000, 4_100.0, MOVING, true), 4_000.0);
|
||||
assert_eq!(chain.schedule(6_900, 4_200.0, MOVING, true), 4_000.0);
|
||||
assert_eq!(
|
||||
chain.schedule(7_300, 4_300.0, MOVING, true),
|
||||
4_300.0,
|
||||
"the next forward stamp steps 300 ms from the last one that counted"
|
||||
);
|
||||
// The wrap: 150 is 251 ms after u32::MAX − 100 on a wrapping ms clock.
|
||||
let mut chain = RelayChain::default();
|
||||
chain.schedule(u32::MAX - 100, 0.0, MOVING, true);
|
||||
assert_eq!(
|
||||
chain.schedule(150, 10.0, MOVING, true),
|
||||
251.0,
|
||||
"the u32 wrap is a forward step, not a 49-day jump backwards"
|
||||
);
|
||||
}
|
||||
|
||||
/// The de-jitter buffer is re-sized **only** on a standing mover with an empty queue (`@0x618ce4` /
|
||||
/// `@0x618cf3`), and it is sized by the window's worst lateness (`0x618b50`) — then held, not
|
||||
/// re-charged: the ring stores lateness *relative to the base*, so a spike already absorbed doesn't
|
||||
/// buy a second helping of buffer on the next idle packet.
|
||||
#[test]
|
||||
fn relay_chain_rebases_the_buffer_only_when_idle_and_unqueued() {
|
||||
// A 200 ms-late packet enters the window while the mover is moving: no re-base, no buffer.
|
||||
let mut chain = RelayChain::default();
|
||||
chain.schedule(0, 0.0, MOVING, true);
|
||||
assert_eq!(chain.schedule(500, 700.0, MOVING, true), 500.0);
|
||||
// Still moving when the next one lands: the chain stays glued to the sender's cadence.
|
||||
let mut moving = chain.clone();
|
||||
assert_eq!(
|
||||
moving.schedule(1000, 1000.0, MOVING, true),
|
||||
1000.0,
|
||||
"mid-motion: the 200 ms spike buys no buffer"
|
||||
);
|
||||
// The same packet on a mover that has come to a stop with nothing queued: NOW the chain
|
||||
// re-bases, and the buffer it takes is exactly the window's worst lateness.
|
||||
let mut idle = chain.clone();
|
||||
assert_eq!(
|
||||
idle.schedule(1000, 1000.0, 0, true),
|
||||
1200.0,
|
||||
"idle + empty: re-based by the window max (200 ms late)"
|
||||
);
|
||||
// ...and the next idle packet holds that buffer rather than charging the spike again.
|
||||
assert_eq!(
|
||||
idle.schedule(1500, 1500.0, 0, true),
|
||||
1700.0,
|
||||
"the absorbed spike is not re-charged: still 200 ms of lead"
|
||||
);
|
||||
// A queued event blocks the re-base even when the mover is idle.
|
||||
let mut queued = chain.clone();
|
||||
assert_eq!(
|
||||
queued.schedule(1000, 1000.0, 0, false),
|
||||
1000.0,
|
||||
"a non-empty queue defers the re-base"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference's skew clamp (`@0x618d0d`/`@0x618d49`): a fire never lands more than 1000 ms after
|
||||
/// its packet's arrival, nor more than 500 ms before it.
|
||||
#[test]
|
||||
fn relay_chain_holds_the_offset_inside_the_reference_clamp() {
|
||||
// A 2 s wire step delivered 100 ms after the last fire would schedule 1.9 s out — capped.
|
||||
let mut chain = RelayChain::default();
|
||||
chain.schedule(0, 0.0, MOVING, true);
|
||||
assert_eq!(chain.schedule(2000, 100.0, MOVING, true), 1100.0);
|
||||
// A stalled sender (no forward step) whose packet lands 4 s after the last fire would schedule
|
||||
// 4 s in the past — floored at arrival − 500 ms, which is due-on-arrival either way.
|
||||
let mut chain = RelayChain::default();
|
||||
chain.schedule(0, 1000.0, MOVING, true);
|
||||
assert_eq!(chain.schedule(0, 5000.0, MOVING, true), 4500.0);
|
||||
}
|
||||
|
||||
/// Under a scripted jitter pattern — a steady stream, a stalled tail, a catch-up burst, an idle
|
||||
/// resync, then motion again — the chain stays well-formed: fire-times never go backwards (which is
|
||||
/// what lets the queue be a plain FIFO with no re-sort), and the lead over arrival stays inside the
|
||||
/// reference's clamp.
|
||||
#[test]
|
||||
fn relay_chain_stays_monotone_and_bounded_under_scripted_jitter() {
|
||||
// (wire stamp, arrival, moving?) — 500 ms stamps throughout; the arrivals are the abuse.
|
||||
let script: [(u32, f64, bool); 14] = [
|
||||
(500, 100.0, true), // first packet
|
||||
(1000, 600.0, true), // steady
|
||||
(1500, 1100.0, true), // steady
|
||||
(2000, 2400.0, true), // a 1.3 s stall
|
||||
(2500, 2410.0, true), // burst catch-up
|
||||
(3000, 2420.0, true), // burst catch-up
|
||||
(3500, 2430.0, true), // burst catch-up
|
||||
(4000, 4000.0, false), // stopped, queue drained: resync
|
||||
(4500, 4500.0, false), // idle heartbeats
|
||||
(5000, 5000.0, false),
|
||||
(5500, 5480.0, true), // moving again, slightly early
|
||||
(6000, 6050.0, true), // slightly late
|
||||
(6500, 6500.0, true),
|
||||
(7000, 7100.0, true),
|
||||
];
|
||||
let mut chain = RelayChain::default();
|
||||
let mut prev_fire = f64::NEG_INFINITY;
|
||||
for (wire, now, moving) in script {
|
||||
let fire = chain.schedule(wire, now, if moving { MOVING } else { 0 }, true);
|
||||
assert!(
|
||||
fire >= prev_fire,
|
||||
"fire-times must never reorder: {fire} after {prev_fire}"
|
||||
);
|
||||
let lead = fire - now;
|
||||
assert!(
|
||||
(-500.0..=1000.0).contains(&lead),
|
||||
"lead {lead} outside the reference clamp at wire={wire}"
|
||||
);
|
||||
prev_fire = fire;
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-fire reconcile lerp (decision 0601; the reference's `0x619090`/`0x6191c0`): an armed
|
||||
@@ -511,3 +641,74 @@ fn facing_lerp_turns_the_short_way_and_lands_at_fire_time() {
|
||||
let held = facing_lerp(1.0, 1.0 + 1.0e-8, 0.1, 0.4);
|
||||
assert_eq!(held, 1.0, "dead-zone: negligible turn skipped");
|
||||
}
|
||||
|
||||
/// The frame loop as [`crate::net`] chains it: `apply_net_updates` routes each arriving packet
|
||||
/// (applied at arrival, or queued), then `drain_pending_moves` empties everything due — apply
|
||||
/// **before** drain, both on the same frame clock. Returns the order packets were actually applied
|
||||
/// in, tagged by their `fall_time`, plus the flags left on the unit at the end.
|
||||
fn replay_frames(script: &[(u32, f64, u32)]) -> (Vec<u32>, u32) {
|
||||
use super::relay::{PendingMove, RelayMove};
|
||||
let mut rm = motion(MOVING, 0.0);
|
||||
let mut applied = Vec::new();
|
||||
let apply = |rm: &mut RemoteMotion, mv: &RelayMove, applied: &mut Vec<u32>| {
|
||||
rm.flags = mv.flags; // the one bit of `apply_move` this ordering question turns on
|
||||
applied.push(mv.fall_time);
|
||||
};
|
||||
for (id, &(wire_ms, arrival_ms, flags)) in script.iter().enumerate() {
|
||||
let now = arrival_ms;
|
||||
let mv = RelayMove {
|
||||
wire_ms,
|
||||
position: [0.0; 3],
|
||||
orientation: 0.0,
|
||||
flags,
|
||||
pitch: 0.0,
|
||||
fall_time: id as u32, // the packet's identity, carried through the queue
|
||||
jump: None,
|
||||
transport: None,
|
||||
heartbeat: false,
|
||||
};
|
||||
let (live, empty) = (rm.flags, rm.pending.is_empty());
|
||||
let fire_ms = rm.relay.schedule(wire_ms, now, live, empty);
|
||||
if rm.fires_at_arrival(fire_ms, now) {
|
||||
apply(&mut rm, &mv, &mut applied);
|
||||
} else {
|
||||
rm.pending.push_back(PendingMove { fire_ms, mv });
|
||||
}
|
||||
while rm.pending.front().is_some_and(|p| p.fire_ms <= now) {
|
||||
let ev = rm.pending.pop_front().expect("front checked");
|
||||
apply(&mut rm, &ev.mv, &mut applied);
|
||||
}
|
||||
}
|
||||
(applied, rm.flags)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_due_arrival_never_jumps_the_queue() {
|
||||
// **The runaway-mover regression** (decision 0618). A mover's packets are applied in the order
|
||||
// they arrived, always — even when the newest one is already due on arrival while older ones sit
|
||||
// in the queue. Fire-times are monotone (0615), so a due arrival means everything queued is due
|
||||
// too: applying the arrival *directly* writes the newest state, and the drain then replays the
|
||||
// older queued packets over it in the same frame. Last write wins, last write is stale.
|
||||
//
|
||||
// The script: a 60 Hz burst that builds a one-deep queue, then a delivery stall (packet 4 lands
|
||||
// 64 ms late) so its fire-time — chained at 48 + 16 = 64 ms — is already past by arrival at 96.
|
||||
// Packet 4 is the STOP; packet 3, still queued in front of it, is FORWARD.
|
||||
let script: [(u32, f64, u32); 5] = [
|
||||
(1000, 0.0, MOVING), // 0 — seeds the chain, fires at arrival
|
||||
(1016, 0.0, MOVING), // 1 — same frame; fire 16, queued
|
||||
(1032, 16.0, MOVING), // 2 — fire 32, queued; the drain releases 1
|
||||
(1048, 32.0, MOVING), // 3 — fire 48, queued; the drain releases 2
|
||||
(1064, 96.0, 0), // 4 — the STOP. Fire 64, already due; 3 is still queued
|
||||
];
|
||||
let (order, flags) = replay_frames(&script);
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![0, 1, 2, 3, 4],
|
||||
"relayed moves apply in arrival order — a due arrival waits behind the queue it belongs after"
|
||||
);
|
||||
assert_eq!(
|
||||
flags, 0,
|
||||
"the mover ends STOPPED: the Stop is the last write, not the FORWARD packet queued in front \
|
||||
of it — a still player then sends nothing, so a stale FORWARD here runs off forever"
|
||||
);
|
||||
}
|
||||
|
||||
+294
-63
@@ -1,108 +1,339 @@
|
||||
//! NPC-text chat macros — the client-side substitution the 1.12 wire leaves **un**-expanded in
|
||||
//! every server-authored text the player reads: gossip greetings, questgiver panel texts, and the
|
||||
//! quest log's description/objectives (the director's screenshot showed a literal "$N" in a quest
|
||||
//! description — the real client substitutes everywhere quest/gossip text renders). One shared
|
||||
//! mechanism (the 0109 look fix promoted it out of `ui_gossip`); every feed seam that pushes NPC
|
||||
//! text into the VM runs it.
|
||||
//! NPC-text macros — the client-side `$`-token expansion the 1.12 wire leaves undone in every
|
||||
//! server-authored text the player reads: gossip greetings, questgiver panel texts, and the quest
|
||||
//! log's description/objectives (the director's screenshot showed a literal `$N` in a quest
|
||||
//! description). One shared mechanism (the 0109 look fix promoted it out of `ui_gossip`); every feed
|
||||
//! seam that pushes NPC text into the VM runs it — as the reference does, routing all fourteen of
|
||||
//! its call sites through the one expander.
|
||||
//!
|
||||
//! Handled: `$N`/`$n` → the player's name; `$B`/`$b` → a newline (the text renderer splits on
|
||||
//! `\n`); `$G male:female;` / `$g …` → the gender branch (`gender`: 0 = male, 1 = female). **Left
|
||||
//! for a follow-up** (and so passed through literally): `$C`/`$c` (class name) and `$R`/`$r` (race
|
||||
//! name) — the client keeps localized class/race name tables the app doesn't have handy yet. An
|
||||
//! unterminated `$G…` is passed through literally too (degrade-to-visible, never eat text).
|
||||
//! The grammar is the reference's, carved at the bytes (wow-re `QuestTextParser.cpp`, driver
|
||||
//! `0x506f70` → token handler `0x5070a0`):
|
||||
//!
|
||||
//! - the accepted set is **exactly** `B C E G N R T W`, in either case. Anything else re-emits the
|
||||
//! `$` and lets the letter fall through as ordinary literal text;
|
||||
//! - an optional decimal prefix is scanned and consumed ahead of *every* token, but only `W`/`E`
|
||||
//! read it;
|
||||
//! - **case is the output switch, not a separate token**: `$R` is the race string verbatim
|
||||
//! (`"Night Elf"`), `$r` is that same string through `_strlwr` (`"night elf"`). Same for
|
||||
//! `$C`/`$c` and `$T`/`$t`; `$N`/`$B`/`$G` are case-insensitive.
|
||||
//!
|
||||
//! The reference's *other* `$`-expander — spell descriptions, with its `$/N;` scale prefixes and
|
||||
//! `$<spellId>` cross-references — is a different function over a different source. Ours is
|
||||
//! [`benilla_formats::substitute`], kept separate here exactly as it is there.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetCommands, ObjectStore, SelfPlayer};
|
||||
|
||||
/// Substitute the chat-text macros into `text` (see the module doc for the handled set).
|
||||
pub(crate) fn substitute(text: &str, name: &str, gender: u8) -> String {
|
||||
/// The unit a `$`-macro expands against. Every seam we have passes the **self player** (the
|
||||
/// reference resolves the subject from the GUID its call site hands over, and only its four chat
|
||||
/// sites pass the speaker instead), so the reference's non-player arm — where `$R`/`$C` emit the
|
||||
/// unit's *name* in place of a race/class — is not reachable from here.
|
||||
pub(crate) struct Subject {
|
||||
pub name: String,
|
||||
/// `UNIT_FIELD_BYTES_0` byte 0 / byte 1 — indices into the `ChrRaces`/`ChrClasses` tables the
|
||||
/// reference reads by locale column. Ours are [`crate::ui_unit`]'s hardcoded English rows, which
|
||||
/// were checked character-for-character against the shipped DBCs.
|
||||
pub race: u8,
|
||||
pub class: u8,
|
||||
/// `UNIT_FIELD_BYTES_0` byte 2. The `$G`/`$T` branch test is `== 0` → the *first* arm, anything
|
||||
/// else → the second — the reference compares against zero, so this is not "1 = female".
|
||||
pub gender: u8,
|
||||
}
|
||||
|
||||
/// Everything a `$`-token can read: the [`Subject`] the person-tokens expand against, and the
|
||||
/// world-state table `$<n>w`/`$<n>e` index. The reference reads the latter from a process global
|
||||
/// (`[0xb71ec8]`); we hold it as a resource, so the expander takes it explicitly — the same shape
|
||||
/// the sibling spell expander already uses (`benilla_formats::TokenContext`).
|
||||
pub(crate) struct MacroContext<'a> {
|
||||
/// `None` is the reference's no-subject case — see [`substitute`].
|
||||
pub subject: Option<&'a Subject>,
|
||||
pub states: &'a crate::world_state::WorldStates,
|
||||
}
|
||||
|
||||
/// Substitute the macros in `text` against `ctx` (see the module doc for the grammar). A `None`
|
||||
/// subject is the reference's no-subject case: every token that needs one fails, which re-emits the
|
||||
/// `$` and leaves the rest of the text literal — so an un-landed player name shows `$N` for the
|
||||
/// moment rather than a hole, and the feeds re-substitute when it arrives.
|
||||
pub(crate) fn substitute(text: &str, ctx: &MacroContext) -> String {
|
||||
let subject = ctx.subject;
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '$' && i + 1 < chars.len() {
|
||||
match chars[i + 1] {
|
||||
'N' | 'n' => {
|
||||
out.push_str(name);
|
||||
i += 2;
|
||||
continue;
|
||||
if chars[i] != '$' {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// The decimal prefix is consumed ahead of the token letter whatever the token turns out to
|
||||
// be — so `$5N` is just `$N`, and an unaccepted `$5X` loses the digits on its way out.
|
||||
let mut j = i + 1;
|
||||
while chars.get(j).is_some_and(char::is_ascii_digit) {
|
||||
j += 1;
|
||||
}
|
||||
// `fail` is the reference's `false` return: the `$` comes back and the cursor stays on the
|
||||
// letter, which the literal path then copies.
|
||||
let mut fail = false;
|
||||
match chars.get(j).copied() {
|
||||
// Exactly one `\n` (no CR) — the text renderer splits on it. Needs no subject.
|
||||
Some('B' | 'b') => {
|
||||
out.push('\n');
|
||||
i = j + 1;
|
||||
}
|
||||
// `$<n>W` / `$<n>E` — the world-state table ([`crate::world_state`]), filled by
|
||||
// SMSG_INIT_WORLD_STATES (`0x2C2`) and SMSG_UPDATE_WORLD_STATE (`0x2C3`); `$…E` reads
|
||||
// that same table at the *negated* key, and both render `%d`. A miss prints `"0"` —
|
||||
// which is every lookup until a zone actually sends states, and is what the reference
|
||||
// prints then too.
|
||||
Some(tok @ ('W' | 'w' | 'E' | 'e')) => {
|
||||
// `SStrToInt` over the prefix. No digits at all reads an uninitialized buffer in
|
||||
// the reference (undefined); we take it as key 0, and an id too wide for the
|
||||
// dword the wire carries goes the same way rather than wrapping to a live key.
|
||||
let n: u32 = chars[i + 1..j]
|
||||
.iter()
|
||||
.collect::<String>()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
let key = if matches!(tok, 'E' | 'e') {
|
||||
n.wrapping_neg()
|
||||
} else {
|
||||
n
|
||||
};
|
||||
out.push_str(&ctx.states.get(key).to_string());
|
||||
i = j + 1;
|
||||
}
|
||||
Some('N' | 'n') => match subject {
|
||||
Some(s) => {
|
||||
out.push_str(&s.name);
|
||||
i = j + 1;
|
||||
}
|
||||
'B' | 'b' => {
|
||||
out.push('\n');
|
||||
i += 2;
|
||||
continue;
|
||||
None => fail = true,
|
||||
},
|
||||
Some(tok @ ('R' | 'r' | 'C' | 'c')) => match subject {
|
||||
Some(s) => {
|
||||
// An id outside the table is where the reference dereferences near-NULL and
|
||||
// faults outright; we emit nothing.
|
||||
let name = if matches!(tok, 'R' | 'r') {
|
||||
crate::ui_unit::race_names(s.race).map_or("", |(display, _)| display)
|
||||
} else {
|
||||
crate::ui_unit::class_names(s.class).map_or("", |(display, _)| display)
|
||||
};
|
||||
if tok.is_ascii_lowercase() {
|
||||
out.push_str(&name.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push_str(name);
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
'G' | 'g' => {
|
||||
if let Some((male, female, end)) = parse_gender_branch(&chars, i + 2) {
|
||||
out.extend(if gender == 1 { female } else { male });
|
||||
i = end;
|
||||
continue;
|
||||
None => fail = true,
|
||||
},
|
||||
// `$T`/`$t` is the PvP rank title, with this same gender branch as its fallback when the
|
||||
// `PVP_RANK_<rank>_<team>` GlobalString misses — which it always does here, since we
|
||||
// ship no rank titles. So the two tokens share one path, and `$t` lower-cases nothing:
|
||||
// the reference never lower-cases the fallback text either.
|
||||
Some('G' | 'g' | 'T' | 't') => match subject {
|
||||
Some(s) => {
|
||||
let mut k = j + 1;
|
||||
while chars.get(k) == Some(&' ') {
|
||||
k += 1;
|
||||
}
|
||||
match parse_branch(&chars, k) {
|
||||
Some((first, second, end)) => {
|
||||
out.extend(if s.gender == 0 { first } else { second });
|
||||
i = end;
|
||||
}
|
||||
// A malformed branch swallows the marker and the spaces after it and emits
|
||||
// nothing at all — its argument survives as plain text, so `$G male female;`
|
||||
// renders `male female;`. (We used to pass the whole token through; the
|
||||
// bytes say the marker is consumed.)
|
||||
None => i = k,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None => fail = true,
|
||||
},
|
||||
// Not in `B C E G N R T W`, or the string ended on the `$`.
|
||||
_ => fail = true,
|
||||
}
|
||||
if fail {
|
||||
out.push('$');
|
||||
i = j;
|
||||
}
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a `$G`/`$g` branch body `male:female;` starting at `start` (just past the `G`), returning
|
||||
/// the two arms (as `&str` slices of `chars`) and the index just past the terminating `;`. `None`
|
||||
/// if there's no `:` before the `;`, or no terminating `;` (malformed → caller keeps it literal).
|
||||
fn parse_gender_branch(chars: &[char], start: usize) -> Option<(&[char], &[char], usize)> {
|
||||
/// Parse a `$G`/`$T` branch body `first:second;` starting at `start` (already past the marker and
|
||||
/// the spaces behind it), returning the two arms and the index just past the terminating `;`.
|
||||
/// `None` — malformed — when there is no `:` before the `;`, or no `;` at all.
|
||||
fn parse_branch(chars: &[char], start: usize) -> Option<(&[char], &[char], usize)> {
|
||||
let colon = (start..chars.len()).find(|&j| chars[j] == ':' || chars[j] == ';')?;
|
||||
if chars[colon] != ':' {
|
||||
return None;
|
||||
}
|
||||
let semi = (colon + 1..chars.len()).find(|&j| chars[j] == ';')?;
|
||||
Some((&chars[start..colon], &chars[colon + 1..semi], semi + 1))
|
||||
Some((
|
||||
trim_spaces(&chars[start..colon]),
|
||||
trim_spaces(&chars[colon + 1..semi]),
|
||||
semi + 1,
|
||||
))
|
||||
}
|
||||
|
||||
/// The self player's `(name, gender)` for substitution: the name from the [`NameCache`] (a miss
|
||||
/// queries the server once, like the unit frames), gender from the descriptor (`unit_gender`:
|
||||
/// 0 male / 1 female). Falls back to an empty name / male when the self player or its name isn't
|
||||
/// known yet (the feeds diff on the name, so text re-substitutes when it lands).
|
||||
/// Drop the spaces the reference's branch copy drops: the leading run (after the marker and after
|
||||
/// the `:`) and the trailing one. Only `' '` — its skip loops test that byte, not whitespace.
|
||||
fn trim_spaces(arm: &[char]) -> &[char] {
|
||||
let start = arm.iter().position(|&c| c != ' ').unwrap_or(arm.len());
|
||||
let end = arm.iter().rposition(|&c| c != ' ').map_or(start, |p| p + 1);
|
||||
&arm[start..end]
|
||||
}
|
||||
|
||||
/// The self player as a macro [`Subject`] — the name from the [`NameCache`] (a miss queries the
|
||||
/// server once, like the unit frames), race/class/gender from the descriptor. `None` until the
|
||||
/// player is streamed *and* its name has landed; the feeds diff on the substituted text, so it
|
||||
/// re-substitutes when that happens.
|
||||
pub(crate) fn player_identity(
|
||||
self_q: &Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
names: &mut NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> (String, u8) {
|
||||
match self_q.iter().next() {
|
||||
Some((store, guid)) => {
|
||||
let name = names.resolve(guid.0, commands).unwrap_or("").to_string();
|
||||
(name, store.0.unit_gender().unwrap_or(0))
|
||||
}
|
||||
None => (String::new(), 0),
|
||||
}
|
||||
) -> Option<Subject> {
|
||||
let (store, guid) = self_q.iter().next()?;
|
||||
Some(Subject {
|
||||
name: names.resolve(guid.0, commands)?.to_string(),
|
||||
race: store.0.unit_race().unwrap_or(0),
|
||||
class: store.0.unit_class().unwrap_or(0),
|
||||
gender: store.0.unit_gender().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::substitute;
|
||||
use super::{substitute, MacroContext, Subject};
|
||||
use crate::world_state::WorldStates;
|
||||
|
||||
/// Thrall the night-elf priest — race 4 / class 5, so both table lookups are real rows.
|
||||
fn subject(gender: u8) -> Subject {
|
||||
Subject {
|
||||
name: "Thrall".into(),
|
||||
race: 4,
|
||||
class: 5,
|
||||
gender,
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand against a subject and an empty world-state table — the shape of every test whose
|
||||
/// concern is a person-token.
|
||||
fn expand(text: &str, subject: Option<&Subject>) -> String {
|
||||
substitute(
|
||||
text,
|
||||
&MacroContext {
|
||||
subject,
|
||||
states: &WorldStates::default(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_substitute() {
|
||||
// $N → name, $B → newline.
|
||||
assert_eq!(substitute("Greetings $N", "Thrall", 0), "Greetings Thrall");
|
||||
assert_eq!(substitute("Hail,$Bfriend", "X", 0), "Hail,\nfriend");
|
||||
assert_eq!(substitute("$N,$Bwelcome", "Jaina", 0), "Jaina,\nwelcome");
|
||||
// $G male:female; branches on gender (0 male / 1 female).
|
||||
fn name_and_newline() {
|
||||
let s = subject(0);
|
||||
assert_eq!(expand("Greetings $N", Some(&s)), "Greetings Thrall");
|
||||
assert_eq!(expand("Greetings $n", Some(&s)), "Greetings Thrall");
|
||||
assert_eq!(expand("Hail,$Bfriend", Some(&s)), "Hail,\nfriend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn race_and_class_follow_the_token_case() {
|
||||
let s = subject(0);
|
||||
assert_eq!(expand("A $C of $R", Some(&s)), "A Priest of Night Elf");
|
||||
assert_eq!(expand("a $c of $r", Some(&s)), "a priest of night elf");
|
||||
// An id outside the table emits nothing (where the reference faults).
|
||||
let unknown = Subject {
|
||||
class: 6,
|
||||
..subject(0)
|
||||
};
|
||||
assert_eq!(expand("[$C]", Some(&unknown)), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gender_branches_on_zero() {
|
||||
assert_eq!(
|
||||
substitute("Well met, $Glad:lass;.", "X", 0),
|
||||
expand("Well met, $Glad:lass;.", Some(&subject(0))),
|
||||
"Well met, lad."
|
||||
);
|
||||
assert_eq!(
|
||||
substitute("Well met, $Glad:lass;.", "X", 1),
|
||||
expand("Well met, $Glad:lass;.", Some(&subject(1))),
|
||||
"Well met, lass."
|
||||
);
|
||||
// Unknown macros + malformed $G pass through literally (degrade-to-visible).
|
||||
assert_eq!(substitute("A $C of $R", "X", 0), "A $C of $R");
|
||||
assert_eq!(substitute("Broken $Gbranch", "X", 0), "Broken $Gbranch");
|
||||
// A trailing lone $ survives.
|
||||
assert_eq!(substitute("Cost: 5$", "X", 0), "Cost: 5$");
|
||||
// Not "1 = female": zero picks the first arm and everything else the second.
|
||||
assert_eq!(expand("$Glad:lass;", Some(&subject(2))), "lass");
|
||||
// The spaces after the marker, after the `:`, and at each arm's end are dropped.
|
||||
assert_eq!(
|
||||
expand("Well met, $G lad : lass ;.", Some(&subject(1))),
|
||||
"Well met, lass."
|
||||
);
|
||||
// `$T` has no rank title to find, so it falls back to this same branch, un-lower-cased.
|
||||
assert_eq!(expand("$tLad:Lass;", Some(&subject(0))), "Lad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_branch_drops_the_marker_not_the_text() {
|
||||
let s = subject(0);
|
||||
assert_eq!(expand("Broken $Gbranch", Some(&s)), "Broken branch");
|
||||
assert_eq!(expand("$G male female;", Some(&s)), "male female;");
|
||||
assert_eq!(expand("$G male:female", Some(&s)), "male:female");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_state_tokens_read_an_empty_table() {
|
||||
let s = subject(0);
|
||||
assert_eq!(expand("$2077w gathered", Some(&s)), "0 gathered");
|
||||
assert_eq!(expand("$2077e gathered", Some(&s)), "0 gathered");
|
||||
// A bare `$w` is key 0 — a miss like any other.
|
||||
assert_eq!(expand("$w", Some(&s)), "0");
|
||||
}
|
||||
|
||||
/// The point of the table: once a zone's states land, `$<n>w` renders the value rather than the
|
||||
/// standing `"0"`. `$<n>e` reads the SAME table at the negated key, so the two tokens with the
|
||||
/// same digits are different lookups — and a value is rendered `%d`, sign and all.
|
||||
#[test]
|
||||
fn world_state_tokens_render_received_values() {
|
||||
let s = subject(0);
|
||||
let mut states = WorldStates::default();
|
||||
states.write(&[
|
||||
(2077, 12),
|
||||
(2077u32.wrapping_neg(), 3),
|
||||
(2264, -5i32 as u32),
|
||||
]);
|
||||
let filled = |text: &str| {
|
||||
substitute(
|
||||
text,
|
||||
&MacroContext {
|
||||
subject: Some(&s),
|
||||
states: &states,
|
||||
},
|
||||
)
|
||||
};
|
||||
assert_eq!(filled("$2077w gathered"), "12 gathered");
|
||||
assert_eq!(filled("$2077e gathered"), "3 gathered");
|
||||
assert_eq!(filled("$2264w"), "-5", "rendered %d, not %u");
|
||||
assert_eq!(filled("$9999w"), "0", "an id the zone never sent");
|
||||
// Case is not the output switch here — unlike `$R`/`$C`, `W` and `w` are one token.
|
||||
assert_eq!(filled("$2077W"), "12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unaccepted_tokens_keep_the_dollar() {
|
||||
let s = subject(0);
|
||||
assert_eq!(expand("A $X here", Some(&s)), "A $X here");
|
||||
// The digits are consumed before the letter is judged, so they are lost with it.
|
||||
assert_eq!(expand("A $5X here", Some(&s)), "A $X here");
|
||||
assert_eq!(expand("Cost: 5$", Some(&s)), "Cost: 5$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_subject_leaves_the_text_literal() {
|
||||
assert_eq!(expand("Greetings $N", None), "Greetings $N");
|
||||
assert_eq!(expand("$Glad:lass; $C", None), "$Glad:lass; $C");
|
||||
// `$B` and the world-state tokens need no subject.
|
||||
assert_eq!(expand("Hail,$Bfriend $w", None), "Hail,\nfriend 0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,12 @@ mod camera;
|
||||
mod gait;
|
||||
mod move_trace;
|
||||
mod movement_net;
|
||||
mod mover;
|
||||
// The kinematic mover step. `pub(crate)` because the grounded walk resolve is **not** the local
|
||||
// player's alone: a remote mover's dead-reckon (`crate::net::motion::remote`) runs its extrapolated
|
||||
// step through the very same code, the way the reference runs every mover through one controller
|
||||
// (decision 0059's byte trail).
|
||||
pub(crate) mod mover;
|
||||
mod probe_look;
|
||||
mod server_ride;
|
||||
mod setup;
|
||||
mod state;
|
||||
@@ -49,13 +54,13 @@ pub(crate) use camera::{head_height, CameraControl, CameraPivot, WorldCamera, CA
|
||||
// The shared avatar state + movement constants live in [`state`]; the private re-imports below are
|
||||
// what lets this module and the concern modules beside it keep naming them `super::X` unchanged.
|
||||
use state::{
|
||||
MoveSpeed, PlayerCapsule, PlayerRide, AIR_NUDGE_SPEED, CAPSULE_RADIUS, FALL_FAR_DROP,
|
||||
FALL_FAR_TIME, GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP,
|
||||
RUN_BACK_RATIO, SETTLE_REACH, SETTLE_TIMEOUT, SKIN_WIDTH, STATIONARY_CHASE_RATE,
|
||||
STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL,
|
||||
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, CAPSULE_RADIUS, FALL_FAR_DROP, FALL_FAR_TIME,
|
||||
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
|
||||
SETTLE_REACH, SETTLE_TIMEOUT, SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO,
|
||||
STEP_SNAP_SLACK, STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL,
|
||||
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
};
|
||||
pub(crate) use state::{Player, CAPSULE_HEIGHT, GRAVITY, TERMINAL_VELOCITY};
|
||||
pub(crate) use state::{Player, PlayerCapsule, CAPSULE_HEIGHT, GRAVITY, TERMINAL_VELOCITY};
|
||||
|
||||
/// The player/camera subsystem: spawns the camera + move/avatar resources at startup, drives the
|
||||
/// third-person/free-fly controller each frame. (The cursor is the [`crate::cursor`] subsystem.)
|
||||
@@ -63,6 +68,9 @@ pub(crate) struct PlayerPlugin;
|
||||
|
||||
impl Plugin for PlayerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
if let Some(look) = probe_look::from_env() {
|
||||
app.insert_resource(look);
|
||||
}
|
||||
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
|
||||
// The world camera renders only when the world can be seen (decision 0540): in world,
|
||||
// or under the opaque loading screen (whose covered render is what compiles the
|
||||
@@ -80,6 +88,17 @@ impl Plugin for PlayerPlugin {
|
||||
.run_if(not(resource_exists::<crate::capture::CaptureMode>))
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// The scripted mouse-turn (`WOW_PROBE_LOOK`, decision 0621): rotates the aim before
|
||||
// `control` reads it, so the frame that sees the new facing is the frame that streams
|
||||
// it. Registered only when the env var asks for it — inert otherwise.
|
||||
.add_systems(
|
||||
Update,
|
||||
probe_look::drive_probe_look
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(resource_exists::<probe_look::ProbeLook>)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// A server-authored spline (Charge/knockback/taxi) driving our own player is mirrored into
|
||||
// `Player` here, *before* `control` reads `pos` to seat the camera and skip input. Same
|
||||
// gates as `control` (not while capturing; in-world only).
|
||||
@@ -451,6 +470,21 @@ fn control(
|
||||
// the facing tracks the camera; **Q/E always strafe**. Movement basis is the *character* facing,
|
||||
// so left-drag (camera-only orbit) doesn't change which way W walks.
|
||||
let mouselook = both_buttons || rig.look == Some(LookButton::Right);
|
||||
// The strafe axis, **netted exactly like `fwd_axis`** — Q/E always strafe, A/D only while
|
||||
// mouse-looking. Netting is not a nicety: the two bits are mutually exclusive on the wire.
|
||||
// Holding both keys used to OR `STRAFE_LEFT | STRAFE_RIGHT` into the flags while the avatar
|
||||
// stood still (the `dir` sum below cancels), and vmangos **silently drops** every movement
|
||||
// packet carrying that pair — never relaying it to anyone (decision 0622). Measured: 48 such
|
||||
// packets in one session, 0 received by a watching client, against 0 in the reference
|
||||
// client's entire 1.12.1 capture. That is decision 0056's invariant — the wire mirrors the
|
||||
// avatar's actual motion — violated on this one axis only; the swim branch already nets.
|
||||
let side_axis = i32::from(keys_pressed(KeyCode::KeyE))
|
||||
- i32::from(keys_pressed(KeyCode::KeyQ))
|
||||
+ if mouselook {
|
||||
i32::from(keys_pressed(KeyCode::KeyD)) - i32::from(keys_pressed(KeyCode::KeyA))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// A/D turn the facing when not mouse-looking (yaw increases turning left, matching mouse-left).
|
||||
let turning = !mouselook && (keys_pressed(KeyCode::KeyA) || keys_pressed(KeyCode::KeyD));
|
||||
// This frame's keyboard-turn rotation — `seat_camera` carries the camera by it rigidly
|
||||
@@ -467,8 +501,7 @@ fn control(
|
||||
}
|
||||
// 0.75× while also translating — the verified `flags & 0x200f` case, so it reads the
|
||||
// *net* axis, not the keys: W+S streams no direction bit and turns at the full rate.
|
||||
let translating =
|
||||
fwd_axis != 0 || keys_pressed(KeyCode::KeyQ) || keys_pressed(KeyCode::KeyE);
|
||||
let translating = fwd_axis != 0 || side_axis != 0;
|
||||
let rate = TURN_RATE * if translating { TURN_RATE_MOVING } else { 1.0 };
|
||||
turn_delta = turn * rate * dt;
|
||||
player.face_yaw += turn_delta;
|
||||
@@ -485,20 +518,12 @@ fn control(
|
||||
-1 => dir -= move_fwd,
|
||||
_ => {}
|
||||
}
|
||||
// Strafe slides without turning: Q/E always; A/D only while mouse-looking (else they turn, above).
|
||||
if keys_pressed(KeyCode::KeyE) {
|
||||
dir += move_right;
|
||||
}
|
||||
if keys_pressed(KeyCode::KeyQ) {
|
||||
dir -= move_right;
|
||||
}
|
||||
if mouselook {
|
||||
if keys_pressed(KeyCode::KeyD) {
|
||||
dir += move_right;
|
||||
}
|
||||
if keys_pressed(KeyCode::KeyA) {
|
||||
dir -= move_right;
|
||||
}
|
||||
// Strafe slides without turning, one step in the netted sign — never a doubled push, and a
|
||||
// cancelled pair is genuinely no strafe (the same shape as the forward/back axis above).
|
||||
match side_axis.signum() {
|
||||
1 => dir += move_right,
|
||||
-1 => dir -= move_right,
|
||||
_ => {}
|
||||
}
|
||||
// Rooted (dead-unreleased): translation intent dies here — turning above stays live, the
|
||||
// real rooted client's behavior (decision 0308 slice 1).
|
||||
@@ -935,21 +960,15 @@ fn control(
|
||||
-1 => move_flags_now |= move_flags::BACKWARD,
|
||||
_ => {}
|
||||
}
|
||||
// Q/E always strafe; A/D strafe while mouse-looking, else they turn the facing (above).
|
||||
if keys_pressed(KeyCode::KeyQ) {
|
||||
move_flags_now |= move_flags::STRAFE_LEFT;
|
||||
// Straight off the netted strafe axis, so a cancelled press pair streams NO strafe bit —
|
||||
// the two are mutually exclusive on the wire, and both-set is silently dropped by the
|
||||
// server (decision 0622).
|
||||
match side_axis.signum() {
|
||||
-1 => move_flags_now |= move_flags::STRAFE_LEFT,
|
||||
1 => move_flags_now |= move_flags::STRAFE_RIGHT,
|
||||
_ => {}
|
||||
}
|
||||
if keys_pressed(KeyCode::KeyE) {
|
||||
move_flags_now |= move_flags::STRAFE_RIGHT;
|
||||
}
|
||||
if mouselook {
|
||||
if keys_pressed(KeyCode::KeyA) {
|
||||
move_flags_now |= move_flags::STRAFE_LEFT;
|
||||
}
|
||||
if keys_pressed(KeyCode::KeyD) {
|
||||
move_flags_now |= move_flags::STRAFE_RIGHT;
|
||||
}
|
||||
} else {
|
||||
if !mouselook {
|
||||
if keys_pressed(KeyCode::KeyA) {
|
||||
move_flags_now |= move_flags::TURN_LEFT;
|
||||
}
|
||||
@@ -1151,10 +1170,13 @@ fn control(
|
||||
&mut player,
|
||||
move_flags_now,
|
||||
swim_pitch,
|
||||
jumped,
|
||||
landed,
|
||||
started_falling,
|
||||
wire_fall_time,
|
||||
movement_net::ArcEdges {
|
||||
jumped,
|
||||
air_nudged,
|
||||
landed,
|
||||
started_falling,
|
||||
fall_time: wire_fall_time,
|
||||
},
|
||||
now,
|
||||
&speed_acks,
|
||||
wire_transport,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
//! The mover's side of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]): one line per
|
||||
//! *interesting* frame of the player mover — a step-down snap, a grounded flip, an airborne
|
||||
//! frame, or any sizeable vertical delta — so a movement-feel report ("it pops when I step off
|
||||
//! the fence") can be read back as per-frame numbers instead of re-guessed from watching the
|
||||
//! screen. The anim driver writes its own `anim` lines into the same file, on the same clock.
|
||||
//! The mover's side of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]), two tags:
|
||||
//!
|
||||
//! - **`move`** — one line per *interesting* frame of the player mover ([`frame`]): a step-down snap,
|
||||
//! a grounded flip, an airborne frame, or any sizeable vertical delta — so a movement-feel report
|
||||
//! ("it pops when I step off the fence") can be read back as per-frame numbers instead of
|
||||
//! re-guessed from watching the screen.
|
||||
//! - **`snd`** — one line per outbound `MSG_MOVE_*` ([`sent`]), the send-side twin of `net::motion`'s
|
||||
//! `rly`: it makes **our own wire cadence measurable** (decision 0617), which is the only way to
|
||||
//! compare it against the reference's — the 1.12.1 sniff's client stream is a list of exactly these
|
||||
//! fields, so `grep snd` on a run of mouse-turning and strafing is directly diffable against it.
|
||||
//!
|
||||
//! The anim driver writes its own `anim` lines into the same file, on the same clock.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -26,6 +33,22 @@ pub(super) struct Frame {
|
||||
pub climb: Option<f32>,
|
||||
}
|
||||
|
||||
/// One `snd` line per outbound movement packet: the opcode we chose, the live move-flags it carries,
|
||||
/// and the facing. Read against the sniff's client stream (opcode + flags + orientation per line) this
|
||||
/// answers "does our wire look like the reference's?" without anyone squinting at a second window.
|
||||
pub(super) fn sent(kind: crate::net::MoveKind, flags: u32, facing: f32, pos: [f32; 3]) {
|
||||
if !dbg_trace::enabled() {
|
||||
return;
|
||||
}
|
||||
dbg_trace::line(
|
||||
"snd",
|
||||
&format!(
|
||||
"{kind:?} flags={flags:#x} o={facing:.4} pos=[{:.2},{:.2},{:.2}]",
|
||||
pos[0], pos[1], pos[2]
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static PREV_GROUNDED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
pub(super) fn frame(f: Frame) {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! movers). [`stream_self_movement`] diffs this frame's CMovement move-flags against last frame's and
|
||||
//! emits a `MSG_MOVE_*` per movement-*axis* transition (start/stop forward-back, strafe, turn), the
|
||||
//! jump/fall lifecycle (JUMP launch, FALL_LAND, step-off heartbeat), a periodic heartbeat while moving,
|
||||
//! and a rate-limited SET_FACING when turning in place — each carrying the live `MovementInfo`
|
||||
//! (decisions 0052 + 0053). Split out of the controller: the wire stream is its own concern.
|
||||
//! and a SET_FACING every frame the facing changes off the turn axis — each carrying the live
|
||||
//! `MovementInfo` (decisions 0052 + 0053 + 0617). Split out of the controller: the wire stream is its
|
||||
//! own concern.
|
||||
//!
|
||||
//! **Invariant — the wire mirrors the avatar's *actual* local motion** (decision 0056). vmangos relays
|
||||
//! what we send verbatim and observers extrapolate it from the moveFlags, so any divergence strands them
|
||||
@@ -32,11 +33,6 @@ use super::Player;
|
||||
/// `clientTime + 500 ms` (`0x615b80`) — the wire report is the per-transition broadcast plus this
|
||||
/// ~500 ms-paced heartbeat, independent of the 250 ms physics substeps.
|
||||
const HEARTBEAT_INTERVAL: f32 = 0.5;
|
||||
/// Minimum interval (s) between `MSG_MOVE_SET_FACING` packets while turning in place — caps a continuous
|
||||
/// mouse-turn to ~10 Hz on the wire (smooth for observers, light on bandwidth).
|
||||
const FACING_INTERVAL: f32 = 0.1;
|
||||
/// Facing change (rad) below which a standing turn isn't worth a `SET_FACING` packet.
|
||||
const FACING_EPSILON: f32 = 0.02;
|
||||
/// The move-flag bits we put on the wire — the base directional / turn / walk set **plus `FALLING`**
|
||||
/// (= `MOVEFLAG_JUMPING` 0x2000): we serialize the jump tail (`zspeed, cos, sin, xyspeed`) whenever it's
|
||||
/// set, so observers replay our jump as a ballistic arc (decision 0053) — **`FALLING_FAR`**
|
||||
@@ -58,33 +54,62 @@ const OUTBOUND_FLAG_MASK: u32 = move_flags::FORWARD
|
||||
| move_flags::SWIMMING
|
||||
| move_flags::ON_TRANSPORT;
|
||||
|
||||
/// This frame's **arc edges** — the airborne lifecycle as the send law reads it. One struct rather
|
||||
/// than four adjacent bools in the argument list, where a miscount is silent and the symptom is a
|
||||
/// wrong opcode on the wire.
|
||||
pub(super) struct ArcEdges {
|
||||
/// A jump launched this frame → `MSG_MOVE_JUMP`, carrying the ballistic tail.
|
||||
pub(super) jumped: bool,
|
||||
/// The standstill air nudge fired ([`super::mover::step`]) — the one mid-air press that really
|
||||
/// moves us, and so the one that breaks the airborne send silence (decision 0627).
|
||||
pub(super) air_nudged: bool,
|
||||
/// The arc ended this frame → `MSG_MOVE_FALL_LAND`.
|
||||
pub(super) landed: bool,
|
||||
/// An arc began with no jump opcode (a walk-off) → an immediate heartbeat, so observers start
|
||||
/// the arc promptly instead of waiting for the periodic one.
|
||||
pub(super) started_falling: bool,
|
||||
/// ms since take-off — the caller's snapshot, because the landing frame's arc bookkeeping has
|
||||
/// already cleared `airborne_since` and the FALL_LAND must still report the accumulated time.
|
||||
pub(super) fall_time: u32,
|
||||
}
|
||||
|
||||
/// Stream this frame's movement to the server the way the real client does: a `MSG_MOVE_*` per movement-
|
||||
/// *axis* transition (start/stop forward-back, strafe, turn), a JUMP on take-off, a SET_FACING when we
|
||||
/// turn in place, and a HEARTBEAT every ~500 ms while moving — each carrying the current `MovementInfo`.
|
||||
/// **VERIFIED** against wow-5875-re (collision "move-send cadence"): the move-state-change broadcaster
|
||||
/// `0x61a820` selects the wire opcode *from the flag delta* (`0x619f00`), and the report is exactly
|
||||
/// "per-transition broadcast + ~500 ms heartbeat". vmangos relays it to nearby players, who extrapolate
|
||||
/// from the flags — how they see us walk/turn/strafe. (We claimed the mover with CMSG_SET_ACTIVE_MOVER at
|
||||
/// login.) **Airborne is its own send law** (VERIFIED, vanilla-sniffs `dwarf_rogue_dun_morogh`): the
|
||||
/// *axis* transition (start/stop forward-back, strafe, turn), a JUMP on take-off, a SET_FACING every
|
||||
/// frame the facing changes off the turn axis, and a HEARTBEAT every ~500 ms while moving — each
|
||||
/// carrying the current `MovementInfo`. **VERIFIED** against wow-5875-re (collision "move-send cadence"):
|
||||
/// the move-state-change broadcaster `0x61a820` selects the wire opcode *from the flag delta*
|
||||
/// (`0x619f00`), and the flag report is exactly "per-transition broadcast + ~500 ms heartbeat" — with
|
||||
/// the *facing* report its own independent emitter alongside it (decision 0617: in the 1.12.1 sniff
|
||||
/// SET_FACING outnumbers every other client-sent movement opcode combined, moving or standing).
|
||||
/// vmangos relays it all to nearby players, who extrapolate from the flags — how they see us walk/turn/
|
||||
/// strafe. (We claimed the mover with CMSG_SET_ACTIVE_MOVER at login.) **Airborne is its own send law** (VERIFIED, vanilla-sniffs `dwarf_rogue_dun_morogh`): the
|
||||
/// fwd/back/strafe transitions and the periodic heartbeat go silent while FALLING — the live flag state
|
||||
/// rides the packets that do go out (turn transitions, SET_FACING, the FALL_LAND) — so a normal jump is
|
||||
/// exactly JUMP → \[turn/facing\] → FALL_LAND, with the landing flags telling observers what the keys
|
||||
/// say *now*. Sends are fire-and-forget; a down thread no-ops. Mutates `player`'s last-sent flags/facing/
|
||||
/// say *now*. The one press that breaks that silence is the standstill air nudge (`air_nudged`,
|
||||
/// decision 0627): it is the case the reference's own deferral excludes, and the only mid-air input
|
||||
/// that actually moves us. Sends are fire-and-forget; a down thread no-ops. Mutates `player`'s last-sent flags/facing/
|
||||
/// heartbeat so next frame can diff against them.
|
||||
// Eight, down from twelve: the arc edges are one struct now (`ArcEdges`). The rest are distinct
|
||||
// types the compiler can tell apart, so the remaining count is noise rather than a miscount risk.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn stream_self_movement(
|
||||
sender: &Sender<ClientCommand>,
|
||||
player: &mut Player,
|
||||
move_flags_now: u32,
|
||||
swim_pitch: f32,
|
||||
jumped: bool,
|
||||
landed: bool,
|
||||
started_falling: bool,
|
||||
fall_time: u32,
|
||||
arc: ArcEdges,
|
||||
now: f32,
|
||||
speed_acks: &[crate::net::SpeedChangeMessage],
|
||||
transport: Option<TransportPose>,
|
||||
) {
|
||||
let ArcEdges {
|
||||
jumped,
|
||||
air_nudged,
|
||||
landed,
|
||||
started_falling,
|
||||
fall_time,
|
||||
} = arc;
|
||||
let wow_pos = bevy_to_wow(player.pos);
|
||||
// Normalize the facing into [0, 2π) before it goes on the wire. `face_yaw` is an unbounded
|
||||
// accumulator (mouse-look and A/D turning keep growing it), but the real client always sends a
|
||||
@@ -154,6 +179,7 @@ pub(super) fn stream_self_movement(
|
||||
facing
|
||||
);
|
||||
}
|
||||
super::move_trace::sent($kind, wire_flags, facing, wow_pos);
|
||||
let _ = sender.send(ClientCommand::Move {
|
||||
kind: $kind,
|
||||
flags: wire_flags,
|
||||
@@ -180,6 +206,19 @@ pub(super) fn stream_self_movement(
|
||||
// arc's momentum froze at takeoff), so their transitions aren't motion changes — the live bits
|
||||
// simply ride every packet that does go out. The TURN axis is the exception (below): turning
|
||||
// genuinely works mid-air, and the sniff shows START_TURN_RIGHT/STOP_TURN with `Falling` set.
|
||||
//
|
||||
// **The standstill air nudge is the other exception** (decision 0627), and it is the *same* rule,
|
||||
// not a carve-out: the wire mirrors actual motion (0056). The reference's airborne silence is a
|
||||
// real flags-side mechanism — while FALLING, `StartMove 0x7c6ae0` defers a new press into an inert
|
||||
// latch (`0x20000`/`0x40000`) instead of flipping the direction bit, "**unless nothing is
|
||||
// currently moving**" (wow-re `hvel-fall-arc.md` Q3, VERIFIED bytes) — and the broadcaster
|
||||
// `0x61a820` picks its opcode from the *flag delta*, so a deferred press produces no delta and
|
||||
// no packet. In the one non-deferred case the bit really flips, so the transition really
|
||||
// broadcasts. That case is exactly our nudge (`mover::step`: airborne, nothing moving, a
|
||||
// direction pressed — the one input that genuinely re-seeds the arc's horizontal velocity), and
|
||||
// it must go out: the packet carries the fresh `JumpInfo` tail, which is the only way an observer
|
||||
// — integrating our arc from a JUMP that said `xy_speed = 0` — can ever learn we started moving
|
||||
// before the FALL_LAND lands.
|
||||
let falling = wire_flags & move_flags::FALLING != 0;
|
||||
// The airborne lifecycle: a JUMP launch (carries the ballistic tail), a FALL_LAND that closes the
|
||||
// arc, or — for a step-off with no jump opcode — an immediate heartbeat so observers start the arc
|
||||
@@ -203,21 +242,24 @@ pub(super) fn stream_self_movement(
|
||||
} else if removed & move_flags::SWIMMING != 0 {
|
||||
send_move!(MoveKind::StopSwim);
|
||||
}
|
||||
// Forward/back axis — silent while airborne (the flag state rides the next packet instead).
|
||||
if !falling {
|
||||
// Forward/back axis — silent while airborne (the flag state rides the next packet instead),
|
||||
// except on the nudge frame. The **stop** arms stay silent while falling no matter what: a
|
||||
// mid-air release is the deferred-latch case, always, and the nudge frame is a press by
|
||||
// definition — so this only keeps a same-frame release on the *other* axis from slipping out.
|
||||
if !falling || air_nudged {
|
||||
if added & move_flags::FORWARD != 0 {
|
||||
send_move!(MoveKind::StartForward);
|
||||
} else if added & move_flags::BACKWARD != 0 {
|
||||
send_move!(MoveKind::StartBackward);
|
||||
} else if removed & FB != 0 && wire_flags & FB == 0 {
|
||||
} else if !falling && removed & FB != 0 && wire_flags & FB == 0 {
|
||||
send_move!(MoveKind::Stop);
|
||||
}
|
||||
// Strafe axis — same airborne silence.
|
||||
// Strafe axis — same airborne silence, same one exception.
|
||||
if added & move_flags::STRAFE_LEFT != 0 {
|
||||
send_move!(MoveKind::StartStrafeLeft);
|
||||
} else if added & move_flags::STRAFE_RIGHT != 0 {
|
||||
send_move!(MoveKind::StartStrafeRight);
|
||||
} else if removed & STRAFE != 0 && wire_flags & STRAFE == 0 {
|
||||
} else if !falling && removed & STRAFE != 0 && wire_flags & STRAFE == 0 {
|
||||
send_move!(MoveKind::StopStrafe);
|
||||
}
|
||||
}
|
||||
@@ -229,6 +271,30 @@ pub(super) fn stream_self_movement(
|
||||
} else if removed & TURN != 0 && wire_flags & TURN == 0 {
|
||||
send_move!(MoveKind::StopTurn);
|
||||
}
|
||||
// The facing report: one `MSG_MOVE_SET_FACING` per frame in which the facing actually changed —
|
||||
// **the single biggest thing our wire stream was missing** (decision 0617). **VERIFIED** against the
|
||||
// real 1.12.1.5875 sniff (`dwarf_rogue_dun_morogh`): 179 of its 336 client-sent movement packets are
|
||||
// SET_FACING — more than every other movement opcode combined — streamed at *frame* cadence (median
|
||||
// 41 ms between them, p25 23 ms, minimum 17 ms) and, decisively, **while moving**: 116 of the 179
|
||||
// carry a direction bit (`Forward` ×68, `StrafeRight` ×12, `Forward+StrafeRight` ×9, `Backward` ×12,
|
||||
// `Forward+Falling` ×4, …). There is no rate limit and no angular epsilon — wow-re's `0x617100`
|
||||
// (SetFacing-then-send) reports whenever `0x617170`'s **exact-equality** change detector says the
|
||||
// facing differs at all, so a frame that didn't move the mouse sends nothing and a frame that did
|
||||
// sends one packet. (Our own `face_yaw` is likewise only written by real input, so the exact
|
||||
// comparison neither floods nor misses.)
|
||||
//
|
||||
// The one exclusion is the **turn axis**: not a single SET_FACING in the capture carries `TURN_LEFT`
|
||||
// or `TURN_RIGHT`. A keyboard turn is already fully described by its flag — observers rotate the
|
||||
// mover at the turn rate for as long as it's set — so the facing needs no separate carrier, and the
|
||||
// STOP_TURN closes the arc with the final angle.
|
||||
//
|
||||
// Not gated on `sent`: the capture repeatedly shows a SET_FACING sharing a millisecond with a
|
||||
// transition (`+1563` SET_FACING & STOP_STRAFE, `+3009` SET_FACING & STOP, `+4971` SET_FACING &
|
||||
// START_STRAFE_RIGHT). They are two independent emitters in the reference — the input-phase facing
|
||||
// report and the move-state broadcaster — not one prioritized channel.
|
||||
if wire_flags & TURN == 0 && facing != player.last_facing {
|
||||
send_move!(MoveKind::SetFacing);
|
||||
}
|
||||
// Board/deboard: the ON_TRANSPORT flip has no axis opcode of its own, so if nothing else went
|
||||
// out this frame, a heartbeat announces it promptly — the server learns the new frame (and its
|
||||
// local-pose tail, or its absence) the frame it changes rather than on the next natural packet.
|
||||
@@ -247,21 +313,14 @@ pub(super) fn stream_self_movement(
|
||||
if !sent && wire_flags != 0 && !falling && now - player.last_heartbeat >= HEARTBEAT_INTERVAL {
|
||||
send_move!(MoveKind::Heartbeat);
|
||||
}
|
||||
// The facing changed with no packet to carry it — standing (a mouse-turn in place), or mid-air
|
||||
// (a jump-turn: no heartbeats stream while FALLING, yet observers must see the turn — the sniff
|
||||
// shows the real client's mid-air SET_FACING with `(Forward, Falling)` and the jump tail).
|
||||
// Rate-limited so a continuous turn doesn't flood the wire.
|
||||
if !sent
|
||||
&& (wire_flags == 0 || falling)
|
||||
&& (facing - player.last_sent_facing).abs() > FACING_EPSILON
|
||||
&& now - player.last_heartbeat >= FACING_INTERVAL
|
||||
{
|
||||
send_move!(MoveKind::SetFacing);
|
||||
}
|
||||
if sent {
|
||||
player.last_heartbeat = now;
|
||||
player.last_sent_facing = facing;
|
||||
}
|
||||
// The change detector's reference is the **previous frame's** facing, not the last one we reported:
|
||||
// the reference compares the new facing against the unit's live facing cell, which its setter
|
||||
// updates on every change whether or not a packet went out. So the turn-axis frames above, which
|
||||
// deliberately send nothing, still leave no catch-up packet behind when the key releases.
|
||||
player.last_facing = facing;
|
||||
player.move_flags = wire_flags;
|
||||
}
|
||||
|
||||
@@ -288,7 +347,7 @@ pub(super) fn park_mover(sender: &Sender<ClientCommand>, player: &mut Player) {
|
||||
transport: None, // flags cleared → no transport tail written
|
||||
});
|
||||
player.move_flags = 0;
|
||||
player.last_sent_facing = facing;
|
||||
player.last_facing = facing;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -312,10 +371,13 @@ mod tests {
|
||||
&mut player,
|
||||
move_flags::FORWARD,
|
||||
0.0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 0,
|
||||
},
|
||||
0.0,
|
||||
&[],
|
||||
None,
|
||||
@@ -349,12 +411,12 @@ mod tests {
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut player,
|
||||
0, // grounded again: no FALLING/FALLINGFAR on the land packet itself
|
||||
0,
|
||||
// grounded again: no FALLING/FALLINGFAR on the land packet itself
|
||||
0.0,
|
||||
false,
|
||||
true, // landed
|
||||
false,
|
||||
1700, // the snapshot: ~1.7 s of fall (> the 1229 ms damage gate)
|
||||
ArcEdges { jumped: false, air_nudged: false, landed: true, started_falling: // landed
|
||||
false, fall_time: 1700 },
|
||||
// the snapshot: ~1.7 s of fall (> the 1229 ms damage gate)
|
||||
0.0,
|
||||
&[],
|
||||
None,
|
||||
@@ -390,10 +452,13 @@ mod tests {
|
||||
&mut player,
|
||||
move_flags::FALLING,
|
||||
0.0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
300,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 300,
|
||||
},
|
||||
0.2,
|
||||
&[],
|
||||
None,
|
||||
@@ -410,10 +475,13 @@ mod tests {
|
||||
&mut player,
|
||||
0,
|
||||
0.0,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
800,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: true,
|
||||
started_falling: false,
|
||||
fall_time: 800,
|
||||
},
|
||||
0.8,
|
||||
&[],
|
||||
None,
|
||||
@@ -430,11 +498,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_standstill_air_nudge_is_the_one_press_that_breaks_the_airborne_silence() {
|
||||
// Decision 0627. The airborne silence is the reference's *deferral* (`StartMove 0x7c6ae0`
|
||||
// latches a mid-air press instead of flipping the direction bit) and the deferral has one
|
||||
// byte-verified exclusion: "unless nothing is currently moving". That case really flips the
|
||||
// bit, so the broadcaster really sends — and it must, because it is the only case where the
|
||||
// press changed our motion, and the observer is integrating an arc that says xy_speed = 0.
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut player = Player {
|
||||
move_flags: move_flags::FALLING, // the standing jump: airborne, no direction
|
||||
// Post-nudge: the mover re-seeded it this frame (Bevy −Z = WoW +X, so |xy| = 2.5).
|
||||
horiz_vel: bevy::prelude::Vec3::new(0.0, 0.0, -2.5),
|
||||
..Default::default()
|
||||
};
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut player,
|
||||
move_flags::FORWARD | move_flags::FALLING,
|
||||
0.0,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: true,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 300,
|
||||
},
|
||||
0.3,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
let ClientCommand::Move {
|
||||
kind, flags, jump, ..
|
||||
} = rx.try_recv().expect("the nudge broadcasts its transition")
|
||||
else {
|
||||
panic!("expected a Move command");
|
||||
};
|
||||
assert_eq!(kind, MoveKind::StartForward);
|
||||
assert_ne!(flags & move_flags::FALLING, 0, "it rides the arc");
|
||||
let tail = jump.expect("an airborne packet carries the ballistic tail");
|
||||
assert!(
|
||||
(tail.xy_speed - 2.5).abs() < 1.0e-3,
|
||||
"the tail carries the RE-SEEDED horizontal — the whole point: an observer whose \
|
||||
arc says xy_speed = 0 learns the mover started moving, got {}",
|
||||
tail.xy_speed
|
||||
);
|
||||
assert!(rx.try_recv().is_err(), "one packet, not a burst");
|
||||
|
||||
// The other side of the same rule: a press that did NOT nudge (a jump taken with momentum —
|
||||
// the reference defers it into an inert latch) stays silent, exactly as the sniff shows.
|
||||
let mut moving = Player {
|
||||
move_flags: move_flags::FORWARD | move_flags::FALLING,
|
||||
..Default::default()
|
||||
};
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut moving,
|
||||
move_flags::FORWARD | move_flags::STRAFE_LEFT | move_flags::FALLING,
|
||||
0.0,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 300,
|
||||
},
|
||||
0.3,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"a deferred mid-air press sends nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn airborne_turn_transitions_and_facing_still_stream() {
|
||||
// The two things that DO go out mid-air (sniff-verified): the turn axis (turning works
|
||||
// airborne — START_TURN_RIGHT/STOP_TURN with Falling set) and SET_FACING for a mouse
|
||||
// jump-turn (no heartbeats stream while FALLING, so facing needs its own carrier).
|
||||
// jump-turn — the capture's `8193 (Forward, Falling)` SET_FACINGs, which carry **no** turn
|
||||
// bit. No heartbeats stream while FALLING, so the facing needs its own carrier.
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut player = Player {
|
||||
move_flags: move_flags::FORWARD | move_flags::FALLING,
|
||||
@@ -445,10 +589,13 @@ mod tests {
|
||||
&mut player,
|
||||
move_flags::FORWARD | move_flags::TURN_LEFT | move_flags::FALLING,
|
||||
0.0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
200,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 200,
|
||||
},
|
||||
0.2,
|
||||
&[],
|
||||
None,
|
||||
@@ -460,28 +607,203 @@ mod tests {
|
||||
};
|
||||
assert_eq!(kind, MoveKind::StartTurnLeft);
|
||||
assert_ne!(flags & move_flags::FALLING, 0, "the packet rides the arc");
|
||||
// Mid-air mouse-turn, flags unchanged, heartbeat interval long past: the periodic
|
||||
// heartbeat stays suppressed while FALLING and the facing streams via SET_FACING instead.
|
||||
assert!(rx.try_recv().is_err(), "the turn axis carries the facing");
|
||||
// Mid-air mouse-turn with the turn key released: the periodic heartbeat stays suppressed
|
||||
// while FALLING, and the facing streams via SET_FACING once the turn flag is gone.
|
||||
player.face_yaw = 1.0;
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut player,
|
||||
move_flags::FORWARD | move_flags::TURN_LEFT | move_flags::FALLING,
|
||||
move_flags::FORWARD | move_flags::FALLING,
|
||||
0.0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
900,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 900,
|
||||
},
|
||||
1.5,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
let ClientCommand::Move { kind, .. } = rx.try_recv().expect("the mid-air facing packet")
|
||||
let kinds: Vec<_> = rx
|
||||
.try_iter()
|
||||
.map(|c| match c {
|
||||
ClientCommand::Move { kind, .. } => kind,
|
||||
_ => panic!("expected Move commands"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![MoveKind::StopTurn, MoveKind::SetFacing],
|
||||
"the turn closes, then the facing reports — no heartbeat while FALLING"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facing_streams_while_running() {
|
||||
// **The regression this law exists for** (decision 0617). A mouse-turn while running used to
|
||||
// put nothing on the wire until the next ~500 ms heartbeat, so observers dead-reckoned us in a
|
||||
// stale direction for half a second and then watched us snap. The reference streams SET_FACING
|
||||
// *while moving* — 116 of the capture's 179 carry a direction bit — so every frame the facing
|
||||
// moves, one packet goes out carrying the live direction flags.
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut player = Player {
|
||||
move_flags: move_flags::FORWARD, // already running: no transition this frame
|
||||
..Default::default()
|
||||
};
|
||||
player.face_yaw = 0.7;
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut player,
|
||||
move_flags::FORWARD,
|
||||
0.0,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 0,
|
||||
},
|
||||
0.05,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
let ClientCommand::Move { kind, flags, .. } = rx
|
||||
.try_recv()
|
||||
.expect("a mouse-turn while running reports its facing")
|
||||
else {
|
||||
panic!("expected a Move command");
|
||||
};
|
||||
assert_eq!(kind, MoveKind::SetFacing, "SET_FACING, not a heartbeat");
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(kind, MoveKind::SetFacing);
|
||||
assert_eq!(
|
||||
flags,
|
||||
move_flags::FORWARD,
|
||||
"the facing report carries the live direction flags"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"exactly one packet per changed frame"
|
||||
);
|
||||
// A frame that didn't move the mouse sends nothing: the detector is exact equality, not a
|
||||
// rate limit — and the heartbeat deadline (0.5 s) has not come round.
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut player,
|
||||
move_flags::FORWARD,
|
||||
0.0,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 0,
|
||||
},
|
||||
0.06,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(rx.try_recv().is_err(), "an unchanged facing is silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_turn_axis_carries_its_own_facing() {
|
||||
// Not one SET_FACING in the capture carries TURN_LEFT/TURN_RIGHT: a keyboard turn is fully
|
||||
// described by its flag (observers rotate at the turn rate while it's set), so the facing it
|
||||
// sweeps needs no packet of its own — and, crucially, the suppressed frames leave no catch-up
|
||||
// packet behind, because the change detector tracks the previous frame's facing regardless.
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut player = Player {
|
||||
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
|
||||
..Default::default()
|
||||
};
|
||||
for (i, yaw) in [0.3_f32, 0.6, 0.9].into_iter().enumerate() {
|
||||
player.face_yaw = yaw;
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut player,
|
||||
move_flags::FORWARD | move_flags::TURN_RIGHT,
|
||||
0.0,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 0,
|
||||
},
|
||||
0.05 * (i as f32 + 1.0),
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"the turn axis is silent (frame {i})"
|
||||
);
|
||||
}
|
||||
// Release the turn key with the facing unchanged since the last swept frame: the STOP_TURN
|
||||
// goes out and nothing follows it.
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut player,
|
||||
move_flags::FORWARD,
|
||||
0.0,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 0,
|
||||
},
|
||||
0.2,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
let ClientCommand::Move { kind, .. } = rx.try_recv().expect("the STOP_TURN") else {
|
||||
panic!("expected a Move command");
|
||||
};
|
||||
assert_eq!(kind, MoveKind::StopTurn);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"no catch-up SET_FACING after a suppressed turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transition_does_not_swallow_the_facing_report() {
|
||||
// The capture repeatedly shows both in one millisecond (`+4971` SET_FACING & START_STRAFE_
|
||||
// RIGHT). They are two independent emitters in the reference, so a frame that both turns and
|
||||
// changes axis puts both on the wire.
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut player = Player {
|
||||
move_flags: move_flags::FORWARD,
|
||||
..Default::default()
|
||||
};
|
||||
player.face_yaw = 1.2;
|
||||
stream_self_movement(
|
||||
&tx,
|
||||
&mut player,
|
||||
move_flags::FORWARD | move_flags::STRAFE_RIGHT,
|
||||
0.0,
|
||||
ArcEdges {
|
||||
jumped: false,
|
||||
air_nudged: false,
|
||||
landed: false,
|
||||
started_falling: false,
|
||||
fall_time: 0,
|
||||
},
|
||||
0.05,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
let kinds: Vec<_> = rx
|
||||
.try_iter()
|
||||
.map(|c| match c {
|
||||
ClientCommand::Move { kind, .. } => kind,
|
||||
_ => panic!("expected Move commands"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, vec![MoveKind::StartStrafeRight, MoveKind::SetFacing]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -148,78 +148,30 @@ pub(super) fn step(
|
||||
air_nudged = true;
|
||||
}
|
||||
|
||||
// The step-up (decision 0209): ATOMIC — a steep face in the way triggers rise →
|
||||
// advance-this-frame's-travel-at-the-raised-height → settle onto a walkable floor, all
|
||||
// committed inside this one frame, or nothing happens and the plain slide runs. There is
|
||||
// no in-between state to be seen wedged or bouncing in (the 0191 ride dwelled mid-face;
|
||||
// every stuck/bounce report of the step-up era was that dwelling). Grazing a face nets
|
||||
// back onto the same floor (reads as sliding); a square push onto a low step lands on its
|
||||
// top; anything taller than [`STEP_UP_HEIGHT`] never commits.
|
||||
let mut stepped = None;
|
||||
if !held && moving && grounded && !jumped {
|
||||
stepped = step_up(&cast, center, dir.normalize_or_zero(), speed * dt);
|
||||
}
|
||||
|
||||
// Held: zero velocity (no move). Grounded: move horizontally only (no gravity-slide).
|
||||
// Jumping/airborne: gravity carries the arc.
|
||||
let velocity = if held {
|
||||
Vec3::ZERO
|
||||
} else if grounded && !jumped {
|
||||
player.horiz_vel
|
||||
} else {
|
||||
player.horiz_vel + Vec3::Y * player.vel_y
|
||||
};
|
||||
let pre_move = center;
|
||||
if let Some(landed) = stepped {
|
||||
// The committed maneuver IS this frame's motion — already settled on a walkable floor,
|
||||
// so the slide and the snap below are skipped.
|
||||
center = landed;
|
||||
} else {
|
||||
let out = ms.move_and_slide(
|
||||
capsule,
|
||||
center,
|
||||
Quat::IDENTITY,
|
||||
velocity,
|
||||
time.delta(),
|
||||
&MoveAndSlideConfig::default(),
|
||||
&filter,
|
||||
|hit| {
|
||||
if grounded && !jumped {
|
||||
if let Some(ride) = walkable_ride_velocity(**hit.normal, *hit.velocity) {
|
||||
*hit.velocity = ride;
|
||||
return MoveAndSlideHitResponse::Accept;
|
||||
}
|
||||
}
|
||||
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
|
||||
if let Ok(wall) = Dir::new(wall) {
|
||||
*hit.normal = wall;
|
||||
}
|
||||
}
|
||||
MoveAndSlideHitResponse::Accept
|
||||
},
|
||||
);
|
||||
center = out.position;
|
||||
}
|
||||
// Snap onto the surface (grounded, not jumping) so we follow downhill slopes + steps down —
|
||||
// the client's step-vs-fall election (`0x6367b0`, wow-re `step-vs-fall-election.md`): the
|
||||
// probe reaches [`STEP_SLOPE_RATIO`]·travel + [`STEP_SNAP_SLACK`] + the unit's collision
|
||||
// height (`0x617430` = `[unit+0xb8]`, our [`CAPSULE_HEIGHT`]; the election's `0x4000000`-
|
||||
// gated extension — decision 0182) and snaps only onto a *walkable* floor (≤50°, the
|
||||
// election's own `cos50°` = [`GROUND_COS`]). A deeper or steeper floor is NOT absorbed: no
|
||||
// snap, the next frame's ground probe misses, and the gap becomes a fall (the client's
|
||||
// `StartFalling(0)` election) — a short ledge drop reads as a quick, continuous, steep
|
||||
// descent, which is what the director's eye confirmed against the reference (decision 0190;
|
||||
// 0189's instant absorbed step read as a teleport and was reverted).
|
||||
let mut snap_probe = None; // (reach, probe outcome) — feeds the WOW_MOVE_TRACE line below
|
||||
if grounded && !jumped && stepped.is_none() {
|
||||
let d = center - pre_move;
|
||||
let reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK + CAPSULE_HEIGHT;
|
||||
let hit = probe_down(center, reach);
|
||||
snap_probe = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y))));
|
||||
if let Some(h) = hit.filter(|h| h.normal1.y >= GROUND_COS) {
|
||||
center.y -= h.distance;
|
||||
ground_entity = Some(h.entity);
|
||||
// The grounded walk is the SHARED resolve ([`grounded_step`]) — step-up, slide, election
|
||||
// snap — the same code every remote mover's dead-reckon runs. Held and airborne/jumping
|
||||
// frames keep their own slide here: no step-up, no snap, and gravity in the velocity.
|
||||
let (mut climb, mut snap_probe) = (None, None);
|
||||
if !held && grounded && !jumped {
|
||||
let g = grounded_step(ms, capsule, &filter, center, player.horiz_vel, time.delta());
|
||||
center = g.center;
|
||||
climb = g.climb;
|
||||
snap_probe = g.snap;
|
||||
if let Some(e) = g.ground {
|
||||
ground_entity = Some(e);
|
||||
}
|
||||
} else {
|
||||
// Held: zero velocity (no move) — `held` already zeroed both terms, but say it outright.
|
||||
// Jumping/airborne: gravity carries the arc.
|
||||
let velocity = if held {
|
||||
Vec3::ZERO
|
||||
} else {
|
||||
player.horiz_vel + Vec3::Y * player.vel_y
|
||||
};
|
||||
// The airborne slide is the OTHER shared resolve ([`airborne_step`]) — the same code a
|
||||
// remote mover's arc runs, so a jump meets our walls whoever is jumping (decision 0627).
|
||||
center = airborne_step(ms, capsule, &filter, center, velocity, time.delta());
|
||||
}
|
||||
// Wedge-rest detection (decisions 0211/0212): airborne, already falling fast, yet the
|
||||
// descent achieved is a sliver of what gravity intended — [`WEDGE_STILL_FRAMES`] in a row
|
||||
@@ -264,7 +216,7 @@ pub(super) fn step(
|
||||
on_walkable,
|
||||
vel_y: player.vel_y,
|
||||
snap: snap_probe,
|
||||
climb: stepped.map(|landed| landed.y - pre_move.y),
|
||||
climb,
|
||||
});
|
||||
|
||||
Outcome {
|
||||
@@ -280,6 +232,166 @@ pub(super) fn step(
|
||||
}
|
||||
}
|
||||
|
||||
/// What one grounded walk step resolved against the world came out as ([`grounded_step`]).
|
||||
pub(crate) struct GroundedStep {
|
||||
/// The resolved capsule centre.
|
||||
pub(crate) center: Vec3,
|
||||
/// The collider of the walkable floor the election snap settled onto, when it ran and hit one.
|
||||
/// `None` means "keep whatever the caller already believed" — a step-up commit and a missed
|
||||
/// snap both leave the support unchanged.
|
||||
pub(crate) ground: Option<Entity>,
|
||||
/// The atomic step-up's committed height gain (yd), when the maneuver ran.
|
||||
pub(crate) climb: Option<f32>,
|
||||
/// The election snap's `(probe reach, what it found)` — trace fodder, `None` when the step-up
|
||||
/// took the frame instead. The inner pair is `(hit distance, hit normal.y)`.
|
||||
pub(crate) snap: Option<(f32, Option<(f32, f32)>)>,
|
||||
}
|
||||
|
||||
/// **One grounded walk step, resolved against the world** — step-up → slide → election snap, from
|
||||
/// a capsule centre and this frame's horizontal velocity. The single place a walking body meets the
|
||||
/// terrain, and deliberately so: the reference drives **every** mover through one controller (0059's
|
||||
/// byte trail — `0x616620` integrates any mover; the local-player GUID compare at `0x6166a9` gates
|
||||
/// only a timing budget; the grounded fork zeroes the vertical and commits through the swept world
|
||||
/// query `0x633840` + the WALK resolver `0x6367b0`, which reads Z off the surface). So does benilla:
|
||||
/// the local controller ([`step`]) calls this for its grounded frames, and every **remote** mover's
|
||||
/// dead-reckon calls it for its extrapolated step ([`crate::net::motion::remote`]).
|
||||
///
|
||||
/// Why a remote needs it at all: dead-reckoning between packets is *our invention*, and an invention
|
||||
/// that ignores the world walks a watched player into a hillside (they sink in, then the next packet
|
||||
/// pops them out) and leaves their height wherever the last packet put it while the ground under them
|
||||
/// rises or falls (they sink or float, and the height arrives as a 2 Hz snap). Both are one defect —
|
||||
/// the extrapolator never touched the world — and both are gone the moment the step is resolved
|
||||
/// through here.
|
||||
///
|
||||
/// Airborne and swimming frames are **not** this function's: a jump is a ballistic arc and a
|
||||
/// swimmer's Z is its depth, exactly as the reference's grounded fork excludes both.
|
||||
pub(crate) fn grounded_step(
|
||||
ms: &MoveAndSlide<'_, '_>,
|
||||
capsule: &Collider,
|
||||
filter: &SpatialQueryFilter,
|
||||
center: Vec3,
|
||||
horiz_vel: Vec3,
|
||||
dt: std::time::Duration,
|
||||
) -> GroundedStep {
|
||||
let cast = |from: Vec3, disp: Vec3| {
|
||||
ms.cast_move(capsule, from, Quat::IDENTITY, disp, SKIN_WIDTH, filter)
|
||||
};
|
||||
let speed = horiz_vel.length();
|
||||
// The step-up (decision 0209): ATOMIC — a steep face in the way triggers rise →
|
||||
// advance-this-frame's-travel-at-the-raised-height → settle onto a walkable floor, all
|
||||
// committed inside this one frame, or nothing happens and the plain slide runs. There is
|
||||
// no in-between state to be seen wedged or bouncing in (the 0191 ride dwelled mid-face;
|
||||
// every stuck/bounce report of the step-up era was that dwelling). Grazing a face nets
|
||||
// back onto the same floor (reads as sliding); a square push onto a low step lands on its
|
||||
// top; anything taller than [`STEP_UP_HEIGHT`] never commits.
|
||||
let stepped = if speed > 1.0e-6 {
|
||||
step_up(&cast, center, horiz_vel / speed, speed * dt.as_secs_f32())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(landed) = stepped {
|
||||
// The committed maneuver IS this frame's motion — already settled on a walkable floor,
|
||||
// so the slide and the snap below are skipped.
|
||||
return GroundedStep {
|
||||
center: landed,
|
||||
ground: None,
|
||||
climb: Some(landed.y - center.y),
|
||||
snap: None,
|
||||
};
|
||||
}
|
||||
let out = ms.move_and_slide(
|
||||
capsule,
|
||||
center,
|
||||
Quat::IDENTITY,
|
||||
horiz_vel,
|
||||
dt,
|
||||
&MoveAndSlideConfig::default(),
|
||||
filter,
|
||||
|hit| {
|
||||
if let Some(ride) = walkable_ride_velocity(**hit.normal, *hit.velocity) {
|
||||
*hit.velocity = ride;
|
||||
return MoveAndSlideHitResponse::Accept;
|
||||
}
|
||||
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
|
||||
if let Ok(wall) = Dir::new(wall) {
|
||||
*hit.normal = wall;
|
||||
}
|
||||
}
|
||||
MoveAndSlideHitResponse::Accept
|
||||
},
|
||||
);
|
||||
let mut slid = out.position;
|
||||
// Snap onto the surface so we follow downhill slopes + steps down — the client's step-vs-fall
|
||||
// election (`0x6367b0`, wow-re `step-vs-fall-election.md`): the probe reaches
|
||||
// [`STEP_SLOPE_RATIO`]·travel + [`STEP_SNAP_SLACK`] + the unit's collision height (`0x617430` =
|
||||
// `[unit+0xb8]`, our [`CAPSULE_HEIGHT`]; the election's `0x4000000`-gated extension — decision
|
||||
// 0182) and snaps only onto a *walkable* floor (≤50°, the election's own `cos50°` =
|
||||
// [`GROUND_COS`]). A deeper or steeper floor is NOT absorbed: no snap, the next frame's ground
|
||||
// probe misses, and the gap becomes a fall (the client's `StartFalling(0)` election) — a short
|
||||
// ledge drop reads as a quick, continuous, steep descent, which is what the director's eye
|
||||
// confirmed against the reference (decision 0190; 0189's instant absorbed step read as a
|
||||
// teleport and was reverted).
|
||||
//
|
||||
// Standing still the reach is slack + the collision height, which is what re-grounds an
|
||||
// *idle* body every frame: the small float a raw wire Z leaves a watched player standing on
|
||||
// our terrain is taken out here, the same way [`crate::net::motion::ground_clamp_creatures`]
|
||||
// takes it out of an idle NPC.
|
||||
let d = slid - center;
|
||||
let reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK + CAPSULE_HEIGHT;
|
||||
let hit = cast(slid, Vec3::NEG_Y * reach);
|
||||
let snap = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y))));
|
||||
let mut ground = None;
|
||||
if let Some(h) = hit.filter(|h| h.normal1.y >= GROUND_COS) {
|
||||
slid.y -= h.distance;
|
||||
ground = Some(h.entity);
|
||||
}
|
||||
GroundedStep {
|
||||
center: slid,
|
||||
ground,
|
||||
climb: None,
|
||||
snap,
|
||||
}
|
||||
}
|
||||
|
||||
/// **One airborne step, resolved against the world** — the arc's slide and nothing else. No
|
||||
/// step-up and no election snap: the arc owns its own height (gravity carries it; the landing is
|
||||
/// next frame's ground probe to call), so the only thing the world may do here is *stop* it. Steep
|
||||
/// faces get [`steep_wall_plane`]'s treatment, exactly as they do on the ground.
|
||||
///
|
||||
/// The airborne twin of [`grounded_step`], and shared for the same reason (0059's one controller,
|
||||
/// every mover): the local controller ([`step`]) calls it for its held/airborne frames, and a
|
||||
/// **remote** mover's ballistic dead-reckon calls it for the arc it invents between packets
|
||||
/// ([`crate::net::motion::remote`]). Without that, a watched player who jumps into a building is
|
||||
/// drawn *inside* it for the length of the jump and pops back out on the landing packet — the
|
||||
/// airborne half of the very defect 0626 fixed on the ground (decision 0627).
|
||||
pub(crate) fn airborne_step(
|
||||
ms: &MoveAndSlide<'_, '_>,
|
||||
capsule: &Collider,
|
||||
filter: &SpatialQueryFilter,
|
||||
center: Vec3,
|
||||
velocity: Vec3,
|
||||
dt: std::time::Duration,
|
||||
) -> Vec3 {
|
||||
ms.move_and_slide(
|
||||
capsule,
|
||||
center,
|
||||
Quat::IDENTITY,
|
||||
velocity,
|
||||
dt,
|
||||
&MoveAndSlideConfig::default(),
|
||||
filter,
|
||||
|hit| {
|
||||
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
|
||||
if let Ok(wall) = Dir::new(wall) {
|
||||
*hit.normal = wall;
|
||||
}
|
||||
}
|
||||
MoveAndSlideHitResponse::Accept
|
||||
},
|
||||
)
|
||||
.position
|
||||
}
|
||||
|
||||
/// The even-speed ramp ride: a walkable slope never slows or deflects the grounded walk. The
|
||||
/// real client's walk step is two-dimensional — the resolver takes speed·dt as a *horizontal*
|
||||
/// distance and a normalized 2D direction, and Z follows purely through the snap/step machinery
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! `WOW_PROBE_LOOK` — the scripted **mouse-turn**, the one player action the probe harness could not
|
||||
//! synthesize (decision 0621).
|
||||
//!
|
||||
//! `WOW_PROBE_KEY` presses keys, which is enough for the transition opcodes; but since decision 0617
|
||||
//! the busiest thing on our wire by far is the facing stream a *mouse* turn produces (~one packet per
|
||||
//! frame). A defect that only shows up under that density — and the runaway hunt looks like exactly
|
||||
//! that — could not be reproduced headlessly at all: an agent had to ask the director to drive, every
|
||||
//! iteration. That is the tooling gap this closes.
|
||||
//!
|
||||
//! Format: `WOW_PROBE_LOOK="<deg_per_sec>@<start_s>:<duration_s>[;…]"`, e.g. `"90@20:6"` — turn the
|
||||
//! avatar's aim 90°/s for six seconds starting twenty seconds in. Negative rates turn the other way.
|
||||
//! It writes [`Player::face_yaw`] directly rather than faking `AccumulatedMouseMotion` + a held right
|
||||
//! button: the camera's look session gates on the OS cursor being inside the viewport
|
||||
//! (`cursor_in_viewport`), which is unreliable for an unfocused probe window, and the camera plumbing
|
||||
//! is not what is under test. From `face_yaw` onward this is the identical path a real mouse-turn
|
||||
//! takes — the same value `stream_self_movement` diffs to decide on a `MSG_MOVE_SET_FACING`.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::Player;
|
||||
|
||||
/// One scripted turn: `rate` (rad/s), when it starts, and how long it runs (seconds, in-world clock).
|
||||
struct Turn {
|
||||
rate: f32,
|
||||
at: f32,
|
||||
until: f32,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub(super) struct ProbeLook {
|
||||
turns: Vec<Turn>,
|
||||
}
|
||||
|
||||
/// Parse the env script. Absent or unparseable entries yield no turns (with a warning), so the probe
|
||||
/// is inert unless asked for.
|
||||
pub(super) fn from_env() -> Option<ProbeLook> {
|
||||
let spec = std::env::var("WOW_PROBE_LOOK").ok()?;
|
||||
let turns: Vec<Turn> = spec
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| {
|
||||
let (rate, rest) = s.split_once('@')?;
|
||||
let (at, dur) = rest.split_once(':')?;
|
||||
match (
|
||||
rate.trim().parse::<f32>(),
|
||||
at.trim().parse::<f32>(),
|
||||
dur.trim().parse::<f32>(),
|
||||
) {
|
||||
(Ok(rate), Ok(at), Ok(dur)) => Some(Turn {
|
||||
rate: rate.to_radians(),
|
||||
at,
|
||||
until: at + dur,
|
||||
}),
|
||||
_ => {
|
||||
warn!("probe-look: unparseable turn {s:?} (want e.g. 90@20:6) — skipped");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(!turns.is_empty()).then_some(ProbeLook { turns })
|
||||
}
|
||||
|
||||
/// Rotate the aim for every turn whose window covers this frame. Runs in `WorldStage::Input`
|
||||
/// **before** the controller, so the frame that sees the new `face_yaw` is the frame that streams it.
|
||||
pub(super) fn drive_probe_look(
|
||||
probe: Res<ProbeLook>,
|
||||
time: Res<Time>,
|
||||
mut player: ResMut<Player>,
|
||||
self_player: Query<(), With<crate::net::SelfPlayer>>,
|
||||
) {
|
||||
if self_player.is_empty() {
|
||||
return; // in-world only, like the other probes
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
let dt = time.delta_secs();
|
||||
let rate: f32 = probe
|
||||
.turns
|
||||
.iter()
|
||||
.filter(|t| now >= t.at && now < t.until)
|
||||
.map(|t| t.rate)
|
||||
.sum();
|
||||
if rate != 0.0 {
|
||||
player.face_yaw += rate * dt;
|
||||
}
|
||||
}
|
||||
@@ -133,10 +133,18 @@ pub(super) const SETTLE_TIMEOUT: f32 = 6.0;
|
||||
/// glimpsed through a building whose collider hasn't streamed in yet.
|
||||
pub(super) const SETTLE_REACH: f32 = 1.0;
|
||||
|
||||
/// The player's collision capsule, built once at startup and swept by avian's `MoveAndSlide` each
|
||||
/// frame. Its origin is the capsule centre; the player's `pos` is its feet (centre − half-height·Y).
|
||||
/// The player body's collision capsule, built once at startup and swept by avian's `MoveAndSlide`
|
||||
/// each frame. Its origin is the capsule centre; the player's `pos` is its feet (centre −
|
||||
/// half-height·Y).
|
||||
///
|
||||
/// **Every player body, not only ours.** A remote mover's dead-reckon sweeps this same capsule
|
||||
/// against the same colliders ([`super::mover::grounded_step`]) — the reference drives non-local
|
||||
/// units through the *same* movement controller as the local player (decision 0059's byte trail:
|
||||
/// `0x616620` integrates any mover; the local-player GUID compare gates only a timing budget). The
|
||||
/// reference reads each unit's own collision height (`[unit+0xb8]`); we use the one player capsule
|
||||
/// for every player body, which the 1.12 races are close enough to share.
|
||||
#[derive(Resource)]
|
||||
pub(super) struct PlayerCapsule(pub(super) Collider);
|
||||
pub(crate) struct PlayerCapsule(pub(crate) Collider);
|
||||
|
||||
/// The avatar run-speed fallback + dev override. `value` is `$WOW_MOVE_SPEED` when set
|
||||
/// (`env_override` — the absolute dev knob, backpedal scaled by [`RUN_BACK_RATIO`] under it), else
|
||||
@@ -187,9 +195,11 @@ pub(crate) struct Player {
|
||||
/// [`crate::net::move_flags`]). Diffed against this frame's flags to emit a `MSG_MOVE_*` per
|
||||
/// movement-axis transition — the way the real client announces its movement.
|
||||
pub(super) move_flags: u32,
|
||||
/// The facing (WoW orientation) we last sent. While standing and mouse-turning, a change beyond a
|
||||
/// small threshold streams a `MSG_MOVE_SET_FACING` (rate-limited), so others see us turn in place.
|
||||
pub(super) last_sent_facing: f32,
|
||||
/// The facing (WoW orientation) as of **last frame** — the reference's facing-change detector
|
||||
/// (`0x617170`, exact equality against the unit's live facing cell). Any change off the turn axis
|
||||
/// streams a `MSG_MOVE_SET_FACING` that frame, moving or standing, so observers see us aim
|
||||
/// (decision 0617). Updated every frame whether or not a packet went out.
|
||||
pub(super) last_facing: f32,
|
||||
/// The stand state we last volunteered (`CMSG_STANDSTATECHANGE`) whose echo into our
|
||||
/// `UNIT_FIELD_BYTES_1` hasn't landed yet — the local commit (the client's `SetStandState`
|
||||
/// `0x6127b0` applies immediately *and* sends; decision 0080c). `None` = at the echoed value.
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
//! *Interim*: the reference's interactability predicate (`CGUnit::CanInteract 0x606880`) isn't
|
||||
//! fully derived; we approximate it as "has service flags and isn't attack-worthy (reaction ≥
|
||||
//! neutral)". Attackability approximates the PvP/attack matrix as "reaction rank ≤ neutral" —
|
||||
//! the same approximation the ring's player branch documents. The questgiver bit skips the
|
||||
//! client's quest-status gate (`0x5df490` — Speak only with an actual quest to show), and the
|
||||
//! auto-loot modifier-key split (Pickup vs LootAll, `0x41f8f0`) waits on auto-loot existing at
|
||||
//! all — until then the base Pickup is the only mode the loot leg can honestly show.
|
||||
//! the same approximation the ring's player branch documents. The auto-loot modifier-key split
|
||||
//! (Pickup vs LootAll, `0x41f8f0`) waits on auto-loot existing at all — until then the base Pickup
|
||||
//! is the only mode the loot leg can honestly show. (The questgiver bit's own quest-status gate,
|
||||
//! `0x5df490`, used to be listed here as unmodelled; it is modelled now — [`questgiver_has_quest`].)
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -295,16 +295,42 @@ fn go_cursor_kind(type_id: i32, lock_cursor: Option<CursorKind>) -> CursorKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The QUESTGIVER leg's own gate — the bit alone is not enough. The ladder calls `0x5df490(unit)`,
|
||||
/// which is `NPC_FLAGS bit 1` **AND** the cached quest status `[unit+0xcb8] ∉ {0, 1}` (wow-re
|
||||
/// `ui/scratch/cursor-system.md` §3, byte-verified row; `object-layer/scratch/questgiver-marker.md`
|
||||
/// independently pins `+0xcb8` as the `SMSG_QUESTGIVER_STATUS` cache, written by `0x607440` and with
|
||||
/// only three writers repo-wide). So NONE(0) and UNAVAILABLE(1) do **not** make a unit talkable;
|
||||
/// every other status does.
|
||||
///
|
||||
/// This is what keeps a questgiver-flagged NPC with nothing to offer from being clickable at all —
|
||||
/// and it is load-bearing far beyond the cursor. Melika Isenstrider (vmangos entry 6778) is flagged
|
||||
/// QUESTGIVER, carries no other service bit, and has zero rows in `creature_questrelation`: without
|
||||
/// this gate she classifies Speak, we send `CMSG_GOSSIP_HELLO`, and vmangos answers the resulting
|
||||
/// `DEFAULT_GOSSIP_MESSAGE` text query with eight literal `"Greetings $N"` blocks
|
||||
/// (`QueryHandler.cpp:210-217`) — an empty gossip frame carrying a placeholder greeting, on an NPC
|
||||
/// the reference never opens anything for. That was the whole of the reported "the client invents
|
||||
/// 'Greetings NAME'" bug: the text was genuine, the *asking* was ours.
|
||||
///
|
||||
/// `None` (no status ever sent) reads as no quest: the server sends the status unprompted for every
|
||||
/// questgiver in range, so its absence means the unit isn't offering us one.
|
||||
fn questgiver_has_quest(quest_status: Option<u32>) -> bool {
|
||||
use benilla_protocol::messages::dialog_status::{NONE, UNAVAILABLE};
|
||||
!matches!(quest_status, None | Some(NONE) | Some(UNAVAILABLE))
|
||||
}
|
||||
|
||||
/// The per-bit service ladder (`0x482336..0x4824e3`, statically unrolled — every row byte-verified
|
||||
/// in the RE note's §3 table): lowest set bit wins. `None` = no *consulted* bit set — the unit
|
||||
/// falls through to the attack/clear leg (this is where repair-only units land: bit 14 is never
|
||||
/// tested in the binary).
|
||||
fn service_cursor(service: u32) -> Option<CursorKind> {
|
||||
///
|
||||
/// `quest_status` is the unit's last `SMSG_QUESTGIVER_STATUS` (`None` = never sent), which gates the
|
||||
/// QUESTGIVER leg — see [`questgiver_has_quest`].
|
||||
fn service_cursor(service: u32, quest_status: Option<u32>) -> Option<CursorKind> {
|
||||
use npc_flags::*;
|
||||
if service & GOSSIP != 0 {
|
||||
Some(CursorKind::Speak)
|
||||
} else if service & QUESTGIVER != 0 {
|
||||
// The client additionally gates this on quest status (`0x5df490`); not yet modeled.
|
||||
// Bits 0 and 1 both land on Speak, and bit 0 is tested first, so the two rows fold into one
|
||||
// condition without changing a single outcome — the same folding the SPIRITHEALER/SPIRITGUIDE
|
||||
// and PETITIONER/TABARDDESIGNER/BATTLEMASTER rows already use. Only bit 1 carries a gate.
|
||||
if service & GOSSIP != 0 || (service & QUESTGIVER != 0 && questgiver_has_quest(quest_status)) {
|
||||
Some(CursorKind::Speak)
|
||||
} else if service & VENDOR != 0 {
|
||||
Some(CursorKind::Pickup)
|
||||
@@ -347,6 +373,9 @@ pub(super) fn classify_cursor(
|
||||
go_templates: Res<crate::go_templates::GameObjectTemplates>,
|
||||
locks: Option<Res<crate::go_templates::Locks>>,
|
||||
lock_types: Option<Res<crate::go_templates::LockTypes>>,
|
||||
// The QUESTGIVER leg's gate reads the per-guid `SMSG_QUESTGIVER_STATUS` store — see
|
||||
// [`questgiver_has_quest`].
|
||||
quest: Res<crate::ui_quest::QuestGiver>,
|
||||
) {
|
||||
// A **highlightable** GameObject shows its **data-driven** cursor (wow-re cursor-system §4): a
|
||||
// mailbox's Mail, a plaque's Inspect, a vein's Mine / herb's GatherHerbs / picked lock's PickLock
|
||||
@@ -414,7 +443,8 @@ pub(super) fn classify_cursor(
|
||||
// Interactable NPC (interim CanInteract): a consulted service bit + not attack-worthy.
|
||||
// A repair-only unit yields None here and falls through, exactly like the binary.
|
||||
if !is_player && rank >= 3 {
|
||||
if let Some(kind) = service_cursor(store.0.unit_npc_flags()) {
|
||||
let status = hovered.guid.and_then(|g| quest.status(g));
|
||||
if let Some(kind) = service_cursor(store.0.unit_npc_flags(), status) {
|
||||
return Some((kind, dist_sq > SERVICE_RANGE_SQ));
|
||||
}
|
||||
}
|
||||
@@ -439,6 +469,7 @@ pub(super) fn classify_cursor(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::dialog_status;
|
||||
|
||||
#[test]
|
||||
fn stems_name_the_shipped_blps() {
|
||||
@@ -497,27 +528,80 @@ mod tests {
|
||||
#[test]
|
||||
fn service_ladder_matches_the_unrolled_binary() {
|
||||
use npc_flags::*;
|
||||
// A quest to offer, so the QUESTGIVER row behaves like the rest of the ladder here; the
|
||||
// gate itself is `questgiver_flag_alone_is_not_talkable`.
|
||||
let has = Some(dialog_status::AVAILABLE);
|
||||
// The rows the RE table pins per byte address.
|
||||
assert_eq!(service_cursor(GOSSIP), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(QUESTGIVER), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(VENDOR), Some(CursorKind::Pickup));
|
||||
assert_eq!(service_cursor(FLIGHTMASTER), Some(CursorKind::Taxi));
|
||||
assert_eq!(service_cursor(TRAINER), Some(CursorKind::Trainer));
|
||||
assert_eq!(service_cursor(SPIRITHEALER), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(INNKEEPER), Some(CursorKind::Interact));
|
||||
assert_eq!(service_cursor(BANKER), Some(CursorKind::Buy));
|
||||
assert_eq!(service_cursor(BATTLEMASTER), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(AUCTIONEER), Some(CursorKind::Buy));
|
||||
assert_eq!(service_cursor(STABLEMASTER), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(GOSSIP, None), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(QUESTGIVER, has), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(VENDOR, None), Some(CursorKind::Pickup));
|
||||
assert_eq!(service_cursor(FLIGHTMASTER, None), Some(CursorKind::Taxi));
|
||||
assert_eq!(service_cursor(TRAINER, None), Some(CursorKind::Trainer));
|
||||
assert_eq!(service_cursor(SPIRITHEALER, None), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(INNKEEPER, None), Some(CursorKind::Interact));
|
||||
assert_eq!(service_cursor(BANKER, None), Some(CursorKind::Buy));
|
||||
assert_eq!(service_cursor(BATTLEMASTER, None), Some(CursorKind::Speak));
|
||||
assert_eq!(service_cursor(AUCTIONEER, None), Some(CursorKind::Buy));
|
||||
assert_eq!(service_cursor(STABLEMASTER, None), Some(CursorKind::Speak));
|
||||
// Lowest bit wins: a gossiping vendor speaks; an innkeeper-banker interacts.
|
||||
assert_eq!(service_cursor(GOSSIP | VENDOR), Some(CursorKind::Speak));
|
||||
assert_eq!(
|
||||
service_cursor(INNKEEPER | BANKER),
|
||||
service_cursor(GOSSIP | VENDOR, None),
|
||||
Some(CursorKind::Speak)
|
||||
);
|
||||
assert_eq!(
|
||||
service_cursor(INNKEEPER | BANKER, None),
|
||||
Some(CursorKind::Interact)
|
||||
);
|
||||
// REPAIR (0x4000) is never consulted — repair-only falls to the attack/clear leg.
|
||||
assert_eq!(service_cursor(0x4000), None);
|
||||
assert_eq!(service_cursor(0), None);
|
||||
assert_eq!(service_cursor(0x4000, None), None);
|
||||
assert_eq!(service_cursor(0, None), None);
|
||||
}
|
||||
|
||||
/// The QUESTGIVER leg's `0x5df490` gate: the bit alone never makes a unit talkable. This is the
|
||||
/// "client invents 'Greetings NAME'" bug at its root — a questgiver-flagged NPC with nothing to
|
||||
/// offer must fall out of the ladder entirely, so we never send `CMSG_GOSSIP_HELLO` and never
|
||||
/// open the empty gossip frame the server would answer with a placeholder greeting.
|
||||
#[test]
|
||||
fn questgiver_flag_alone_is_not_talkable() {
|
||||
use npc_flags::*;
|
||||
// Melika Isenstrider's exact shape: QUESTGIVER, no other service bit, nothing on offer.
|
||||
for status in [
|
||||
None,
|
||||
Some(dialog_status::NONE),
|
||||
Some(dialog_status::UNAVAILABLE),
|
||||
] {
|
||||
assert_eq!(
|
||||
service_cursor(QUESTGIVER, status),
|
||||
None,
|
||||
"status {status:?} must not classify Speak"
|
||||
);
|
||||
}
|
||||
// Every other status is a quest worth talking about — `[unit+0xcb8] ∉ {0, 1}`.
|
||||
for status in [
|
||||
dialog_status::CHAT,
|
||||
dialog_status::INCOMPLETE,
|
||||
dialog_status::REWARD_REP,
|
||||
dialog_status::AVAILABLE,
|
||||
dialog_status::REWARD_OLD,
|
||||
dialog_status::REWARD2,
|
||||
] {
|
||||
assert_eq!(
|
||||
service_cursor(QUESTGIVER, Some(status)),
|
||||
Some(CursorKind::Speak),
|
||||
"status {status} must classify Speak"
|
||||
);
|
||||
}
|
||||
// The gate is the QUESTGIVER leg's alone: a quest-less unit that also gossips still speaks
|
||||
// (bit 0 is tested first and carries no gate), and a quest-less vendor still shows Pickup
|
||||
// rather than falling out of the ladder.
|
||||
assert_eq!(
|
||||
service_cursor(GOSSIP | QUESTGIVER, None),
|
||||
Some(CursorKind::Speak)
|
||||
);
|
||||
assert_eq!(
|
||||
service_cursor(QUESTGIVER | VENDOR, None),
|
||||
Some(CursorKind::Pickup)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
use benilla_formats::SpellDisplay;
|
||||
|
||||
/// Wire reason → its `SPELL_FAILED_*` GlobalStrings key (the `0x6e23e0` table, byte-exact).
|
||||
const CAST_FAIL_KEYS: [&str; 146] = [
|
||||
pub(super) const CAST_FAIL_KEYS: [&str; 146] = [
|
||||
"SPELL_FAILED_AFFECTING_COMBAT", // 0x00
|
||||
"SPELL_FAILED_ALREADY_AT_FULL_HEALTH", // 0x01
|
||||
"SPELL_FAILED_ALREADY_AT_FULL_POWER", // 0x02
|
||||
|
||||
@@ -345,6 +345,7 @@ fn feed_actions(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<FeedMemory>,
|
||||
) {
|
||||
@@ -370,6 +371,29 @@ fn feed_actions(
|
||||
.filter_map(|(spell_id, reason)| {
|
||||
let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
|
||||
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
// 0x19/0x1a/0x1b EQUIPPED_ITEM_CLASS* — the other argument-formatted family whose
|
||||
// `%s` benilla models (`0x6e1db7`, the arm that resolves an item class/subclass name
|
||||
// through `0x6e2380`): "Must have a **Wand** equipped", the SINGULAR DisplayName,
|
||||
// where the spell tooltip's own requirement line takes the verbose plural. Purely a
|
||||
// DBC read, so no query/redisplay round trip. A multi-bit mask resolves too — through
|
||||
// ItemSubClassMask.dbc's group name, else the FIRST matching subclass (law
|
||||
// §3-EQUIPITEM; the tooltip's twin joins instead).
|
||||
if let (0x19..=0x1b, Some(d), Some(subs)) = (reason, d, sub_classes.as_deref()) {
|
||||
if let Some(name) = (d.equipped_item_class >= 0)
|
||||
.then(|| {
|
||||
subs.0.requirement_display_name(
|
||||
d.equipped_item_class as u32,
|
||||
d.equipped_item_subclass_mask,
|
||||
)
|
||||
})
|
||||
.flatten()
|
||||
{
|
||||
let key = cast_fail::CAST_FAIL_KEYS[reason as usize];
|
||||
return get(key)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|t| t.replace("%s", &name));
|
||||
}
|
||||
}
|
||||
if reason == 0x78 || reason == 0x5c {
|
||||
let d = d?;
|
||||
let failing = if reason == 0x78 {
|
||||
|
||||
@@ -51,6 +51,36 @@ pub(crate) struct UsableCtx<'a> {
|
||||
pub(crate) now: Instant,
|
||||
}
|
||||
|
||||
/// Leg 4's own test (`0x6e40e0`), shared with the spell tooltip's requirement line: does some
|
||||
/// WORN item match `EquippedItemClass` + `EquippedItemSubClassMask`? `true` when the spell asks
|
||||
/// for nothing (`class < 0`). An equipped item whose template hasn't streamed yet counts as a
|
||||
/// match — never grey (and never red) on missing data, the catalog-absent convention.
|
||||
pub(crate) fn equipped_item_fits(
|
||||
d: &SpellDisplay,
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
commands: &NetCommands,
|
||||
) -> bool {
|
||||
if d.equipped_item_class < 0 {
|
||||
return true;
|
||||
}
|
||||
let class = d.equipped_item_class as u32;
|
||||
(0..19).any(|slot| {
|
||||
let Some(guid) = store.0.player_inv_slot(slot).filter(|&g| g != 0) else {
|
||||
return false;
|
||||
};
|
||||
let Some(entry) = items.object(guid).and_then(|o| o.object_entry()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(t) = items.template(entry, guid, commands) else {
|
||||
return true; // unresolved template: benefit of the doubt
|
||||
};
|
||||
t.class == class
|
||||
&& (d.equipped_item_subclass_mask == 0
|
||||
|| d.equipped_item_subclass_mask & (1 << t.subclass) != 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// The walk. Returns `(usable, not_enough_mana)` — the `IsUsableAction` pair.
|
||||
pub(crate) fn spell_usable(
|
||||
spell_id: u32,
|
||||
@@ -79,28 +109,9 @@ pub(crate) fn spell_usable(
|
||||
return (false, false);
|
||||
}
|
||||
}
|
||||
// Leg 4 (`0x6e40e0`): some worn item must match the class + subclass mask. An equipped
|
||||
// item whose template hasn't streamed yet counts as a match — never grey on missing data
|
||||
// (the catalog-absent convention).
|
||||
if d.equipped_item_class >= 0 {
|
||||
let class = d.equipped_item_class as u32;
|
||||
let fits = (0..19).any(|slot| {
|
||||
let Some(guid) = ctx.store.0.player_inv_slot(slot).filter(|&g| g != 0) else {
|
||||
return false;
|
||||
};
|
||||
let Some(entry) = items.object(guid).and_then(|o| o.object_entry()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(t) = items.template(entry, guid, commands) else {
|
||||
return true; // unresolved template: benefit of the doubt
|
||||
};
|
||||
t.class == class
|
||||
&& (d.equipped_item_subclass_mask == 0
|
||||
|| d.equipped_item_subclass_mask & (1 << t.subclass) != 0)
|
||||
});
|
||||
if !fits {
|
||||
return (false, false);
|
||||
}
|
||||
// Leg 4 (`0x6e40e0`): some worn item must match the class + subclass mask.
|
||||
if !equipped_item_fits(d, ctx.store, items, commands) {
|
||||
return (false, false);
|
||||
}
|
||||
// Leg 6 (`0x612480`): the shapeshift-form gate — the form's stance flag from
|
||||
// SpellShapeshiftForm.dbc decides whether it counts as "shapeshifted".
|
||||
|
||||
@@ -290,7 +290,10 @@ fn feed_auras(
|
||||
name: display.map(|d| d.name.clone()),
|
||||
icon: display.and_then(|d| d.icon.clone()),
|
||||
count: c.stacks,
|
||||
debuff_type: display.and_then(|d| d.debuff_type()).map(str::to_string),
|
||||
debuff_type: display
|
||||
.zip(catalog)
|
||||
.and_then(|(d, cat)| cat.dispel_name(d))
|
||||
.map(str::to_string),
|
||||
duration,
|
||||
expiration_time,
|
||||
helpful: c.slot < UNIT_AURA_POSITIVE_SLOTS,
|
||||
@@ -366,7 +369,10 @@ fn feed_auras(
|
||||
name: display.map(|d| d.name.clone()),
|
||||
icon: display.and_then(|d| d.icon.clone()),
|
||||
count: a.stacks,
|
||||
debuff_type: display.and_then(|d| d.debuff_type()).map(str::to_string),
|
||||
debuff_type: display
|
||||
.zip(catalog)
|
||||
.and_then(|(d, cat)| cat.dispel_name(d))
|
||||
.map(str::to_string),
|
||||
// No duration for any unit but yourself — the 1.12 wire carries none
|
||||
// (byte-verified, 0257 B6); the reference's target frame shows no timers.
|
||||
duration: 0.0,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_protocol::messages::GossipOption;
|
||||
use benilla_protocol::messages::{select_greeting, GossipOption, NpcTextBlock};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{GossipMenu, GossipOptionView, GossipQuestRow, ScriptValue, UiScript};
|
||||
@@ -26,16 +26,23 @@ use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
||||
|
||||
/// The open gossip menu, filled by the net bridge ([`crate::net`]) and read by [`feed_gossip`]. The
|
||||
/// ask-once `greetings` cache keyed by `text_id` (mirroring [`crate::items::Items`]'s template cache)
|
||||
/// means a revisit to the same NPC never re-queries the greeting. Cleared on `SMSG_GOSSIP_COMPLETE`,
|
||||
/// a client-side close, and disconnect; the greeting cache survives (NPC text is static per id).
|
||||
/// ask-once `records` cache keyed by `text_id` (mirroring [`crate::items::Items`]'s template cache)
|
||||
/// means a revisit to the same NPC never re-queries the text. Cleared on `SMSG_GOSSIP_COMPLETE`,
|
||||
/// a client-side close, and disconnect; the record cache survives (NPC text is static per id).
|
||||
///
|
||||
/// The cache holds the **record**, not a greeting. Which of its 8 blocks greets you is drawn per
|
||||
/// menu-open from the NPC's gender and a die roll (`benilla_protocol::messages::select_greeting`),
|
||||
/// exactly as the reference re-draws it in its gossip handler — so the same `text_id` can greet you
|
||||
/// differently on the next visit, and two NPCs sharing a `text_id` can differ by gender. Caching a
|
||||
/// resolved string, as this did, froze the first draw for the whole session.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct GossipState {
|
||||
/// The NPC whose menu is open; `None` = no menu open.
|
||||
pub(crate) npc: Option<u64>,
|
||||
/// The open menu's `NpcText` id (drives the greeting query / cache).
|
||||
/// The open menu's `NpcText` id (drives the text query / cache).
|
||||
pub(crate) text_id: u32,
|
||||
/// The resolved greeting (`SMSG_NPC_TEXT_UPDATE`), or `None` while its query is in flight.
|
||||
/// The greeting drawn for THIS menu-open, or `None` while its query is in flight (or when the
|
||||
/// record names no line for this NPC — the reference's "Missing gossip text!" path).
|
||||
pub(crate) greeting: Option<String>,
|
||||
/// The selectable option rows (wire `GossipOption`: `index` echoed on select, `icon`, `coded`,
|
||||
/// `message`).
|
||||
@@ -44,21 +51,39 @@ pub(crate) struct GossipState {
|
||||
/// window lists them above the options; a click sends `CMSG_QUESTGIVER_QUERY_QUEST` /
|
||||
/// `_COMPLETE_QUEST` (decision 0088).
|
||||
pub(crate) quests: Vec<(u32, u32, String)>,
|
||||
/// Ask-once greeting cache keyed by `text_id`.
|
||||
greetings: HashMap<u32, String>,
|
||||
/// Ask-once NPC-text record cache keyed by `text_id` — the 8 undrawn blocks.
|
||||
records: HashMap<u32, Vec<NpcTextBlock>>,
|
||||
}
|
||||
|
||||
impl GossipState {
|
||||
/// The cached greeting for `text_id`, if `CMSG_NPC_TEXT_QUERY` was already answered for it.
|
||||
pub(crate) fn cached_greeting(&self, text_id: u32) -> Option<String> {
|
||||
self.greetings.get(&text_id).cloned()
|
||||
/// The cached record for `text_id`, if `CMSG_NPC_TEXT_QUERY` was already answered for it.
|
||||
pub(crate) fn cached_record(&self, text_id: u32) -> Option<&[NpcTextBlock]> {
|
||||
self.records.get(&text_id).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Record a greeting answer (`SMSG_NPC_TEXT_UPDATE`) into the ask-once cache.
|
||||
pub(crate) fn remember_greeting(&mut self, text_id: u32, greeting: String) {
|
||||
self.greetings.insert(text_id, greeting);
|
||||
/// Record an NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) into the ask-once cache.
|
||||
pub(crate) fn remember_record(&mut self, text_id: u32, blocks: Vec<NpcTextBlock>) {
|
||||
self.records.insert(text_id, blocks);
|
||||
}
|
||||
|
||||
/// Draw this menu-open's greeting out of `text_id`'s cached record for an NPC of `npc_gender`
|
||||
/// — `None` if the record hasn't arrived yet, or names no line for that gender.
|
||||
pub(crate) fn draw_greeting(&self, text_id: u32, npc_gender: u8) -> Option<String> {
|
||||
let blocks = self.cached_record(text_id)?;
|
||||
select_greeting(blocks, npc_gender, greeting_roll()).map(str::to_string)
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's uniform float in `[1.0, 2.0)` for the greeting draw: it stuffs PRNG bits straight
|
||||
/// into a mantissa (`(rand & 0x7fffff) | 0x3f800000`), which is what makes the range `[1, 2)` rather
|
||||
/// than the `[0, 1)` you would expect. We build the same shape from our own source — the *stream*
|
||||
/// can't match (it is the client's own PRNG, seeded per run) but the distribution is what the law
|
||||
/// needs.
|
||||
fn greeting_roll() -> f32 {
|
||||
f32::from_bits((rand::random::<u32>() & 0x7f_ffff) | 0x3f80_0000)
|
||||
}
|
||||
|
||||
impl GossipState {
|
||||
/// Close the open menu (`SMSG_GOSSIP_COMPLETE` / a client-side close). Keeps the greeting cache.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.npc = None;
|
||||
@@ -163,6 +188,7 @@ fn feed_gossip(
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
quest_log: Res<QuestLog>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
mut last: Local<Option<GossipMenu>>,
|
||||
mut last_name: Local<Option<String>>,
|
||||
mut last_npc: Local<Option<u64>>,
|
||||
@@ -171,10 +197,16 @@ fn feed_gossip(
|
||||
return;
|
||||
};
|
||||
let mut fresh = snapshot(&state, &quest_log);
|
||||
// Expand the greeting's chat-text macros ($N/$B/$G) client-side, as the real client does.
|
||||
// Expand the greeting's chat-text macros ($N/$B/$G/$<n>w) client-side, as the real client does.
|
||||
if let Some(greeting) = fresh.as_mut().and_then(|m| m.greeting.as_mut()) {
|
||||
let (name, gender) = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
*greeting = crate::npc_text::substitute(greeting, &name, gender);
|
||||
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
*greeting = crate::npc_text::substitute(
|
||||
greeting,
|
||||
&crate::npc_text::MacroContext {
|
||||
subject: player.as_ref(),
|
||||
states: &states,
|
||||
},
|
||||
);
|
||||
}
|
||||
// The gossip NPC's name resolves through the NameCache (ask-once — the merchant feed's pattern),
|
||||
// delivered as arg1 of GOSSIP_SHOW; `None`/empty while the query is in flight, and the diff below
|
||||
@@ -307,12 +339,50 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_once_greeting_cache_survives_clear() {
|
||||
fn ask_once_text_cache_survives_clear() {
|
||||
let mut state = GossipState::default();
|
||||
state.remember_greeting(50, "Greetings $N".into());
|
||||
state.remember_record(
|
||||
50,
|
||||
vec![NpcTextBlock {
|
||||
probability: 0.0,
|
||||
male: "Greetings $N".into(),
|
||||
female: String::new(),
|
||||
}],
|
||||
);
|
||||
state.clear();
|
||||
// The open menu is gone but the cached greeting is still servable (a revisit won't re-query).
|
||||
assert_eq!(state.cached_greeting(50).as_deref(), Some("Greetings $N"));
|
||||
// The open menu is gone but the cached RECORD is still servable (a revisit won't re-query
|
||||
// — it re-draws from these blocks).
|
||||
assert!(state.cached_record(50).is_some());
|
||||
assert_eq!(
|
||||
state.draw_greeting(50, 0).as_deref(),
|
||||
Some("Greetings $N"),
|
||||
"the all-zero record draws block 0 on every roll"
|
||||
);
|
||||
assert!(state.npc.is_none());
|
||||
}
|
||||
|
||||
/// The record is cached, the GREETING is not: a female NPC and a male NPC sharing one `text_id`
|
||||
/// must read their own columns. Caching a resolved string (as this did) froze whichever one
|
||||
/// asked first for the rest of the session.
|
||||
#[test]
|
||||
fn one_cached_record_greets_each_gender_from_its_own_column() {
|
||||
let mut state = GossipState::default();
|
||||
state.remember_record(
|
||||
77,
|
||||
vec![NpcTextBlock {
|
||||
probability: 0.0,
|
||||
male: "Well met, friend.".into(),
|
||||
female: "Well met, sister.".into(),
|
||||
}],
|
||||
);
|
||||
assert_eq!(
|
||||
state.draw_greeting(77, 0).as_deref(),
|
||||
Some("Well met, friend.")
|
||||
);
|
||||
assert_eq!(
|
||||
state.draw_greeting(77, 1).as_deref(),
|
||||
Some("Well met, sister.")
|
||||
);
|
||||
assert_eq!(state.draw_greeting(78, 0), None, "record not yet arrived");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,9 +198,9 @@ fn snapshot(
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
quest_log: &QuestLog,
|
||||
player: (&str, u8),
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
) -> Option<QuestState> {
|
||||
let sub = |t: &str| crate::npc_text::substitute(t, player.0, player.1);
|
||||
let sub = |t: &str| crate::npc_text::substitute(t, macros);
|
||||
Some(match giver.view.as_ref()? {
|
||||
QuestView::Greeting(l) => {
|
||||
let mut active_titles = Vec::new();
|
||||
@@ -274,6 +274,7 @@ fn feed_quest(
|
||||
commands: Res<NetCommands>,
|
||||
mut names: ResMut<NameCache>,
|
||||
quest_log: Res<QuestLog>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
mut last: Local<Option<QuestState>>,
|
||||
mut last_name: Local<Option<String>>,
|
||||
@@ -287,15 +288,17 @@ fn feed_quest(
|
||||
if giver.take_completed_fanfare() {
|
||||
script.queue_sound_kit("QUESTCOMPLETED");
|
||||
}
|
||||
let (player_name, player_gender) =
|
||||
crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let fresh = snapshot(
|
||||
&giver,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
&quest_log,
|
||||
(&player_name, player_gender),
|
||||
&crate::npc_text::MacroContext {
|
||||
subject: player.as_ref(),
|
||||
states: &states,
|
||||
},
|
||||
);
|
||||
let npc_name = giver
|
||||
.npc
|
||||
@@ -476,8 +479,24 @@ mod tests {
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let quest_log = QuestLog::default();
|
||||
let snap =
|
||||
snapshot(&giver, &mut items, None, &commands, &quest_log, ("Tri", 0)).expect("open");
|
||||
let player = crate::npc_text::Subject {
|
||||
name: "Tri".into(),
|
||||
race: 1,
|
||||
class: 1,
|
||||
gender: 0,
|
||||
};
|
||||
let snap = snapshot(
|
||||
&giver,
|
||||
&mut items,
|
||||
None,
|
||||
&commands,
|
||||
&quest_log,
|
||||
&crate::npc_text::MacroContext {
|
||||
subject: Some(&player),
|
||||
states: &crate::world_state::WorldStates::default(),
|
||||
},
|
||||
)
|
||||
.expect("open");
|
||||
assert_eq!(snap.panel, QuestPanel::Detail);
|
||||
assert_eq!(snap.title, "A Threat Within");
|
||||
// The wire text's $N substituted through the shared expander (crate::npc_text).
|
||||
@@ -512,7 +531,18 @@ mod tests {
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let quest_log = QuestLog::default();
|
||||
let snap = snapshot(&giver, &mut items, None, &commands, &quest_log, ("", 0)).unwrap();
|
||||
let snap = snapshot(
|
||||
&giver,
|
||||
&mut items,
|
||||
None,
|
||||
&commands,
|
||||
&quest_log,
|
||||
&crate::npc_text::MacroContext {
|
||||
subject: None,
|
||||
states: &crate::world_state::WorldStates::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(snap.panel, QuestPanel::Progress);
|
||||
assert_eq!(snap.required.len(), 1);
|
||||
assert_eq!(snap.required_money, 500);
|
||||
|
||||
@@ -322,7 +322,7 @@ fn build_detail(
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
player: (&str, u8),
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
) -> QuestLogDetail {
|
||||
let (required_money, reward_money) = money_split(template.money);
|
||||
let rewards = template
|
||||
@@ -339,10 +339,10 @@ fn build_detail(
|
||||
.collect();
|
||||
|
||||
QuestLogDetail {
|
||||
// The wire delivers quest text with its chat macros ($N/$B/$G) un-expanded; the client
|
||||
// substitutes (crate::npc_text — the 0109 look fix's shared mechanism).
|
||||
description: crate::npc_text::substitute(&template.details, player.0, player.1),
|
||||
objectives_text: crate::npc_text::substitute(&template.objectives_text, player.0, player.1),
|
||||
// The wire delivers quest text with its chat macros ($N/$B/$G/$<n>w) un-expanded; the
|
||||
// client substitutes (crate::npc_text — the 0109 look fix's shared mechanism).
|
||||
description: crate::npc_text::substitute(&template.details, macros),
|
||||
objectives_text: crate::npc_text::substitute(&template.objectives_text, macros),
|
||||
required_money,
|
||||
reward_money,
|
||||
choices,
|
||||
@@ -396,6 +396,7 @@ fn feed_quest_log(
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
commands: Res<NetCommands>,
|
||||
header_names: Option<Res<QuestHeaderNamesRes>>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
mut last: Local<QuestLogState>,
|
||||
mut prior_quest_ids: Local<Option<HashSet<u32>>>,
|
||||
) {
|
||||
@@ -526,8 +527,11 @@ fn feed_quest_log(
|
||||
quest_log.entry_slots = entry_slots;
|
||||
quest_log.header_keys = header_keys;
|
||||
|
||||
let (player_name, player_gender) =
|
||||
crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let macros = crate::npc_text::MacroContext {
|
||||
subject: player.as_ref(),
|
||||
states: &states,
|
||||
};
|
||||
let sel = script.quest_log_selection() as usize;
|
||||
let detail = sel
|
||||
.checked_sub(1)
|
||||
@@ -535,15 +539,9 @@ fn feed_quest_log(
|
||||
.filter(|e| !e.is_header)
|
||||
.map(|e| e.quest_id)
|
||||
.and_then(|quest_id| {
|
||||
quest_log.template(quest_id, &commands).map(|t| {
|
||||
build_detail(
|
||||
t,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
(&player_name, player_gender),
|
||||
)
|
||||
})
|
||||
quest_log
|
||||
.template(quest_id, &commands)
|
||||
.map(|t| build_detail(t, &mut items, icons.as_deref(), &commands, ¯os))
|
||||
});
|
||||
|
||||
let fresh = QuestLogState {
|
||||
|
||||
@@ -39,23 +39,41 @@ impl Plugin for UiTooltipPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a view needs beyond the spell catalogs — the player-dependent halves of the line
|
||||
/// law: the worn set (law §3.6's equipped-item test), the bags (§3.8's reagent possession, and
|
||||
/// the item-name cache the reagent names come from), the current form, and the bind point `$z`
|
||||
/// substitutes against.
|
||||
struct ViewCtx<'a> {
|
||||
home_area: Option<&'a str>,
|
||||
form: u8,
|
||||
store: Option<&'a ObjectStore>,
|
||||
items: &'a mut Items,
|
||||
commands: &'a NetCommands,
|
||||
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
|
||||
}
|
||||
|
||||
/// Build one spell's tooltip view (decision 0274 P2) — the verified spell line law's inputs,
|
||||
/// every string resolved here where the catalogs live: the cost cell (power-typed; rage prints
|
||||
/// wire-cost ÷ 10; "Next melee" for on-next-swing attributes), the range cell ("N yd range",
|
||||
/// "N-M yd range" when the row's min is nonzero — the law's `"%d-%d"` fork; "Melee Range" for
|
||||
/// the melee family — INTERIM text, the proper source is SpellRange.dbc's own display-name
|
||||
/// column), the cast cell ("Instant"/"Instant cast"/"N sec cast"; None = passive, which omits
|
||||
/// the whole line), the cooldown cell (`max(RecoveryTime, CategoryRecoveryTime)` — law §3.4),
|
||||
/// the required-form line (law §3.6: the `Stances` mask's form names, met/unmet against `form`),
|
||||
/// and the $-substituted description/aura-description (byte-exact formulas,
|
||||
/// column), the cast cell ("Instant"/"Instant cast"/"N sec cast"; None = the law's passive gate,
|
||||
/// which omits the whole line), the cooldown cell (`max(RecoveryTime, CategoryRecoveryTime)` —
|
||||
/// law §3.4), the required-item and required-form lines (law §3.6), the reagents line (law
|
||||
/// §3.8), and the $-substituted description/aura-description (byte-exact formulas,
|
||||
/// `benilla_formats::substitute`).
|
||||
///
|
||||
/// Line TEXT is composed in English here, as every cell in this builder is; the reference reads
|
||||
/// its own GlobalStrings templates ("Reagents: ", "Requires %s"). That is this builder's standing
|
||||
/// INTERIM shape, not a new debt introduced by these lines.
|
||||
fn spell_tooltip_view(
|
||||
spell_id: u32,
|
||||
spells: &Spells,
|
||||
home_area: Option<&str>,
|
||||
form: u8,
|
||||
vctx: &mut ViewCtx,
|
||||
) -> Option<benilla_ui::script::SpellTooltipView> {
|
||||
let d = spells.catalog.get(spell_id)?;
|
||||
let home_area = vctx.home_area;
|
||||
let form = vctx.form;
|
||||
let ctx = benilla_formats::TokenContext {
|
||||
durations: &spells.durations,
|
||||
radii: &spells.radii,
|
||||
@@ -90,7 +108,9 @@ fn spell_tooltip_view(
|
||||
None
|
||||
}
|
||||
});
|
||||
let cast_time = if d.passive {
|
||||
// Law §3.4's own gate — wider than the spellbook's `passive`: a TRADE_SKILL or ATTACK
|
||||
// Effect[0] omits the line too ([`SpellDisplay::tooltip_omits_cast_line`]).
|
||||
let cast_time = if d.tooltip_omits_cast_line() {
|
||||
None
|
||||
} else {
|
||||
let base = spells
|
||||
@@ -132,15 +152,72 @@ fn spell_tooltip_view(
|
||||
})
|
||||
.flatten();
|
||||
let form_met = form != 0 && d.stances & (1u32 << (u32::from(form) - 1)) != 0;
|
||||
// The equipped-item-class half of law §3.6 — "Requires Wands" on the wand Shoot, and "Requires
|
||||
// Melee Weapon" on Parry. A mask with several bits set is NOT a reason to print nothing (what we
|
||||
// used to do): the reference names the whole mask through ItemSubClassMask.dbc first, and only
|
||||
// falls back to comma-joining the individual subclasses. Law §3-EQUIPITEM, in the catalog.
|
||||
// §3-EQUIPITEM's three entry gates, all of which skip the line: Targets bit 0x10 set, class < 0,
|
||||
// or an empty mask.
|
||||
let requires_item = (d.targets & TARGET_ITEM == 0 && d.equipped_item_class >= 0)
|
||||
.then(|| {
|
||||
vctx.sub_classes?
|
||||
.requirement_name(d.equipped_item_class as u32, d.equipped_item_subclass_mask)
|
||||
})
|
||||
.flatten()
|
||||
.map(|name| format!("Requires {name}"));
|
||||
// The chance-to-X line (law line 10, §3-CHANCE): `Effect[0]` picks which of the player's four
|
||||
// avoidance/crit percentages to print, and — except for ATTACK, which bypasses the gate — the
|
||||
// spell must be passive. The percentages are already percents on the wire.
|
||||
let chance = chance_line(d, vctx.store);
|
||||
let item_met = vctx.store.is_none_or(|s| {
|
||||
crate::ui_action::usable::equipped_item_fits(d, s, vctx.items, vctx.commands)
|
||||
});
|
||||
// Reagents (law §3.8): the named slots, `count > 1` suffixed, a slot the player is short of
|
||||
// wrapped in the builder's inline red. A reagent whose item template hasn't streamed yet is
|
||||
// simply absent from this snapshot — `feed_spell_tooltips` re-pushes when it lands, which is
|
||||
// our shape of the ref's own query-then-redisplay callback.
|
||||
let reagents = {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for (entry, count) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
|
||||
let Some(name) = vctx
|
||||
.items
|
||||
.template(entry, 0, vctx.commands)
|
||||
.map(|t| t.name.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let text = if count > 1 {
|
||||
format!("{name} ({count})")
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let short = vctx
|
||||
.store
|
||||
.is_some_and(|s| crate::ui_items::count_of(&s.0, vctx.items, entry) < count);
|
||||
parts.push(if short {
|
||||
format!("|cffff2020{text}|r")
|
||||
} else {
|
||||
text
|
||||
});
|
||||
}
|
||||
(!parts.is_empty()).then(|| format!("Reagents: {}", parts.join(", ")))
|
||||
};
|
||||
Some(benilla_ui::script::SpellTooltipView {
|
||||
name: d.name.clone(),
|
||||
rank: d.rank.clone(),
|
||||
// The aura variant's right column (law §3-BUFF) — gated inside the catalog by
|
||||
// SpellDispelType.dbc's own `[+0x28]`, so Stealth-class auras hand back None.
|
||||
dispel_type: spells.catalog.dispel_name(d).map(str::to_string),
|
||||
cost,
|
||||
range,
|
||||
cast_time,
|
||||
cooldown,
|
||||
requires_item,
|
||||
item_met,
|
||||
requires_form,
|
||||
form_met,
|
||||
chance,
|
||||
reagents,
|
||||
description: d
|
||||
.description
|
||||
.as_deref()
|
||||
@@ -154,6 +231,36 @@ fn spell_tooltip_view(
|
||||
})
|
||||
}
|
||||
|
||||
/// `Targets` bit `0x10` — set ⇒ the equipped-item requirement line is skipped whatever the class
|
||||
/// and mask say (§3-EQUIPITEM's first gate; the bit test is verified, the `TARGET_FLAG_ITEM` name
|
||||
/// wow-re flags as inferred).
|
||||
const TARGET_ITEM: u32 = 0x10;
|
||||
|
||||
/// `SPELL_EFFECT_DODGE` / `_PARRY` / `_BLOCK` / `_ATTACK` — the four `Effect[0]` values that select
|
||||
/// a chance-to-X line (law §3-CHANCE's jump table).
|
||||
const EFFECT_DODGE: u32 = 20;
|
||||
const EFFECT_PARRY: u32 = 22;
|
||||
const EFFECT_BLOCK: u32 = 23;
|
||||
const EFFECT_ATTACK: u32 = 78;
|
||||
|
||||
/// The chance-to-X line (law line 10 / §3-CHANCE) — `None` when the spell names none of the four
|
||||
/// effects, when the gate rejects it, or before the player's descriptor has streamed.
|
||||
///
|
||||
/// The predicate is the reference's, and its asymmetry is the point: **ATTACK bypasses the passive
|
||||
/// gate** while dodge/parry/block require it. That is why Attack — whose `Attributes` carry `0x10`,
|
||||
/// not the passive `0x40` — still shows a crit line, which is exactly the missing line reported.
|
||||
fn chance_line(d: &benilla_formats::SpellDisplay, store: Option<&ObjectStore>) -> Option<String> {
|
||||
let (label, percentage) = match d.effect_1 {
|
||||
EFFECT_ATTACK => ("crit", store?.0.player_crit_percentage()?),
|
||||
EFFECT_DODGE if d.passive => ("dodge", store?.0.player_dodge_percentage()?),
|
||||
EFFECT_PARRY if d.passive => ("parry", store?.0.player_parry_percentage()?),
|
||||
EFFECT_BLOCK if d.passive => ("block", store?.0.player_block_percentage()?),
|
||||
_ => return None,
|
||||
};
|
||||
// The reference's `%.2f%%` lives in its GlobalStrings, not the binary — this is the same shape.
|
||||
Some(format!("{percentage:.2}% chance to {label}"))
|
||||
}
|
||||
|
||||
/// The `%.3g`-style terse seconds (1.5 → "1.5", 2.0 → "2") — the SPELL_CAST_TIME/RECAST shape.
|
||||
fn trim_secs(v: f64) -> String {
|
||||
if (v - v.round()).abs() < 1e-9 {
|
||||
@@ -163,6 +270,27 @@ fn trim_secs(v: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The push loop's change detectors — everything a view SNAPSHOTS at build time. A change to any
|
||||
/// of them means every pushed view is stale, so the loop re-pushes the lot (the reference simply
|
||||
/// re-runs its builder on every hover, so a stale snapshot is a benilla-only failure mode).
|
||||
#[derive(Default)]
|
||||
struct SpellFeedMemory {
|
||||
/// Spell ids whose view has been pushed at least once.
|
||||
pushed: std::collections::HashSet<u32>,
|
||||
/// The bind point `$z` substitutes against.
|
||||
home: Option<String>,
|
||||
/// The current shapeshift form (law §3.6's stance line, white/red).
|
||||
form: Option<u8>,
|
||||
/// The 19 worn-slot guids (law §3.6's item line, white/red).
|
||||
worn: Option<[u64; 19]>,
|
||||
/// The player's block/dodge/parry/crit percentages, as raw bit patterns so the diff needs no
|
||||
/// float comparison (law line 10's printed value — it moves with gear, buffs and talents).
|
||||
avoidance: Option<[u32; 4]>,
|
||||
/// Per reagent entry currently on show: `(owned count, item name resolved)` — law §3.8's
|
||||
/// inline red plus the ask-once template landing.
|
||||
reagents: std::collections::BTreeMap<u32, (u32, bool)>,
|
||||
}
|
||||
|
||||
/// The spell-tooltip store's push half: every spell the UI can HOVER is owed its view at
|
||||
/// arrival — the known book (spellbook/bar), the class's talent rank spells (`SetTalent`'s
|
||||
/// display + next-rank reads), and the live aura spells (`SetPlayerBuff`) — so a first hover
|
||||
@@ -180,9 +308,10 @@ fn feed_spell_tooltips(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
home_bind: Option<Res<crate::net::HomeBind>>,
|
||||
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
|
||||
mut pushed: Local<std::collections::HashSet<u32>>,
|
||||
mut last_home: Local<Option<String>>,
|
||||
mut last_form: Local<Option<u8>>,
|
||||
mut items: ResMut<Items>,
|
||||
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<SpellFeedMemory>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -197,7 +326,7 @@ fn feed_spell_tooltips(
|
||||
.spells
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| !pushed.contains(s)),
|
||||
.filter(|s| !memory.pushed.contains(s)),
|
||||
);
|
||||
}
|
||||
// The talent window's hoverables: every rank spell of the class's pages (a talent tooltip
|
||||
@@ -212,14 +341,14 @@ fn feed_spell_tooltips(
|
||||
t.ranks
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| *s != 0 && !pushed.contains(s)),
|
||||
.filter(|s| *s != 0 && !memory.pushed.contains(s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The buff bar's hoverables: the live aura spells, at arrival.
|
||||
if let Some(auras) = auras.as_deref() {
|
||||
wanted.extend(auras.spell_ids().filter(|s| !pushed.contains(s)));
|
||||
wanted.extend(auras.spell_ids().filter(|s| !memory.pushed.contains(s)));
|
||||
}
|
||||
// The minimap tracking icon's hover (SetTrackingSpell): the tracking aura never enters the
|
||||
// display cache above (the rebuild's tracking-effect exclusion, `ui_aura`), so pre-feed from
|
||||
@@ -231,7 +360,7 @@ fn feed_spell_tooltips(
|
||||
.0
|
||||
.unit_auras()
|
||||
.map(|a| a.spell_id)
|
||||
.filter(|s| !pushed.contains(s)),
|
||||
.filter(|s| !memory.pushed.contains(s)),
|
||||
);
|
||||
}
|
||||
// The target frame's aura rows (SetUnitBuff/SetUnitDebuff): the target's live aura spells,
|
||||
@@ -242,7 +371,7 @@ fn feed_spell_tooltips(
|
||||
.0
|
||||
.unit_auras()
|
||||
.map(|a| a.spell_id)
|
||||
.filter(|s| !pushed.contains(s)),
|
||||
.filter(|s| !memory.pushed.contains(s)),
|
||||
);
|
||||
}
|
||||
let home_area: Option<String> = home_bind
|
||||
@@ -251,9 +380,9 @@ fn feed_spell_tooltips(
|
||||
.and_then(|id| area_names.as_deref()?.0.resolve(id as i32))
|
||||
.map(str::to_string);
|
||||
// A bind-point change re-substitutes every pushed view ($z — Astral Recall's shape).
|
||||
if *last_home != home_area {
|
||||
*last_home = home_area.clone();
|
||||
wanted.extend(pushed.drain());
|
||||
if memory.home != home_area {
|
||||
memory.home = home_area.clone();
|
||||
wanted.extend(memory.pushed.drain());
|
||||
}
|
||||
// A stance/form change re-pushes too: the required-form line's white/red tracks the CURRENT
|
||||
// form (law §3.6), and the views are static snapshots until re-pushed.
|
||||
@@ -261,14 +390,77 @@ fn feed_spell_tooltips(
|
||||
.single()
|
||||
.map(|s| s.0.unit_shapeshift_form())
|
||||
.unwrap_or(0);
|
||||
if *last_form != Some(form) {
|
||||
*last_form = Some(form);
|
||||
wanted.extend(pushed.drain());
|
||||
if memory.form != Some(form) {
|
||||
memory.form = Some(form);
|
||||
wanted.extend(memory.pushed.drain());
|
||||
}
|
||||
let self_store = self_q.single().ok();
|
||||
// …and so do the two BAG-dependent halves, which the views likewise snapshot: the worn set
|
||||
// (law §3.6's item line flips white/red on a weapon swap) and the owned counts of the
|
||||
// reagents currently on show (law §3.8's inline red, plus the item names themselves landing
|
||||
// from the ask-once template cache — the ref's query-then-redisplay callback in our shape).
|
||||
// Both signatures are tiny: 19 slot guids, and one count per DISTINCT reagent in play.
|
||||
let worn =
|
||||
self_store.map(|s| std::array::from_fn(|i| s.0.player_inv_slot(i as u8).unwrap_or(0)));
|
||||
if memory.worn != worn {
|
||||
memory.worn = worn;
|
||||
wanted.extend(memory.pushed.drain());
|
||||
}
|
||||
// …and so does the chance-to-X line's percentage (law line 10), which the views snapshot the
|
||||
// same way: a weapon swap, a buff or a talent moves it.
|
||||
let avoidance = self_store.map(|s| {
|
||||
[
|
||||
s.0.player_block_percentage(),
|
||||
s.0.player_dodge_percentage(),
|
||||
s.0.player_parry_percentage(),
|
||||
s.0.player_crit_percentage(),
|
||||
]
|
||||
.map(|v| v.unwrap_or(0.0).to_bits())
|
||||
});
|
||||
if memory.avoidance != avoidance {
|
||||
memory.avoidance = avoidance;
|
||||
wanted.extend(memory.pushed.drain());
|
||||
}
|
||||
let watched: Vec<u32> = memory.reagents.keys().copied().collect();
|
||||
let reagent_state: std::collections::BTreeMap<u32, (u32, bool)> = watched
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let named = items.template(entry, 0, &commands).is_some();
|
||||
let owned = self_store.map_or(0, |s| crate::ui_items::count_of(&s.0, &items, entry));
|
||||
(entry, (owned, named))
|
||||
})
|
||||
.collect();
|
||||
if memory.reagents != reagent_state {
|
||||
memory.reagents = reagent_state;
|
||||
wanted.extend(memory.pushed.drain());
|
||||
}
|
||||
let mut vctx = ViewCtx {
|
||||
home_area: home_area.as_deref(),
|
||||
form,
|
||||
store: self_store,
|
||||
items: &mut items,
|
||||
commands: &commands,
|
||||
sub_classes: sub_classes.as_deref().map(|c| &c.0),
|
||||
};
|
||||
for id in wanted {
|
||||
if let Some(view) = spell_tooltip_view(id, spells, home_area.as_deref(), form) {
|
||||
if let Some(view) = spell_tooltip_view(id, spells, &mut vctx) {
|
||||
// Register this spell's reagents in the watch set, seeded with the state the view was
|
||||
// just built against — so the next recompute re-pushes on a REAL change only.
|
||||
if let Some(d) = spells.catalog.get(id) {
|
||||
for (entry, _) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
|
||||
if let std::collections::btree_map::Entry::Vacant(slot) =
|
||||
memory.reagents.entry(entry)
|
||||
{
|
||||
let named = vctx.items.template(entry, 0, vctx.commands).is_some();
|
||||
let owned = vctx
|
||||
.store
|
||||
.map_or(0, |s| crate::ui_items::count_of(&s.0, vctx.items, entry));
|
||||
slot.insert((owned, named));
|
||||
}
|
||||
}
|
||||
}
|
||||
script.set_spell_tooltip(id, view);
|
||||
pushed.insert(id);
|
||||
memory.pushed.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,6 +632,57 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::ui_action::Spells;
|
||||
|
||||
/// A view context with no player state — the DBC-only half of the builder (the shape the
|
||||
/// pre-0616 test used). `sub_classes` is threaded in by the caller when the case needs it.
|
||||
struct TestCtx {
|
||||
items: Items,
|
||||
commands: NetCommands,
|
||||
_rx: crossbeam_channel::Receiver<crate::net::ClientCommand>,
|
||||
}
|
||||
|
||||
impl TestCtx {
|
||||
fn new() -> Self {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
Self {
|
||||
items: Items::default(),
|
||||
commands: NetCommands(tx),
|
||||
_rx: rx,
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx<'a>(
|
||||
&'a mut self,
|
||||
form: u8,
|
||||
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
|
||||
) -> ViewCtx<'a> {
|
||||
self.ctx_for(form, sub_classes, None)
|
||||
}
|
||||
|
||||
fn ctx_for<'a>(
|
||||
&'a mut self,
|
||||
form: u8,
|
||||
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
|
||||
store: Option<&'a ObjectStore>,
|
||||
) -> ViewCtx<'a> {
|
||||
ViewCtx {
|
||||
home_area: None,
|
||||
form,
|
||||
store,
|
||||
items: &mut self.items,
|
||||
commands: &self.commands,
|
||||
sub_classes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A player descriptor with nothing worn and nothing in the bags — the "owns none of it"
|
||||
/// pole of both possession tests.
|
||||
fn empty_player() -> ObjectStore {
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
|
||||
22u16, 100u32,
|
||||
)]))
|
||||
}
|
||||
|
||||
/// The full spell-tooltip view off the REAL 5875 data — Fireball rank 1 (133) end to end:
|
||||
/// the pinned columns (description 138, cast index 18→1500 ms, duration 30), the token
|
||||
/// engine's byte formulas, and the view's verified cell shapes. Skips without client data.
|
||||
@@ -462,7 +705,8 @@ mod tests {
|
||||
.expect("SpellDuration.dbc"),
|
||||
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
|
||||
};
|
||||
let v = spell_tooltip_view(133, &spells, None, 0).expect("Fireball view");
|
||||
let mut t = TestCtx::new();
|
||||
let v = spell_tooltip_view(133, &spells, &mut t.ctx(0, None)).expect("Fireball view");
|
||||
assert_eq!(v.name, "Fireball");
|
||||
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
|
||||
assert_eq!(v.cost.as_deref(), Some("30 Mana"));
|
||||
@@ -492,7 +736,7 @@ mod tests {
|
||||
// Charge rank 1 (100) — the director's reference shot, end to end: the dual-bound range
|
||||
// row (SpellRange 95 = {8, 25}), the CATEGORY-column cooldown (recoveryTime 0 /
|
||||
// categoryRecoveryTime 15000), and the Stances-mask form line (0x10000 → form 17).
|
||||
let v = spell_tooltip_view(100, &spells, None, 0).expect("Charge view");
|
||||
let v = spell_tooltip_view(100, &spells, &mut t.ctx(0, None)).expect("Charge view");
|
||||
assert_eq!(v.name, "Charge");
|
||||
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
|
||||
assert_eq!(v.cost, None, "Charge costs nothing (it generates rage)");
|
||||
@@ -505,7 +749,101 @@ mod tests {
|
||||
v.description,
|
||||
"Charge an enemy, generate 9 rage, and stun it for 1 sec. Cannot be used in combat."
|
||||
);
|
||||
let v = spell_tooltip_view(100, &spells, None, 17).expect("Charge view");
|
||||
let v = spell_tooltip_view(100, &spells, &mut t.ctx(17, None)).expect("Charge view");
|
||||
assert!(v.form_met, "form 17 = Battle Stance satisfies the mask");
|
||||
}
|
||||
|
||||
/// The three lines the 2026-07-25 reference captures pinned (decision 0620), each against the
|
||||
/// REAL 5875 data. Skips without client data.
|
||||
#[test]
|
||||
fn the_pinned_c6_lines_on_real_data() {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let spells = Spells {
|
||||
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
|
||||
forms: benilla_formats::load_shapeshift_forms(&mut chain)
|
||||
.expect("SpellShapeshiftForm.dbc"),
|
||||
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
|
||||
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
|
||||
.expect("SpellCastTimes.dbc"),
|
||||
durations: benilla_formats::load_spell_durations(&mut chain)
|
||||
.expect("SpellDuration.dbc"),
|
||||
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
|
||||
};
|
||||
let subs = benilla_formats::load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc");
|
||||
let mut t = TestCtx::new();
|
||||
let store = empty_player();
|
||||
|
||||
// 1 · The wand Shoot (5019, class 2 / submask bit 19) — "Requires Wands", red with no
|
||||
// wand worn. The same row feeds the cast-fail line's SINGULAR "Wand" (see `cast_fail`).
|
||||
assert_eq!(subs.name(2, 19), Some("Wands"), "the verbose plural");
|
||||
assert_eq!(subs.display_name(2, 19), Some("Wand"), "the singular");
|
||||
let v = spell_tooltip_view(5019, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
|
||||
.expect("Shoot view");
|
||||
assert_eq!(v.requires_item.as_deref(), Some("Requires Wands"));
|
||||
assert!(!v.item_met, "nothing worn satisfies class 2 / bit 19 → red");
|
||||
// A multi-bit mask is named by ItemSubClassMask.dbc, not skipped (law §3-EQUIPITEM — we
|
||||
// printed nothing here until `0x6e2380` was carved): Parry's 0x2a5f3 is exactly the eleven
|
||||
// melee subclasses, which that table names in one word.
|
||||
let parry = spells.catalog.get(3127).expect("Parry 3127");
|
||||
assert!(parry.equipped_item_subclass_mask.count_ones() > 1);
|
||||
let v = spell_tooltip_view(3127, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
|
||||
.expect("Parry view");
|
||||
assert_eq!(v.requires_item.as_deref(), Some("Requires Melee Weapon"));
|
||||
|
||||
// 2 · Attack (6603) — `Effect[0] == 78` omits the cast|cooldown line WHOLE, even though
|
||||
// `Attributes & 0x40` is clear. Before the §3.4 gate widened, this read "Instant".
|
||||
let d = spells.catalog.get(6603).expect("Attack 6603");
|
||||
assert_eq!(d.effect_1, 78, "SPELL_EFFECT_ATTACK");
|
||||
assert!(!d.passive, "6603 carries Attributes 0x10, not 0x40");
|
||||
let v = spell_tooltip_view(6603, &spells, &mut t.ctx(0, None)).expect("Attack view");
|
||||
assert_eq!(v.cast_time, None, "the law's Effect[0] gate");
|
||||
// …and the chance line the same Effect[0] selects (law line 10 / §3-CHANCE). ATTACK
|
||||
// BYPASSES the passive gate, which is the whole reason a non-passive Attack shows a crit
|
||||
// line at all. No descriptor = no line; the percentages are already percents on the wire.
|
||||
assert_eq!(v.chance, None, "no player streamed yet");
|
||||
let rated = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(22u16, 100u32),
|
||||
(1109u16, 2.62f32.to_bits()), // PLAYER_CRIT_PERCENTAGE
|
||||
(1107u16, 5.5f32.to_bits()), // PLAYER_DODGE_PERCENTAGE
|
||||
]));
|
||||
let v = spell_tooltip_view(6603, &spells, &mut t.ctx_for(0, None, Some(&rated)))
|
||||
.expect("Attack view");
|
||||
assert_eq!(v.chance.as_deref(), Some("2.62% chance to crit"));
|
||||
// Dodge (81) is passive and reads its own field.
|
||||
let dodge = spells.catalog.get(81).expect("Dodge 81");
|
||||
assert_eq!(dodge.effect_1, 20, "SPELL_EFFECT_DODGE");
|
||||
assert!(dodge.passive, "81 carries Attributes 0x40");
|
||||
let v = spell_tooltip_view(81, &spells, &mut t.ctx_for(0, None, Some(&rated)))
|
||||
.expect("Dodge view");
|
||||
assert_eq!(v.chance.as_deref(), Some("5.50% chance to dodge"));
|
||||
// A spell naming none of the four effects has no line at all.
|
||||
let v = spell_tooltip_view(133, &spells, &mut t.ctx_for(0, None, Some(&rated)))
|
||||
.expect("Fireball view");
|
||||
assert_eq!(v.chance, None);
|
||||
|
||||
// 3 · Slow Fall (130) — "Reagents: Light Feather", inline-red while unowned (no store =
|
||||
// owns nothing). The name rides the ask-once item cache, seeded here as the server would.
|
||||
let d = spells.catalog.get(130).expect("Slow Fall 130");
|
||||
assert_eq!(d.reagents[0], (17056, 1), "Light Feather ×1");
|
||||
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
|
||||
.expect("Slow Fall view");
|
||||
assert_eq!(
|
||||
v.reagents, None,
|
||||
"the template hasn't landed: the line waits rather than printing an id"
|
||||
);
|
||||
t.items
|
||||
.insert_template(17056, Some(crate::items::test_template("Light Feather")));
|
||||
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
|
||||
.expect("Slow Fall view");
|
||||
assert_eq!(
|
||||
v.reagents.as_deref(),
|
||||
Some("Reagents: |cffff2020Light Feather|r"),
|
||||
"count 1 prints no (N); unowned wraps in the builder's inline red"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,8 +242,9 @@ fn fire_unit_combat(
|
||||
|
||||
/// The 1.12 race id → (localized display, `raceFile` token) — `UnitRace`'s two returns. The file
|
||||
/// token is the client's internal name (undead = `"Scourge"`, the space dropped from `"NightElf"`),
|
||||
/// the same vocabulary the 2D portrait stand-in files use (`portrait::temporary_portrait`).
|
||||
fn race_names(race: u8) -> Option<(&'static str, &'static str)> {
|
||||
/// the same vocabulary the 2D portrait stand-in files use (`portrait::temporary_portrait`). The
|
||||
/// display column is also what `$R`/`$r` expand to ([`crate::npc_text`]) — one table, both readers.
|
||||
pub(crate) fn race_names(race: u8) -> Option<(&'static str, &'static str)> {
|
||||
Some(match race {
|
||||
1 => ("Human", "Human"),
|
||||
2 => ("Orc", "Orc"),
|
||||
@@ -260,7 +261,7 @@ fn race_names(race: u8) -> Option<(&'static str, &'static str)> {
|
||||
/// The 1.12 class id → (localized display, `classFileName`) — `UnitClass`'s two returns. The file
|
||||
/// name is uppercase (the ref's `strupper(classFileName)` tooltip lookups index GlobalStrings keys
|
||||
/// like `WARRIOR_STRENGTH_TOOLTIP` directly with it).
|
||||
fn class_names(class: u8) -> Option<(&'static str, &'static str)> {
|
||||
pub(crate) fn class_names(class: u8) -> Option<(&'static str, &'static str)> {
|
||||
Some(match class {
|
||||
1 => ("Warrior", "WARRIOR"),
|
||||
2 => ("Paladin", "PALADIN"),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! The world-state table — the server's key/value scoreboard, fed by `SMSG_INIT_WORLD_STATES` and
|
||||
//! `SMSG_UPDATE_WORLD_STATE` ([`benilla_protocol::messages::world_state`] for both wires).
|
||||
//!
|
||||
//! Its one reader today is the NPC-text expander's `$<n>w` / `$<n>e` tokens
|
||||
//! ([`crate::npc_text`]): the reference renders a hit as `%d` and a **miss as `"0"`**, so every
|
||||
//! such token printed `"0"` until this landed — faithful while the table was empty, wrong the
|
||||
//! moment a zone sent real states. A battleground/PvP scoreboard is the other consumer, later.
|
||||
//!
|
||||
//! Shape is the reference's, not a convenience: it keeps an open hash keyed by the **raw wire
|
||||
//! dword** (`[0xb71ec8]`, bucket `mask & key`, value at node `+0x18`, miss → `0` at `0x4c5867`).
|
||||
//! Raw matters because `$<n>e` reads the table at the *negated* key — `$2077e` looks up
|
||||
//! `0xFFFFF7E3`, which only resolves if nothing re-interpreted the id on the way in.
|
||||
//!
|
||||
//! **One thing is deliberately not modelled, because it is not known.** The reference hands
|
||||
//! `SMSG_INIT_WORLD_STATES`' two leading dwords (map, zone) to `0x4c5650` before the pair loop
|
||||
//! runs, and wow-re has not carved that function — so whether an init *clears* the table first is
|
||||
//! unrecorded. We do not clear: with no evidence either way, inventing a reset is the speculative
|
||||
//! act, and vmangos re-sends its full default set on every zone change (`Player.cpp:8267-8270`),
|
||||
//! which makes a stale key from a previous zone hard to reach in practice. If `0x4c5650` turns out
|
||||
//! to reset, this is a one-line change in [`WorldStates::write`]'s caller.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The live world-state table. Keys and values are raw wire dwords; see the module doc.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct WorldStates {
|
||||
values: HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
impl WorldStates {
|
||||
/// The reference's getter `0x4c5810`, including its miss leg: an absent key reads **0**, which
|
||||
/// is why an un-received world state renders `"0"` rather than blank or the literal token.
|
||||
///
|
||||
/// Returned as `i32` because that is the width the one caller formats — the reference prints
|
||||
/// the dword through `"%d"`, so a value with the top bit set shows as negative.
|
||||
pub(crate) fn get(&self, key: u32) -> i32 {
|
||||
self.values.get(&key).copied().unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
/// Apply a run of `(id, value)` writes — the reference's setter `0x4c5870`, which both opcodes
|
||||
/// funnel into. Init's trailing `(0, 0)` terminator is written like any other pair, exactly as
|
||||
/// the reference's count loop does; it is data, not a sentinel.
|
||||
pub(crate) fn write(&mut self, states: &[(u32, u32)]) {
|
||||
self.values.extend(states.iter().copied());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The getter's miss leg and the `%d` width: an unknown key is 0 (not blank), a known one is
|
||||
/// its value, and the top bit reads through as a negative — the three things `$<n>w` depends on.
|
||||
#[test]
|
||||
fn a_miss_reads_zero_and_a_value_keeps_its_sign() {
|
||||
let mut table = WorldStates::default();
|
||||
assert_eq!(table.get(2077), 0, "empty table — the reference's `\"0\"`");
|
||||
table.write(&[(2077, 7), (2264, 0xFFFF_FFFF)]);
|
||||
assert_eq!(table.get(2077), 7);
|
||||
assert_eq!(table.get(2264), -1, "printed through `%d`");
|
||||
assert_eq!(table.get(9999), 0, "still a miss");
|
||||
}
|
||||
|
||||
/// `$<n>e`'s negated key is a *different* entry, not the same one — the whole reason ids stay
|
||||
/// raw dwords. Writing id 2077 must not make `$2077e` (key `0xFFFFF7E3`) resolve.
|
||||
#[test]
|
||||
fn the_negated_key_is_a_separate_entry() {
|
||||
let mut table = WorldStates::default();
|
||||
table.write(&[(2077, 7)]);
|
||||
assert_eq!(table.get(2077u32.wrapping_neg()), 0);
|
||||
table.write(&[(2077u32.wrapping_neg(), 42)]);
|
||||
assert_eq!(table.get(2077u32.wrapping_neg()), 42);
|
||||
assert_eq!(table.get(2077), 7, "the positive key is untouched");
|
||||
}
|
||||
|
||||
/// A later write to a live key replaces it (`SMSG_UPDATE_WORLD_STATE`'s whole job), and an
|
||||
/// init's terminator lands as the ordinary entry it is.
|
||||
#[test]
|
||||
fn a_write_replaces_and_the_terminator_is_just_data() {
|
||||
let mut table = WorldStates::default();
|
||||
table.write(&[(2264, 1), (0, 0)]);
|
||||
table.write(&[(2264, 5)]);
|
||||
assert_eq!(table.get(2264), 5);
|
||||
assert_eq!(table.get(0), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user