the micro menu, the ping meter, and the PvP flag
- The main menu bar's two empty recesses fill. The micro menu — the eight arched buttons: character, spellbook, talents, quest log, socials, world map, main menu, help — sits in the dark panel between the page arrows and the bags, transcribed from the reference's own layout: the character button wears your portrait, a button holds itself down while its panel is up, and the talent button appears at level 10. The latency meter fills the one slot the bar art leaves transparent for exactly it, tinted green/yellow/red on the reference's 300/600 ms thresholds and drawn UNDER the bar art, seen through its window, the way 1.12 actually does it. The socials and main-menu buttons find their panels one snapshot later; help still waits. - The UI explains itself on hover: 1.12 ships its detailed two-line tooltips ON — a FrameXML default, not the opt-in extra it is often remembered as — so the micro buttons, your own portrait, the XP bar, the five bag buttons and the ping meter all answer a hover with the reference's own explanations. - The PvP flag, end to end: crossed swords on the player, target and party frames, driven by the two descriptor bits the wire actually carries, and `/pvp` speaks — the yellow toast plus the system chat line, the OFF sentence being the one that warns the flag lingers for five minutes. It really does linger: turning it off waits out the server's timer, which looks like a bug and is the reference. - Quest markers stopped freezing. The grey `!` over a questgiver above your level goes gold the moment you ding, an NPC re-entering view is asked afresh instead of wearing a session-old answer, and the marker drops when the questgiver bit goes off — the re-ask triggers mirror the reference's own set, plus one named addition (your own level changing). - Water and heights: swimming downhill no longer bounces between swim and fall ten times a second — the surface line is a two-sided constraint, so the swimmer settles with the river instead of being left behind by it. And collision height is per-unit now, read from the unit's own model: a gnome used to swim with a third of a yard of water over her head because five depth lines were quoting a human's number. - A round of pixel honesty in text and chat: the newest chat line no longer wears a wider drop shadow than every line above it (shadows move in whole pixels now), keeps its descenders, and hover boxes stop painting over the chat body. The chat input's history browse survives programmatic edits, a failed script chunk warns instead of vanishing, and recall deepens to 64 lines. Idle animation got honest too: a model's idle is Stand resolved through its own lookup table — never file order, which had the duel flag bobbing on its spawn clip — and an armed idle is culled on the box its animation authors, not the bind pose, so the flag stops vanishing at most camera angles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -150,6 +150,38 @@ pub struct ModelEmitter {
|
||||
pub geometry: Option<Handle<M2Model>>,
|
||||
}
|
||||
|
||||
/// Whether looping `anim` would look like anything other than the **static mesh** — the doodad
|
||||
/// content gate (decision 0130, premise corrected by 0637).
|
||||
///
|
||||
/// The original gate asked "does some track have >1 key", reasoning that a constant pose renders
|
||||
/// identically to the un-rigged mesh. That premise is false: the static mesh is the **bind** pose,
|
||||
/// and a constant clip only equals it when the constant *is* rest. `DuelingFlag.m2` is the
|
||||
/// counter-example that cost us the bug — its Stand band brackets to a constant bone-0 translation
|
||||
/// of `−9.124` (the flag planted in the ground) while its bind pose is 9 yards up in the air, so a
|
||||
/// "constant ⇒ skip the rig" gate renders it floating.
|
||||
///
|
||||
/// So: a track that MOVES qualifies, and so does a constant sitting away from rest — translation
|
||||
/// ≠ 0 (the M2 translation track is a delta on the pivot offset), rotation ≠ identity, scale ≠ 1.
|
||||
/// Genuinely rest-posed idles — the ~90% of placed doodads decision 0130 measured — still take the
|
||||
/// static path, so that optimization survives intact.
|
||||
fn idle_pose_differs(anim: &benilla_formats::ModelAnimation) -> bool {
|
||||
const EPS: f32 = 1e-4;
|
||||
anim.bones.iter().any(|b| {
|
||||
b.translation.len() > 1
|
||||
|| b.rotation.len() > 1
|
||||
|| b.scale.len() > 1
|
||||
|| b.translation
|
||||
.iter()
|
||||
.any(|(_, v)| v.iter().any(|c| c.abs() > EPS))
|
||||
|| b.rotation
|
||||
.iter()
|
||||
.any(|(_, q)| (q[3].abs() - 1.0).abs() > EPS)
|
||||
|| b.scale
|
||||
.iter()
|
||||
.any(|(_, s)| s.iter().any(|c| (c - 1.0).abs() > EPS))
|
||||
})
|
||||
}
|
||||
|
||||
/// A model reference inside an M2 record (`.mdx`/`.mdl`, mixed case, backslashes) → its
|
||||
/// `mpq://…m2` load URL — the same extension remap the client's loader does (the physical
|
||||
/// archive file is `.m2`).
|
||||
@@ -421,19 +453,28 @@ impl AssetLoader for M2ModelLoader {
|
||||
graph.add_target_to_mask_group(target, 4);
|
||||
}
|
||||
}
|
||||
// The model's baked PlayableAnimationLookup (decision 0082): parsed straight off the M2
|
||||
// header alongside the sequences above, so a model lacking a requested clip can resolve
|
||||
// to its own baked substitute at play time (`ModelAnimations::resolve`) instead of the
|
||||
// creature driver silently doing nothing. Read BEFORE the clip walk because the
|
||||
// loader-idle seed below resolves through it.
|
||||
let playable_animation_lookup =
|
||||
parse_m2_playable_animation_lookup(&bytes).unwrap_or_default();
|
||||
let mut clips = Vec::new();
|
||||
// The doodad-animation content gate (decision 0130, `ModelAnimations::first_seq`): the
|
||||
// file-order-first sequence's clip index, kept only when that sequence really MOVES a bone
|
||||
// (>1 key on some track). A constant 1-key pose still builds a clip below, but looping it
|
||||
// is visually the static mesh — the spawn site must not pay a skin + player for it.
|
||||
// The loader-idle seed (decision 0637, wow-re `gameobject-anim-arm.md` §1 — the
|
||||
// corrected `0x71019b` read): the loader arms **animation id 0 ("Stand")** resolved
|
||||
// through the model's own `playableAnimationLookup`, NOT the file-order-first
|
||||
// sequence. For the overwhelming majority of models those coincide, which is why the
|
||||
// old file-order read survived; they diverge exactly on models whose first sequence is
|
||||
// a Spawn — `DuelingFlag.m2` is Spawn(145)/Stand(0)/Despawn(157), and looping its
|
||||
// Spawn band left the flag hanging 9 yards in the air on a 3.3 s cycle.
|
||||
let idle_id = playable_animation_lookup
|
||||
.first()
|
||||
.map_or(0, |p: &benilla_formats::PlayableAnim| p.resolved_id);
|
||||
let mut first_seq = None;
|
||||
for (i, anim) in sequences.iter().enumerate() {
|
||||
if let Some(clip) = build_animation_clip(anim, &skeleton) {
|
||||
if i == 0
|
||||
&& anim.bones.iter().any(|b| {
|
||||
b.translation.len() > 1 || b.rotation.len() > 1 || b.scale.len() > 1
|
||||
})
|
||||
{
|
||||
if first_seq.is_none() && anim.anim_id == idle_id && idle_pose_differs(anim) {
|
||||
first_seq = Some(clips.len());
|
||||
}
|
||||
let clip = ctx.add_labeled_asset(format!("clip{i}"), clip);
|
||||
@@ -504,12 +545,6 @@ impl AssetLoader for M2ModelLoader {
|
||||
hand_close[1] = Some(graph.add_clip_with_mask(grip.clone(), 1 << 4, 1.0, root));
|
||||
}
|
||||
}
|
||||
// The model's baked PlayableAnimationLookup (decision 0082): parsed straight off the M2
|
||||
// header alongside the sequences above, so a model lacking a requested clip can resolve
|
||||
// to its own baked substitute at play time (`ModelAnimations::resolve`) instead of the
|
||||
// creature driver silently doing nothing.
|
||||
let playable_animation_lookup =
|
||||
parse_m2_playable_animation_lookup(&bytes).unwrap_or_default();
|
||||
// Global-sequence channels alone are enough to carry `ModelAnimations`: a model whose
|
||||
// sequences produced no clips can still pulse/flicker on its free-running loops (the
|
||||
// doodad `GlobalSeqOnly` tier, decision 0130) — dropping them because `clips` came up
|
||||
|
||||
@@ -101,13 +101,19 @@ pub struct ModelAnimations {
|
||||
/// over the sequence pose (see [`super::GlobalBone`]). Empty for a model with no global-sequence
|
||||
/// tracks.
|
||||
pub global_bones: Vec<super::GlobalBone>,
|
||||
/// Index into [`Self::clips`] of the **file-order-first sequence**, when that sequence actually
|
||||
/// moves a bone (some track with >1 key). This is the doodad-animation content gate (decision
|
||||
/// 0130): the client arms every M2 instance once at load with `animations[0].id` and loops it
|
||||
/// forever (wow-re `doodad-anim-host.md`), so a placed doodad plays exactly this clip. `None` for
|
||||
/// a model whose first sequence is a motionless pose (constant 1-key tracks build a clip too, but
|
||||
/// looping it renders identically to the static mesh — the ~90% of placed doodads `doodadscan`
|
||||
/// measured stay on the static path).
|
||||
/// Index into [`Self::clips`] of the model's **loader-idle seed** — the sequence the client's
|
||||
/// M2-instance loader arms once at load and plays forever (wow-re `gameobject-anim-arm.md` §1,
|
||||
/// byte-verified `0x71019b`): **animation id 0 ("Stand")**, resolved through the model's own
|
||||
/// `playableAnimationLookup`. NOT the file-order-first sequence — an earlier reading of
|
||||
/// `doodad-anim-host.md` said `animations[0].id`, and wow-re corrected it in place (0637).
|
||||
/// The two coincide for almost every model and diverge exactly on one whose first sequence is
|
||||
/// a Spawn: `DuelingFlag.m2` is Spawn/Stand/Despawn, and arming file-order-0 looped its Spawn
|
||||
/// band, leaving the duel flag hanging 9 yards in the air on a 3.3 s cycle.
|
||||
///
|
||||
/// `None` when the resolved idle leaves every bone at **bind pose** — looping that really does
|
||||
/// render identically to the static mesh, so the spawn site skips the skin + player (the
|
||||
/// content gate, decision 0130; the ~90% of placed doodads `doodadscan` measured). A *constant
|
||||
/// but non-rest* pose does NOT qualify for that skip — see `m2::idle_pose_differs`.
|
||||
pub first_seq: Option<usize>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Print a model's particle-emitter records — the authored numbers to hold a live
|
||||
//! `WOW_PARTICLE_CENSUS` line against: `cargo run -p benilla-formats --example dump_emitters --
|
||||
//! 'World\...\RubyCrystalLarge01.m2'`.
|
||||
//!
|
||||
//! The pair is the check that matters (decision 0653): the census says how many particles are
|
||||
//! *live*, this says how many the file asks for (`rate × lifespan`), and the over-life ramp says
|
||||
//! how big they get. A mismatch is our sim's bug; a match moves the question to the look.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let virt = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or_else(|| anyhow::anyhow!("usage: dump_emitters <m2 path>"))?;
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let bytes = chain.read_file(&virt)?;
|
||||
for (i, e) in benilla_formats::parse_m2_particle_emitters(&bytes)?
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
println!(
|
||||
"emitter[{i}] flags {:#x} bone {} shape {:?} blend {:?}",
|
||||
e.flags, e.bone, e.shape, e.blend
|
||||
);
|
||||
println!(
|
||||
" texture {:?} tiles {}x{} head_tail {}",
|
||||
e.texture, e.tile_rows, e.tile_cols, e.head_tail
|
||||
);
|
||||
println!(
|
||||
" speed {} var {} vrange {} hrange {} gravity {} life {} drag {}",
|
||||
e.emission_speed,
|
||||
e.speed_variation,
|
||||
e.vertical_range,
|
||||
e.horizontal_range,
|
||||
e.gravity,
|
||||
e.lifespan,
|
||||
e.drag
|
||||
);
|
||||
println!(" rate {:?}", e.emission_rate);
|
||||
println!(
|
||||
" area {}x{} zsrc {} tail {} spin {}",
|
||||
e.area_length, e.area_width, e.z_source, e.tail_time, e.spin
|
||||
);
|
||||
println!(
|
||||
" twinkle speed {} pct {} min {} max {}",
|
||||
e.twinkle_speed, e.twinkle_percent, e.twinkle_min, e.twinkle_max
|
||||
);
|
||||
println!(" over_life {:?}", e.over_life);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! What can this model move, at all? Print the [`M2AnimSummary`](benilla_formats::M2AnimSummary)
|
||||
//! — every animation channel family — plus the texture-transform (UV-scroll) tracks, for one M2 or
|
||||
//! for every M2 whose path matches a substring:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p benilla-formats --example dump_model_anim -- 'World\...\RubyCrystalLarge01.m2'
|
||||
//! cargo run -p benilla-formats --example dump_model_anim -- maraudon # every match
|
||||
//! ```
|
||||
//!
|
||||
//! This is the **falsifier for a flicker report** (decision 0653): if the summary says a model is
|
||||
//! fully static and the screen shows it changing every frame, the change is not coming from the
|
||||
//! model, and the hunt moves to what is drawn *over* it (particles, a second doodad) or to the
|
||||
//! renderer. Answering that for the Maraudon crystal took one run of this and one of
|
||||
//! `WOW_NO_PARTICLES=1`.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let arg = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or_else(|| anyhow::anyhow!("usage: dump_model_anim <m2 path | substring>"))?;
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
|
||||
// An exact path dumps in full; anything else is a substring sweep that prints only the models
|
||||
// with something to say (a listing of "all static" is noise when you asked about a zone).
|
||||
let exact = chain.contains(&arg);
|
||||
let names: Vec<String> = if exact {
|
||||
vec![arg]
|
||||
} else {
|
||||
let pat = arg.to_lowercase();
|
||||
chain
|
||||
.list()?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.filter(|n| {
|
||||
let l = n.to_lowercase();
|
||||
l.ends_with(".m2") && l.contains(&pat)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for name in names {
|
||||
let Ok(bytes) = chain.read_file(&name) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(sum) = benilla_formats::parse_m2_animation_summary(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if !exact && sum.is_fully_static() {
|
||||
continue;
|
||||
}
|
||||
println!("{name}");
|
||||
if sum.is_fully_static() {
|
||||
println!(" FULLY STATIC — no channel family animates anything");
|
||||
}
|
||||
println!(
|
||||
" bones {} (seq0 motion {}) gseq {} alpha {:?} rgb {:?} transp {:?} \
|
||||
texanim {} particles {} ribbons {}",
|
||||
sum.seq0_animated_bone_count,
|
||||
sum.seq0_has_bone_motion,
|
||||
sum.global_seq_channels.len(),
|
||||
sum.color_alpha_tracks,
|
||||
sum.color_rgb_tracks,
|
||||
sum.transparency_tracks,
|
||||
sum.texture_transform_count,
|
||||
sum.particle_emitter_count,
|
||||
sum.ribbon_emitter_count,
|
||||
);
|
||||
if sum.texture_transform_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let Ok(fmt) = parse_m2(&bytes) else { continue };
|
||||
let m = fmt.model();
|
||||
println!(
|
||||
" global_sequences {:?} texAnimLookup {:?}",
|
||||
m.global_sequences, m.texture_transform_lookup
|
||||
);
|
||||
for (i, t) in m.texture_transforms.iter().enumerate() {
|
||||
let tr = &t.translation;
|
||||
println!(
|
||||
" tt[{i}] translation: interp {} gseq {:#x} keys {}",
|
||||
tr.interp,
|
||||
tr.gseq,
|
||||
tr.keys.len()
|
||||
);
|
||||
for (ts, v) in tr.keys.iter().take(8) {
|
||||
println!(" t={ts:>7} ms {v:?}");
|
||||
}
|
||||
if tr.keys.len() > 8 {
|
||||
println!(" … {} more", tr.keys.len() - 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_m2(bytes: &[u8]) -> anyhow::Result<benilla_m2::M2Format> {
|
||||
benilla_m2::parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! List every file in the 5875 patch chain whose path contains a substring (case-insensitive):
|
||||
//! `cargo run -p benilla-formats --example list_chain -- maraudon`. The "what is this thing
|
||||
//! actually called" tool — a report names a *place* ("the red crystal in Maraudon"), and finding
|
||||
//! the asset behind it is the first step of every scene-render diagnosis.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let pat = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or_else(|| anyhow::anyhow!("usage: list_chain <substring> [ext]"))?
|
||||
.to_lowercase();
|
||||
// An optional second argument filters by extension — `list_chain maraudon m2`.
|
||||
let ext = std::env::args()
|
||||
.nth(2)
|
||||
.map(|e| format!(".{}", e.to_lowercase()));
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
let chain = benilla_formats::open_chain(&data)?;
|
||||
for e in chain.list()? {
|
||||
let lower = e.name.to_lowercase();
|
||||
if lower.contains(&pat) && ext.as_ref().is_none_or(|x| lower.ends_with(x)) {
|
||||
println!("{:>9} {}", e.size, e.name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! What is actually standing at a world coordinate:
|
||||
//! `cargo run -p benilla-formats --example what_is_here -- <map> <x> <y> [radius_yd]`
|
||||
//! e.g. `what_is_here Kalimdor 295.72 -3689.61 60`.
|
||||
//!
|
||||
//! A bug report names a *place* — a `.go xyz` pin — and every scene-render diagnosis starts by
|
||||
//! turning that pin into the **placements** behind it: which WMOs and which M2 doodads, with their
|
||||
//! distance from the pin. The distinction is load-bearing for draw-order defects: two batches of one
|
||||
//! WMO share the authored MOBA order the pipeline biases by, while a WMO and a nearby M2 doodad are
|
||||
//! separate lanes with no ordering relationship at all.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let usage = || anyhow::anyhow!("usage: what_is_here <map> <x> <y> [radius_yd]");
|
||||
let map = args.next().ok_or_else(usage)?;
|
||||
let x: f32 = args.next().ok_or_else(usage)?.parse()?;
|
||||
let y: f32 = args.next().ok_or_else(usage)?.parse()?;
|
||||
let radius: f32 = args.next().map_or(Ok(60.0), |r| r.parse())?;
|
||||
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let tiles = benilla_formats::MapTiles::load(&mut chain, &map)?;
|
||||
let (tx, ty) = tiles.tile_at(x, y);
|
||||
println!("{map}: pin ({x}, {y}) is tile {tx}_{ty}; scanning r={radius} yd across 3x3");
|
||||
|
||||
// The pin can sit near a tile seam and the building it names be authored in the neighbour, so
|
||||
// sweep the 3x3 — a placement is deduped by uniqueId, which is stable across the tiles it spans.
|
||||
let mut seen_wmo = std::collections::HashSet::new();
|
||||
let mut seen_doodad = std::collections::HashSet::new();
|
||||
let mut wmos: Vec<(f32, String, [f32; 3], u32)> = Vec::new();
|
||||
let mut doodads: Vec<(f32, String, [f32; 3], f32, u32)> = Vec::new();
|
||||
let dist = |p: [f32; 3]| ((p[0] - x).powi(2) + (p[1] - y).powi(2)).sqrt();
|
||||
|
||||
for (tx, ty) in tiles.existing_in_radius(x, y, 1) {
|
||||
let tile = benilla_formats::load_tile_mesh(&mut chain, &map, tx, ty)?;
|
||||
for w in &tile.wmos {
|
||||
let d = dist(w.position);
|
||||
if d <= radius && seen_wmo.insert(w.unique_id) {
|
||||
wmos.push((d, w.model.clone(), w.position, w.unique_id));
|
||||
}
|
||||
}
|
||||
for m in &tile.doodads {
|
||||
let d = dist(m.position);
|
||||
if d <= radius && seen_doodad.insert(m.unique_id) {
|
||||
doodads.push((d, m.model.clone(), m.position, m.scale, m.unique_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
wmos.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
doodads.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
||||
println!("\n=== WMOs ({}) ===", wmos.len());
|
||||
for (d, model, p, id) in &wmos {
|
||||
println!(
|
||||
"{d:7.1} yd #{id:<10} ({:.2}, {:.2}, {:.2}) {model}",
|
||||
p[0], p[1], p[2]
|
||||
);
|
||||
}
|
||||
println!("\n=== doodads ({}) ===", doodads.len());
|
||||
for (d, model, p, scale, id) in &doodads {
|
||||
println!(
|
||||
"{d:7.1} yd #{id:<10} x{scale:.2} ({:.2}, {:.2}, {:.2}) {model}",
|
||||
p[0], p[1], p[2]
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Which batches of a WMO are **coplanar with each other**, and by how much:
|
||||
//! `cargo run -p benilla-formats --example wmo_coplanar -- <wmo-path-or-substring> [gap_yd]`
|
||||
//! e.g. `wmo_coplanar orctower 0.05`.
|
||||
//!
|
||||
//! The falsifier for a z-fighting report. Two surfaces that swap which one is in front do it for one
|
||||
//! of two reasons, and they take different fixes: a **true tie** (the author put both faces on the
|
||||
//! same plane, so the depth test has nothing to decide with and the winner is whoever drew last) or
|
||||
//! **depth precision** (a real but tiny separation that quantises away). The distinction is a
|
||||
//! property of the *file*, not of the frame — so read it here rather than inferring it from pixels.
|
||||
//!
|
||||
//! Prints, for every pair of batches whose geometry overlaps in space, the smallest plane gap between
|
||||
//! near-parallel faces that actually overlap. A `0.000` gap is a tie; anything else is a separation
|
||||
//! to compare against the depth buffer's resolution at that range.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
use benilla_formats::RenderSubmesh;
|
||||
|
||||
/// Faces this close to parallel are treated as the same plane orientation (≈2.6°).
|
||||
const PARALLEL_DOT: f32 = 0.999;
|
||||
|
||||
/// One triangle reduced to its plane plus the box it occupies — everything the gap test needs.
|
||||
struct Face {
|
||||
normal: [f32; 3],
|
||||
/// Plane offset: `normal · vertex`.
|
||||
d: f32,
|
||||
min: [f32; 3],
|
||||
max: [f32; 3],
|
||||
/// The triangle's own corners, kept so a batch can be compared against **itself**: two faces of
|
||||
/// one flat quad share an edge and are trivially coplanar, which is not a defect. Real overlapping
|
||||
/// sheets share no vertex.
|
||||
verts: [[f32; 3]; 3],
|
||||
}
|
||||
|
||||
fn faces(sub: &RenderSubmesh) -> Vec<Face> {
|
||||
sub.indices
|
||||
.chunks_exact(3)
|
||||
.filter_map(|t| {
|
||||
let p: Vec<[f32; 3]> = t.iter().map(|&i| sub.positions[i as usize]).collect();
|
||||
let (u, v) = (sub3(p[1], p[0]), sub3(p[2], p[0]));
|
||||
let n = [
|
||||
u[1] * v[2] - u[2] * v[1],
|
||||
u[2] * v[0] - u[0] * v[2],
|
||||
u[0] * v[1] - u[1] * v[0],
|
||||
];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
if len < 1e-9 {
|
||||
return None; // degenerate triangle: no plane to speak of
|
||||
}
|
||||
let normal = [n[0] / len, n[1] / len, n[2] / len];
|
||||
let mut min = p[0];
|
||||
let mut max = p[0];
|
||||
for q in &p[1..] {
|
||||
for c in 0..3 {
|
||||
min[c] = min[c].min(q[c]);
|
||||
max[c] = max[c].max(q[c]);
|
||||
}
|
||||
}
|
||||
Some(Face {
|
||||
d: dot(normal, p[0]),
|
||||
normal,
|
||||
min,
|
||||
max,
|
||||
verts: [p[0], p[1], p[2]],
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
/// Do the two boxes overlap, allowing `slack` of separation on every axis?
|
||||
fn boxes_touch(a: &Face, b: &Face, slack: f32) -> bool {
|
||||
(0..3).all(|c| a.min[c] - slack <= b.max[c] && b.min[c] - slack <= a.max[c])
|
||||
}
|
||||
|
||||
/// Do the triangles share a corner? Two halves of a quad do; two stacked sheets do not.
|
||||
fn shares_a_vertex(a: &Face, b: &Face) -> bool {
|
||||
a.verts.iter().any(|p| {
|
||||
b.verts
|
||||
.iter()
|
||||
.any(|q| (0..3).all(|c| (p[c] - q[c]).abs() < 1e-5))
|
||||
})
|
||||
}
|
||||
|
||||
/// The smallest plane gap between near-parallel, spatially-overlapping faces of `a` and `b`, or
|
||||
/// `None` if no such pair sits within `gap`. When `a` and `b` are the same batch, edge-sharing
|
||||
/// neighbours are skipped — they are how a flat surface is built, not a defect.
|
||||
fn closest_coplanar(a: &[Face], b: &[Face], gap: f32, same_batch: bool) -> Option<f32> {
|
||||
let mut best: Option<f32> = None;
|
||||
for (i, fa) in a.iter().enumerate() {
|
||||
// Within one batch every unordered pair is visited once, and a face never fights itself.
|
||||
let rest = if same_batch { &b[i + 1..] } else { b };
|
||||
for fb in rest {
|
||||
if !boxes_touch(fa, fb, gap) || (same_batch && shares_a_vertex(fa, fb)) {
|
||||
continue;
|
||||
}
|
||||
let d = dot(fa.normal, fb.normal);
|
||||
if d.abs() < PARALLEL_DOT {
|
||||
continue;
|
||||
}
|
||||
// Flip B's plane to A's orientation so the offsets are comparable.
|
||||
let db = if d < 0.0 { -fb.d } else { fb.d };
|
||||
let g = (fa.d - db).abs();
|
||||
if g <= gap && best.is_none_or(|x| g < x) {
|
||||
best = Some(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let pat = args
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("usage: wmo_coplanar <wmo-path-or-substring> [gap_yd]"))?;
|
||||
// Report pairs whose faces sit within this gap. The default is generous — a real z-fight needs a
|
||||
// gap far under a centimetre, but seeing the near misses tells you where the margin actually is.
|
||||
let gap: f32 = args.next().map_or(Ok(0.05), |g| g.parse())?;
|
||||
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let path = if pat.to_lowercase().ends_with(".wmo") {
|
||||
pat.clone()
|
||||
} else {
|
||||
let lower = pat.to_lowercase();
|
||||
chain
|
||||
.list()?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.find(|n| {
|
||||
let l = n.to_lowercase();
|
||||
l.ends_with(".wmo") && l.contains(&lower) && !l.contains("_00")
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("no root .wmo matching {pat:?}"))?
|
||||
};
|
||||
println!("{path}\n");
|
||||
|
||||
let subs = benilla_formats::load_wmo(&mut chain, &path)?;
|
||||
println!(
|
||||
"=== {} batches (spawn order = depth-bias order) ===",
|
||||
subs.len()
|
||||
);
|
||||
for (i, s) in subs.iter().enumerate() {
|
||||
let n = s.positions.len();
|
||||
let (mut min, mut max) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for p in &s.positions {
|
||||
for c in 0..3 {
|
||||
min[c] = min[c].min(p[c]);
|
||||
max[c] = max[c].max(p[c]);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"[{i:3}] bias {:3} {:5} tri {:?}{}{} span ({:.1},{:.1},{:.1})..({:.1},{:.1},{:.1}) {}",
|
||||
i + 1,
|
||||
s.indices.len() / 3,
|
||||
s.blend,
|
||||
if s.interior { " INT" } else { "" },
|
||||
if s.emissive { " UNLIT" } else { "" },
|
||||
min[0], min[1], min[2], max[0], max[1], max[2],
|
||||
s.texture.as_deref().unwrap_or("(none)"),
|
||||
);
|
||||
let _ = n;
|
||||
}
|
||||
|
||||
let all: Vec<Vec<Face>> = subs.iter().map(faces).collect();
|
||||
// Facing is ignored throughout: two coplanar faces z-fight whether or not their normals agree.
|
||||
println!("\n=== batches coplanar WITH THEMSELVES (gap <= {gap}) ===");
|
||||
println!("(no per-batch depth bias can separate these — the pipeline draws them as one call)");
|
||||
let mut self_found = 0;
|
||||
for (i, f) in all.iter().enumerate() {
|
||||
if let Some(g) = closest_coplanar(f, f, gap, true) {
|
||||
self_found += 1;
|
||||
println!(
|
||||
"[{i:3}] gap {g:.5} yd {}",
|
||||
subs[i].texture.as_deref().unwrap_or("(none)")
|
||||
);
|
||||
}
|
||||
}
|
||||
if self_found == 0 {
|
||||
println!("(none)");
|
||||
}
|
||||
|
||||
println!("\n=== coplanar batch pairs (gap <= {gap}) ===");
|
||||
let mut found = 0;
|
||||
for a in 0..all.len() {
|
||||
for b in (a + 1)..all.len() {
|
||||
if let Some(g) = closest_coplanar(&all[a], &all[b], gap, false) {
|
||||
found += 1;
|
||||
println!(
|
||||
"[{a:3}] x [{b:3}] gap {g:.5} yd {} | {}",
|
||||
subs[a].texture.as_deref().unwrap_or("(none)"),
|
||||
subs[b].texture.as_deref().unwrap_or("(none)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
println!("(none — no two batches share a plane within {gap} yd)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -188,6 +188,17 @@ enum Command {
|
||||
/// 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 measure how far our SKELETAL parse
|
||||
/// sits from the reference's sampler, per bone track per sequence band: empty bands (our
|
||||
/// nearest-key clamp vs the file's own `interpolation_ranges` window), held band edges (our
|
||||
/// hold vs the reference's ongoing lerp toward the next key), and STEP tracks (`interp == 0`,
|
||||
/// which the reference copies and we interpolate). The population instrument behind decision
|
||||
/// 0133's named residual — and the safety check that a band's keys never fall outside the
|
||||
/// window the reference searches.
|
||||
Bonescan {
|
||||
/// 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
|
||||
@@ -394,6 +405,7 @@ fn main() -> Result<()> {
|
||||
Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Geosetscan { prefix } => scan::geosetscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Alphascan { prefix } => scan::alphascan(&mut chain, prefix.as_deref())?,
|
||||
Command::Bonescan { prefix } => scan::bonescan(&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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! a map block actually animates) — the population instruments that sweep many models/placements
|
||||
//! rather than dumping one.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_formats::{Chain, M2AnimSummary, M2Light};
|
||||
@@ -1429,3 +1429,510 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One bone `M2Track` read straight off the file bytes (v256 stride `0x1c`: interp@0, gseq@2,
|
||||
/// interpolation_ranges `M2Array`@0x04/0x08, timestamps@0x0c/0x10, values@0x14/0x18).
|
||||
///
|
||||
/// Deliberately raw rather than via `parse_m2_animations`: this instrument's whole job is to
|
||||
/// compare what the **file** says against what our parser currently emits, so it must not go
|
||||
/// through the parser under test.
|
||||
struct RawBoneTrack {
|
||||
interp: u16,
|
||||
gseq: u16,
|
||||
ranges: Vec<(u32, u32)>,
|
||||
ts: Vec<u32>,
|
||||
vals: Vec<[f32; 4]>,
|
||||
comps: usize,
|
||||
}
|
||||
|
||||
impl RawBoneTrack {
|
||||
fn read(b: &[u8], o: usize, comps: usize) -> Option<Self> {
|
||||
let u32_at = |p: usize| -> Option<u32> {
|
||||
b.get(p..p + 4)
|
||||
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
||||
};
|
||||
let u16_at = |p: usize| -> Option<u16> {
|
||||
b.get(p..p + 2)
|
||||
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
|
||||
};
|
||||
let f32_at = |p: usize| -> Option<f32> {
|
||||
b.get(p..p + 4)
|
||||
.map(|s| f32::from_le_bytes(s.try_into().unwrap()))
|
||||
};
|
||||
let (interp, gseq) = (u16_at(o)?, u16_at(o + 2)?);
|
||||
let (rn, ro) = (u32_at(o + 0x04)? as usize, u32_at(o + 0x08)? as usize);
|
||||
let (tn, to) = (u32_at(o + 0x0c)? as usize, u32_at(o + 0x10)? as usize);
|
||||
let (vn, vo) = (u32_at(o + 0x14)? as usize, u32_at(o + 0x18)? as usize);
|
||||
let ranges = (0..rn)
|
||||
.map_while(|i| Some((u32_at(ro + i * 8)?, u32_at(ro + i * 8 + 4)?)))
|
||||
.collect();
|
||||
let stride = comps * 4;
|
||||
let n = tn.min(vn);
|
||||
let mut ts = Vec::with_capacity(n);
|
||||
let mut vals = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let Some(t) = u32_at(to + i * 4) else { break };
|
||||
let mut v = [0.0f32; 4];
|
||||
let mut ok = true;
|
||||
for (c, slot) in v.iter_mut().take(comps).enumerate() {
|
||||
match f32_at(vo + i * stride + c * 4) {
|
||||
Some(f) => *slot = f,
|
||||
None => ok = false,
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
break;
|
||||
}
|
||||
ts.push(t);
|
||||
vals.push(v);
|
||||
}
|
||||
Some(Self {
|
||||
interp,
|
||||
gseq,
|
||||
ranges,
|
||||
ts,
|
||||
vals,
|
||||
comps,
|
||||
})
|
||||
}
|
||||
|
||||
/// The reference's sample at absolute time `t_ms` for sequence file slot `slot` — FN1
|
||||
/// (`0x713d50`) verbatim, then the sampler's own lerp/step leg (wow-re `eval.md` FN1/FN2/FN6).
|
||||
fn reference(&self, slot: usize, t_ms: u32) -> Option<[f32; 4]> {
|
||||
let last = self.ts.len().checked_sub(1)?;
|
||||
// FN1 §1: the window is `ranges[slot]` when the array is present, else the whole key list.
|
||||
let (lo, hi) = match self.ranges.get(slot) {
|
||||
Some(&(lo, hi)) => (lo as usize, hi as usize),
|
||||
None => (0, last),
|
||||
};
|
||||
let (lo, hi) = (lo.min(last), hi.min(last));
|
||||
// FN1 §2: a collapsed window resolves to `keys[lo]` outright.
|
||||
if lo >= hi {
|
||||
return Some(self.vals[lo]);
|
||||
}
|
||||
let mut k0 = lo;
|
||||
for (k, &ts) in self.ts.iter().enumerate().take(hi + 1).skip(lo) {
|
||||
if ts <= t_ms {
|
||||
k0 = k;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// FN1 §5: `k1 = k0+1`, bounded by the TOTAL key count — never by the window's `hi`.
|
||||
if self.interp == 0 || k0 + 1 > last {
|
||||
return Some(self.vals[k0]);
|
||||
}
|
||||
let (t0, t1) = (self.ts[k0], self.ts[k0 + 1]);
|
||||
if t1 <= t0 {
|
||||
return Some(self.vals[k0]);
|
||||
}
|
||||
let f = (t_ms as f32 - t0 as f32) / (t1 as f32 - t0 as f32);
|
||||
let (a, b) = (self.vals[k0], self.vals[k0 + 1]);
|
||||
let mut out = [0.0f32; 4];
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
*slot = a[i] + (b[i] - a[i]) * f;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// What `models::anim::read_bone_track` emits for this band today: the in-band keys, or — when
|
||||
/// the band holds none — the single **nearest** out-of-band key (benilla decision 0133).
|
||||
/// Returns `(value_at_band_start, value_at_band_end, band_was_empty)`.
|
||||
fn benilla_today(&self, start: u32, end: u32) -> Option<([f32; 4], [f32; 4], bool)> {
|
||||
let inb: Vec<usize> = (0..self.ts.len())
|
||||
.filter(|&k| self.ts[k] >= start && self.ts[k] <= end)
|
||||
.collect();
|
||||
if let (Some(&f), Some(&l)) = (inb.first(), inb.last()) {
|
||||
// Bevy holds the first key before it and the last key after it.
|
||||
return Some((self.vals[f], self.vals[l], false));
|
||||
}
|
||||
let mut best: Option<(u32, usize)> = None;
|
||||
for (k, &ts) in self.ts.iter().enumerate() {
|
||||
let d = if ts < start { start - ts } else { ts - end };
|
||||
if best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, k));
|
||||
}
|
||||
}
|
||||
let (_, k) = best?;
|
||||
Some((self.vals[k], self.vals[k], true))
|
||||
}
|
||||
|
||||
/// Distance between two sampled values in the channel's own units: **degrees** of rotation for
|
||||
/// a quaternion (numerically stable near identity — `acos(dot)` has a ~0.04° floor in f32),
|
||||
/// **model units** for a translation/scale vector.
|
||||
fn delta(&self, a: [f32; 4], b: [f32; 4]) -> f32 {
|
||||
if self.comps == 4 {
|
||||
let norm = |q: [f32; 4]| {
|
||||
let n = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
|
||||
if n > 0.0 {
|
||||
[q[0] / n, q[1] / n, q[2] / n, q[3] / n]
|
||||
} else {
|
||||
q
|
||||
}
|
||||
};
|
||||
let (a, mut b) = (norm(a), norm(b));
|
||||
if a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>() < 0.0 {
|
||||
b = [-b[0], -b[1], -b[2], -b[3]];
|
||||
}
|
||||
let d = a
|
||||
.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| (x - y) * (x - y))
|
||||
.sum::<f32>()
|
||||
.sqrt();
|
||||
2.0 * (d / 2.0).min(1.0).asin().to_degrees()
|
||||
} else {
|
||||
(0..3)
|
||||
.map(|i| (a[i] - b[i]) * (a[i] - b[i]))
|
||||
.sum::<f32>()
|
||||
.sqrt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and measure, per bone track and per sequence
|
||||
/// band, how far **our** skeletal parse sits from the **reference's** sampler — the population
|
||||
/// instrument behind benilla decision 0133's named residual ("an empty band clamps to the nearest
|
||||
/// authored key … a named approximation of the mid-gap lerp").
|
||||
///
|
||||
/// Three separately-reported disagreements, each a distinct mechanism (wow-re `eval.md`):
|
||||
///
|
||||
/// - **EMPTY bands** — a band with no keys of its own. We hold the nearest authored key; the
|
||||
/// reference holds `keys[ranges[slot].lo]`, or lerps across the bracket when the window spans two
|
||||
/// keys. The 0133 residual proper.
|
||||
/// - **HELD edges** — a keyed band whose first key is late / last key is early. Bevy holds the edge
|
||||
/// key; the reference keeps interpolating toward the neighbouring **out-of-band** key, because
|
||||
/// FN1's `k1 = k0+1` is bounded by the total key count and not by the window.
|
||||
/// - **STEP tracks** — `interpolation_type == 0`. The reference's samplers branch on it and copy
|
||||
/// `keys[k0]` with no interpolation; our bone parse emits keys and lets Bevy interpolate, so a
|
||||
/// snap becomes a glide.
|
||||
///
|
||||
/// Plus the safety check the whole idea rests on: whether any band's own keys fall **outside** the
|
||||
/// window `ranges[slot]` would search (they never do — if they did, adopting the window would
|
||||
/// reintroduce the garbage pose 0133 records).
|
||||
pub fn bonescan(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();
|
||||
// Above the f32 noise floor of the stable angle formula (~1e-4°) by two orders, and far below
|
||||
// anything an eye could catch — a slot over this is a real authored difference.
|
||||
const ROT_EPS: f32 = 0.01; // degrees
|
||||
const VEC_EPS: f32 = 1e-4; // model units
|
||||
let (mut scanned, mut models_step, mut models_empty_differ, mut models_edge_differ) =
|
||||
(0u32, 0u32, 0u32, 0u32);
|
||||
let (mut slots_keyed, mut slots_empty) = (0u64, 0u64);
|
||||
let (mut empty_differ, mut edge_differ, mut ranges_absent, mut window_violation) =
|
||||
(0u64, 0u64, 0u64, 0u64);
|
||||
let (mut step_tracks, mut total_tracks, mut step_bands) = (0u64, 0u64, 0u64);
|
||||
let (mut gseq_tracks, mut gseq_multi, mut gseq_orphan) = (0u64, 0u64, 0u64);
|
||||
let (mut gseq_ranges_absent, mut gseq_ranges_restrict) = (0u64, 0u64);
|
||||
let mut gseq_orphan_models: BTreeSet<String> = BTreeSet::new();
|
||||
let mut gseq_restrict_models: BTreeSet<String> = BTreeSet::new();
|
||||
let mut emitted_extra = 0u64;
|
||||
// (empty-band peak, keyed-edge peak) per channel kind — the bound, not a threshold count.
|
||||
let (mut peak_rot, mut peak_vec) = ((0.0f32, 0.0f32), (0.0f32, 0.0f32));
|
||||
let mut emitted_models: BTreeSet<String> = BTreeSet::new();
|
||||
// Worst offender per class: (delta, model, bone, channel, slot).
|
||||
let mut worst_empty: Vec<(f32, String, usize, &'static str, usize)> = Vec::new();
|
||||
let mut worst_edge: Vec<(f32, String, usize, &'static str, usize)> = Vec::new();
|
||||
let mut step_models: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut worst_step: Vec<(f32, String, usize, &'static str, usize, usize)> = Vec::new();
|
||||
for name in names {
|
||||
let Ok(b) = chain.read_file(&name) else {
|
||||
continue;
|
||||
};
|
||||
if b.len() < 0x40 || &b[0..4] != b"MD20" {
|
||||
continue;
|
||||
}
|
||||
scanned += 1;
|
||||
let u32_at = |o: usize| -> usize {
|
||||
b.get(o..o + 4)
|
||||
.map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
// Sequences in FILE order (count@0x1c/ofs@0x20, stride 0x44) — the order `ranges` indexes.
|
||||
let (sn, so) = (u32_at(0x1c), u32_at(0x20));
|
||||
let seqs: Vec<(u32, u32)> = (0..sn)
|
||||
.map_while(|i| {
|
||||
let e = so + i * 0x44;
|
||||
(e + 0x44 <= b.len()).then(|| (u32_at(e + 4) as u32, u32_at(e + 8) as u32))
|
||||
})
|
||||
.collect();
|
||||
// globalSequences @0x14/0x18 — a duration per entry; 0 means the loop has no period.
|
||||
let (gn, go) = (u32_at(0x14), u32_at(0x18));
|
||||
let gseq_period = |g: u16| -> u32 {
|
||||
let i = g as usize;
|
||||
if i >= gn {
|
||||
0
|
||||
} else {
|
||||
u32_at(go + i * 4) as u32
|
||||
}
|
||||
};
|
||||
let (bn, bo) = (u32_at(0x34), u32_at(0x38));
|
||||
let (mut m_step, mut m_empty, mut m_edge) = (0u64, 0u64, 0u64);
|
||||
for bi in 0..bn {
|
||||
let brec = bo + bi * 0x6c;
|
||||
if brec + 0x6c > b.len() {
|
||||
break;
|
||||
}
|
||||
for (off, comps, ch) in [(0x0c, 3, "trans"), (0x28, 4, "rot"), (0x44, 3, "scale")] {
|
||||
let Some(tr) = RawBoneTrack::read(&b, brec + off, comps) else {
|
||||
continue;
|
||||
};
|
||||
if tr.ts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
total_tracks += 1;
|
||||
if tr.interp == 0 {
|
||||
step_tracks += 1;
|
||||
m_step += 1;
|
||||
// The step deviation only becomes visible when a band holds TWO keys: that is
|
||||
// where the reference snaps and we glide. Measure the size of the snap.
|
||||
for (slot, &(start, end)) in seqs.iter().enumerate() {
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
let inb: Vec<usize> = (0..tr.ts.len())
|
||||
.filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end)
|
||||
.collect();
|
||||
if inb.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
step_bands += 1;
|
||||
let jump = inb
|
||||
.windows(2)
|
||||
.map(|w| tr.delta(tr.vals[w[0]], tr.vals[w[1]]))
|
||||
.fold(0.0f32, f32::max);
|
||||
if jump > if comps == 4 { ROT_EPS } else { VEC_EPS } {
|
||||
worst_step.push((jump, name.clone(), bi, ch, slot, inb.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A global-sequence track runs on its own clock. `read_bone_track` keeps the
|
||||
// SINGLE-key case (a constant channel — the stowed-weapon rest quats) and leaves
|
||||
// the multi-key case to `parse_m2_global_sequence_bones` → the `GlobalSeqDrive`
|
||||
// lane, which needs a non-zero `globalSequences[gseq]` period. A multi-key channel
|
||||
// on a ZERO-period global sequence falls between the two and is sampled by
|
||||
// neither — census that gap, and the shape of the `ranges` window the reference
|
||||
// would still apply here (FN1 selects the window BEFORE it resolves the gseq
|
||||
// clock, so a restrictive window would clip the loop).
|
||||
if tr.gseq != 0xffff {
|
||||
gseq_tracks += 1;
|
||||
if tr.ts.len() > 1 {
|
||||
gseq_multi += 1;
|
||||
if gseq_period(tr.gseq) == 0 {
|
||||
gseq_orphan += 1;
|
||||
gseq_orphan_models.insert(name.clone());
|
||||
}
|
||||
match tr.ranges.len() {
|
||||
0 => gseq_ranges_absent += 1,
|
||||
_ => {
|
||||
if tr
|
||||
.ranges
|
||||
.iter()
|
||||
.any(|&(lo, hi)| lo != 0 || hi as usize != tr.ts.len() - 1)
|
||||
{
|
||||
gseq_ranges_restrict += 1;
|
||||
gseq_restrict_models.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let eps = if comps == 4 { ROT_EPS } else { VEC_EPS };
|
||||
for (slot, &(start, end)) in seqs.iter().enumerate() {
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
if tr.ranges.get(slot).is_none() {
|
||||
ranges_absent += 1;
|
||||
}
|
||||
let Some((mine_a, mine_b, was_empty)) = tr.benilla_today(start, end) else {
|
||||
continue;
|
||||
};
|
||||
let (Some(ref_a), Some(ref_b)) =
|
||||
(tr.reference(slot, start), tr.reference(slot, end))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let d = tr.delta(mine_a, ref_a).max(tr.delta(mine_b, ref_b));
|
||||
let peak = if comps == 4 {
|
||||
&mut peak_rot
|
||||
} else {
|
||||
&mut peak_vec
|
||||
};
|
||||
if was_empty {
|
||||
peak.0 = peak.0.max(d);
|
||||
slots_empty += 1;
|
||||
if d > eps {
|
||||
empty_differ += 1;
|
||||
m_empty += 1;
|
||||
worst_empty.push((d, name.clone(), bi, ch, slot));
|
||||
}
|
||||
} else {
|
||||
peak.1 = peak.1.max(d);
|
||||
slots_keyed += 1;
|
||||
// Does this band's own key set sit inside the window the reference
|
||||
// searches? If not, adopting the window would drop playable keys.
|
||||
if let Some(&(lo, hi)) = tr.ranges.get(slot) {
|
||||
let inb: Vec<usize> = (0..tr.ts.len())
|
||||
.filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end)
|
||||
.collect();
|
||||
if inb.first().is_some_and(|&f| (f as u32) < lo)
|
||||
|| inb.last().is_some_and(|&l| (l as u32) > hi)
|
||||
{
|
||||
window_violation += 1;
|
||||
}
|
||||
}
|
||||
if d > eps {
|
||||
edge_differ += 1;
|
||||
m_edge += 1;
|
||||
worst_edge.push((d, name.clone(), bi, ch, slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The other half of the check: what our parser ACTUALLY emits. A band-slot whose emitted
|
||||
// key count exceeds its own in-band key count is one where the head/tail sample differed
|
||||
// from the edge key and had to be carried — the only slots this parse changes.
|
||||
for a in benilla_formats::parse_m2_animations(&b) {
|
||||
for bk in &a.bones {
|
||||
for (off, comps, emitted) in [
|
||||
(0x0c, 3, bk.translation.len()),
|
||||
(0x28, 4, bk.rotation.len()),
|
||||
(0x44, 3, bk.scale.len()),
|
||||
] {
|
||||
let brec = bo + bk.bone as usize * 0x6c;
|
||||
let Some(tr) = RawBoneTrack::read(&b, brec + off, comps) else {
|
||||
continue;
|
||||
};
|
||||
if tr.gseq != 0xffff {
|
||||
continue;
|
||||
}
|
||||
let inb = tr
|
||||
.ts
|
||||
.iter()
|
||||
.filter(|&&ts| ts >= a.start_ms && ts <= a.end_ms)
|
||||
.count();
|
||||
if emitted > inb.max(1) {
|
||||
emitted_extra += 1;
|
||||
emitted_models.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if m_step > 0 {
|
||||
models_step += 1;
|
||||
step_models.insert(name.clone(), m_step);
|
||||
}
|
||||
if m_empty > 0 {
|
||||
models_empty_differ += 1;
|
||||
}
|
||||
if m_edge > 0 {
|
||||
models_edge_differ += 1;
|
||||
}
|
||||
// Keep the worst-offender lists bounded without losing the tail.
|
||||
for w in [&mut worst_empty, &mut worst_edge] {
|
||||
if w.len() > 4096 {
|
||||
w.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
w.truncate(512);
|
||||
}
|
||||
}
|
||||
if worst_step.len() > 4096 {
|
||||
worst_step.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
worst_step.truncate(512);
|
||||
}
|
||||
}
|
||||
worst_empty.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
worst_edge.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
println!("scanned {scanned} models · {total_tracks} keyed bone tracks");
|
||||
println!("\nbone×channel×sequence slots: {slots_keyed} keyed, {slots_empty} EMPTY");
|
||||
println!(" ranges array absent for the slot: {ranges_absent}");
|
||||
println!(" band keys OUTSIDE their own ranges window: {window_violation} (must be 0 to adopt the window)");
|
||||
for (label, n, models, worst) in [
|
||||
(
|
||||
"EMPTY band (nearest-key vs the file's window)",
|
||||
empty_differ,
|
||||
models_empty_differ,
|
||||
&worst_empty,
|
||||
),
|
||||
(
|
||||
"HELD edge (hold vs the reference's ongoing lerp)",
|
||||
edge_differ,
|
||||
models_edge_differ,
|
||||
&worst_edge,
|
||||
),
|
||||
] {
|
||||
println!("\n{label}: {n} slots differ, across {models} models");
|
||||
for (d, m, bi, ch, slot) in worst.iter().take(15) {
|
||||
let unit = if *ch == "rot" { "deg" } else { "u" };
|
||||
println!(" {d:9.3}{unit:<4} {m:<52} bone {bi:<4} {ch:<6} slot {slot}");
|
||||
}
|
||||
}
|
||||
worst_step.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
println!(
|
||||
"\nSTEP bone tracks (interp == 0, the reference copies keys[k0]): {step_tracks} of \
|
||||
{total_tracks}, in {models_step} models"
|
||||
);
|
||||
println!(
|
||||
" bands where a step track holds 2+ keys (we glide, the reference snaps): {step_bands}; \
|
||||
{} of them snap by more than the noise floor. Worst:",
|
||||
worst_step.len()
|
||||
);
|
||||
for (d, m, bi, ch, slot, n) in worst_step.iter().take(20) {
|
||||
let unit = if *ch == "rot" { "deg" } else { "u" };
|
||||
println!(" {d:9.3}{unit:<4} {m:<52} bone {bi:<4} {ch:<6} slot {slot:<4} ({n} keys)");
|
||||
}
|
||||
println!(
|
||||
"\nGLOBAL-SEQUENCE bone tracks: {gseq_tracks} total, {gseq_multi} multi-key (the \
|
||||
`GlobalSeqDrive` lane's input; single-key ones are constants folded into every clip)"
|
||||
);
|
||||
println!(
|
||||
" multi-key on a ZERO-period global sequence — sampled by NEITHER lane: {gseq_orphan}, \
|
||||
in {} models",
|
||||
gseq_orphan_models.len()
|
||||
);
|
||||
for m in gseq_orphan_models.iter().take(10) {
|
||||
println!(" {m}");
|
||||
}
|
||||
println!(
|
||||
" their `ranges` window: absent (whole key list) {gseq_ranges_absent}, RESTRICTIVE \
|
||||
(not [0, last] — would clip the loop) {gseq_ranges_restrict} in {} models",
|
||||
gseq_restrict_models.len()
|
||||
);
|
||||
for m in gseq_restrict_models.iter().take(10) {
|
||||
println!(" {m}");
|
||||
}
|
||||
println!(
|
||||
"\nworst disagreement anywhere in the corpus (a BOUND, not a threshold count):\n \
|
||||
rotation: empty band {:.5} deg, keyed edge {:.5} deg\n \
|
||||
translation/scale: empty band {:.6} u, keyed edge {:.6} u",
|
||||
peak_rot.0, peak_rot.1, peak_vec.0, peak_vec.1
|
||||
);
|
||||
println!(
|
||||
"\nEMITTED clips: {emitted_extra} band-slots carry a head/tail sample beyond their own \
|
||||
in-band keys, in {} models",
|
||||
emitted_models.len()
|
||||
);
|
||||
for m in emitted_models.iter().take(10) {
|
||||
println!(" {m}");
|
||||
}
|
||||
println!(" models with any step track:");
|
||||
for (m, n) in step_models.iter().take(10) {
|
||||
println!(" {n:>6} {m}");
|
||||
}
|
||||
if step_models.len() > 10 {
|
||||
println!(" … and {} more models", step_models.len() - 10);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//!
|
||||
//! Layouts verified against build 5875 (field counts from the file header, cross-checked with
|
||||
//! wowdev.wiki + vmangos `DBCStructure.h`): CreatureModelData = 16 fields (ID@0, ModelName@2,
|
||||
//! ModelScale@4); CreatureDisplayInfo = 12 fields (ID@0, ModelID@1, ExtendedDisplayInfoID@3, Scale@4,
|
||||
//! ModelScale@4, CollisionHeight@15); CreatureDisplayInfo = 12 fields (ID@0, ModelID@1, ExtendedDisplayInfoID@3, Scale@4,
|
||||
//! TextureVariation@6/7/8); CreatureDisplayInfoExtra = 19 fields (ID@0, Race@1, Sex@2, Skin@3, Face@4,
|
||||
//! HairStyle@5, HairColor@6, FacialHair@7, Equipment@8..17, BakeName@18).
|
||||
|
||||
@@ -55,6 +55,10 @@ pub struct CreatureModel {
|
||||
/// `CreatureModelData.BloodID`; `≤ 0` = a bloodless model (elementals, ghosts — the client's
|
||||
/// negative-id skip). Consumed via [`crate::BloodCatalog::effect_id`].
|
||||
pub blood: i32,
|
||||
/// `CreatureModelData.collisionHeight` — the unit's collision box height in **raw model units**
|
||||
/// (multiply by the unit's render scale for world yards; see
|
||||
/// [`CreatureCatalog::collision_height`], which is the accessor every consumer should use).
|
||||
pub collision_height: f32,
|
||||
}
|
||||
|
||||
/// A character-model NPC's appearance, from **CreatureDisplayInfoExtra.dbc**. A humanoid NPC wears a
|
||||
@@ -98,12 +102,23 @@ struct DisplayRow {
|
||||
blood_level: u32,
|
||||
}
|
||||
|
||||
/// One CreatureModelData row (the parts we use).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ModelRow {
|
||||
path: String,
|
||||
scale: f32,
|
||||
/// `BloodID` — see [`CreatureModel::blood`]. Reads signed: `−1` marks a bloodless model.
|
||||
blood: i32,
|
||||
/// `collisionHeight` (field 15), raw model units — see [`CreatureCatalog::collision_height`].
|
||||
collision_height: f32,
|
||||
}
|
||||
|
||||
/// Display/model tables loaded from the DBCs, resolving `displayId` → [`CreatureModel`].
|
||||
pub struct CreatureCatalog {
|
||||
/// CreatureDisplayInfo: displayId → row.
|
||||
display: HashMap<u32, DisplayRow>,
|
||||
/// CreatureModelData: modelId → (model path, model scale, BloodID — see [`CreatureModel::blood`]).
|
||||
models: HashMap<u32, (String, f32, i32)>,
|
||||
/// CreatureModelData: modelId → row.
|
||||
models: HashMap<u32, ModelRow>,
|
||||
/// CreatureDisplayInfoExtra: extendedDisplayInfoId → character-model NPC appearance.
|
||||
extra: HashMap<u32, NpcAppearance>,
|
||||
}
|
||||
@@ -118,24 +133,41 @@ impl CreatureCatalog {
|
||||
self.display.get(&display_id).map(|r| r.scale)
|
||||
}
|
||||
|
||||
/// A display's **collision height** in raw model units — `CreatureModelData.collisionHeight`,
|
||||
/// the per-unit `h` every depth line in the client is a fraction of (swim at `0.75·h`, splash at
|
||||
/// `0.4·h`, the foam gate at `2·h`; wow-re has each byte-pinned against `CMovement+0xb4`). World
|
||||
/// yards = this × the unit's render scale (`OBJECT_FIELD_SCALE_X`, which the server has already
|
||||
/// folded the DBC scales into) — the caller multiplies, because only it knows the live scale.
|
||||
///
|
||||
/// The column is **exactly the model's own MD20 collision-box Z extent**, verified against the
|
||||
/// shipped client for all thirteen character models (`tests::collision_height_is_the_m2_box`) —
|
||||
/// which is what settles the space: it is authored pre-scale, like the geometry it bounds.
|
||||
/// `None` when either DBC lookup misses; callers fall back to the client's own ctor default
|
||||
/// (`2.0277777`, `0x616fd8`).
|
||||
pub fn collision_height(&self, display_id: u32) -> Option<f32> {
|
||||
let row = self.display.get(&display_id)?;
|
||||
Some(self.models.get(&row.model_id)?.collision_height)
|
||||
}
|
||||
|
||||
/// Resolve an NPC display id to its model, or `None` if either DBC lookup misses.
|
||||
pub fn model(&self, display_id: u32) -> Option<CreatureModel> {
|
||||
let row = self.display.get(&display_id)?;
|
||||
let (model_path, model_scale, model_blood) = self.models.get(&row.model_id)?;
|
||||
let model = self.models.get(&row.model_id)?;
|
||||
// A non-zero ExtendedDisplayInfoID that resolves to an extra row ⇒ a character-model NPC.
|
||||
let npc_appearance = (row.extended_id != 0)
|
||||
.then(|| self.extra.get(&row.extended_id).cloned())
|
||||
.flatten();
|
||||
Some(CreatureModel {
|
||||
model_path: model_path.clone(),
|
||||
scale: model_scale * row.scale,
|
||||
model_path: model.path.clone(),
|
||||
scale: model.scale * row.scale,
|
||||
textures: row.textures.clone(),
|
||||
npc_appearance,
|
||||
blood: if row.blood_level != 0 {
|
||||
row.blood_level as i32
|
||||
} else {
|
||||
*model_blood
|
||||
model.blood
|
||||
},
|
||||
collision_height: model.collision_height,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,9 +273,16 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
|
||||
let mut m = HashMap::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
if let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 2)) {
|
||||
// BloodID (field 5) reads signed: −1 marks a bloodless model in the real data.
|
||||
let blood = u32_at(r, 5).map_or(0, |v| v as i32);
|
||||
m.insert(id, (name, f32_at(r, 4).unwrap_or(1.0), blood));
|
||||
m.insert(
|
||||
id,
|
||||
ModelRow {
|
||||
path: name,
|
||||
scale: f32_at(r, 4).unwrap_or(1.0),
|
||||
// BloodID (field 5) reads signed: −1 marks a bloodless model in the real data.
|
||||
blood: u32_at(r, 5).map_or(0, |v| v as i32),
|
||||
collision_height: f32_at(r, 15).unwrap_or(0.0),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
m
|
||||
@@ -412,4 +451,78 @@ mod tests {
|
||||
"SW Guard worn-equipment ids in bodyslot order"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The column's space, pinned.** `CreatureModelData.collisionHeight` is not a derived or
|
||||
/// hand-authored number: it is a verbatim copy of the model's own MD20 **collision-box** Z
|
||||
/// extent, in raw model units. Asserted for all thirteen player-race body models against the
|
||||
/// shipped client — which is what licenses [`CreatureCatalog::collision_height`]'s contract
|
||||
/// that world yards = column × render scale (it is pre-scale, exactly like the geometry it
|
||||
/// bounds), and pins field index 15 so a schema shift can't silently return garbage.
|
||||
///
|
||||
/// Tauren Female is the load-bearing row: `modelScale` 1.25 with a 2.111 column. If the column
|
||||
/// were authored post-`modelScale` the box would read 2.111/1.25 = 1.689, so this row alone
|
||||
/// refutes the "divide the model scale out" reading (which is what vmangos's server-side
|
||||
/// `Unit::UpdateModelData` does).
|
||||
#[test]
|
||||
fn collision_height_is_the_m2_collision_box() {
|
||||
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_creature_catalog(&mut chain).expect("load creature catalog");
|
||||
|
||||
// (display id, label, the column's expected value). Display ids are the ChrRaces
|
||||
// Male/FemaleDisplayId columns; Goblin shares one display across both sexes.
|
||||
let races: &[(u32, &str, f32)] = &[
|
||||
(49, "HumanMale", 2.031),
|
||||
(50, "HumanFemale", 1.913),
|
||||
(51, "OrcMale", 2.361),
|
||||
(52, "OrcFemale", 2.051),
|
||||
(53, "DwarfMale", 1.667),
|
||||
(54, "DwarfFemale", 1.528),
|
||||
(55, "NightElfMale", 2.438),
|
||||
(56, "NightElfFemale", 2.250),
|
||||
(57, "ScourgeMale", 1.861),
|
||||
(58, "ScourgeFemale", 1.844),
|
||||
(59, "TaurenMale", 1.653),
|
||||
(60, "TaurenFemale", 2.111),
|
||||
(1563, "GnomeMale", 1.056),
|
||||
(1564, "GnomeFemale", 1.000),
|
||||
(1478, "TrollMale", 2.083),
|
||||
(1479, "TrollFemale", 1.839),
|
||||
];
|
||||
for &(display_id, label, expect) in races {
|
||||
let m = cat
|
||||
.model(display_id)
|
||||
.unwrap_or_else(|| panic!("{label}: display {display_id} resolves"));
|
||||
let h = cat.collision_height(display_id).expect("collision height");
|
||||
assert!(
|
||||
(h - expect).abs() < 5e-4,
|
||||
"{label}: column is {h}, expected {expect}"
|
||||
);
|
||||
assert_eq!(h, m.collision_height, "{label}: accessor vs CreatureModel");
|
||||
|
||||
let bytes = chain
|
||||
.read_file(&crate::models::model_path(&m.model_path))
|
||||
.unwrap_or_else(|e| panic!("{label}: read {}: {e:#}", m.model_path));
|
||||
let fmt = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes[..]))
|
||||
.unwrap_or_else(|e| panic!("{label}: parse M2: {e:#}"));
|
||||
let hdr = &fmt.model().header;
|
||||
let box_z = (hdr.collision_box_max[2] - hdr.collision_box_min[2]).abs();
|
||||
assert!(
|
||||
(box_z - h).abs() < 5e-4,
|
||||
"{label}: MD20 collision box Z extent {box_z} != column {h}"
|
||||
);
|
||||
}
|
||||
|
||||
// Not vacuous: the races must actually differ, or "one constant for everyone" would pass.
|
||||
let gnome = cat.collision_height(1564).unwrap();
|
||||
let nelf = cat.collision_height(55).unwrap();
|
||||
assert!(
|
||||
nelf > gnome * 2.0,
|
||||
"a night elf is over twice a gnome ({nelf} vs {gnome}) — the whole point of the plumb"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ pub struct ItemDisplayCatalog {
|
||||
}
|
||||
|
||||
impl ItemDisplayCatalog {
|
||||
/// Build a catalog from an explicit row map — for tests and synthetic fixtures, the twin of
|
||||
/// `SpellCatalog::from_displays`. The live path is [`load_item_display_catalog`].
|
||||
pub fn from_displays(displays: HashMap<u32, ItemDisplay>) -> Self {
|
||||
ItemDisplayCatalog { displays }
|
||||
}
|
||||
|
||||
/// Look up a display id, or `None` if unknown.
|
||||
pub fn get(&self, display_id: u32) -> Option<&ItemDisplay> {
|
||||
self.displays.get(&display_id)
|
||||
|
||||
@@ -324,103 +324,176 @@ pub struct ModelAnimation {
|
||||
pub events: Vec<AnimEvent>,
|
||||
}
|
||||
|
||||
/// Read one bone `M2Track`'s keyframes for the sequence spanning `[seq_start_ms, seq_end_ms]` (absolute
|
||||
/// ms), rebased to seconds from the start. `read_value` decodes one value at a byte offset; `stride` is
|
||||
/// its size. The v256 `M2Track` (0x1c): `interp_type`@0, `global_seq`@2, then three `M2Array`s —
|
||||
/// interpolation_ranges@0x04, **timestamps@0x0c**, **values@0x14** (each `{count u32, offset u32}`).
|
||||
///
|
||||
/// Keys are selected by **absolute timestamp within the sequence's time band**, NOT via
|
||||
/// `interpolation_ranges`: the flat timestamp/value arrays concatenate every sequence's keys
|
||||
/// (timestamps absolute, VERIFIED), the sequences occupy disjoint bands, and this is exactly what the
|
||||
/// real client's kernel does (rebase playback time into `[seqStart, seqEnd]`, then search the
|
||||
/// timestamps). The `interpolation_ranges` window proved unreliable in real vanilla art — its end index
|
||||
/// can point past the sequence into a later one (Chicken/Bear/Kobold/…: a key 14–64 s away), which froze
|
||||
/// those creatures at a garbage pose: the range's far key is a clamp *endpoint*, never a playable
|
||||
/// in-clip key. A **global-sequence** track (`global_seq != 0xffff`) loops on its own clock and is
|
||||
/// skipped here (the global-sequence post-pass is deferred).
|
||||
///
|
||||
/// A band with **no keys at all** still has an authored pose: the real sampler clamps/lerps between
|
||||
/// the keys bracketing the band (that is what the per-sequence `interpolation_ranges` pairs encode —
|
||||
/// HumanMale bone 27 rot has 4 keys and `ranges[Run] = (2, 3)`, both outside Run's band), so a keyed
|
||||
/// bone is **never** left unsampled. Dropping the track instead froze the bone at whatever the
|
||||
/// *previous* clip left (the task-#14 tilt: a Stand-variation waist twist rode through the entire
|
||||
/// run, exposed the day the 0123 variation roll first armed non-head Stands). We emit one constant
|
||||
/// key holding the **nearest** authored key's value — the value the real clamp lands on whenever the
|
||||
/// band sits close to one bracket (vanilla bands are 0.3–4 s; bracket gaps are minutes), a named
|
||||
/// approximation of the mid-gap lerp otherwise.
|
||||
fn read_bone_track<T>(
|
||||
/// One bone-channel `M2Track`, read **once per model** rather than once per sequence: the v256 track
|
||||
/// (0x1c) is `interp_type`@0, `global_seq`@2, then three `M2Array`s — interpolation_ranges@0x04,
|
||||
/// **timestamps@0x0c**, **values@0x14** (each `{count u32, offset u32}`). Every sequence carves its
|
||||
/// own band out of this one shared key list ([`ChannelTrack::band`]), so re-reading the arrays per
|
||||
/// sequence was pure waste — HumanMale walked 143 sequences × 119 bones × 3 channels of headers to
|
||||
/// build 357 tracks' worth of keys.
|
||||
struct ChannelTrack<T> {
|
||||
/// `interp_type == 0`: hold each key until the next, no interpolation (the samplers' shared
|
||||
/// `cmp word[track],0` dispatch — wow-re `eval.md` FN2/FN6).
|
||||
step: bool,
|
||||
/// Global-sequence id, `0xffff` for an ordinary sequence-timeline track.
|
||||
gseq: u16,
|
||||
/// The per-sequence **key-index window** `(lo, hi)`, indexed by the sequence's FILE slot — the
|
||||
/// array the reference's key search selects before it looks at a single timestamp (VERIFIED
|
||||
/// wow-re `eval.md` FN1 `0x713d50` §1). Empty ⇒ the reference's own `[track+4]==0` fallback,
|
||||
/// "search the whole key list".
|
||||
ranges: Vec<(u32, u32)>,
|
||||
/// `(absolute ms, value)` keys, file order — every sequence's keys concatenated on one
|
||||
/// timeline (timestamps absolute, VERIFIED).
|
||||
keys: Vec<(u32, T)>,
|
||||
}
|
||||
|
||||
/// One bone's three channel tracks, in the bone record's own order: translation `+0x0c`, rotation
|
||||
/// `+0x28`, scale `+0x44`.
|
||||
type BoneChannels = (
|
||||
ChannelTrack<[f32; 3]>,
|
||||
ChannelTrack<[f32; 4]>,
|
||||
ChannelTrack<[f32; 3]>,
|
||||
);
|
||||
|
||||
/// Read one bone channel's `M2Track` whole (see [`ChannelTrack`]). `read_value` decodes one value at
|
||||
/// a byte offset; `stride` is its size. Out-of-range reads truncate the key list — real vanilla art
|
||||
/// relies on that tolerance.
|
||||
fn read_channel_track<T>(
|
||||
b: &[u8],
|
||||
track: usize,
|
||||
seq_start_ms: u32,
|
||||
seq_end_ms: u32,
|
||||
stride: usize,
|
||||
read_value: impl Fn(&[u8], usize) -> T,
|
||||
) -> Vec<(f32, T)> {
|
||||
) -> ChannelTrack<T> {
|
||||
if track + 0x1c > b.len() {
|
||||
return Vec::new();
|
||||
return ChannelTrack {
|
||||
step: false,
|
||||
gseq: 0xffff,
|
||||
ranges: Vec::new(),
|
||||
keys: Vec::new(),
|
||||
};
|
||||
}
|
||||
let (times_n, times_o) = (
|
||||
let (rn, ro) = (
|
||||
le_u32(b, track + 0x04) as usize,
|
||||
le_u32(b, track + 0x08) as usize,
|
||||
);
|
||||
let (tn, to) = (
|
||||
le_u32(b, track + 0x0c) as usize,
|
||||
le_u32(b, track + 0x10) as usize,
|
||||
);
|
||||
let vals_o = le_u32(b, track + 0x18) as usize;
|
||||
if le_u16(b, track + 0x02) != 0xffff {
|
||||
// A **global-sequence** track: driven by its own looping clock, independent of the playing
|
||||
// sequence. The single-key case is a pure CONSTANT channel — this is how vanilla art authors
|
||||
// the stowed-weapon attach-bone orientations (HumanMale bones 29/30/58–62: one rotation key
|
||||
// at t=0 on global-seq 0, itself 0 ms long — the blade-down / shield-on-back quaternions;
|
||||
// dumped 2026-07-03, the floating-stowed-sword bug). Emit the constant into every sequence's
|
||||
// window (`keyframe_curve` holds a lone key across the clip). A *time-varying* multi-key
|
||||
// global track (a fidget glow on its own clock) still needs the global clock — deferred.
|
||||
if times_n == 1 && vals_o + stride <= b.len() {
|
||||
return vec![(0.0, read_value(b, vals_o))];
|
||||
}
|
||||
return Vec::new();
|
||||
let vo = le_u32(b, track + 0x18) as usize;
|
||||
let ranges = (0..rn)
|
||||
.map_while(|i| {
|
||||
let e = ro + i * 8;
|
||||
(e + 8 <= b.len()).then(|| (le_u32(b, e), le_u32(b, e + 4)))
|
||||
})
|
||||
.collect();
|
||||
let keys = (0..tn)
|
||||
.map_while(|k| {
|
||||
let (t_off, v_off) = (to + k * 4, vo + k * stride);
|
||||
(t_off + 4 <= b.len() && v_off + stride <= b.len())
|
||||
.then(|| (le_u32(b, t_off), read_value(b, v_off)))
|
||||
})
|
||||
.collect();
|
||||
ChannelTrack {
|
||||
step: le_u16(b, track) == 0,
|
||||
gseq: le_u16(b, track + 0x02),
|
||||
ranges,
|
||||
keys,
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
// The closest out-of-band key, by distance to the band — the clamp value if the band is empty.
|
||||
let mut nearest: Option<(u32, usize)> = None;
|
||||
for k in 0..times_n {
|
||||
let (t_off, v_off) = (times_o + k * 4, vals_o + k * stride);
|
||||
if t_off + 4 > b.len() || v_off + stride > b.len() {
|
||||
break;
|
||||
}
|
||||
let ts = le_u32(b, t_off);
|
||||
if ts < seq_start_ms || ts > seq_end_ms {
|
||||
// A key in another sequence's band — not playable here, but remembered as the
|
||||
// clamp candidate (see the module doc: an empty band still has an authored pose).
|
||||
let d = if ts < seq_start_ms {
|
||||
seq_start_ms - ts
|
||||
} else {
|
||||
ts - seq_end_ms
|
||||
};
|
||||
if nearest.is_none_or(|(best, _)| d < best) {
|
||||
nearest = Some((d, k));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(((ts - seq_start_ms) as f32 / 1000.0, read_value(b, v_off)));
|
||||
}
|
||||
if out.is_empty() {
|
||||
if let Some((_, k)) = nearest {
|
||||
let v_off = vals_o + k * stride;
|
||||
if v_off + stride <= b.len() {
|
||||
out.push((0.0, read_value(b, v_off)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The weapon-grip finger poses: each requested `bone`'s rotation at the **HandsClosed** (`AnimationData`
|
||||
/// id 15) frame, **clamped** to the nearest key at-or-before that frame. Scoped to the grip on purpose —
|
||||
/// a weapon-hand finger bone is keyed in *adjacent* global-timeline bands but NOT the HandsClosed frame
|
||||
/// itself (on HumanMale bone 102 is keyed at 43333/70000 ms, both curled, not the 60000 ms frame), so the
|
||||
/// general per-sequence in-band read ([`read_bone_track`]) correctly drops it — yet the real client
|
||||
/// evaluates the global track there and gets the curled pose (wow-re `hand-grip-mechanism.md`). Doing the
|
||||
/// clamp *only* for these few finger bones is what makes the weapon hand close without re-introducing the
|
||||
/// distant-key bleed the in-band read deliberately guards against. Returns `(bone, quat[x,y,z,w])` for the
|
||||
/// bones that carry a plain rotation track; empty if the model has no HandsClosed sequence.
|
||||
impl<T: super::key_anim::Lerp + PartialEq> ChannelTrack<T> {
|
||||
/// This channel's keyframes for the sequence at file slot `slot`, spanning
|
||||
/// `[start, end]` (absolute ms), rebased to seconds from the band start.
|
||||
///
|
||||
/// In-band keys are selected by **absolute timestamp**, which is what the reference's search
|
||||
/// resolves to: its window is `ranges[slot]`, and that window brackets the band's own keys in
|
||||
/// every model of the 1.12.1 corpus (`benilla-extract bonescan`: 0 violations in 516 633 keyed
|
||||
/// band-slots — the check that makes reading the window safe at all). Selecting *the window's
|
||||
/// keys* as in-clip keys is the thing that never worked: `hi` routinely points at a key in a
|
||||
/// later band 14–64 s away, which froze Chicken/Bear/Kobold at a garbage pose. It is a
|
||||
/// **bracket**, not a key set.
|
||||
///
|
||||
/// Its load-bearing use is the band **edges**, where the reference keeps sampling and a
|
||||
/// truncated key list does not (wow-re `eval.md` FN1 `0x713d50`):
|
||||
///
|
||||
/// - a band with **no keys of its own** still has an authored pose — the window resolves it,
|
||||
/// either outright (`lo >= hi` ⇒ `keys[lo]`, the degenerate `{lo, lo, 0}` result) or as the
|
||||
/// lerp across the bracketing pair. Leaving the track empty instead froze the bone at
|
||||
/// whatever the *previous* clip left it (decision 0133's tilt: a Stand-variation waist twist
|
||||
/// rode through the entire run);
|
||||
/// - past the band's last key the reference keeps ramping toward `k1 = k0 + 1`, which is
|
||||
/// bounded by the TOTAL key count and not by the window — so a key belonging to a later
|
||||
/// sequence is a live lerp endpoint.
|
||||
///
|
||||
/// A head/tail sample is emitted only when it differs from the edge key it would otherwise
|
||||
/// hold, so the clips are unchanged wherever the reference and a plain hold agree — which,
|
||||
/// measured, is everywhere but `Creature\Zombie`'s root translation (decision 0643).
|
||||
///
|
||||
/// A **global-sequence** channel (`gseq != 0xffff`) runs on its own clock, not this band: a
|
||||
/// lone key is a pure CONSTANT folded into every clip (how vanilla authors the stowed-weapon
|
||||
/// attach-bone orientations — HumanMale bones 29/30/58–62), and a multi-key one belongs to
|
||||
/// [`parse_m2_global_sequence_bones`] and the `GlobalSeqDrive` lane, so it is left out here.
|
||||
fn band(&self, slot: usize, start: u32, end: u32) -> Vec<(f32, T)> {
|
||||
if self.keys.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.gseq != 0xffff {
|
||||
return match self.keys.len() {
|
||||
1 => vec![(0.0, self.keys[0].1)],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
}
|
||||
let last = self.keys.len() - 1;
|
||||
let (lo, hi) = self
|
||||
.ranges
|
||||
.get(slot)
|
||||
.map_or((0, last), |&(lo, hi)| (lo as usize, hi as usize));
|
||||
let at = |t| super::key_anim::sample_window(&self.keys, self.step, lo, hi, t);
|
||||
let rebase = |ts: u32| (ts.saturating_sub(start)) as f32 / 1000.0;
|
||||
let in_band: Vec<(u32, T)> = self
|
||||
.keys
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(ts, _)| ts >= start && ts <= end)
|
||||
.collect();
|
||||
let Some(&(first_ms, first_v)) = in_band.first() else {
|
||||
// An empty band: the window's value, held across it — two keys only when the bracket
|
||||
// lerp actually moves within the band.
|
||||
let head = match at(start) {
|
||||
Some(v) => v,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
return match at(end) {
|
||||
Some(tail) if tail != head => vec![(0.0, head), (rebase(end), tail)],
|
||||
_ => vec![(0.0, head)],
|
||||
};
|
||||
};
|
||||
let (last_ms, last_v) = in_band[in_band.len() - 1];
|
||||
let mut out = Vec::with_capacity(in_band.len() + 2);
|
||||
if first_ms > start && at(start).is_some_and(|h| h != first_v) {
|
||||
out.push((0.0, at(start).unwrap()));
|
||||
}
|
||||
out.extend(in_band.iter().map(|&(ts, v)| (rebase(ts), v)));
|
||||
if last_ms < end && at(end).is_some_and(|t| t != last_v) {
|
||||
out.push((rebase(end), at(end).unwrap()));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The weapon-grip finger poses: each requested `bone`'s rotation **as the reference samples it at
|
||||
/// the HandsClosed frame** (`AnimationData` id 15), through the same window kernel every other
|
||||
/// channel uses ([`ChannelTrack::band`]).
|
||||
///
|
||||
/// This exists as its own entry point because the grip is not a *clip* — it is a pose lifted out of
|
||||
/// one frame and worn as a masked overlay over whatever animation is playing, so the weapon hand
|
||||
/// stays closed while the body runs (wow-re `hand-grip-mechanism.md`). HandsClosed is a 33 ms band
|
||||
/// that keys nothing of its own on the finger bones (HumanMale bone 102 is keyed at 43333/70000 ms,
|
||||
/// both curled, not the 60000 ms frame) — its `interpolation_ranges` pair brackets it, which is
|
||||
/// exactly the case the window rule resolves.
|
||||
///
|
||||
/// Returns `(bone, quat[x,y,z,w])` for the bones that carry a plain rotation track; empty if the
|
||||
/// model has no HandsClosed sequence.
|
||||
pub fn hand_grip_finger_poses(bytes: &[u8], bones: &[u16]) -> Vec<(u16, [f32; 4])> {
|
||||
let b = bytes;
|
||||
if b.len() < 0x40 || &b[0..4] != b"MD20" {
|
||||
@@ -428,65 +501,49 @@ pub fn hand_grip_finger_poses(bytes: &[u8], bones: &[u16]) -> Vec<(u16, [f32; 4]
|
||||
}
|
||||
let (seq_count, seq_ofs) = (le_u32(b, 0x1c) as usize, le_u32(b, 0x20) as usize);
|
||||
let (bone_count, bone_ofs) = (le_u32(b, 0x34) as usize, le_u32(b, 0x38) as usize);
|
||||
// HandsClosed (anim id 15) → its frame's absolute start ms.
|
||||
let mut frame = None;
|
||||
// HandsClosed (anim id 15) → its FILE slot (what indexes the key windows) and frame start ms.
|
||||
let mut hands_closed = None;
|
||||
for s in 0..seq_count {
|
||||
let rec = seq_ofs + s * 0x44;
|
||||
if rec + 0x44 > b.len() {
|
||||
break;
|
||||
}
|
||||
if le_u16(b, rec) == 15 {
|
||||
frame = Some(le_u32(b, rec + 0x04));
|
||||
hands_closed = Some((s, le_u32(b, rec + 0x04)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(frame) = frame else {
|
||||
let Some((slot, frame)) = hands_closed else {
|
||||
return Vec::new();
|
||||
};
|
||||
let quat = |b: &[u8], o: usize| {
|
||||
[
|
||||
le_f32(b, o),
|
||||
le_f32(b, o + 4),
|
||||
le_f32(b, o + 8),
|
||||
le_f32(b, o + 12),
|
||||
]
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for &bone in bones {
|
||||
let bi = bone as usize;
|
||||
if bi >= bone_count {
|
||||
if bi >= bone_count || bone_ofs + bi * 0x6c + 0x6c > b.len() {
|
||||
continue;
|
||||
}
|
||||
let track = bone_ofs + bi * 0x6c + 0x28; // the bone's rotation M2Track (+0x28 in the 0x6c record)
|
||||
if track + 0x1c > b.len() || le_u16(b, track + 0x02) != 0xffff {
|
||||
continue; // out of range, or a global-sequence track (own clock — not this)
|
||||
// The bone's rotation M2Track (+0x28 in the 0x6c record), stride 16 (a 4×f32 quaternion).
|
||||
let tr = read_channel_track(b, bone_ofs + bi * 0x6c + 0x28, 16, quat);
|
||||
if tr.gseq != 0xffff || tr.keys.is_empty() {
|
||||
continue; // a global-sequence track runs on its own clock — not this
|
||||
}
|
||||
let (nts, ots) = (
|
||||
le_u32(b, track + 0x0c) as usize,
|
||||
le_u32(b, track + 0x10) as usize,
|
||||
);
|
||||
let vals_o = le_u32(b, track + 0x18) as usize;
|
||||
if nts == 0 {
|
||||
let last = tr.keys.len() - 1;
|
||||
let (lo, hi) = tr
|
||||
.ranges
|
||||
.get(slot)
|
||||
.map_or((0, last), |&(lo, hi)| (lo as usize, hi as usize));
|
||||
let Some(v) = super::key_anim::sample_window(&tr.keys, tr.step, lo, hi, frame) else {
|
||||
continue;
|
||||
}
|
||||
// Clamp: the last key with timestamp <= the frame (keys are time-ascending); else the first.
|
||||
let mut k = 0usize;
|
||||
for i in 0..nts {
|
||||
let t_off = ots + i * 4;
|
||||
if t_off + 4 > b.len() {
|
||||
break;
|
||||
}
|
||||
if le_u32(b, t_off) <= frame {
|
||||
k = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let v = vals_o + k * 16; // rotation stride 16 (a 4×f32 quaternion)
|
||||
if v + 16 > b.len() {
|
||||
continue;
|
||||
}
|
||||
out.push((
|
||||
bone,
|
||||
[
|
||||
le_f32(b, v),
|
||||
le_f32(b, v + 4),
|
||||
le_f32(b, v + 8),
|
||||
le_f32(b, v + 12),
|
||||
],
|
||||
));
|
||||
};
|
||||
out.push((bone, v));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -544,6 +601,21 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec<ModelAnimation> {
|
||||
model_events.push((ident, data, times));
|
||||
}
|
||||
|
||||
// Every bone's three channel tracks, read ONCE: the key arrays are shared by every sequence,
|
||||
// which only slices its own band out of them (`ChannelTrack::band`).
|
||||
let bone_tracks: Vec<BoneChannels> = (0..bone_count)
|
||||
.map_while(|i| {
|
||||
let brec = bone_ofs + i * 0x6c;
|
||||
(brec + 0x60 <= b.len()).then(|| {
|
||||
(
|
||||
read_channel_track(b, brec + 0x0c, 12, vec3),
|
||||
read_channel_track(b, brec + 0x28, 16, quat),
|
||||
read_channel_track(b, brec + 0x44, 12, vec3),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for s in 0..seq_count {
|
||||
let rec = seq_ofs + s * 0x44;
|
||||
@@ -582,14 +654,11 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec<ModelAnimation> {
|
||||
}
|
||||
let looping = flags & 1 == 0; // VERIFIED polarity: bit0 clear loops, set clamps
|
||||
let mut bones = Vec::new();
|
||||
for i in 0..bone_count {
|
||||
let brec = bone_ofs + i * 0x6c;
|
||||
if brec + 0x60 > b.len() {
|
||||
break;
|
||||
}
|
||||
let translation = read_bone_track(b, brec + 0x0c, start, end, 12, vec3);
|
||||
let rotation = read_bone_track(b, brec + 0x28, start, end, 16, quat);
|
||||
let scale = read_bone_track(b, brec + 0x44, start, end, 12, vec3);
|
||||
for (i, tracks) in bone_tracks.iter().enumerate() {
|
||||
let (tr, rot, sc) = tracks;
|
||||
let translation = tr.band(seq_index, start, end);
|
||||
let rotation = rot.band(seq_index, start, end);
|
||||
let scale = sc.band(seq_index, start, end);
|
||||
if !(translation.is_empty() && rotation.is_empty() && scale.is_empty()) {
|
||||
bones.push(BoneKeys {
|
||||
bone: i as u16,
|
||||
@@ -788,13 +857,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty band clamps to the nearest authored key instead of dropping the track (the task-#14
|
||||
/// tilt): HumanMale bone 27 (waist) rot has 4 keys, all outside Run's band — the real sampler
|
||||
/// pins it via `interpolation_ranges` (Run's pair is `(2, 3)`, both out-of-band), ours must emit
|
||||
/// the constant so a Stand-variation twist can't ride through the run. And the guarantee that
|
||||
/// makes the fix total: on every sequence of the model, any bone keyed *anywhere* stays keyed.
|
||||
/// An empty band still poses the bone instead of dropping the track (decision 0133's tilt):
|
||||
/// HumanMale bone 27 (waist) rot has 4 keys, all outside Run's band, and `ranges[Run] = (2, 3)`
|
||||
/// brackets it — so the clip must carry the bracketed value as a constant, or a Stand-variation
|
||||
/// twist rides through the entire run. And the guarantee that makes it total: on every sequence
|
||||
/// of the model, any bone keyed *anywhere* stays keyed.
|
||||
#[test]
|
||||
fn empty_band_clamps_to_nearest_key_never_drops_the_bone() {
|
||||
fn empty_band_poses_the_bone_and_never_drops_it() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
@@ -858,4 +927,122 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one place in the whole 1.12.1 corpus where the file's key window and decision 0133's
|
||||
/// nearest-key approximation disagree (`benilla-extract bonescan`: 9 of 210 368 empty band-slots,
|
||||
/// all three `Creature\Zombie` models) — and it disagrees for a reason worth pinning.
|
||||
///
|
||||
/// `Zombie.m2` bone 0 (the root) translation is a **step** track (`interp == 0`) with two keys:
|
||||
/// `3333 = (0,0,0)` and `30000 = (0.0047, 0, 0)`. Walk's band `[26667, 27333]` keys nothing and
|
||||
/// sits nearer the *second* key in time, so "nearest authored key" hands back a value the
|
||||
/// reference has not stepped to yet — a key from the future. The reference searches `ranges[slot]
|
||||
/// = (0, 1)`, takes `k0` = the last key at or before the time (key 0), and — being a step track —
|
||||
/// copies it. Small in yards, wrong in direction, and only the file's own window gets it right.
|
||||
#[test]
|
||||
fn empty_band_takes_the_window_key_not_a_later_one() {
|
||||
let data = vanilla_data_dir();
|
||||
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 bytes = chain
|
||||
.read_file("Creature\\Zombie\\Zombie.m2")
|
||||
.expect("read Zombie.m2");
|
||||
let seqs = parse_m2_animations(&bytes);
|
||||
for anim in [4, 5] {
|
||||
let s = seqs
|
||||
.iter()
|
||||
.find(|s| s.anim_id == anim)
|
||||
.unwrap_or_else(|| panic!("Zombie has anim {anim}"));
|
||||
let root = s
|
||||
.bones
|
||||
.iter()
|
||||
.find(|b| b.bone == 0)
|
||||
.expect("the root is carried into the clip");
|
||||
assert_eq!(
|
||||
root.translation.len(),
|
||||
1,
|
||||
"a step track's empty band holds one value across the band"
|
||||
);
|
||||
assert_eq!(
|
||||
root.translation[0].1,
|
||||
[0.0, 0.0, 0.0],
|
||||
"anim {anim} must hold key 0, not step early to the 0.0047 key at 30000 ms"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The weapon grip, which now reads through the shared window kernel rather than its own key
|
||||
/// scan: HandsClosed on `HumanMale.m2` is the 33 ms band `[60000, 60033]`, which keys nothing on
|
||||
/// finger bone 102 — its window brackets it with the keys at 43333/70000 ms, both the same
|
||||
/// curled quaternion, so the pose the overlay wears is that curl and not the rest pose. The
|
||||
/// substitution is a measured no-op: across the eight playable-race models with a HandsClosed
|
||||
/// sequence, all 460 finger-capable rotation tracks give the same quaternion under the old
|
||||
/// at-or-before clamp and under the window (decision 0643).
|
||||
#[test]
|
||||
fn hand_grip_reads_the_curled_pose_through_the_window() {
|
||||
let data = vanilla_data_dir();
|
||||
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 bytes = chain
|
||||
.read_file("Character\\Human\\Male\\HumanMale.m2")
|
||||
.expect("read HumanMale.m2");
|
||||
let poses = hand_grip_finger_poses(&bytes, &[102]);
|
||||
let (bone, q) = *poses.first().expect("bone 102 carries a grip pose");
|
||||
assert_eq!(bone, 102);
|
||||
let want = [0.2536, -0.016, 0.0577, 0.9654];
|
||||
for (i, (&got, &w)) in q.iter().zip(want.iter()).enumerate() {
|
||||
assert!(
|
||||
(got - w).abs() < 1e-3,
|
||||
"grip quat component {i}: got {got}, want ~{w} (the 43333/70000 ms curl)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The invariant that makes reading `interpolation_ranges` safe at all, pinned on the model
|
||||
/// decision 0133 named as the counter-example (`Bear`, which froze at a garbage pose when an
|
||||
/// earlier parse treated the window's far bracket as a *playable in-clip key* — it is a
|
||||
/// bracket, 14–64 s away). Two halves: no emitted keyframe may fall outside its own clip, and a
|
||||
/// sequence that keys a bone must carry every one of that band's own keys. Corpus-wide the
|
||||
/// first half is `bonescan`'s "band keys OUTSIDE their own ranges window: 0".
|
||||
#[test]
|
||||
fn no_clip_carries_a_key_from_another_sequences_band() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
for model in [
|
||||
"Creature\\Bear\\Bear.m2",
|
||||
"Creature\\Chicken\\Chicken.m2",
|
||||
"Character\\Human\\Male\\HumanMale.m2",
|
||||
] {
|
||||
let bytes = chain.read_file(model).expect("read model");
|
||||
for s in parse_m2_animations(&bytes) {
|
||||
for b in &s.bones {
|
||||
let times = b
|
||||
.translation
|
||||
.iter()
|
||||
.map(|&(t, _)| ("translation", t))
|
||||
.chain(b.rotation.iter().map(|&(t, _)| ("rotation", t)))
|
||||
.chain(b.scale.iter().map(|&(t, _)| ("scale", t)));
|
||||
for (ch, t) in times {
|
||||
assert!(
|
||||
(0.0..=s.duration + 1e-3).contains(&t),
|
||||
"{model} anim {} bone {} {ch}: key at {t}s is outside the \
|
||||
{}s clip — a bracket key leaked in as a playable key",
|
||||
s.anim_id,
|
||||
b.bone,
|
||||
s.duration
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,17 @@ impl Lerp for [f32; 3] {
|
||||
}
|
||||
}
|
||||
|
||||
impl Lerp for [f32; 4] {
|
||||
fn lerp(a: Self, b: Self, f: f32) -> Self {
|
||||
[
|
||||
f32::lerp(a[0], b[0], f),
|
||||
f32::lerp(a[1], b[1], f),
|
||||
f32::lerp(a[2], b[2], f),
|
||||
f32::lerp(a[3], b[3], f),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The sequence a sequence-timeline track bakes against: the model's **file** sequence slot (which
|
||||
/// is what indexes the track's [`M2Track::ranges`] — NOT an index into a filtered animation list)
|
||||
/// and that sequence's absolute `(start_ms, end_ms)` band.
|
||||
@@ -112,7 +123,7 @@ impl<V: Lerp> KeyAnim<V> {
|
||||
/// the band, and an extrapolated *fix16 factor* lands outside `[0, 1]` — where a negative value
|
||||
/// would cull the batch (`A <= 0`) on a data quirk rather than on authoring. We clamp the fraction
|
||||
/// to `[0, 1]`, i.e. hold at the bracket instead of running past it.
|
||||
fn sample_window<V: Lerp>(
|
||||
pub(super) fn sample_window<V: Lerp>(
|
||||
keys: &[(u32, V)],
|
||||
step: bool,
|
||||
lo: usize,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The loader-idle seed is **Stand**, not the file-order-first sequence (the floating duel-flag
|
||||
//! bug, 2026-07-25 — decision 0637).
|
||||
//!
|
||||
//! `DuelingFlag.m2` is the model that separates the two readings. Its sequence table is authored
|
||||
//! **Spawn(145) / Stand(0) / Despawn(157)** — file order 0 is the *Spawn*, not the idle — and its
|
||||
//! geometry is modelled in the air: the bind pose sits at z ≈ +8.9…+14.7, and bone 0's translation
|
||||
//! track is what plants it (`t=700 → −9.124`, held to `t=5333`). So arming file-order-0 and looping
|
||||
//! it flies the flag 9 yards up every 3.3 s, while arming Stand holds it planted — which is what
|
||||
//! the reference does (wow-re `gameobject-anim-arm.md` §1, byte-verified `0x71019b`: the loader
|
||||
//! arms animation id 0 resolved through the model's own `playableAnimationLookup`).
|
||||
//!
|
||||
//! This test pins the three facts the fix rests on, straight off the shipped file. Skips (passes)
|
||||
//! when the client isn't present at `<repo>/WoW/Data`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use benilla_formats::{open_chain, parse_m2_animations, parse_m2_playable_animation_lookup};
|
||||
|
||||
fn vanilla_data_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
|
||||
}
|
||||
|
||||
const DUEL_FLAG: &str = "World\\Generic\\PassiveDoodads\\DuelingFlag\\DuelingFlag.m2";
|
||||
/// `AnimationData.dbc`: 0 = Stand, 145 = Spawn, 157 = Despawn.
|
||||
const STAND: u16 = 0;
|
||||
const SPAWN: u16 = 145;
|
||||
/// Bone 0's planted height (raw WoW z, the value both keys bracketing the Stand band carry).
|
||||
const PLANTED_Z: f32 = -9.124369;
|
||||
|
||||
#[test]
|
||||
fn the_duel_flag_idle_resolves_to_stand_and_sits_planted() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||||
let bytes = chain
|
||||
.read_file(DUEL_FLAG)
|
||||
.expect("DuelingFlag.m2 in the chain");
|
||||
let anims = parse_m2_animations(&bytes);
|
||||
|
||||
// 1. File order really does lead with Spawn — without this the test proves nothing.
|
||||
assert_eq!(
|
||||
anims.first().map(|a| a.anim_id),
|
||||
Some(SPAWN),
|
||||
"the model's FIRST sequence is Spawn, not Stand — the whole point of this model"
|
||||
);
|
||||
|
||||
// 2. The loader's seed resolves to Stand through the model's own table.
|
||||
let playable = parse_m2_playable_animation_lookup(&bytes).expect("playable lookup");
|
||||
let idle_id = playable.first().map_or(0, |p| p.resolved_id);
|
||||
assert_eq!(
|
||||
idle_id, STAND,
|
||||
"playableAnimationLookup[0] resolves to Stand"
|
||||
);
|
||||
|
||||
// 3. Stand holds the flag IN THE GROUND: bone 0's translation over the Stand band is the
|
||||
// constant −9.124 (both bracketing keys carry it), not the bind pose it would sit at with
|
||||
// no clip. This is the assertion that actually catches the bug coming back.
|
||||
let stand = anims
|
||||
.iter()
|
||||
.find(|a| a.anim_id == STAND)
|
||||
.expect("DuelingFlag authors a Stand sequence");
|
||||
let root = stand
|
||||
.bones
|
||||
.iter()
|
||||
.find(|b| b.bone == 0)
|
||||
.expect("Stand keys bone 0");
|
||||
assert!(
|
||||
!root.translation.is_empty(),
|
||||
"Stand's band has no interior keys, so it must be bracketed to the authored pose — an \
|
||||
empty channel here means the flag renders at bind pose, 9 yards in the air"
|
||||
);
|
||||
for (_, v) in &root.translation {
|
||||
assert!(
|
||||
(v[2] - PLANTED_Z).abs() < 1e-3,
|
||||
"bone 0 z should be the planted {PLANTED_Z}, got {}",
|
||||
v[2]
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Stand's AUTHORED bounds describe the planted flag — ground to tip — and emphatically NOT
|
||||
// the bind pose (z +8.9..+14.7). This is the mouseover picker's volume for the armed idle
|
||||
// (entity M2s are never frustum-culled — the renderer takes `NoFrustumCulling`, faithful
|
||||
// to the reference's ≈1e7 entity render bounds; wow-re `unit-anim-visibility-gate.md`).
|
||||
// The bind-pose `Aabb` Bevy would derive sits a whole model-height ABOVE the geometry that
|
||||
// draws — as a cull box it hid the planted flag from every ground-level camera, and as a
|
||||
// pick box it would put the hover target in the sky.
|
||||
assert!(
|
||||
stand.bounds_min[2] > -1.0 && stand.bounds_min[2] < 0.5,
|
||||
"Stand's authored min z should sit at the ground, got {}",
|
||||
stand.bounds_min[2]
|
||||
);
|
||||
assert!(
|
||||
stand.bounds_max[2] > 4.0 && stand.bounds_max[2] < 8.0,
|
||||
"Stand's authored max z should be the flag's tip a few yards up, got {}",
|
||||
stand.bounds_max[2]
|
||||
);
|
||||
assert!(
|
||||
stand.bounds_min[2] < 8.9,
|
||||
"the authored box must NOT be the bind pose — that box floats a model-height in the air"
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
//! slot-keyed probe account (method.md "The local vmangos server"; NEVER `one`, the director's
|
||||
//! account — a probe login there kicks their live session). Two caveats: this is a COMBAT probe,
|
||||
//! so it runs director-supervised (method.md: no unattended combat probes), and `.learn` is
|
||||
//! SEC_DEVELOPER (5) while probe accounts are gmlevel 3 — raise the slot account via the vmangos
|
||||
//! SEC_DEVELOPER (5); probe accounts are gmlevel 6 (SEC_ADMINISTRATOR), so it lands. The vmangos
|
||||
//! console for the run and restore it after.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -65,7 +65,7 @@ struct Cli {
|
||||
/// Live-verify the melee-swing wire (decision 0073): GM-teleport (`.go xyz`) onto a Northshire
|
||||
/// Kobold Vermin spawn, `CMSG_ATTACKSWING` the nearest streamed creature, and require ≥1
|
||||
/// `SMSG_ATTACKERSTATEUPDATE` to arrive and decode (attacker/victim/hitInfo/damage). Needs a GM
|
||||
/// account (the deploy's are gmlevel 3).
|
||||
/// account (the deploy's probes are gmlevel 6).
|
||||
#[arg(long)]
|
||||
attack: bool,
|
||||
/// Live-verify `CMSG_USE_ITEM`: use the item in this 1-based backpack slot (the wire's bag
|
||||
@@ -105,7 +105,7 @@ struct Cli {
|
||||
/// require `SMSG_LOOT_RESPONSE` to arrive and parse, `CMSG_AUTOSTORE_LOOT_ITEM` every row,
|
||||
/// `CMSG_LOOT_MONEY` if it carried gold, and `CMSG_LOOT_RELEASE` — requiring
|
||||
/// `SMSG_LOOT_RELEASE_RESPONSE` to close it. Prints every loot-related packet decoded. Needs a
|
||||
/// GM account (the deploy's `three` is gmlevel 4).
|
||||
/// GM account (the deploy's probes are gmlevel 6).
|
||||
#[arg(long)]
|
||||
loot: bool,
|
||||
/// Skip the nearest-creature search for `--loot` and loot this guid directly (decimal or
|
||||
@@ -119,7 +119,7 @@ struct Cli {
|
||||
/// (`.quest complete`), then `CMSG_QUESTGIVER_COMPLETE_QUEST` → `_REQUEST_REWARD` → `_CHOOSE_REWARD`
|
||||
/// requiring `SMSG_QUESTGIVER_QUEST_COMPLETE` (XP/money) + a `PLAYER_FIELD_COINAGE` delta. Uses
|
||||
/// quest 7 "Kobold Camp Cleanup" (McBride gives + takes it; XP 170, money 25c). Needs a GM
|
||||
/// account (the deploy's `three` is gmlevel 4).
|
||||
/// account (the deploy's probes are gmlevel 6).
|
||||
#[arg(long)]
|
||||
quest: bool,
|
||||
/// Live-verify the quest-LOG wire (decision 0109 — the questgiver wire's deferred second
|
||||
@@ -134,7 +134,7 @@ struct Cli {
|
||||
/// (`.quest complete 7`) and require the slot's count-state field to gain the `COMPLETE` state
|
||||
/// byte, then `CMSG_QUESTLOG_REMOVE_QUEST` that slot and require its id field to clear to `0`
|
||||
/// (no ack SMSG on this wire — the field clear *is* the confirmation). Needs a GM account (the
|
||||
/// deploy's `three` is gmlevel 4).
|
||||
/// deploy's probes are gmlevel 6).
|
||||
#[arg(long)]
|
||||
questlog: bool,
|
||||
/// Live-verify the force-speed-change wire: GM `.modify speed 1.5` (self-targeted), require
|
||||
@@ -143,7 +143,7 @@ struct Cli {
|
||||
/// then `.modify speed 1` and require a SECOND change (counter incremented, speed 7.0) on a
|
||||
/// still-live stream. A malformed ack body throws in the server's parser and drops the session
|
||||
/// (the `--charge` precedent), so survival through both round trips is the wire proof. Needs a
|
||||
/// GM account (the deploy's `three` is gmlevel 4).
|
||||
/// GM account (the deploy's probes are gmlevel 6).
|
||||
#[arg(long)]
|
||||
speed: bool,
|
||||
|
||||
@@ -156,7 +156,7 @@ struct Cli {
|
||||
/// requires an `SMSG_UPDATE_AURA_DURATION` for each aura's own slot carrying the duration we
|
||||
/// asked for, and asserts it arrived **before** the descriptor delta that names the slot (the
|
||||
/// ordering the aura model depends on). Leaves the character as found (`.unaura` both). Needs a
|
||||
/// GM account (the deploy's `three` is gmlevel 4).
|
||||
/// GM account (the deploy's probes are gmlevel 6).
|
||||
#[arg(long)]
|
||||
aura: bool,
|
||||
|
||||
@@ -171,7 +171,7 @@ struct Cli {
|
||||
/// `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
|
||||
/// account: `.debug send worldstate` is gmlevel 5, under the 6 the slot-keyed probe
|
||||
/// accounts carry (decision 0450), so the update leg needs a temporary grant.
|
||||
#[arg(long)]
|
||||
worldstate: bool,
|
||||
@@ -183,7 +183,7 @@ struct Cli {
|
||||
/// the same server spline machinery as any creature (not a teleport / not a knockback). Prints
|
||||
/// the self spline in full (facing kind, duration, waypoints, flying bit) so the ride + the
|
||||
/// `CMSG_MOVE_SPLINE_DONE` ack it obliges can be built from the real numbers. Needs a GM warrior
|
||||
/// (the deploy's `three`/Tri, gmlevel 4).
|
||||
/// (any slot-keyed probe account, gmlevel 6).
|
||||
#[arg(long)]
|
||||
charge: bool,
|
||||
|
||||
@@ -195,7 +195,7 @@ struct Cli {
|
||||
/// leave the character alive. Recommend `--seconds 45`: the `.die`→repop→revive round trip
|
||||
/// itself completes in ~10s, but the reclaim delay is only ever observed as a packet value
|
||||
/// (never awaited), and the slack keeps a slow local server from truncating the ghost phase.
|
||||
/// Needs a GM account (the deploy's `three` is gmlevel 4).
|
||||
/// Needs a GM account (the deploy's probes are gmlevel 6).
|
||||
#[arg(long)]
|
||||
death: bool,
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ impl Probe for Aura {
|
||||
"--aura: spell 1126 never appeared in UNIT_FIELD_AURA. Either the descriptor field \
|
||||
index is wrong, or the GM `.aura` command was refused — it needs gmlevel >= 4 \
|
||||
(VERIFIED vmangos `Chat/Chat.cpp:1229`: SEC_BASIC_ADMIN, which is 4 in \
|
||||
`shared/Common.h:142`), and the slot-keyed probe accounts are gmlevel 3, so this \
|
||||
`shared/Common.h:142`), and the slot-keyed probe accounts are gmlevel 6, so this \
|
||||
probe cannot run as `probeN` without a temporary grant (method.md, decision 0450's \
|
||||
precedent for --worldstate).",
|
||||
)?;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//!
|
||||
//! **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
|
||||
//! gmlevel 6 the slot-keyed probe accounts carry (decisions 0450/0651). 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.
|
||||
//!
|
||||
|
||||
@@ -129,8 +129,9 @@ pub use quest::{
|
||||
};
|
||||
pub use roster::{
|
||||
CharCreateReq, CharEnumItem, Character, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK,
|
||||
CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, CHAR_CREATE_NAME_IN_USE, CHAR_CREATE_SUCCESS,
|
||||
CHAR_DELETE_SUCCESS, CLASS_WARRIOR, GENDER_MALE, RACE_HUMAN,
|
||||
CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, CHAR_CREATE_NAME_IN_USE,
|
||||
CHAR_CREATE_SERVER_LIMIT, CHAR_CREATE_SUCCESS, CHAR_DELETE_SUCCESS, CLASS_WARRIOR, GENDER_MALE,
|
||||
RACE_HUMAN,
|
||||
};
|
||||
pub use skills::unlearn_skill;
|
||||
pub use spellbook::SpellCooldown;
|
||||
|
||||
@@ -514,6 +514,14 @@ pub const CMSG_LEARN_TALENT: u16 = 0x0251; // 593
|
||||
/// field update.
|
||||
pub const CMSG_UNLEARN_SKILL: u16 = 0x0202; // 514
|
||||
|
||||
/// Ask to flip our own PvP flag (decision 0646). A **two-way** opcode: vmangos reads a `bool`
|
||||
/// target state iff the body is exactly one byte (`Server/Packets/Misc.cpp`
|
||||
/// `TogglePvP::ReadFromWorldPacket`) and otherwise *toggles* `PLAYER_FLAGS_PVP_DESIRED`
|
||||
/// (`Handlers/MiscHandler.cpp` `HandleTogglePvP`). benilla sends the **empty** body — the verb is
|
||||
/// a toggle. No ack: the answer arrives as the `UNIT_FIELD_FLAGS` PvP bit in a descriptor update,
|
||||
/// and turning the preference *off* changes nothing until the server's 300 s drop timer expires.
|
||||
pub const CMSG_TOGGLE_PVP: u16 = 0x0253; // 595
|
||||
|
||||
// The solo-loot wire family (VERIFIED vmangos `Opcodes_1_12_1.h`: 264, 349-355, 357-358).
|
||||
// Bodies in [`super::loot`].
|
||||
pub const CMSG_AUTOSTORE_LOOT_ITEM: u16 = 0x0108; // 264
|
||||
|
||||
@@ -9,6 +9,11 @@ use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8, Vector3d};
|
||||
/// `WorldResult::CharCreateSuccess` / `CharCreateNameInUse` (`SMSG_CHAR_CREATE`).
|
||||
pub const CHAR_CREATE_SUCCESS: u8 = 0x2E;
|
||||
pub const CHAR_CREATE_NAME_IN_USE: u8 = 0x31;
|
||||
/// `WorldResult::CharCreateServerLimit` — the account already holds `CharactersPerRealm` characters
|
||||
/// (vmangos `HandleCharCreateOpcode`, checked against the last char-enum's count). Counted from the
|
||||
/// `CHAR_CREATE_SUCCESS = 0x2E` anchor through the `SharedDefines.h` ResponseCodes order
|
||||
/// (SUCCESS, ERROR, FAILED, NAME_IN_USE, DISABLED, PVP_TEAMS_VIOLATION, SERVER_LIMIT).
|
||||
pub const CHAR_CREATE_SERVER_LIMIT: u8 = 0x34;
|
||||
/// `WorldResult::CharDeleteSuccess` (`SMSG_CHAR_DELETE`; vmangos `SharedDefines.h` ResponseCodes,
|
||||
/// counted from the verified `CHAR_CREATE_SUCCESS = 0x2E` anchor).
|
||||
pub const CHAR_DELETE_SUCCESS: u8 = 0x39;
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
//! | [`trade`] | the player-trade dance |
|
||||
//! | [`duel`] | accept and cancel |
|
||||
//! | [`mail`] | the mailbox |
|
||||
//! | [`pvp`] | the one PvP-flag toggle |
|
||||
|
||||
use std::net::TcpStream;
|
||||
|
||||
@@ -63,6 +64,7 @@ mod mail;
|
||||
mod names;
|
||||
mod pose;
|
||||
mod progression;
|
||||
mod pvp;
|
||||
mod quest;
|
||||
mod selection;
|
||||
mod self_movement;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//! The PvP-flag `WorldWriter` send — the one outbound verb the whole PvP-flag family has
|
||||
//! (decision 0646).
|
||||
//!
|
||||
//! There is no body builder in [`crate::messages`] to mirror here: the packet is empty, which is
|
||||
//! itself the meaningful choice (see [`opcode::CMSG_TOGGLE_PVP`] — a one-byte body would mean
|
||||
//! "set this exact state" instead).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::messages::opcode;
|
||||
|
||||
use super::WorldWriter;
|
||||
|
||||
impl WorldWriter {
|
||||
/// Ask the server to flip our own PvP flag (`CMSG_TOGGLE_PVP`, empty body) — `/pvp` and the
|
||||
/// unit popup's PvP row. There is no ack and no immediate local effect: flagging *on* comes
|
||||
/// back as the `UNIT_FIELD_FLAGS` PvP bit within the next descriptor update, while flagging
|
||||
/// *off* only clears the preference — the flag itself survives until vmangos' 300 s drop
|
||||
/// timer expires (`Player::UpdatePvP`). The client predicts neither.
|
||||
pub fn toggle_pvp(&mut self) -> Result<()> {
|
||||
self.send(opcode::CMSG_TOGGLE_PVP, &[])
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,23 @@ impl Loader<'_> {
|
||||
if el.attr_bool("enableMouse") {
|
||||
self.call(wrapper, "EnableMouse", true, dbg);
|
||||
}
|
||||
// `<HitRectInsets><AbsInset left= right= top= bottom=/></HitRectInsets>` (also accepted
|
||||
// inline on the element) → SetHitRectInsets: the frame's MOUSE rect, inset from its
|
||||
// resolved rect. Absent sides read 0, so a partial element insets only what it names.
|
||||
if let Some(ins) = children_named(el, "HitRectInsets").next() {
|
||||
let src = children_named(ins, "AbsInset").next().unwrap_or(ins);
|
||||
let side = |k: &str| {
|
||||
src.attr(k)
|
||||
.and_then(|v| v.trim().parse::<f32>().ok())
|
||||
.unwrap_or(0.0)
|
||||
};
|
||||
self.call(
|
||||
wrapper,
|
||||
"SetHitRectInsets",
|
||||
(side("left"), side("right"), side("top"), side("bottom")),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
// Still genuine gaps — the window-behaviour surface (input arbitration, decision 0068).
|
||||
// Flag them rather than silently dropping or faking them.
|
||||
for (attr, api) in [
|
||||
|
||||
@@ -813,6 +813,40 @@ mod loader_tests {
|
||||
"the text region is anchored by the insets"
|
||||
);
|
||||
}
|
||||
|
||||
/// `<HitRectInsets>` reaches SetHitRectInsets, and the inset band stops capturing the mouse
|
||||
/// while the frame's own geometry is untouched — the ref micro-button shape (a 29x58 frame whose
|
||||
/// art fills only the lower ~40, `top="18"`).
|
||||
#[test]
|
||||
fn hit_rect_insets_element_shrinks_the_mouse_rect() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<Button name="Micro">
|
||||
<Size><AbsDimension x="29" y="58"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"/></Anchors>
|
||||
<HitRectInsets><AbsInset left="0" right="0" top="18" bottom="0"/></HitRectInsets>
|
||||
</Button>
|
||||
</Ui>"#,
|
||||
);
|
||||
let report = load(&s, &doc, &no_files);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
s.resolve();
|
||||
|
||||
let (l, r, t, b) = s
|
||||
.eval::<(f64, f64, f64, f64)>("return Micro:GetHitRectInsets()")
|
||||
.unwrap();
|
||||
assert_eq!((l, r, t, b), (0.0, 0.0, 18.0, 0.0));
|
||||
// Geometry untouched: still a 58-tall button.
|
||||
assert_eq!(s.eval::<f64>("return Micro:GetHeight()").unwrap(), 58.0);
|
||||
// A button is mouse-enabled by construction, so the hit test is live.
|
||||
assert!(
|
||||
s.hit_test(14.0, 50.0).is_none(),
|
||||
"the dead 18-unit header must not capture"
|
||||
);
|
||||
assert!(s.hit_test(14.0, 20.0).is_some(), "the art band still hits");
|
||||
}
|
||||
}
|
||||
|
||||
mod layer_blend_tests {
|
||||
|
||||
@@ -215,6 +215,31 @@ impl ZKey {
|
||||
ZKey(bits)
|
||||
}
|
||||
|
||||
/// The key a frame's own **content** draws at: its bare [`ZKey::frame`] slot promoted into its
|
||||
/// region band at `layer` — behind that layer's textures, ahead of its declared font strings.
|
||||
///
|
||||
/// Content is what a widget kind draws *itself*, with no region to hang it on — today only a
|
||||
/// ScrollingMessageFrame's ring lines, which the client materializes as the frame's own font
|
||||
/// strings (its `<FontString>` child is their font instance, and a font string with no
|
||||
/// `<Layer>` is `ARTWORK`). At the bare frame slot they sorted before **all** of the frame's
|
||||
/// regions, so the chat window's own `$parentBackground` — a `BACKGROUND` texture, faded in
|
||||
/// on hover — painted over the messages and dimmed them (director, 2026-07-26; the white
|
||||
/// text measured 192, exactly `255 × (1 − 0.25)`, the box's own alpha). The frame slot stays
|
||||
/// what it was — the backdrop/scissor setup that must precede everything the frame owns.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn content(self, layer: DrawLayer) -> ZKey {
|
||||
ZKey(
|
||||
self.0
|
||||
| (1u64 << IS_REGION_SHIFT)
|
||||
| ((layer.index() as u64) << LAYER_SHIFT)
|
||||
// Sub-level 0 in the same +128 bias `region` uses, and the font-string bit: content
|
||||
// is text, so it takes the same textures-draw-first slot a font string would.
|
||||
| (128u64 << SUBLEVEL_SHIFT)
|
||||
| (1u64 << FONTSTRING_SHIFT),
|
||||
)
|
||||
}
|
||||
|
||||
/// The raw packed value (the sort key).
|
||||
#[inline]
|
||||
pub const fn raw(self) -> u64 {
|
||||
|
||||
@@ -226,6 +226,12 @@ fn set_focus_handle(lua: &Lua, h: FrameHandle) {
|
||||
}
|
||||
let old = model.focused_editbox;
|
||||
model.focused_editbox = Some(h);
|
||||
// A fresh focus starts a fresh history session: the browse position must not leak from
|
||||
// the box's previous open (programmatic SetText deliberately preserves it — the chat
|
||||
// live parse's recall rewrite — so this is where a stale walk gets dropped).
|
||||
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
eb.end_history_browse();
|
||||
}
|
||||
(old.map(|o| model.frame_id(o)), model.frame_id(h))
|
||||
};
|
||||
if let Some(oid) = old_id {
|
||||
@@ -375,8 +381,8 @@ fn delete_dir(lua: &Lua, h: FrameHandle, forward: bool) {
|
||||
|
||||
/// One UP/DOWN history-recall step: ask the state for the line to show and route it through the
|
||||
/// internal `set_text` path (region sync + `OnTextSet`/`OnTextChanged` — a recall resets FrameXML
|
||||
/// tab-complete state exactly like any other text change). `set_text` itself leaves the browse
|
||||
/// position intact; only typed edits and Lua `SetText` end browsing.
|
||||
/// tab-complete state exactly like any other text change). `set_text` (internal AND Lua) leaves
|
||||
/// the browse position intact; typed edits, `AddHistoryLine`, and focus gain end browsing.
|
||||
fn history_step_key(lua: &Lua, h: FrameHandle, older: bool) {
|
||||
let recalled = with_eb(lua, h, |eb| eb.history_step(older)).flatten();
|
||||
if let Some(text) = recalled {
|
||||
|
||||
@@ -32,9 +32,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, s): (Table, Option<String>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// Programmatic SetText ends a history browse (the engine-internal recall path calls
|
||||
// `set_text` directly and keeps its browse position).
|
||||
with_eb(lua, h, EditBoxState::end_history_browse);
|
||||
// Programmatic SetText KEEPS a history browse in progress: the chat live parse
|
||||
// rewrites the box on every recalled slash line ("/s hi" → Say + "hi"), and ending
|
||||
// the browse there reset every UP to the newest entry — "history only goes back 1"
|
||||
// (director's report, 2026-07-26). The browse ends on typed edits, AddHistoryLine,
|
||||
// and focus gain (`set_focus_handle`) — the fresh-session reset.
|
||||
set_text(lua, h, &s.unwrap_or_default());
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -142,6 +144,27 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"GetBlinkSpeed",
|
||||
lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.blink_period))?,
|
||||
)?;
|
||||
// SetTextColor(r, g, b [, a]) — the FontInstance half an EditBox inherits in the client
|
||||
// (`ChatEdit_UpdateHeader` colors the typed text this way): tint the box's text region,
|
||||
// creating it lazily like every text-touching path.
|
||||
m.set(
|
||||
"SetTextColor",
|
||||
lua.create_function(
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let Some(rh) = super::ensure_text_region(lua, h) else {
|
||||
return Err(mlua::Error::runtime("not an EditBox"));
|
||||
};
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.region_data
|
||||
.entry(rh)
|
||||
.or_default()
|
||||
.color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetTextInsets",
|
||||
lua.create_function(
|
||||
|
||||
@@ -526,6 +526,33 @@ fn typing_ends_the_history_browse() {
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programmatic_set_text_keeps_the_browse_and_focus_gain_resets_it() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetHistoryLines(32)
|
||||
E:AddHistoryLine("/say one")
|
||||
E:AddHistoryLine("/g two")
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Recall the newest, then rewrite the box the way the chat live parse does on a slash
|
||||
// recall ("/g two" → Guild + "two"). The browse walk must survive: the next UP lands on
|
||||
// the OLDER entry, not back on the newest (the "history only goes back 1" bug).
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
|
||||
s.run(r#"E:SetText("two")"#).unwrap();
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/say one");
|
||||
// Refocusing starts a fresh session: the stale walk drops, UP recalls the newest again.
|
||||
s.run(r#"E:ClearFocus() E:SetFocus()"#).unwrap();
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_caps_at_history_lines_drop_oldest() {
|
||||
let mut s = script();
|
||||
@@ -607,6 +634,40 @@ fn text_insets_shrink_the_text_region_rect() {
|
||||
assert_eq!((l, r_, t, b), (15.0, 13.0, 2.0, 3.0));
|
||||
}
|
||||
|
||||
// The FontInstance half an EditBox inherits in the client: SetTextColor tints the box's text
|
||||
// region (`ChatEdit_UpdateHeader` colors the typed text this way — its absence killed the chat
|
||||
// header chunk mid-run and left the /w insets stale, the caret-in-the-header bug).
|
||||
#[test]
|
||||
fn set_text_color_tints_the_text_region() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetPoint("BOTTOMLEFT", 100, 50)
|
||||
E:SetWidth(400)
|
||||
E:SetHeight(32)
|
||||
E:SetText("hello")
|
||||
E:SetTextColor(1.0, 0.5, 0.25)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let color = s
|
||||
.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
crate::script::QuadContent::Text {
|
||||
text: Some(t),
|
||||
color,
|
||||
..
|
||||
} if t == "hello" => Some(*color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the edit box text renders");
|
||||
assert_eq!(color, Some([1.0, 0.5, 0.25, 1.0]));
|
||||
}
|
||||
|
||||
// ── the host text-UI seam: advances, caret/selection geometry, mouse, clipboard ─────────────
|
||||
|
||||
/// Build a focused 200×32 box at BOTTOMLEFT(100,50), resolve it, type `text`, and answer its
|
||||
|
||||
@@ -226,11 +226,23 @@ impl UiScript {
|
||||
// its live fade alpha in the text color. The generic region path can't do this (the lines
|
||||
// aren't declared FontStrings); the ring lives in the frame's kind state.
|
||||
if let (ZTarget::Frame(fh), Some(fr)) = (target, rect) {
|
||||
// The frame's own draw slot also carries its Backdrop (behind its regions, which sort
|
||||
// after the frame slot) and, for a ScrollingMessageFrame, its ring lines — both clip
|
||||
// like the frame's own slot (the frame IS the clipped owner when it's a scroll child).
|
||||
// The frame's own draw slot carries its Backdrop, behind its regions (which sort
|
||||
// after the frame slot). A ScrollingMessageFrame's ring lines do NOT: they take the
|
||||
// slot's ARTWORK *content* key ([`crate::order::ZKey::content`]) — the layer the
|
||||
// client's own message font strings live in — so the frame's BACKGROUND textures
|
||||
// stay behind them. At the bare slot the chat window's hover box painted over its
|
||||
// own messages. Both clip like the frame's own slot (the frame IS the clipped owner
|
||||
// when it's a scroll child).
|
||||
Self::emit_backdrop(&model, fh, fr, zkey.raw(), alpha, clip, &mut out);
|
||||
Self::emit_message_lines(&model, fh, fr, zkey.raw(), alpha, clip, &mut out);
|
||||
Self::emit_message_lines(
|
||||
&model,
|
||||
fh,
|
||||
fr,
|
||||
zkey.content(order::DrawLayer::Artwork).raw(),
|
||||
alpha,
|
||||
clip,
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -462,6 +462,62 @@ mod tests {
|
||||
assert!((clip.top - 124.0).abs() < 0.01 && (clip.bottom - 90.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// The ring lines are the frame's ARTWORK content, not its bare draw slot: a BACKGROUND
|
||||
/// texture of the same frame draws BEHIND them, an OVERLAY one in front. The chat window's
|
||||
/// hover box is that background texture — at the bare slot it painted over the messages and
|
||||
/// dimmed them (director, 2026-07-26).
|
||||
#[test]
|
||||
fn ring_lines_draw_above_the_frames_background_and_below_its_overlay() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
|
||||
f:SetPoint('BOTTOMLEFT', 32, 90)\n\
|
||||
f:SetWidth(430)\n\
|
||||
f:SetHeight(60)\n\
|
||||
local bg = f:CreateTexture('CFBg', 'BACKGROUND')\n\
|
||||
bg:SetTexture('Interface\\\\Bg')\n\
|
||||
bg:SetAllPoints(f)\n\
|
||||
local over = f:CreateTexture('CFOver', 'OVERLAY')\n\
|
||||
over:SetTexture('Interface\\\\Over')\n\
|
||||
over:SetAllPoints(f)\n\
|
||||
f:AddMessage('hello', 1, 1, 1)",
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
let z_of = |path: &str| {
|
||||
quads
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
crate::script::QuadContent::Texture { path: Some(p), .. } if p == path => {
|
||||
Some(q.z)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("texture drew")
|
||||
};
|
||||
let line_z = quads
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
crate::script::QuadContent::Text { text: Some(t), .. } if t == "hello" => Some(q.z),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the ring line drew");
|
||||
let slot_z = quads
|
||||
.iter()
|
||||
.find(|q| matches!(q.content, crate::script::QuadContent::Frame))
|
||||
.map(|q| q.z)
|
||||
.expect("the frame slot drew");
|
||||
let (bg_z, over_z) = (z_of("Interface\\Bg"), z_of("Interface\\Over"));
|
||||
assert!(slot_z < bg_z, "the frame's own slot still leads");
|
||||
assert!(
|
||||
bg_z < line_z,
|
||||
"a BACKGROUND texture draws behind the messages"
|
||||
);
|
||||
assert!(line_z < over_z, "an OVERLAY texture draws in front of them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_change_invalidates_row_measures() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -65,9 +65,11 @@ mod merchant;
|
||||
mod messageframe;
|
||||
mod minimap;
|
||||
mod model;
|
||||
mod net_stats;
|
||||
mod object;
|
||||
mod party;
|
||||
mod pointer;
|
||||
mod pvp;
|
||||
mod quest;
|
||||
mod quest_log;
|
||||
mod region;
|
||||
@@ -248,6 +250,7 @@ impl UiScript {
|
||||
unit::install(&lua)?;
|
||||
party::install(&lua)?;
|
||||
duel::install(&lua)?;
|
||||
pvp::install(&lua)?;
|
||||
death::install(&lua)?;
|
||||
aura::install(&lua)?;
|
||||
sound::install(&lua)?;
|
||||
@@ -283,6 +286,7 @@ impl UiScript {
|
||||
cooldown::install(&lua)?;
|
||||
tooltip::install(&lua)?;
|
||||
worldmap::install(&lua)?;
|
||||
net_stats::install(&lua)?;
|
||||
|
||||
Ok(UiScript {
|
||||
lua,
|
||||
@@ -404,6 +408,15 @@ impl UiScript {
|
||||
std::mem::take(&mut self.model_mut().chat_input)
|
||||
}
|
||||
|
||||
/// Queue a line as if it had been typed into the chat EditBox and submitted — the headless
|
||||
/// twin of `SubmitChatInput`, for scripted probes (`WOW_PROBE_CHAT`). Going through this seam
|
||||
/// rather than straight to the wire is what lets a probe drive **client-side** slash commands
|
||||
/// (`/duel`, `/reaction`); a plain line or a `.gm`-style server command still reaches the wire
|
||||
/// exactly as before, because that is what the chat drain does with anything it doesn't own.
|
||||
pub fn push_chat_input(&mut self, line: String) {
|
||||
self.model_mut().chat_input.push(line);
|
||||
}
|
||||
|
||||
/// Whether Tab was pressed in the chat edit box since the last call (`BenillaChatTabPressed`)
|
||||
/// — the whisper-target cycle's trigger (decision 0288 P5).
|
||||
pub fn take_chat_tab(&mut self) -> bool {
|
||||
|
||||
@@ -152,6 +152,10 @@ pub(crate) struct Model {
|
||||
/// [`super::UiScript::take_duel_requests`] drain — the outbound seam ([`duel`]). There is no
|
||||
/// duel *snapshot* beside it: everything the UI reads arrives as event arguments.
|
||||
pub(crate) duel_requests: Vec<duel::DuelRequest>,
|
||||
/// PvP-flag toggles (`TogglePVP`) queued since the app's last
|
||||
/// [`super::UiScript::take_pvp_toggles`] drain — the outbound seam ([`pvp`]). A count, not a
|
||||
/// payload: `CMSG_TOGGLE_PVP` carries no body.
|
||||
pub(crate) pvp_toggles: u32,
|
||||
|
||||
/// Sounds queued by the Lua `PlaySound`/`PlaySoundFile` bindings since the app's last
|
||||
/// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]).
|
||||
@@ -501,6 +505,11 @@ pub(crate) struct Model {
|
||||
/// merchant window's coin line read it.
|
||||
pub(crate) money: u64,
|
||||
|
||||
/// The reported connection round trip in ms — `GetNetStats`'s third return, pushed by the app's
|
||||
/// net feed ([`UiScript::set_latency_ms`]). The AVERAGE over the app's RTT ring, not the last
|
||||
/// sample; `0` = nothing measured yet. See [`super::net_stats`].
|
||||
pub(crate) net_latency_ms: u32,
|
||||
|
||||
/// The player's experience within the current level (`UnitXP("player")`) and the amount required
|
||||
/// to level (`UnitXPMax("player")`), pushed each frame they change by the app's `PLAYER_XP` /
|
||||
/// `PLAYER_NEXT_LEVEL_XP` feed ([`UiScript::set_player_xp`]). A player-level pair (like `money`),
|
||||
@@ -625,6 +634,7 @@ impl Model {
|
||||
party_requests: Vec::new(),
|
||||
tell_requests: Vec::new(),
|
||||
duel_requests: Vec::new(),
|
||||
pvp_toggles: 0,
|
||||
sound_queue: Vec::new(),
|
||||
actions: HashMap::new(),
|
||||
action_states: HashMap::new(),
|
||||
@@ -746,6 +756,7 @@ impl Model {
|
||||
cursor_pos: (0.0, 0.0),
|
||||
modifiers: (false, false, false),
|
||||
money: 0,
|
||||
net_latency_ms: 0,
|
||||
player_xp: 0,
|
||||
player_next_level_xp: 0,
|
||||
player_combat_stats: None,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! `GetNetStats()` — the connection-telemetry read the main bar's performance ("ping") meter polls.
|
||||
//!
|
||||
//! One global over one pushed number. The reference binding (`0x48b8b0`, wow-re
|
||||
//! `system/ui/scratch/bindings.md`) returns `bandwidthIn, bandwidthOut, latency`; its latency is
|
||||
//! **not** the last round trip but the average over the connection's RTT ring — wow-re net W1
|
||||
//! records `HandlePong 0x537d60` as "RTT into ring, head/tail wrap 16" feeding
|
||||
//! `0x537f20 throughput/avg-RTT math (host telemetry; no wire bytes)". The app owns that ring
|
||||
//! ([`crate`]'s consumer keeps it beside its ping clock) and pushes the average here; the engine
|
||||
//! stays free of ECS/net reach (decision 0068 §3), exactly as [`super::unit`]'s `GetMoney` does.
|
||||
//!
|
||||
//! **The two bandwidth returns are `0`.** benilla measures no throughput — its socket threads tally
|
||||
//! no bytes — and nothing in the 1.12 UI reads them: `MainMenuBarPerformanceBarFrame` is the whole
|
||||
//! shipped caller of `GetNetStats` and discards both. They stay in the signature (numbers, not
|
||||
//! `nil`, so arithmetic on them can't error) as a named gap, not a claim.
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::Model;
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push the reported round trip (ms) — the app's average over its RTT ring. `None` = no sample
|
||||
/// yet (before the connection's first pong, or after a disconnect), which reads as `0` through
|
||||
/// `GetNetStats`: the reference reports 0 for an unmeasured connection too, and the bar's
|
||||
/// thresholds put 0 in the green band, which is what the real client shows at login.
|
||||
pub fn set_latency_ms(&mut self, ms: Option<u32>) {
|
||||
self.model_mut().net_latency_ms = ms.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the net-stats globals.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// GetNetStats() → bandwidthIn, bandwidthOut, latency (see the module doc for the two zeros).
|
||||
lua.globals().set(
|
||||
"GetNetStats",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok((0.0_f64, 0.0_f64, i64::from(model.net_latency_ms)))
|
||||
})?,
|
||||
)
|
||||
}
|
||||
@@ -344,6 +344,32 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
Ok(model.arena.is_clamped_to_screen(h))
|
||||
})?,
|
||||
)?;
|
||||
// Hit-rect insets — the MOUSE rect only (widget::Frame::hit_rect_insets): the hit test shrinks
|
||||
// the resolved rect by these four before testing the cursor, and nothing else reads them, so a
|
||||
// frame's geometry/draw/anchor answers are unchanged. The ref sets them wherever a button's
|
||||
// frame is larger than its art (the micro buttons' 18 px empty header).
|
||||
m.set(
|
||||
"SetHitRectInsets",
|
||||
lua.create_function(
|
||||
|lua, (this, left, right, top, bottom): (Table, f32, f32, f32, f32)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.arena
|
||||
.set_hit_rect_insets(h, [left, right, top, bottom]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetHitRectInsets",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let i = model.arena.hit_rect_insets(h);
|
||||
Ok((i[0], i[1], i[2], i[3]))
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -85,10 +85,9 @@ impl UiScript {
|
||||
let scroll_sources = scroll_clip_sources(&model);
|
||||
let hit = order::hit_test(&sorted, |fh| {
|
||||
model.arena.is_mouse_enabled(fh)
|
||||
&& model
|
||||
.resolved
|
||||
.get(&fh)
|
||||
.is_some_and(|r| point_in_rect(*r, x, y))
|
||||
&& model.resolved.get(&fh).is_some_and(|r| {
|
||||
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
|
||||
})
|
||||
&& effective_clip(&model, &scroll_sources, fh)
|
||||
.is_none_or(|c| point_in_rect(c, x, y))
|
||||
});
|
||||
@@ -435,3 +434,17 @@ impl UiScript {
|
||||
pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool {
|
||||
x >= r.left && x <= r.right && y >= r.bottom && y <= r.top
|
||||
}
|
||||
|
||||
/// A frame's **mouse** rect: its resolved rect shrunk by `[left, right, top, bottom]`
|
||||
/// ([`crate::widget::Frame::hit_rect_insets`]). y-up, so a `top` inset lowers `top` and a `bottom`
|
||||
/// inset raises `bottom`. Insets that over-shrink collapse the rect rather than inverting it — an
|
||||
/// empty rect simply captures nothing, which is what the API's own "inset past the far edge" case
|
||||
/// means.
|
||||
fn inset_rect(r: Rect, [left, right, top, bottom]: [f32; 4]) -> Rect {
|
||||
Rect {
|
||||
left: r.left + left,
|
||||
right: (r.right - right).max(r.left + left),
|
||||
top: r.top - top,
|
||||
bottom: (r.bottom + bottom).min(r.top - top),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The PvP-flag **Era API surface** (decision 0646) — one global, no state.
|
||||
//!
|
||||
//! `TogglePVP()` is the whole outbound half of the PvP family: everything the UI *reads* is unit
|
||||
//! state that already arrives on the [`super::unit`] snapshot (`UnitIsPVP`, `UnitIsPVPFreeForAll`,
|
||||
//! `UnitFactionGroup`). The call queues a toggle the app drains
|
||||
//! ([`super::UiScript::take_pvp_toggles`]) and turns into its `CMSG_TOGGLE_PVP` send, keeping the
|
||||
//! engine free of ECS/net reach (decision 0068 §3) — [`super::duel`]'s shape, one verb smaller.
|
||||
//!
|
||||
//! The reference registers the binding at `0x48d700` and calls it from exactly one place in the
|
||||
//! whole shipped 1.12 UI: `SlashCmdList["PVP"]` (ChatFrame.lua). benilla's popup row is a
|
||||
//! deliberate second caller — decision 0646 §3.
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::Model;
|
||||
|
||||
impl super::UiScript {
|
||||
/// Drain the PvP-flag toggles queued since the last call — each one is a `CMSG_TOGGLE_PVP`.
|
||||
/// A count rather than a payload: the packet is empty, so two toggles in a frame are two
|
||||
/// sends, not one collapsed intent.
|
||||
pub fn take_pvp_toggles(&mut self) -> u32 {
|
||||
std::mem::take(&mut self.model_mut().pvp_toggles)
|
||||
}
|
||||
|
||||
/// Queue a toggle from the app side — the `/pvp` slash command. In the reference that slash
|
||||
/// handler *is* Lua (a one-liner over `TogglePVP`); benilla parses slash lines in Rust, so the
|
||||
/// same intent enters the same queue here rather than through the global ([`super::duel`]'s
|
||||
/// `queue_duel_request` reasoning, verbatim).
|
||||
pub fn queue_pvp_toggle(&mut self) {
|
||||
self.model_mut().pvp_toggles += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the PvP globals.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// TogglePVP() — /pvp and the unit popup's PvP row. Takes no argument in 1.12: the *state*
|
||||
// form of the opcode (a one-byte body) has no binding, so there is nothing to pass.
|
||||
lua.globals().set(
|
||||
"TogglePVP",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.pvp_toggles += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)
|
||||
}
|
||||
@@ -205,3 +205,59 @@ fn mouse_wheel_passes_delta_to_the_captured_frame() {
|
||||
assert!((s.eval::<f64>("return wheel").unwrap() + 1.0).abs() < 1e-6);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_rect_insets_shrink_the_mouse_rect_only() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// A 100×100 frame at the origin with the micro-button shape: an 18-unit dead header at the
|
||||
// top, and 5 off each other side.
|
||||
s.run(
|
||||
r#"
|
||||
entered = false
|
||||
local a = CreateFrame("Frame", "A")
|
||||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(100, 100); a:EnableMouse(true)
|
||||
a:SetScript("OnEnter", function() entered = true end)
|
||||
a:SetHitRectInsets(5, 5, 18, 5)
|
||||
local l, r, t, b = a:GetHitRectInsets()
|
||||
assert(l == 5 and r == 5 and t == 18 and b == 5)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Geometry is untouched — insets move the MOUSE rect, never the frame.
|
||||
assert_eq!(s.eval::<f64>("return A:GetHeight()").unwrap(), 100.0);
|
||||
assert_eq!(s.eval::<f64>("return A:GetTop()").unwrap(), 100.0);
|
||||
|
||||
// Inside the frame but inside the dead header ⇒ no capture (the case the ref's top=18 buys:
|
||||
// the empty band above a micro button's art must not eat the click).
|
||||
assert!(
|
||||
s.hit_test(50.0, 90.0).is_none(),
|
||||
"a point in the inset header is outside the hit rect"
|
||||
);
|
||||
assert!(s.mouse_move(50.0, 90.0).is_none());
|
||||
assert!(!s.eval::<bool>("return entered").unwrap());
|
||||
// …and each of the other three insets bites too.
|
||||
assert!(s.hit_test(2.0, 50.0).is_none(), "left inset");
|
||||
assert!(s.hit_test(98.0, 50.0).is_none(), "right inset");
|
||||
assert!(s.hit_test(50.0, 2.0).is_none(), "bottom inset");
|
||||
|
||||
// Just below the header, still inside the shrunken rect ⇒ captures.
|
||||
assert!(s.hit_test(50.0, 80.0).is_some());
|
||||
assert!(s.mouse_move(50.0, 80.0).is_some());
|
||||
assert!(s.eval::<bool>("return entered").unwrap());
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_rect_insets_default_to_zero() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"local a = CreateFrame("Frame", "Plain")"#).unwrap();
|
||||
let (l, r, t, b) = s
|
||||
.eval::<(f64, f64, f64, f64)>("return Plain:GetHitRectInsets()")
|
||||
.unwrap();
|
||||
assert_eq!((l, r, t, b), (0.0, 0.0, 0.0, 0.0), "no inset by default");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -341,6 +341,22 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// UnitFactionGroup(unit) → (englishGroup, localizedName), the pair the PvP icon law reads:
|
||||
// the first names the `UI-PVP-<group>` texture, the second titles the player frame's hit-area
|
||||
// tooltip. nil, nil for a unit with no side (Monster/neutral templates) or no snapshot — the
|
||||
// ref's icon branches gate on exactly that (decision 0646 §1/§3).
|
||||
g.set(
|
||||
"UnitFactionGroup",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
match with_unit(lua, &token, None, |u| u.faction_group.clone()) {
|
||||
Some(group) => {
|
||||
let s = Value::String(lua.create_string(&group)?);
|
||||
Ok((s.clone(), s))
|
||||
}
|
||||
None => Ok((Value::Nil, Value::Nil)),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitRace(unit) → (localized, raceFile) or nil, nil; UnitClass(unit) → (localized,
|
||||
// classFileName) — the paper doll's "Level %d %s %s" line + the CLASSFILENAME-keyed
|
||||
|
||||
@@ -125,6 +125,16 @@ pub struct UnitState {
|
||||
/// bit) — distinct from [`Self::pvp`]'s ordinary faction PvP flag; a unit can carry either, both,
|
||||
/// or neither independently (an FFA zone flags this without the ordinary flag ever setting).
|
||||
pub is_pvp_ffa: bool,
|
||||
/// The unit's PvP faction group — `"Alliance"` or `"Horde"`, else `None` (`UnitFactionGroup`;
|
||||
/// decision 0646 §1). App-resolved from `UNIT_FIELD_FACTIONTEMPLATE` → `FactionTemplate.dbc`'s
|
||||
/// group mask, with the Player bit skipped: every playable race's template carries
|
||||
/// Player|<side> (mask 3 or 5), and so do the PvP-flagged city guards, so the side bit is the
|
||||
/// only one that can name a shipped `UI-PVP-<group>` texture. `None` for Monster/neutral
|
||||
/// templates is a state the icon callers explicitly handle (`if ( factionGroup and … )`).
|
||||
///
|
||||
/// The Era binding returns it twice — English token first, localized name second; enUS ships
|
||||
/// the same word for both (FactionGroup.dbc's `InternalName` and `Name0`).
|
||||
pub faction_group: Option<String>,
|
||||
/// The unit's GUID (`OBJECT_FIELD_GUID`) — the identity the cross-token predicates compare
|
||||
/// (`UnitIsUnit`, `UnitInParty`; decision 0434 §5's popup gating). `0` = the app's feed didn't
|
||||
/// resolve one; two zero guids never compare equal.
|
||||
|
||||
@@ -518,3 +518,49 @@ fn unit_race_class_sex_report_the_snapshot_or_the_absent_shape() {
|
||||
.eval::<bool>(r#"return UnitSex("target") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `UnitFactionGroup` returns the (english, localized) pair the PvP-icon law reads, and `nil, nil`
|
||||
/// for a unit with no side — the state the reference's `if ( factionGroup and … )` gate exists
|
||||
/// for (decision 0646 §1). `TogglePVP` queues one toggle per call.
|
||||
#[test]
|
||||
fn faction_group_pair_and_the_pvp_toggle() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
faction_group: Some("Horde".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let (english, localized) = s
|
||||
.eval::<(String, String)>(r#"return UnitFactionGroup("target")"#)
|
||||
.unwrap();
|
||||
assert_eq!((english.as_str(), localized.as_str()), ("Horde", "Horde"));
|
||||
|
||||
// No side (a Monster/neutral template, or a unit whose template hasn't streamed).
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
faction_group: None,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"local a, b = UnitFactionGroup("target") return a == nil and b == nil"#)
|
||||
.unwrap());
|
||||
// An absent unit answers the same way.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"local a = UnitFactionGroup("focus") return a == nil"#)
|
||||
.unwrap());
|
||||
|
||||
assert_eq!(s.take_pvp_toggles(), 0, "nothing queued yet");
|
||||
s.run("TogglePVP() TogglePVP()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_pvp_toggles(),
|
||||
2,
|
||||
"one send per call, never collapsed"
|
||||
);
|
||||
assert_eq!(s.take_pvp_toggles(), 0, "the drain empties the queue");
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ pub struct EditBoxState {
|
||||
/// default; ChatFrame's edit box declares 32).
|
||||
pub history_max: usize,
|
||||
/// The active recall position while browsing (an index into [`Self::history`]); `None` = live
|
||||
/// editing. Any typed edit or `SetText` ends browsing.
|
||||
/// editing. Typed edits, [`Self::add_history_line`], and focus gain end browsing —
|
||||
/// programmatic `SetText` deliberately does NOT (the chat live parse rewrites the box on
|
||||
/// every recalled slash line; ending the browse there pinned every UP to the newest entry).
|
||||
pub history_pos: Option<usize>,
|
||||
/// The live line stashed when browsing starts, restored when DOWN walks past the newest entry.
|
||||
pub history_draft: Option<String>,
|
||||
@@ -183,8 +185,9 @@ impl EditBoxState {
|
||||
self.history_pos.map(|p| self.history[p].clone())
|
||||
}
|
||||
|
||||
/// A typed edit or programmatic `SetText` ends any history browse (the recalled line becomes
|
||||
/// an ordinary draft; the stash is dropped).
|
||||
/// End any history browse (the recalled line becomes an ordinary draft; the stash is
|
||||
/// dropped). Called on typed edits, [`Self::add_history_line`], and focus gain — never on
|
||||
/// programmatic `SetText` (see [`Self::history_pos`]).
|
||||
pub fn end_history_browse(&mut self) {
|
||||
self.history_pos = None;
|
||||
self.history_draft = None;
|
||||
|
||||
@@ -232,6 +232,14 @@ pub struct Frame {
|
||||
/// instead of leaving the window) though its XML never sets the attribute — the class supplies
|
||||
/// the flag — and the rule here is absolute: no tooltip ever leaves the window (decision 0352).
|
||||
pub clamped_to_screen: bool,
|
||||
/// `SetHitRectInsets` / XML `<HitRectInsets>` — how far the frame's **mouse** rect is inset
|
||||
/// from its resolved rect, as `[left, right, top, bottom]` (default all 0, i.e. the hit rect
|
||||
/// IS the frame rect). Only [`crate::order::hit_test`] reads it: drawing, anchoring, and
|
||||
/// `GetLeft`/`GetWidth` all stay on the un-inset rect. The reference uses it wherever a
|
||||
/// button's art fills less than its frame — the micro buttons are 29×58 frames whose art
|
||||
/// occupies only the lower ~40 px, and a `top="18"` inset is what stops that empty header
|
||||
/// from swallowing hover and clicks aimed past the bar.
|
||||
pub hit_rect_insets: [f32; 4],
|
||||
/// This frame's own scale (`ownScale +0xb8`, default 1.0).
|
||||
pub scale: f32,
|
||||
/// `layoutScale` = `parentEffective * ownScale`, ε-gated (`effective_scale 0x76ac90`).
|
||||
@@ -452,6 +460,9 @@ impl WidgetArena {
|
||||
// yet the reference plate observably never leaves the window — the class supplies
|
||||
// geometry flags bit4; decision 0352's law: no tooltip off-screen, ever).
|
||||
clamped_to_screen: matches!(kind, FrameKind::GameTooltip),
|
||||
// No frame is born with an inset hit rect — `<HitRectInsets>`/SetHitRectInsets is the
|
||||
// only source (the client's ctor zeroes the four floats too).
|
||||
hit_rect_insets: [0.0; 4],
|
||||
scale,
|
||||
effective_scale: parent_scale * scale,
|
||||
ignore_parent_scale: false,
|
||||
|
||||
@@ -264,6 +264,22 @@ impl WidgetArena {
|
||||
self.frame(h).map(|f| f.clamped_to_screen).unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── Hit-rect insets (the mouse rect, not the draw rect) ──────────────────────────────────────
|
||||
|
||||
/// Set a frame's hit-rect insets, `[left, right, top, bottom]` (`SetHitRectInsets`; see
|
||||
/// [`Frame::hit_rect_insets`]). A stale handle is a no-op.
|
||||
pub fn set_hit_rect_insets(&mut self, h: FrameHandle, insets: [f32; 4]) {
|
||||
if let Some(f) = self.frame_mut(h) {
|
||||
f.hit_rect_insets = insets;
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame's hit-rect insets, `[left, right, top, bottom]` (`GetHitRectInsets`). A stale handle
|
||||
/// reads as all-zero — the same "no inset" answer an untouched frame gives.
|
||||
pub fn hit_rect_insets(&self, h: FrameHandle) -> [f32; 4] {
|
||||
self.frame(h).map(|f| f.hit_rect_insets).unwrap_or([0.0; 4])
|
||||
}
|
||||
|
||||
// ── Small helpers ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Is `maybe_ancestor` on the parent chain of `node` (walking up, loop-guarded)?
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! in the `benilla` crate) and diffs them. This crate is the pure-math half: given two equally-sized
|
||||
//! images it reports how far they diverge ([`Metrics`]) and renders a heatmap of *where* ([`diff_image`]).
|
||||
//!
|
||||
//! Two uses, one tool: (1) **self-regression** — capture baselines on the current pipeline, then diff
|
||||
//! Three uses, one tool: (1) **self-regression** — capture baselines on the current pipeline, then diff
|
||||
//! every linear-HDR rework step against them so a machine catches a regression before the director's
|
||||
//! eye; (2) **determinism check** — diffing two captures of the same scenario must come out ≈0, which is
|
||||
//! what makes (1) trustworthy.
|
||||
//! what makes (1) trustworthy; (3) **flicker** ([`envelope`]) — the same arithmetic over *time* instead
|
||||
//! of over versions: a burst of adjacent frames from a parked camera (`WOW_LIVE_SHOT_COUNT`), collapsed
|
||||
//! to where the picture would not hold still.
|
||||
|
||||
use image::RgbImage;
|
||||
|
||||
@@ -88,6 +90,443 @@ pub fn diff_image(a: &RgbImage, b: &RgbImage, amplify: u32) -> anyhow::Result<Rg
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How far a burst of frames moved, and where — the output of [`envelope`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Envelope {
|
||||
/// Per-channel `(max − min)` across the stack, amplified — the picture of *where* it flickers.
|
||||
pub image: RgbImage,
|
||||
/// Largest single-channel swing anywhere (0..255). The headline "does it flicker at all".
|
||||
pub max_swing: u8,
|
||||
/// Mean per-channel swing (0..255) over the whole frame.
|
||||
pub mean_swing: f64,
|
||||
/// Fraction of pixels (0..1) whose largest channel swing exceeds [`OVER_THRESHOLD`].
|
||||
pub pct_unstable: f64,
|
||||
}
|
||||
|
||||
/// Collapse a burst of equally-sized frames to their per-pixel **envelope** — `max − min` per channel
|
||||
/// across the whole stack. This is the flicker instrument: a still image cannot show a temporal
|
||||
/// artefact, but the envelope of adjacent frames from a *parked* camera can, and it localises the
|
||||
/// unstable pixels in one picture instead of `n−1` pairwise diffs.
|
||||
///
|
||||
/// A pixel that holds still contributes 0 whatever its colour, so a scene that is merely *wrong* comes
|
||||
/// out black — only what refuses to settle lights up. Errors on fewer than two frames or a size
|
||||
/// mismatch.
|
||||
pub fn envelope(frames: &[RgbImage], amplify: u32) -> anyhow::Result<Envelope> {
|
||||
let (w, h) = burst_dimensions(frames, 2)?;
|
||||
let mut image = RgbImage::new(w, h);
|
||||
let mut sum_swing = 0u64;
|
||||
let mut max_swing = 0u8;
|
||||
let mut unstable = 0u64;
|
||||
for (x, y, out) in image.enumerate_pixels_mut() {
|
||||
let mut pixel_max = 0u8;
|
||||
for c in 0..3 {
|
||||
let (mut lo, mut hi) = (u8::MAX, u8::MIN);
|
||||
for f in frames {
|
||||
let v = f.get_pixel(x, y)[c];
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
let swing = hi - lo;
|
||||
sum_swing += swing as u64;
|
||||
max_swing = max_swing.max(swing);
|
||||
pixel_max = pixel_max.max(swing);
|
||||
out[c] = (swing as u32 * amplify).min(255) as u8;
|
||||
}
|
||||
if pixel_max > OVER_THRESHOLD {
|
||||
unstable += 1;
|
||||
}
|
||||
}
|
||||
let n_pixels = (w as u64) * (h as u64);
|
||||
Ok(Envelope {
|
||||
image,
|
||||
max_swing,
|
||||
mean_swing: sum_swing as f64 / (n_pixels * 3).max(1) as f64,
|
||||
pct_unstable: unstable as f64 / n_pixels.max(1) as f64,
|
||||
})
|
||||
}
|
||||
|
||||
/// How often a burst *reversed direction*, and where — the output of [`toggles`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Toggles {
|
||||
/// Per-channel reversal count across the stack, amplified — the picture of *where* it toggles.
|
||||
pub image: RgbImage,
|
||||
/// Most reversals any one channel made (0..frames−2).
|
||||
pub max_reversals: u32,
|
||||
/// Fraction of pixels (0..1) that reversed at least [`TOGGLE_MIN_REVERSALS`] times.
|
||||
pub pct_toggling: f64,
|
||||
/// Row-major `width × height` flags: did this pixel toggle? Carried out of the scan because
|
||||
/// *where* the toggling pixels sit relative to each other is a separate reading ([`shape`]) and
|
||||
/// re-thresholding the amplified image would only recover it approximately.
|
||||
pub mask: Vec<bool>,
|
||||
}
|
||||
|
||||
/// A pixel counts as "toggling" for [`Toggles::pct_toggling`] at this many direction reversals. Two
|
||||
/// is the smallest count that cannot be a single overshoot, and it already needs a four-frame
|
||||
/// up-down-up run — which smooth motion across an edge does not produce.
|
||||
pub const TOGGLE_MIN_REVERSALS: u32 = 2;
|
||||
|
||||
/// Count, per pixel and channel, how many times the value **reversed direction** across the burst,
|
||||
/// ignoring steps smaller than `min_delta`.
|
||||
///
|
||||
/// This is [`envelope`]'s companion for the case the envelope cannot serve: **a moving camera**.
|
||||
/// When the view is panning, every edge in the frame sweeps across pixels and `max − min` lights up
|
||||
/// the whole picture, so it says nothing. But smooth motion is *monotone* — a pixel being crossed by
|
||||
/// an edge goes one way and stays going that way — while z-fighting and an unstable draw order
|
||||
/// **alternate**, A/B/A/B, however the camera moves. Counting reversals separates the two.
|
||||
///
|
||||
/// The director's report is what forced this: the Far Watch Post tower "mostly only flickers while
|
||||
/// moving the cam, not while still" — the one condition under which a parked burst is blind by
|
||||
/// construction (decision 0653's blind spot, named in 0656).
|
||||
pub fn toggles(frames: &[RgbImage], min_delta: u8, amplify: u32) -> anyhow::Result<Toggles> {
|
||||
let (w, h) = burst_dimensions(frames, 3)?;
|
||||
let mut image = RgbImage::new(w, h);
|
||||
let mut max_reversals = 0u32;
|
||||
let mut toggling = 0u64;
|
||||
let mut mask = vec![false; (w as usize) * (h as usize)];
|
||||
for (x, y, out) in image.enumerate_pixels_mut() {
|
||||
let mut pixel_max = 0u32;
|
||||
for c in 0..3 {
|
||||
// `dir` is the sign of the last step big enough to count; a run of sub-threshold steps
|
||||
// neither reverses nor resets it, so dithering noise cannot manufacture a toggle.
|
||||
let (mut dir, mut reversals) = (0i8, 0u32);
|
||||
for pair in frames.windows(2) {
|
||||
let (a, b) = (
|
||||
pair[0].get_pixel(x, y)[c] as i32,
|
||||
pair[1].get_pixel(x, y)[c] as i32,
|
||||
);
|
||||
let step = b - a;
|
||||
if step.unsigned_abs() < min_delta as u32 {
|
||||
continue;
|
||||
}
|
||||
let sign = if step > 0 { 1i8 } else { -1i8 };
|
||||
if dir != 0 && sign != dir {
|
||||
reversals += 1;
|
||||
}
|
||||
dir = sign;
|
||||
}
|
||||
max_reversals = max_reversals.max(reversals);
|
||||
pixel_max = pixel_max.max(reversals);
|
||||
out[c] = (reversals * amplify).min(255) as u8;
|
||||
}
|
||||
if pixel_max >= TOGGLE_MIN_REVERSALS {
|
||||
toggling += 1;
|
||||
mask[(y as usize) * (w as usize) + x as usize] = true;
|
||||
}
|
||||
}
|
||||
let n_pixels = (w as u64) * (h as u64);
|
||||
Ok(Toggles {
|
||||
image,
|
||||
max_reversals,
|
||||
pct_toggling: toggling as f64 / n_pixels.max(1) as f64,
|
||||
mask,
|
||||
})
|
||||
}
|
||||
|
||||
/// A rectangle of the frame, in pixels — `x1`/`y1` exclusive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rect {
|
||||
pub x0: u32,
|
||||
pub y0: u32,
|
||||
pub x1: u32,
|
||||
pub y1: u32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn width(&self) -> u32 {
|
||||
self.x1 - self.x0
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
self.y1 - self.y0
|
||||
}
|
||||
/// Grow by `pad` on every side, clamped to a `w × h` frame.
|
||||
pub fn padded(&self, pad: u32, w: u32, h: u32) -> Rect {
|
||||
Rect {
|
||||
x0: self.x0.saturating_sub(pad),
|
||||
y0: self.y0.saturating_sub(pad),
|
||||
x1: (self.x1 + pad).min(w),
|
||||
y1: (self.y1 + pad).min(h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One connected run of toggling pixels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Region {
|
||||
pub bounds: Rect,
|
||||
/// Toggling pixels in the run (≤ the bounds' area).
|
||||
pub pixels: u64,
|
||||
/// Up to [`REGION_SAMPLES`] pixels spread evenly through the run, in scan order — coordinates
|
||||
/// Every pixel in the run, in scan order. Kept whole because the run's *own* time series
|
||||
/// ([`Region::steps`]) is the reading that follows, and it has to be over the run, not over a
|
||||
/// bounding box that also contains whatever else was passing through.
|
||||
pub members: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// How many representative pixels [`Region::samples`] hands back.
|
||||
pub const REGION_SAMPLES: usize = 8;
|
||||
|
||||
/// One frame-to-frame step of a run, from [`Region::steps`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Step {
|
||||
/// The run's mean luma (0..255) in the earlier frame. The *level*, not just the change: a
|
||||
/// two-state flip's ratio is what identifies the multiplier behind it, and a delta alone hides it.
|
||||
pub mean_from: f64,
|
||||
/// The run's mean R/G/B (0..255) in the earlier frame. Whether a two-state flip preserves the
|
||||
/// channel ratios says what kind of flip it is: a pure **intensity** scale (one light term
|
||||
/// switching on or off) keeps the hue, while swapping to a differently-coloured lighting lane
|
||||
/// does not — and luma alone cannot tell those apart.
|
||||
pub mean_rgb_from: [f64; 3],
|
||||
/// Change in the run's mean luma (0..255 units) from this frame to the next.
|
||||
pub mean_delta: f64,
|
||||
/// Fraction of the run's pixels (0..1) that moved the **same way** as the mean did. Near 1 = the
|
||||
/// whole surface changed together; near 0.5 = as many pixels went up as down.
|
||||
pub agreement: f64,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// How much of its own bounding box the run actually fills, 0..1. A solid surface approaches 1;
|
||||
/// a scattering of speckles inside a wide box is near 0.
|
||||
pub fn fill(&self) -> f64 {
|
||||
let area = u64::from(self.bounds.width()) * u64::from(self.bounds.height());
|
||||
self.pixels as f64 / area.max(1) as f64
|
||||
}
|
||||
|
||||
/// Up to [`REGION_SAMPLES`] pixels spread evenly through the run — coordinates that are
|
||||
/// *provably* in it, to hand to the in-game ray pick (`WOW_PICK`). Reading them off a heatmap by
|
||||
/// eye lands next to the run as often as in it, and then the pick names the wrong surface with
|
||||
/// total confidence.
|
||||
pub fn samples(&self) -> Vec<(u32, u32)> {
|
||||
let step = (self.members.len() / REGION_SAMPLES).max(1);
|
||||
self.members
|
||||
.iter()
|
||||
.copied()
|
||||
.step_by(step)
|
||||
.take(REGION_SAMPLES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The run's own frame-to-frame time series — the reading that separates a surface being
|
||||
/// **re-shaded** from one whose **edges are moving**.
|
||||
///
|
||||
/// [`shape`] says the toggling pixels form one coherent surface; it cannot say what the surface
|
||||
/// is doing. If the whole run brightens and dims together (`agreement` near 1, `mean_delta`
|
||||
/// swinging), it is being lit differently frame to frame — a shading defect. If half its pixels
|
||||
/// go up while the other half go down (`agreement` near 0.5, `mean_delta` near 0), nothing is
|
||||
/// being re-lit: a boundary is sweeping across it, and the run is an edge, not a surface.
|
||||
pub fn steps(&self, frames: &[RgbImage]) -> Vec<Step> {
|
||||
frames
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let mut from = 0.0;
|
||||
let mut rgb = [0.0f64; 3];
|
||||
let deltas: Vec<f64> = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|&(x, y)| {
|
||||
let p = pair[0].get_pixel(x, y);
|
||||
for c in 0..3 {
|
||||
rgb[c] += f64::from(p[c]);
|
||||
}
|
||||
let a = luma(p);
|
||||
from += a;
|
||||
luma(pair[1].get_pixel(x, y)) - a
|
||||
})
|
||||
.collect();
|
||||
let n = deltas.len().max(1) as f64;
|
||||
let mean_delta = deltas.iter().sum::<f64>() / n;
|
||||
// A pixel that did not move is counted as disagreeing: it is evidence against "the
|
||||
// whole surface moved together", which is exactly the claim under test.
|
||||
let same = deltas
|
||||
.iter()
|
||||
.filter(|d| **d != 0.0 && d.is_sign_positive() == mean_delta.is_sign_positive())
|
||||
.count();
|
||||
Step {
|
||||
mean_from: from / n,
|
||||
mean_rgb_from: [rgb[0] / n, rgb[1] / n, rgb[2] / n],
|
||||
mean_delta,
|
||||
agreement: same as f64 / n,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Rec. 601 luma — one number per pixel for "how bright", so a colour shift and a brightness shift
|
||||
/// are not confused with each other.
|
||||
fn luma(p: &image::Rgb<u8>) -> f64 {
|
||||
0.299 * f64::from(p[0]) + 0.587 * f64::from(p[1]) + 0.114 * f64::from(p[2])
|
||||
}
|
||||
|
||||
/// The **spatial** structure of a toggle map — the reading that separates two defects the toggle
|
||||
/// percentage alone cannot tell apart.
|
||||
///
|
||||
/// Both z-fighting and a visibility flip make pixels alternate, so both score the same on
|
||||
/// [`Toggles::pct_toggling`]. They differ in *shape*: z-fighting resolves per fragment, so it lands
|
||||
/// as moiré banding or speckle — many small runs, low neighbour agreement. A whole surface blinking
|
||||
/// in and out (a culling, portal-visibility or draw-submission flip) lands as **one large solid run**
|
||||
/// with the surface's own silhouette — few runs, high fill, high coherence. That distinction decides
|
||||
/// which subsystem is at fault, so it is measured rather than eyeballed off a heatmap.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Shape {
|
||||
/// Connected runs of at least [`MIN_REGION_PIXELS`], largest first.
|
||||
pub regions: Vec<Region>,
|
||||
/// Toggling pixels that fell in runs too small to list.
|
||||
pub scattered: u64,
|
||||
/// Mean fraction of the 4-neighbours of a toggling pixel that also toggle, 0..1. Near 1 = solid
|
||||
/// areas; near 0 = isolated pixels. A one-pixel-wide moiré band sits near 0.5.
|
||||
pub coherence: f64,
|
||||
}
|
||||
|
||||
/// A connected run smaller than this is noise, not a surface — counted in [`Shape::scattered`].
|
||||
pub const MIN_REGION_PIXELS: u64 = 16;
|
||||
|
||||
/// Group a toggle mask into connected runs (4-connectivity) and measure their coherence.
|
||||
pub fn shape(t: &Toggles) -> Shape {
|
||||
let (w, h) = t.image.dimensions();
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let idx = |x: usize, y: usize| y * wu + x;
|
||||
|
||||
let mut coherent = 0u64;
|
||||
let mut toggling = 0u64;
|
||||
for y in 0..hu {
|
||||
for x in 0..wu {
|
||||
if !t.mask[idx(x, y)] {
|
||||
continue;
|
||||
}
|
||||
toggling += 1;
|
||||
// Off-frame neighbours count as non-toggling: an edge pixel is genuinely less enclosed,
|
||||
// and pretending otherwise would inflate the coherence of anything touching the border.
|
||||
let n = u64::from(x > 0 && t.mask[idx(x - 1, y)])
|
||||
+ u64::from(x + 1 < wu && t.mask[idx(x + 1, y)])
|
||||
+ u64::from(y > 0 && t.mask[idx(x, y - 1)])
|
||||
+ u64::from(y + 1 < hu && t.mask[idx(x, y + 1)]);
|
||||
coherent += n;
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative flood fill — a recursive one blows the stack on a full-screen region.
|
||||
let mut seen = vec![false; wu * hu];
|
||||
let mut regions: Vec<Region> = Vec::new();
|
||||
let mut scattered = 0u64;
|
||||
let mut stack: Vec<(usize, usize)> = Vec::new();
|
||||
for y0 in 0..hu {
|
||||
for x0 in 0..wu {
|
||||
if !t.mask[idx(x0, y0)] || seen[idx(x0, y0)] {
|
||||
continue;
|
||||
}
|
||||
seen[idx(x0, y0)] = true;
|
||||
stack.push((x0, y0));
|
||||
let (mut pixels, mut b) = (
|
||||
0u64,
|
||||
Rect {
|
||||
x0: x0 as u32,
|
||||
y0: y0 as u32,
|
||||
x1: x0 as u32 + 1,
|
||||
y1: y0 as u32 + 1,
|
||||
},
|
||||
);
|
||||
let mut members: Vec<(u32, u32)> = Vec::new();
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
pixels += 1;
|
||||
members.push((x as u32, y as u32));
|
||||
b.x0 = b.x0.min(x as u32);
|
||||
b.y0 = b.y0.min(y as u32);
|
||||
b.x1 = b.x1.max(x as u32 + 1);
|
||||
b.y1 = b.y1.max(y as u32 + 1);
|
||||
let mut push = |nx: usize, ny: usize, stack: &mut Vec<(usize, usize)>| {
|
||||
if t.mask[idx(nx, ny)] && !seen[idx(nx, ny)] {
|
||||
seen[idx(nx, ny)] = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
push(x - 1, y, &mut stack);
|
||||
}
|
||||
if x + 1 < wu {
|
||||
push(x + 1, y, &mut stack);
|
||||
}
|
||||
if y > 0 {
|
||||
push(x, y - 1, &mut stack);
|
||||
}
|
||||
if y + 1 < hu {
|
||||
push(x, y + 1, &mut stack);
|
||||
}
|
||||
}
|
||||
if pixels >= MIN_REGION_PIXELS {
|
||||
// The flood fill visits in stack order, so sort to scan order — otherwise
|
||||
// `Region::samples` spreads its picks through the traversal, not through the run.
|
||||
members.sort_unstable_by_key(|&(x, y)| (y, x));
|
||||
regions.push(Region {
|
||||
bounds: b,
|
||||
pixels,
|
||||
members,
|
||||
});
|
||||
} else {
|
||||
scattered += pixels;
|
||||
}
|
||||
}
|
||||
}
|
||||
regions.sort_by_key(|r| std::cmp::Reverse(r.pixels));
|
||||
Shape {
|
||||
regions,
|
||||
scattered,
|
||||
coherence: coherent as f64 / (toggling.max(1) * 4) as f64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut `rect` out of `img`, clamped to its bounds.
|
||||
pub fn crop(img: &RgbImage, rect: Rect) -> RgbImage {
|
||||
let r = rect.padded(0, img.width(), img.height());
|
||||
let (w, h) = (r.width().max(1), r.height().max(1));
|
||||
RgbImage::from_fn(w, h, |x, y| {
|
||||
*img.get_pixel(
|
||||
(r.x0 + x).min(img.width() - 1),
|
||||
(r.y0 + y).min(img.height() - 1),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lay tiles out left-to-right, top-to-bottom in a `cols`-wide grid on a mid-grey mat, so a burst's
|
||||
/// crops read as one contact sheet. Tiles of different sizes are placed at their own size.
|
||||
pub fn contact_strip(tiles: &[RgbImage], cols: u32, gap: u32) -> RgbImage {
|
||||
let cols = cols.max(1);
|
||||
let (tw, th) = tiles
|
||||
.iter()
|
||||
.fold((1, 1), |(w, h), t| (w.max(t.width()), h.max(t.height())));
|
||||
let rows = (tiles.len() as u32).div_ceil(cols);
|
||||
let out_w = cols * tw + (cols + 1) * gap;
|
||||
let out_h = rows * th + (rows + 1) * gap;
|
||||
let mut out = RgbImage::from_pixel(out_w, out_h, image::Rgb([32, 32, 32]));
|
||||
for (i, tile) in tiles.iter().enumerate() {
|
||||
let (cx, cy) = (i as u32 % cols, i as u32 / cols);
|
||||
let (ox, oy) = (gap + cx * (tw + gap), gap + cy * (th + gap));
|
||||
for (x, y, p) in tile.enumerate_pixels() {
|
||||
if ox + x < out_w && oy + y < out_h {
|
||||
out.put_pixel(ox + x, oy + y, *p);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The common size of a burst, erroring unless it has at least `min` equally-sized frames.
|
||||
fn burst_dimensions(frames: &[RgbImage], min: usize) -> anyhow::Result<(u32, u32)> {
|
||||
if frames.len() < min {
|
||||
anyhow::bail!("needs at least {min} frames, got {}", frames.len());
|
||||
}
|
||||
let (w, h) = frames[0].dimensions();
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
if f.dimensions() != (w, h) {
|
||||
anyhow::bail!(
|
||||
"frame {i} is {:?}, expected {:?} — a burst must be one camera, one window size",
|
||||
f.dimensions(),
|
||||
(w, h)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok((w, h))
|
||||
}
|
||||
|
||||
/// Stitch two images side by side (`left | right`) with a `gap`-px dark separator, for at-a-glance A/B
|
||||
/// comparison (e.g. the faithful vs modern render). Heights may differ; the output is the max height
|
||||
/// with each image top-aligned.
|
||||
@@ -177,6 +616,128 @@ mod tests {
|
||||
assert_eq!(d2.get_pixel(0, 0), &Rgb([255, 0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_burst_has_no_envelope() {
|
||||
// The point of max−min: a scene that never moves comes out black however bright it is, so a
|
||||
// merely *wrong* render cannot masquerade as a flickering one.
|
||||
let img = solid(4, 4, [200, 30, 90]);
|
||||
let e = envelope(&[img.clone(), img.clone(), img], 8).unwrap();
|
||||
assert_eq!(e.max_swing, 0);
|
||||
assert_eq!(e.mean_swing, 0.0);
|
||||
assert_eq!(e.pct_unstable, 0.0);
|
||||
assert_eq!(e.image.get_pixel(0, 0), &Rgb([0, 0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_spans_the_whole_stack_not_just_neighbours() {
|
||||
// 10 → 60 → 10: consecutive pairs each swing 50, and so does the envelope. But a value that
|
||||
// drifts monotonically (10 → 35 → 60) swings only 25 per pair while the envelope still says
|
||||
// 50 — which is the reason this is a stack operation and not a chain of pairwise diffs.
|
||||
let base = solid(2, 1, [10, 10, 10]);
|
||||
let alternating = {
|
||||
let mut m = base.clone();
|
||||
m.put_pixel(0, 0, Rgb([60, 10, 10]));
|
||||
m
|
||||
};
|
||||
let drifting = {
|
||||
let mut m = base.clone();
|
||||
m.put_pixel(0, 0, Rgb([35, 10, 10]));
|
||||
m
|
||||
};
|
||||
for stack in [
|
||||
[base.clone(), alternating.clone(), base.clone()],
|
||||
[base.clone(), drifting, alternating],
|
||||
] {
|
||||
let e = envelope(&stack, 1).unwrap();
|
||||
assert_eq!(e.max_swing, 50);
|
||||
assert_eq!(e.image.get_pixel(0, 0), &Rgb([50, 0, 0]));
|
||||
assert_eq!(e.image.get_pixel(1, 0), &Rgb([0, 0, 0]));
|
||||
// one unstable pixel of two
|
||||
assert!((e.pct_unstable - 0.5).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_amplifies_and_clamps_like_the_diff() {
|
||||
let a = solid(1, 1, [10, 10, 10]);
|
||||
let b = solid(1, 1, [20, 10, 10]);
|
||||
assert_eq!(
|
||||
envelope(&[a.clone(), b.clone()], 8)
|
||||
.unwrap()
|
||||
.image
|
||||
.get_pixel(0, 0),
|
||||
&Rgb([80, 0, 0])
|
||||
);
|
||||
assert_eq!(
|
||||
envelope(&[a, b], 32).unwrap().image.get_pixel(0, 0),
|
||||
&Rgb([255, 0, 0])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_needs_a_burst_and_one_window_size() {
|
||||
let a = solid(4, 4, [0, 0, 0]);
|
||||
assert!(envelope(&[], 8).is_err());
|
||||
assert!(envelope(std::slice::from_ref(&a), 8).is_err());
|
||||
assert!(envelope(&[a, solid(4, 5, [0, 0, 0])], 8).is_err());
|
||||
}
|
||||
|
||||
/// One red value per frame, at pixel (0,0) of a 1×1 burst.
|
||||
fn ramp(reds: &[u8]) -> Vec<RgbImage> {
|
||||
reds.iter()
|
||||
.map(|r| RgbImage::from_pixel(1, 1, Rgb([*r, 0, 0])))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_monotone_sweep_never_toggles_however_far_it_moves() {
|
||||
// The whole point: this is what a moving camera does to a pixel an edge sweeps across —
|
||||
// a huge envelope (0 → 240) and zero reversals. `envelope` calls it maximally unstable;
|
||||
// `toggles` correctly says it is not flickering.
|
||||
let sweep = ramp(&[0, 60, 120, 180, 240]);
|
||||
assert_eq!(envelope(&sweep, 1).unwrap().max_swing, 240);
|
||||
let t = toggles(&sweep, 4, 60).unwrap();
|
||||
assert_eq!(t.max_reversals, 0);
|
||||
assert_eq!(t.pct_toggling, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alternating_pixel_toggles_every_step() {
|
||||
// A/B/A/B/A — z-fighting's signature. Four steps, three of them reversals.
|
||||
let t = toggles(&ramp(&[10, 90, 10, 90, 10]), 4, 60).unwrap();
|
||||
assert_eq!(t.max_reversals, 3);
|
||||
assert_eq!(t.pct_toggling, 1.0);
|
||||
assert_eq!(t.image.get_pixel(0, 0), &Rgb([180, 0, 0])); // 3 × 60, unclamped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_threshold_noise_neither_toggles_nor_forgets_the_direction() {
|
||||
// ±1 dither must not manufacture reversals …
|
||||
let t = toggles(&ramp(&[50, 51, 50, 51, 50, 51]), 4, 60).unwrap();
|
||||
assert_eq!(t.max_reversals, 0);
|
||||
// … and a run of it in the middle of a real climb must not reset the direction either,
|
||||
// or every noisy edge would read as one reversal per crossing.
|
||||
let t = toggles(&ramp(&[0, 40, 41, 40, 41, 80]), 4, 60).unwrap();
|
||||
assert_eq!(t.max_reversals, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_overshoot_is_one_reversal_and_below_the_toggle_bar() {
|
||||
// Down-then-up once: real, but not a flicker — `TOGGLE_MIN_REVERSALS` is what draws that
|
||||
// line, so a lone direction change must not be counted as a toggling pixel.
|
||||
let t = toggles(&ramp(&[10, 90, 40]), 4, 60).unwrap();
|
||||
assert_eq!(t.max_reversals, 1);
|
||||
assert_eq!(t.pct_toggling, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggles_needs_three_frames_and_one_window_size() {
|
||||
let a = solid(4, 4, [0, 0, 0]);
|
||||
assert!(toggles(&[a.clone(), a.clone()], 4, 60).is_err());
|
||||
assert!(toggles(&[a.clone(), a.clone(), solid(4, 5, [0, 0, 0])], 4, 60).is_err());
|
||||
assert!(toggles(&[a.clone(), a.clone(), a], 4, 60).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_places_both_with_gap() {
|
||||
let l = solid(3, 2, [10, 20, 30]);
|
||||
@@ -187,4 +748,138 @@ mod tests {
|
||||
assert_eq!(out.get_pixel(3, 0), &Rgb([24, 24, 24])); // separator
|
||||
assert_eq!(out.get_pixel(4, 0), &Rgb([40, 50, 60])); // right image
|
||||
}
|
||||
|
||||
/// A burst in which exactly the pixels selected by `flips` alternate 0/200 and everything else
|
||||
/// holds still — the shape tests differ only in *which* pixels those are.
|
||||
fn alternating(
|
||||
w: u32,
|
||||
h: u32,
|
||||
frames: usize,
|
||||
flips: impl Fn(u32, u32) -> bool,
|
||||
) -> Vec<RgbImage> {
|
||||
(0..frames)
|
||||
.map(|i| {
|
||||
RgbImage::from_fn(w, h, |x, y| {
|
||||
let on = flips(x, y) && i % 2 == 1;
|
||||
Rgb([if on { 200 } else { 0 }, 0, 0])
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_solid_block_reads_as_a_single_filled_run() {
|
||||
// A whole surface blinking: one run, filling its own box, every interior pixel enclosed.
|
||||
let frames = alternating(20, 20, 6, |x, y| {
|
||||
(4..12).contains(&x) && (4..12).contains(&y)
|
||||
});
|
||||
let s = shape(&toggles(&frames, 4, 60).unwrap());
|
||||
assert_eq!(s.regions.len(), 1);
|
||||
assert_eq!(s.regions[0].pixels, 64);
|
||||
assert_eq!(
|
||||
s.regions[0].bounds,
|
||||
Rect {
|
||||
x0: 4,
|
||||
y0: 4,
|
||||
x1: 12,
|
||||
y1: 12
|
||||
}
|
||||
);
|
||||
assert!((s.regions[0].fill() - 1.0).abs() < 1e-9);
|
||||
assert_eq!(s.scattered, 0);
|
||||
// 8x8 block: 4 corners see 2 neighbours, 24 edges see 3, 36 interior see 4 → 224/256.
|
||||
assert!(
|
||||
(s.coherence - 224.0 / 256.0).abs() < 1e-9,
|
||||
"{}",
|
||||
s.coherence
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_checkerboard_covers_the_same_area_with_none_of_the_shape() {
|
||||
// Z-fighting resolving per fragment. Same region of the frame, ~half the pixels toggling,
|
||||
// but no pixel touches another — the reading that separates it from the block above.
|
||||
let frames = alternating(20, 20, 6, |x, y| {
|
||||
(4..12).contains(&x) && (4..12).contains(&y) && (x + y) % 2 == 0
|
||||
});
|
||||
let s = shape(&toggles(&frames, 4, 60).unwrap());
|
||||
assert_eq!(s.coherence, 0.0);
|
||||
assert!(s.regions.is_empty(), "isolated pixels are not runs");
|
||||
assert_eq!(s.scattered, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_come_back_largest_first_and_short_ones_are_scattered() {
|
||||
// Two blocks plus a speck: the big one leads, the speck falls below MIN_REGION_PIXELS.
|
||||
let frames = alternating(40, 20, 6, |x, y| {
|
||||
let big = (2..12).contains(&x) && (2..12).contains(&y); // 100 px
|
||||
let small = (20..25).contains(&x) && (2..7).contains(&y); // 25 px
|
||||
let speck = (30..33).contains(&x) && y == 10; // 3 px — noise
|
||||
big || small || speck
|
||||
});
|
||||
let s = shape(&toggles(&frames, 4, 60).unwrap());
|
||||
assert_eq!(
|
||||
s.regions.iter().map(|r| r.pixels).collect::<Vec<_>>(),
|
||||
vec![100, 25]
|
||||
);
|
||||
assert_eq!(s.scattered, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_burst_has_no_shape_at_all() {
|
||||
let frames = alternating(8, 8, 6, |_, _| false);
|
||||
let s = shape(&toggles(&frames, 4, 60).unwrap());
|
||||
assert!(s.regions.is_empty());
|
||||
assert_eq!(s.scattered, 0);
|
||||
assert_eq!(s.coherence, 0.0); // no toggling pixels — not a divide-by-zero
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_takes_the_named_rect() {
|
||||
let img = RgbImage::from_fn(10, 10, |x, y| Rgb([x as u8, y as u8, 0]));
|
||||
let out = crop(
|
||||
&img,
|
||||
Rect {
|
||||
x0: 3,
|
||||
y0: 4,
|
||||
x1: 6,
|
||||
y1: 9,
|
||||
},
|
||||
);
|
||||
assert_eq!(out.dimensions(), (3, 5));
|
||||
assert_eq!(out.get_pixel(0, 0), &Rgb([3, 4, 0]));
|
||||
assert_eq!(out.get_pixel(2, 4), &Rgb([5, 8, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_a_rect_stops_at_the_frame_edge() {
|
||||
let r = Rect {
|
||||
x0: 2,
|
||||
y0: 2,
|
||||
x1: 5,
|
||||
y1: 5,
|
||||
};
|
||||
assert_eq!(
|
||||
r.padded(10, 8, 8),
|
||||
Rect {
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
x1: 8,
|
||||
y1: 8
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contact_strip_lays_tiles_out_in_a_grid() {
|
||||
let tiles: Vec<RgbImage> = (0..3)
|
||||
.map(|i| RgbImage::from_pixel(2, 2, Rgb([10 * (i + 1) as u8, 0, 0])))
|
||||
.collect();
|
||||
let out = contact_strip(&tiles, 2, 1);
|
||||
assert_eq!(out.dimensions(), (2 * 2 + 3, 2 * 2 + 3)); // 2 cols, 2 rows, 1px mat
|
||||
assert_eq!(out.get_pixel(1, 1), &Rgb([10, 0, 0])); // tile 0
|
||||
assert_eq!(out.get_pixel(4, 1), &Rgb([20, 0, 0])); // tile 1, next column
|
||||
assert_eq!(out.get_pixel(1, 4), &Rgb([30, 0, 0])); // tile 2, wrapped
|
||||
assert_eq!(out.get_pixel(4, 4), &Rgb([32, 32, 32])); // empty cell stays mat
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,54 @@
|
||||
//! Usage:
|
||||
//! benilla-visual diff <a.png> <b.png> [--out <diff.png>] [--fail <mae>] [--amplify <n>]
|
||||
//! benilla-visual diff-dir <dir_a> <dir_b> [--out <diff_dir>] [--fail <mae>] [--amplify <n>]
|
||||
//! benilla-visual flicker <burst_dir> [--out <envelope.png>] [--fail <mae>] [--amplify <n>]
|
||||
//!
|
||||
//! `diff` compares two images; `diff-dir` compares every `*.png` present in *both* directories by name.
|
||||
//! Prints the metrics; writes amplified heatmap(s) when `--out` is given; exits non-zero if any image's
|
||||
//! MAE exceeds `--fail` (when given). Typical loop: capture baselines, change the renderer, re-capture,
|
||||
//! `diff-dir baseline candidate --out diff --fail 1.5`.
|
||||
//!
|
||||
//! `flicker` is the same arithmetic over *time*: point it at a `WOW_LIVE_SHOT_COUNT` burst (adjacent
|
||||
//! frames, parked camera) and it prints the frame-to-frame table and the whole-stack envelope, and
|
||||
//! writes the envelope heatmap — the picture of which pixels would not hold still.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use benilla_visual::{compare, compose_side_by_side, diff_image, Metrics, OVER_THRESHOLD};
|
||||
use benilla_visual::{
|
||||
compare, compose_side_by_side, contact_strip, crop, diff_image, envelope, shape, toggles,
|
||||
Metrics, OVER_THRESHOLD,
|
||||
};
|
||||
|
||||
/// A frame-to-frame step must move a channel by this much to count as a direction change (override
|
||||
/// with `--toggle-delta`). Above PNG/dither noise, below any real surface swap.
|
||||
///
|
||||
/// **The pan rate matters more than this number.** A moving camera sweeps texture detail across
|
||||
/// every pixel, and detail is not monotone — at 8°/s (≈15 px/frame at 3200 px) the map saturates at
|
||||
/// 93% and says nothing. Keep the sweep **sub-pixel per frame** (≈0.2°/s) so ordinary surfaces
|
||||
/// barely move while a depth-comparison flip still swaps whole surfaces at full amplitude.
|
||||
const TOGGLE_MIN_DELTA: u8 = 4;
|
||||
|
||||
/// Reversal counts are small integers, so the toggle map needs its own (much larger) gain than the
|
||||
/// 0..255 swings the envelope amplifies: at 60, three reversals saturate.
|
||||
const TOGGLE_AMPLIFY: u32 = 60;
|
||||
|
||||
/// Gap (px) between the two halves of a side-by-side compose.
|
||||
const COMPOSE_GAP: u32 = 6;
|
||||
|
||||
/// Tiles per row on a `hotspot` contact sheet — wide enough that a 24-frame burst reads as a few
|
||||
/// rows of adjacent frames rather than one unscannable ribbon.
|
||||
const STRIP_COLS: u32 = 6;
|
||||
|
||||
/// Pixels of context kept around a hotspot crop (override with `--pad`). Enough to see what the
|
||||
/// toggling region borders — which is usually the whole point.
|
||||
const DEFAULT_PAD: u32 = 24;
|
||||
|
||||
/// How many runs `hotspot` reports a time series for. Enough to see whether the top runs flip on the
|
||||
/// same frames (one cause) or independently (several), narrow enough to stay one screen.
|
||||
const SERIES_RUNS: usize = 4;
|
||||
|
||||
/// Default amplification for the heatmap output (per-channel abs-diff ×N, clamped).
|
||||
const DEFAULT_AMPLIFY: u32 = 8;
|
||||
|
||||
@@ -25,6 +58,8 @@ struct Opts {
|
||||
out: Option<PathBuf>,
|
||||
fail: Option<f64>,
|
||||
amplify: u32,
|
||||
toggle_delta: u8,
|
||||
pad: u32,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -34,6 +69,8 @@ fn main() -> Result<()> {
|
||||
out: None,
|
||||
fail: None,
|
||||
amplify: DEFAULT_AMPLIFY,
|
||||
toggle_delta: TOGGLE_MIN_DELTA,
|
||||
pad: DEFAULT_PAD,
|
||||
};
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
@@ -46,11 +83,21 @@ fn main() -> Result<()> {
|
||||
.context("--fail not a number")?,
|
||||
)
|
||||
}
|
||||
"--toggle-delta" => {
|
||||
opts.toggle_delta = next(&mut it, "--toggle-delta")?
|
||||
.parse()
|
||||
.context("--toggle-delta not a 0..255 integer")?
|
||||
}
|
||||
"--amplify" => {
|
||||
opts.amplify = next(&mut it, "--amplify")?
|
||||
.parse()
|
||||
.context("--amplify not an integer")?
|
||||
}
|
||||
"--pad" => {
|
||||
opts.pad = next(&mut it, "--pad")?
|
||||
.parse()
|
||||
.context("--pad not an integer")?
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print_usage();
|
||||
return Ok(());
|
||||
@@ -96,6 +143,30 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"flicker" => {
|
||||
let [dir] = one(rest, "flicker")?;
|
||||
let worst = flicker(
|
||||
Path::new(dir),
|
||||
opts.out.as_deref(),
|
||||
opts.amplify,
|
||||
opts.toggle_delta,
|
||||
)?;
|
||||
if over_fail(&worst, opts.fail) {
|
||||
bail!(
|
||||
"frame-to-frame MAE {:.3} exceeds --fail {:.3}",
|
||||
worst.mae,
|
||||
opts.fail.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
"hotspot" => {
|
||||
let [dir] = one(rest, "hotspot")?;
|
||||
let out = opts
|
||||
.out
|
||||
.as_deref()
|
||||
.context("hotspot needs --out <strip.png>")?;
|
||||
hotspot(Path::new(dir), out, opts.toggle_delta, opts.pad)?;
|
||||
}
|
||||
"compose-dir" => {
|
||||
let [da, db] = two(rest, "compose-dir")?;
|
||||
let out = opts
|
||||
@@ -135,6 +206,200 @@ fn compose_dir(da: &Path, db: &Path, out: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report how far a burst of adjacent frames moved: the frame-to-frame table, then the whole-stack
|
||||
/// envelope. Returns the worst consecutive-pair metrics (the `--fail` subject) — the envelope is a
|
||||
/// *stack* number and can exceed any single pair, so failing on the pair is the conservative gate.
|
||||
fn flicker(dir: &Path, out: Option<&Path>, amplify: u32, toggle_delta: u8) -> Result<Metrics> {
|
||||
let names: Vec<String> = pngs(dir)?.into_iter().collect(); // BTreeSet — already shot order
|
||||
if names.len() < 2 {
|
||||
bail!(
|
||||
"{} holds {} *.png — a flicker burst needs at least 2 (WOW_LIVE_SHOT_COUNT=<n>)",
|
||||
dir.display(),
|
||||
names.len()
|
||||
);
|
||||
}
|
||||
let frames: Vec<image::RgbImage> = names
|
||||
.iter()
|
||||
.map(|n| load(&dir.join(n)))
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
let mut worst = Metrics {
|
||||
mae: 0.0,
|
||||
rmse: 0.0,
|
||||
max_delta: 0,
|
||||
pct_over: 0.0,
|
||||
};
|
||||
for (pair, window) in frames.windows(2).enumerate() {
|
||||
let m = compare(&window[0], &window[1])?;
|
||||
println!(
|
||||
"{:<24} {}",
|
||||
format!("{} -> {}", names[pair], names[pair + 1]),
|
||||
fmt_metrics(&m)
|
||||
);
|
||||
if m.mae > worst.mae {
|
||||
worst = m;
|
||||
}
|
||||
}
|
||||
|
||||
let e = envelope(&frames, amplify)?;
|
||||
println!(
|
||||
"envelope of {} frames: max swing {:>3} mean {:>6.3} unstable pixels {:>6.2}%",
|
||||
frames.len(),
|
||||
e.max_swing,
|
||||
e.mean_swing,
|
||||
e.pct_unstable * 100.0
|
||||
);
|
||||
// The moving-camera reading. Printed always, because which of the two numbers is meaningful
|
||||
// depends on whether the camera was parked — and a burst does not carry that fact.
|
||||
let t = toggles(&frames, toggle_delta, TOGGLE_AMPLIFY)?;
|
||||
println!(
|
||||
"toggles of {} frames (delta {toggle_delta}): max reversals {:>3} toggling pixels {:>6.2}% \
|
||||
(the moving-camera reading — a monotone sweep scores 0)",
|
||||
frames.len(),
|
||||
t.max_reversals,
|
||||
t.pct_toggling * 100.0
|
||||
);
|
||||
if let Some(out) = out {
|
||||
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
e.image
|
||||
.save(out)
|
||||
.with_context(|| format!("writing envelope {}", out.display()))?;
|
||||
println!("envelope -> {}", out.display());
|
||||
let toggle_out = sibling(out, "-toggle");
|
||||
t.image
|
||||
.save(&toggle_out)
|
||||
.with_context(|| format!("writing toggle map {}", toggle_out.display()))?;
|
||||
println!("toggles -> {}", toggle_out.display());
|
||||
}
|
||||
Ok(worst)
|
||||
}
|
||||
|
||||
/// Follow the toggle map to the thing it found: measure the toggling pixels' **shape**, then crop
|
||||
/// the largest run out of every frame into one contact sheet.
|
||||
///
|
||||
/// The map says *where*; this says *what kind*, and shows the frames that prove it. A run that fills
|
||||
/// its box and holds together is one surface blinking (visibility/culling); many thin runs are
|
||||
/// z-fighting resolving per fragment. Both read identically on the toggle percentage.
|
||||
fn hotspot(dir: &Path, out: &Path, toggle_delta: u8, pad: u32) -> Result<()> {
|
||||
let names: Vec<String> = pngs(dir)?.into_iter().collect();
|
||||
let frames: Vec<image::RgbImage> = names
|
||||
.iter()
|
||||
.map(|n| load(&dir.join(n)))
|
||||
.collect::<Result<_>>()?;
|
||||
let t = toggles(&frames, toggle_delta, TOGGLE_AMPLIFY)?;
|
||||
let s = shape(&t);
|
||||
let (w, h) = t.image.dimensions();
|
||||
println!(
|
||||
"toggling {:.2}% coherence {:.3} {} runs >= {} px ({} px scattered)",
|
||||
t.pct_toggling * 100.0,
|
||||
s.coherence,
|
||||
s.regions.len(),
|
||||
benilla_visual::MIN_REGION_PIXELS,
|
||||
s.scattered,
|
||||
);
|
||||
for (i, r) in s.regions.iter().take(5).enumerate() {
|
||||
println!(
|
||||
" #{i} {:>8} px fill {:.2} box {}x{} at ({}, {})",
|
||||
r.pixels,
|
||||
r.fill(),
|
||||
r.bounds.width(),
|
||||
r.bounds.height(),
|
||||
r.bounds.x0,
|
||||
r.bounds.y0,
|
||||
);
|
||||
// Pasteable straight into the in-game ray pick, which is the next question every time:
|
||||
// WOW_PICK="<this>" names the surfaces at these exact pixels, front to back.
|
||||
let pick = r
|
||||
.samples()
|
||||
.iter()
|
||||
.map(|(x, y)| format!("{x},{y}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
println!(" WOW_PICK=\"{pick}\"");
|
||||
}
|
||||
let Some(biggest) = s.regions.first() else {
|
||||
bail!(
|
||||
"no toggling run of {} px or more — nothing to crop",
|
||||
benilla_visual::MIN_REGION_PIXELS
|
||||
);
|
||||
};
|
||||
// What the top runs are actually *doing*, frame by frame: re-shaded together, or edges moving.
|
||||
// Side by side, because whether they flip on the SAME steps is the difference between one global
|
||||
// cause and several local ones — and that is invisible in any single run's series.
|
||||
let series: Vec<Vec<benilla_visual::Step>> = s
|
||||
.regions
|
||||
.iter()
|
||||
.take(SERIES_RUNS)
|
||||
.map(|r| r.steps(&frames))
|
||||
.collect();
|
||||
println!(" step-by-step per run — mean luma, its delta, and how much of the run agreed:");
|
||||
for (i, _) in frames.windows(2).enumerate() {
|
||||
let cols: Vec<String> = series
|
||||
.iter()
|
||||
.map(|s| {
|
||||
format!(
|
||||
"{:6.2}{:+7.2} @{:.2}",
|
||||
s[i].mean_from, s[i].mean_delta, s[i].agreement
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
println!(" {:>2} -> {:<2} {}", i, i + 1, cols.join(" "));
|
||||
}
|
||||
// The two extremes of run #0, per channel. If a two-state flip is one light term switching, the
|
||||
// channel ratios come out equal; if the surface swapped to a different (differently-coloured)
|
||||
// lighting lane, they do not — and that is the whole difference between the two diagnoses.
|
||||
let s0 = &series[0];
|
||||
let lo = s0
|
||||
.iter()
|
||||
.min_by(|a, b| a.mean_from.total_cmp(&b.mean_from))
|
||||
.expect("a burst has at least one step");
|
||||
let hi = s0
|
||||
.iter()
|
||||
.max_by(|a, b| a.mean_from.total_cmp(&b.mean_from))
|
||||
.expect("a burst has at least one step");
|
||||
let ratio: Vec<String> = (0..3)
|
||||
.map(|c| format!("{:.3}", hi.mean_rgb_from[c] / lo.mean_rgb_from[c].max(1e-9)))
|
||||
.collect();
|
||||
println!(
|
||||
" #0 extremes: dim rgb ({:.1}, {:.1}, {:.1}) bright rgb ({:.1}, {:.1}, {:.1}) per-channel ratio {}",
|
||||
lo.mean_rgb_from[0], lo.mean_rgb_from[1], lo.mean_rgb_from[2],
|
||||
hi.mean_rgb_from[0], hi.mean_rgb_from[1], hi.mean_rgb_from[2],
|
||||
ratio.join(" / "),
|
||||
);
|
||||
let rect = biggest.bounds.padded(pad, w, h);
|
||||
// The toggle map goes in as the first tile, so the sheet carries its own legend: this is the
|
||||
// region, and here is what the frames did inside it.
|
||||
let mut tiles = vec![crop(&t.image, rect)];
|
||||
tiles.extend(frames.iter().map(|f| crop(f, rect)));
|
||||
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
contact_strip(&tiles, STRIP_COLS, COMPOSE_GAP)
|
||||
.save(out)
|
||||
.with_context(|| format!("writing hotspot strip {}", out.display()))?;
|
||||
println!(
|
||||
"crop {}x{} at ({}, {}) x {} tiles -> {}",
|
||||
rect.width(),
|
||||
rect.height(),
|
||||
rect.x0,
|
||||
rect.y0,
|
||||
tiles.len(),
|
||||
out.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `foo.png` + `-toggle` → `foo-toggle.png`. The two maps answer the same question under opposite
|
||||
/// camera conditions, so they are written as a pair rather than behind a second flag nobody would
|
||||
/// remember to pass.
|
||||
fn sibling(out: &Path, suffix: &str) -> PathBuf {
|
||||
let ext = out.extension().and_then(|e| e.to_str()).unwrap_or("png");
|
||||
let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or("out");
|
||||
out.with_file_name(format!("{stem}{suffix}.{ext}"))
|
||||
}
|
||||
|
||||
fn next(it: &mut std::slice::Iter<String>, flag: &str) -> Result<String> {
|
||||
it.next()
|
||||
.cloned()
|
||||
@@ -148,6 +413,13 @@ fn two<'a>(rest: &'a [String], cmd: &str) -> Result<[&'a str; 2]> {
|
||||
}
|
||||
}
|
||||
|
||||
fn one<'a>(rest: &'a [String], cmd: &str) -> Result<[&'a str; 1]> {
|
||||
match rest {
|
||||
[a] => Ok([a]),
|
||||
_ => bail!("{cmd} needs exactly one path, got {}", rest.len()),
|
||||
}
|
||||
}
|
||||
|
||||
fn over_fail(m: &Metrics, fail: Option<f64>) -> bool {
|
||||
fail.is_some_and(|t| m.mae > t)
|
||||
}
|
||||
@@ -240,8 +512,14 @@ fn print_usage() {
|
||||
USAGE:\n \
|
||||
benilla-visual diff <a.png> <b.png> [--out <diff.png>] [--fail <mae>] [--amplify <n>]\n \
|
||||
benilla-visual diff-dir <dir_a> <dir_b> [--out <diff_dir>] [--fail <mae>] [--amplify <n>]\n \
|
||||
benilla-visual flicker <burst_dir> [--out <envelope.png>] [--fail <mae>] [--amplify <n>] [--toggle-delta <n>]\n \
|
||||
benilla-visual hotspot <burst_dir> --out <strip.png> [--toggle-delta <n>] [--pad <n>]\n \
|
||||
benilla-visual compose-dir <dir_a> <dir_b> --out <dir> (side-by-side `a | b` per image)\n\
|
||||
\n\
|
||||
flicker reads a WOW_LIVE_SHOT_COUNT burst (adjacent frames) in shot order and reports both\n\
|
||||
the parked reading (envelope) and the moving-camera one (toggles).\n\
|
||||
hotspot follows the toggle map: it measures the toggling pixels' shape — one solid run is a\n\
|
||||
surface blinking, many thin ones are z-fighting — and crops the largest into a contact sheet.\n\
|
||||
Exits non-zero if any image's MAE exceeds --fail."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,9 +39,12 @@
|
||||
ref (which HIDES an empty, non-grid slot entirely), our bar always shows all 12 wells; the
|
||||
grid only swaps an EMPTY slot's ring art between the occupied and empty Quickslot textures
|
||||
— the default (nothing held) look is unchanged from before this slice, no capture regression.
|
||||
- Exhaustion tick + rest fill, XP text (hover/cvar-gated), MaxLevelBar, the performance bar,
|
||||
the four bag slots / keyring / micro buttons: OUT. The bag/keyring/micro cluster on the
|
||||
bar's right is the visible remaining gap (only the backpack toggle is relocated there).
|
||||
- Exhaustion tick + rest fill, XP text (hover/cvar-gated), MaxLevelBar: OUT. The bar's
|
||||
right-hand cluster is otherwise complete — the backpack toggle + four bag slots live in
|
||||
BagFrame.xml and the eight micro buttons in MicroMenu.xml (both anchored into
|
||||
BenillaActionBarArtFrame and seated over the art by BenillaActionBarArt_SeatAbove below),
|
||||
and the performance meter is at the foot of this file. Only the KEYRING is still out, with
|
||||
the keyring container itself (the ref hides that button by default anyway).
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
BENILLA_FALLBACK_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
|
||||
@@ -67,6 +70,24 @@
|
||||
-- feedback events per-button; our bar-level fan needs the full set in one place.
|
||||
BENILLA_ACTION_BUTTONS = {}
|
||||
|
||||
-- Seat a TOP-LEVEL frame that anchors INTO the bar art (the bag bar + backpack toggle,
|
||||
-- BagFrame.xml; the micro-button row, MicroMenu.xml) one frame level above the art, so its
|
||||
-- icons draw over the bar plate instead of under it.
|
||||
--
|
||||
-- Why it's needed: those frames are top-level, so they default to a LOWER frame level than
|
||||
-- the bar's own art (a child hierarchy — the ExpBar's dwarf notches + the metal/well art sit
|
||||
-- at the bar's child level). Frame level dominates the flat painter's order, so the bar art
|
||||
-- would paint OVER them — the ring shows but the centered icon is fully covered (the "empty
|
||||
-- rings, no bag icons" bug). The bar's OWN 12 buttons avoid this by being CHILDREN of the art
|
||||
-- frame (child-over-parent). (SetParent onto the art frame would be the structural twin, but
|
||||
-- the engine keys draw order at creation and does not re-key on a runtime reparent, so the
|
||||
-- explicit level is the reliable lever.) Lives here, with the frame it's about, because two
|
||||
-- files now need it — it began as BagFrame.xml's private BenillaBagBar_SeatAboveActionBar.
|
||||
function BenillaActionBarArt_SeatAbove(frame)
|
||||
local art = getglobal("BenillaActionBarArtFrame")
|
||||
if art then frame:SetFrameLevel(art:GetFrameLevel() + 1) end
|
||||
end
|
||||
|
||||
-- The wire/API action id the MAIN bar's button i displays (the vanilla page formula).
|
||||
-- INTERNAL to BenillaActionButton_Action below — the ONLY caller. A button path that
|
||||
-- reaches past it computes a MAIN-bar id for a multibar button (base ignored), which
|
||||
@@ -463,6 +484,101 @@
|
||||
function BenillaExpBar_OnEvent(bar)
|
||||
BenillaExpBar_Update(bar)
|
||||
end
|
||||
|
||||
-- The XP bar's hover (ref-MainMenuBar.xml l.136-147, MainMenuExpBar's own OnEnter/OnLeave).
|
||||
-- ref GlobalStrings.lua l.5461 / l.2789 (enUS), in the `X = X or` FALLBACK form: the app
|
||||
-- runs the real localized GlobalStrings.lua off the patch chain BEFORE this file
|
||||
-- (ui_script/mod.rs load_global_strings), so the installed locale wins and these copies
|
||||
-- only fill in for the test harnesses, which load no patch chain. CombatText.xml's shape.
|
||||
XPBAR_LABEL = XPBAR_LABEL or "XP Bar"
|
||||
NEWBIE_TOOLTIP_XPBAR = NEWBIE_TOOLTIP_XPBAR or "The amount of experience (XP) you have earned. The color of the XP bar indicates your rest state: light blue for Rested, and purple for Normal. Rested players earn twice the experience they would normally gain from slaying a monster. Characters become less rested when they kill monsters, and more rested by spending time at or logging out at an inn or city."
|
||||
|
||||
-- The ref's OnEnter does three more things, all of them machinery we don't have yet, and
|
||||
-- each one its own slice rather than a gap in this one:
|
||||
-- TextStatusBar_UpdateTextString() + ShowTextStatusBarText(this) — the "1234/5678" numerals
|
||||
-- that appear ON the bar while hovered. No TextStatusBar text layer exists here at all.
|
||||
-- ExhaustionTick.timer = 1 and GameTooltip.canAddRestStateLine = 1 — the rest-state line
|
||||
-- ("You should rest at an Inn", EXHAUST_TOOLTIP1-4) the exhaustion tick appends. There is
|
||||
-- no rest-state feed, so there is nothing to append.
|
||||
-- The trailing 1 is the ref's noNormalText: with detailed tips OFF this hover shows NOTHING
|
||||
-- rather than a bare "XP Bar". Transcribed, though 1.12's default (0661) never takes it.
|
||||
function BenillaExpBar_OnEnter(bar)
|
||||
if not GameTooltip_AddNewbieTip then return end -- engine tests load file subsets
|
||||
GameTooltip_AddNewbieTip(bar, XPBAR_LABEL, 1.0, 1.0, 1.0, NEWBIE_TOOLTIP_XPBAR, 1)
|
||||
end
|
||||
|
||||
function BenillaExpBar_OnLeave()
|
||||
if GameTooltip then GameTooltip:Hide() end
|
||||
end
|
||||
|
||||
-- ── The performance ("ping") meter ────────────────────────────────────────────────────
|
||||
-- ref-MainMenuBar.xml l.344-407: a 16x64 frame in the bar's own right-hand recess whose
|
||||
-- 20x66 bar texture is TINTED green/yellow/red by the connection's latency, plus an
|
||||
-- all-points button on top for the hover tooltip. The thresholds, the 10 s poll and the
|
||||
-- inline handlers below are the ref's, transcribed out of its XML into named functions.
|
||||
-- The meter shows LATENCY ONLY: 1.12 never scales the bar's height, only its color.
|
||||
PERFORMANCEBAR_LOW_LATENCY = 300
|
||||
PERFORMANCEBAR_MEDIUM_LATENCY = 600
|
||||
PERFORMANCEBAR_UPDATE_INTERVAL = 10
|
||||
|
||||
-- ref-GlobalStrings.lua l.2659 / l.2689 / l.2746 (enUS), fallback form — see the XP bar's
|
||||
-- note above for why these never override a real localized install.
|
||||
MAINMENUBAR_LATENCY_LABEL = MAINMENUBAR_LATENCY_LABEL or "Latency:"
|
||||
MILLISECONDS_ABBR = MILLISECONDS_ABBR or "ms"
|
||||
NEWBIE_TOOLTIP_LATENCY = NEWBIE_TOOLTIP_LATENCY or "The average time it takes to talk with the game server. A low latency will display as a green bar, higher latencies will be yellow or even red. Consistently high latencies may indicate a problem with your Internet connection."
|
||||
|
||||
function BenillaPerformanceBar_OnLoad(frame)
|
||||
frame.hover = nil
|
||||
-- 0, not the interval: the ref's first OnUpdate tick runs the poll immediately, so the
|
||||
-- bar is never briefly untinted (its texture is grey until the first SetVertexColor).
|
||||
frame.updateInterval = 0
|
||||
end
|
||||
|
||||
function BenillaPerformanceBar_TooltipText()
|
||||
local _, _, latency = GetNetStats()
|
||||
return MAINMENUBAR_LATENCY_LABEL .. " " .. latency .. MILLISECONDS_ABBR
|
||||
end
|
||||
|
||||
-- ref l.389 (the held-open refresh) and l.400 (OnEnter), both the same call:
|
||||
-- GameTooltip_AddNewbieTip(MAINMENUBAR_LATENCY_LABEL.." "..latency..MILLISECONDS_ABBR,
|
||||
-- 1.0, 1.0, 1.0, NEWBIE_TOOLTIP_LATENCY)
|
||||
-- So the reference hover is TWO lines — the number, then the wrapped explanation — seated
|
||||
-- at the default screen corner, not beside the meter (GameTooltip.xml, decision 0661).
|
||||
function BenillaPerformanceBar_ShowTooltip(owner)
|
||||
if not GameTooltip_AddNewbieTip then return end -- engine tests load file subsets
|
||||
GameTooltip_AddNewbieTip(owner, BenillaPerformanceBar_TooltipText(), 1.0, 1.0, 1.0,
|
||||
NEWBIE_TOOLTIP_LATENCY)
|
||||
end
|
||||
|
||||
function BenillaPerformanceBar_OnUpdate(frame, elapsed)
|
||||
if frame.updateInterval > 0 then
|
||||
frame.updateInterval = frame.updateInterval - elapsed
|
||||
return
|
||||
end
|
||||
frame.updateInterval = PERFORMANCEBAR_UPDATE_INTERVAL
|
||||
local _, _, latency = GetNetStats()
|
||||
if latency > PERFORMANCEBAR_MEDIUM_LATENCY then
|
||||
BenillaPerformanceBar:SetVertexColor(1, 0, 0)
|
||||
elseif latency > PERFORMANCEBAR_LOW_LATENCY then
|
||||
BenillaPerformanceBar:SetVertexColor(1, 1, 0)
|
||||
else
|
||||
BenillaPerformanceBar:SetVertexColor(0, 1, 0)
|
||||
end
|
||||
-- Held-open tooltips re-read the number on the same 10 s beat (the ref's own refresh).
|
||||
if frame.hover then
|
||||
BenillaPerformanceBar_ShowTooltip(frame)
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaPerformanceBar_OnEnter(button)
|
||||
BenillaPerformanceBarFrame.hover = 1
|
||||
BenillaPerformanceBar_ShowTooltip(button)
|
||||
end
|
||||
|
||||
function BenillaPerformanceBar_OnLeave()
|
||||
BenillaPerformanceBarFrame.hover = nil
|
||||
if GameTooltip then GameTooltip:Hide() end
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!--
|
||||
@@ -548,7 +664,9 @@
|
||||
Drawn first among the bar's children ⇒ lowest, so the dwarf art overlaps it (the
|
||||
real RaiseFrameLevel(art) order).
|
||||
-->
|
||||
<StatusBar name="BenillaExpBar">
|
||||
<!-- enableMouse: the ref's MainMenuExpBar takes the mouse for its own hover (l.136-147).
|
||||
The strip is the bar's top 13 px, clear of every button on it. -->
|
||||
<StatusBar name="BenillaExpBar" enableMouse="true">
|
||||
<Size><AbsDimension x="1024" y="13"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||||
@@ -587,6 +705,8 @@
|
||||
<Scripts>
|
||||
<OnLoad>BenillaExpBar_OnLoad(self)</OnLoad>
|
||||
<OnEvent>BenillaExpBar_OnEvent(self)</OnEvent>
|
||||
<OnEnter>BenillaExpBar_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaExpBar_OnLeave()</OnLeave>
|
||||
</Scripts>
|
||||
</StatusBar>
|
||||
|
||||
@@ -723,6 +843,53 @@
|
||||
</Button>
|
||||
</Frames>
|
||||
</Frame>
|
||||
|
||||
<!-- The performance ("ping") meter (ref-MainMenuBar.xml l.344-407). 16x64 pinned to the
|
||||
bar's BOTTOMRIGHT +(-227,-10) — the empty recess between the last micro button and
|
||||
the bag slots, which the bar art leaves TRANSPARENT for exactly this. The 20x66
|
||||
texture hangs off the frame's TOPRIGHT (so it overspills 4 left and 2 below), and
|
||||
the frame itself hangs 10 below the bar, putting the bar's grey column in the slot.
|
||||
|
||||
frameStrata="LOW" is load-bearing, not decoration: the meter draws UNDER the bar art
|
||||
and shows through its transparent window. At the default MEDIUM it would paint over
|
||||
the metal surround, since the texture is wider than the slot. (The ref gets the same
|
||||
order via strata too — MainMenuBar's OnLoad RaiseFrameLevel can't reach across it.)
|
||||
|
||||
The hover button is the ref's own split: a HIGH-strata all-points button, because the
|
||||
LOW frame beneath the art can't take the mouse itself. The ref declares it as a
|
||||
sibling with parent="MainMenuBarPerformanceBarFrame"; nesting it is the same frame
|
||||
tree, and its explicit strata survives the parent's LOW subtree-force (attributes
|
||||
apply before nested <Frames> are built).
|
||||
|
||||
SCOPE: the ref's MainMenuBar_UpdateKeyRing shifts this frame to -235 when the keyring
|
||||
is shown. No keyring here (see the header's OUT list), so the un-shifted -227 is the
|
||||
only position. -->
|
||||
<Frame name="BenillaPerformanceBarFrame" frameStrata="LOW">
|
||||
<Size><AbsDimension x="16" y="64"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMRIGHT" relativeTo="BenillaActionBar" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-227" y="-10"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="BenillaPerformanceBar" file="Interface\MainMenuBar\UI-MainMenuBar-PerformanceBar">
|
||||
<Size><AbsDimension x="20" y="66"/></Size>
|
||||
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<Button name="BenillaPerformanceBarButton" setAllPoints="true" frameStrata="HIGH">
|
||||
<Scripts>
|
||||
<OnEnter>BenillaPerformanceBar_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaPerformanceBar_OnLeave()</OnLeave>
|
||||
</Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaPerformanceBar_OnLoad(self)</OnLoad>
|
||||
<OnUpdate>BenillaPerformanceBar_OnUpdate(self, arg1)</OnUpdate>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaActionBar_OnLoad(self)</OnLoad>
|
||||
|
||||
@@ -568,20 +568,14 @@
|
||||
-- DEFERRAL: no drag-to-equip a bag onto the bar yet (that's the paper-doll drag arc, slice
|
||||
-- 3's scope, not this one).
|
||||
-- The bag bar (and the backpack toggle) are RELOCATED onto the action bar — anchored into
|
||||
-- BenillaActionBarArtFrame's bottom-right. But they are top-level frames, so they default to
|
||||
-- a LOWER frame level than the bar's own art (which is a child hierarchy: the ExpBar's dwarf
|
||||
-- notches + the metal/well art sit at the bar's child level). Frame level dominates the flat
|
||||
-- painter's order, so the bar art would paint OVER these buttons' icons — the icon draws, the
|
||||
-- ring shows around it, but the centered icon is fully covered (the "empty rings, no bag
|
||||
-- icons" bug). The action bar's OWN 12 buttons avoid this because they're CHILDREN of the art
|
||||
-- frame (child-over-parent). Seat the bag-bar frames one level above the art — the same level
|
||||
-- the action buttons get — so their icons draw above the bar art. (SetParent onto the art
|
||||
-- frame would be the structural twin, but the engine keys draw order at creation and does not
|
||||
-- re-key on a runtime reparent, so the explicit level is the reliable lever.)
|
||||
function BenillaBagBar_SeatAboveActionBar(frame)
|
||||
local art = getglobal("BenillaActionBarArtFrame")
|
||||
if art then frame:SetFrameLevel(art:GetFrameLevel() + 1) end
|
||||
end
|
||||
-- BenillaActionBarArtFrame's bottom-right — which means they need seating one frame level
|
||||
-- above the bar art or it paints over their icons. That lever lives with the frame it's
|
||||
-- about: BenillaActionBarArt_SeatAbove (ActionBar.xml, loaded before this file), which
|
||||
-- carries the full explanation; the micro-button row is its second caller. Called
|
||||
-- nil-GUARDED here: the shipped load order always has the bar first, but ~25 bag/bank/
|
||||
-- escape/merchant test harnesses load THIS file alone — and with no bar loaded there is by
|
||||
-- definition no bar art to seat above, so the guard's no-op is the right answer, not a
|
||||
-- swallowed failure.
|
||||
|
||||
-- Bag0Slot..Bag3Slot = live inv ids 20..23 (char_stats.rs SLOT_INFO, DBC-verified); OUR bag
|
||||
-- ids 1..4 map the same way (bagId N ⇒ inv slot 19+N), the id PickupInventoryItem/
|
||||
@@ -591,7 +585,7 @@
|
||||
function BenillaBagBarSlot_OnLoad(button, bagId)
|
||||
button.bagId = bagId
|
||||
button.icon = getglobal(button:GetName() .. "Icon")
|
||||
BenillaBagBar_SeatAboveActionBar(button)
|
||||
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end
|
||||
button:RegisterForClicks("LeftButtonUp")
|
||||
-- Drag-to-equip (decision 0208 phase 1b's deferred bag-icon arm, now landed): the bar
|
||||
-- slot is a paper-doll equipment slot for bags. LEFT drag picks the equipped bag up;
|
||||
@@ -642,6 +636,49 @@
|
||||
BenillaBagBarSlot_Update(button)
|
||||
end
|
||||
|
||||
-- ── The bar buttons' hovers ───────────────────────────────────────────────────────────
|
||||
-- ref GlobalStrings.lua l.132 / l.1458 (enUS), fallback form: the real localized
|
||||
-- GlobalStrings.lua runs before this file at runtime (ui_script/mod.rs), so these only
|
||||
-- fill in for the harnesses that load no patch chain.
|
||||
BACKPACK_TOOLTIP = BACKPACK_TOOLTIP or "Backpack"
|
||||
EQUIP_CONTAINER = EQUIP_CONTAINER or "Equip Container"
|
||||
|
||||
-- Both are PLAIN tooltips — the reference calls GameTooltip:SetText here, not
|
||||
-- GameTooltip_AddNewbieTip, so unlike the micro buttons next door (0661) there is no second
|
||||
-- explanation line to be missing. They also anchor beside the button (ANCHOR_LEFT, so the
|
||||
-- plate opens toward screen center off a bottom-right button), NOT at the default corner.
|
||||
--
|
||||
-- ref MainMenuBarBackpackButton OnEnter (ref-MainMenuBarBagButtons.xml l.91-99). The ref
|
||||
-- appends the live TOGGLEBACKPACK key with GameTooltip:AppendText; this engine has neither
|
||||
-- GetBindingKey nor AppendText, so the suffix is concatenated from the one bare-key binding
|
||||
-- we actually ship (ui_script/input.rs `bare_key_binding`: KeyB) — MicroMenu.xml's own shape
|
||||
-- for the same problem, and the rendered line is identical.
|
||||
function BenillaBagToggle_OnEnter(button)
|
||||
if not GameTooltip then return end -- engine tests load file subsets
|
||||
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
|
||||
GameTooltip:SetText(
|
||||
BACKPACK_TOOLTIP .. " " .. NORMAL_FONT_COLOR_CODE .. "(B)" .. FONT_COLOR_CODE_CLOSE,
|
||||
1.0, 1.0, 1.0)
|
||||
end
|
||||
|
||||
-- ref BagSlotButton_OnEnter (ref-MainMenuBarBagButtons.lua l.86-96): the equipped bag's own
|
||||
-- item tooltip, or "Equip Container" for an empty slot. The ref's "(F1)".."(F4)" suffix
|
||||
-- needs GetBindingKey AND the TOGGLEBAG1..4 bindings — benilla ships neither, so no slot
|
||||
-- carries one; the day those bind, this is where the suffix goes.
|
||||
function BenillaBagBarSlot_OnEnter(button)
|
||||
if not GameTooltip then return end
|
||||
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
|
||||
if not GameTooltip:SetInventoryItem("player", BenillaBagBarSlot_InvSlot(button)) then
|
||||
GameTooltip:SetText(EQUIP_CONTAINER, 1.0, 1.0, 1.0)
|
||||
end
|
||||
end
|
||||
|
||||
-- ref OnLeave for both (l.100-102 / the template's own): a plain Hide, no fade — the item
|
||||
-- half of the split BenillaUnitFrame_OnLeave's comment describes.
|
||||
function BenillaBagBarButton_OnLeave()
|
||||
if GameTooltip then GameTooltip:Hide() end
|
||||
end
|
||||
|
||||
function BenillaBagBarSlot_Update(button)
|
||||
local invSlot = BenillaBagBarSlot_InvSlot(button)
|
||||
local texture = GetInventoryItemTexture("player", invSlot)
|
||||
@@ -1164,9 +1201,11 @@
|
||||
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
|
||||
<Scripts>
|
||||
<!-- Seat the backpack icon above the bar art too (same reason as the bag-bar slots —
|
||||
BenillaBagBar_SeatAboveActionBar's comment). -->
|
||||
<OnLoad>BenillaBagBar_SeatAboveActionBar(self)</OnLoad>
|
||||
BenillaActionBarArt_SeatAbove's comment). -->
|
||||
<OnLoad>if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end</OnLoad>
|
||||
<OnClick>BenillaBagToggle_OnClick()</OnClick>
|
||||
<OnEnter>BenillaBagToggle_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
@@ -1196,6 +1235,8 @@
|
||||
<OnLoad>BenillaBagBarSlot_OnLoad(self, 1)</OnLoad>
|
||||
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
|
||||
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
|
||||
<OnEnter>BenillaBagBarSlot_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
|
||||
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
|
||||
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
|
||||
bag places), mirroring the paper-doll/bag-slot drag routing. -->
|
||||
@@ -1219,6 +1260,8 @@
|
||||
<OnLoad>BenillaBagBarSlot_OnLoad(self, 2)</OnLoad>
|
||||
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
|
||||
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
|
||||
<OnEnter>BenillaBagBarSlot_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
|
||||
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
|
||||
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
|
||||
bag places), mirroring the paper-doll/bag-slot drag routing. -->
|
||||
@@ -1242,6 +1285,8 @@
|
||||
<OnLoad>BenillaBagBarSlot_OnLoad(self, 3)</OnLoad>
|
||||
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
|
||||
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
|
||||
<OnEnter>BenillaBagBarSlot_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
|
||||
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
|
||||
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
|
||||
bag places), mirroring the paper-doll/bag-slot drag routing. -->
|
||||
@@ -1265,6 +1310,8 @@
|
||||
<OnLoad>BenillaBagBarSlot_OnLoad(self, 4)</OnLoad>
|
||||
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
|
||||
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
|
||||
<OnEnter>BenillaBagBarSlot_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
|
||||
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
|
||||
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
|
||||
bag places), mirroring the paper-doll/bag-slot drag routing. -->
|
||||
|
||||
@@ -99,16 +99,17 @@
|
||||
970-978`): no Lua in this session's extraction ever populates it (likely an ammo-picker-
|
||||
addon-only overlay outside this pull) — dropped rather than guessed at.
|
||||
|
||||
Engine-gap notes (transcribed anyway per this file's own instructions, or worked around):
|
||||
Engine-gap notes (transcribed anyway per this file's own instructions, or worked around) —
|
||||
the first two are CLOSED, kept only because the workarounds below still read as if they held:
|
||||
- `HitRectInsets` (both `BenillaCharacterFrame` and `BenillaPaperDollFrame`, ref's identical
|
||||
0/30/0/45 on both) has no loader wiring yet (grepped `benilla-ui/src/loader` — no
|
||||
`HitRectInsets` handling exists) — included anyway (harmless no-op, documents the ref's
|
||||
intent for whenever hit-rect insets land).
|
||||
- `enableMouse`/`setAllPoints` (frame-level) are also no-ops on `<Frame>` (only the
|
||||
region-level `setAllPoints` shorthand is wired, `loader/regions.rs:130`) — `
|
||||
BenillaPaperDollFrame` therefore gets an explicit `Size`+`TOPLEFT` anchor instead of the
|
||||
ref's `setAllPoints="true"` (the same effect, since it's sized to exactly fill its
|
||||
parent).
|
||||
0/30/0/45 on both) was a loader no-op when this file was written; it landed with the
|
||||
micro menu (decision 0655) and now really insets the mouse rect.
|
||||
- `enableMouse`/`setAllPoints` (frame-level) were no-ops on `<Frame>` too; both are wired
|
||||
now (`loader/geometry.rs`'s `apply_attrs`/`apply_anchors`, and
|
||||
`loader/tests.rs::frame_set_all_points_pins_to_parent`). `BenillaPaperDollFrame` still
|
||||
carries an explicit `Size`+`TOPLEFT` anchor rather than the ref's `setAllPoints="true"`
|
||||
— exactly equivalent for a child sized to fill its parent, so it is left alone rather
|
||||
than churned.
|
||||
- A plain `<Frame>` isn't mouse-enabled by construction in this engine (only Button/
|
||||
CheckButton/EditBox/ScrollingMessageFrame are — `widget/mod.rs`'s ctor comment, already
|
||||
the reason `UnitFrames.xml` calls `EnableMouse(true)` on its own root frame) — every
|
||||
@@ -291,18 +292,21 @@
|
||||
PanelTemplates_SetTab(frame, 1)
|
||||
end
|
||||
|
||||
-- ref CharacterFrame_OnShow (l.82-93): UpdateMicroButtons/Show*TextStatusBarText/
|
||||
-- ShowWatchedReputationBarText all OMITTED — no micro-button bar, unit-frame bar-text-mode
|
||||
-- toggle, or reputation-bar-text mechanism exists in this engine. UnitPVPName -> plain
|
||||
-- UnitName (this file's header comment: PVP rank is a named deferral).
|
||||
-- ref CharacterFrame_OnShow (l.82-93): Show*TextStatusBarText/ShowWatchedReputationBarText
|
||||
-- OMITTED — no unit-frame bar-text-mode toggle or reputation-bar-text mechanism exists in
|
||||
-- this engine. UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml); it was
|
||||
-- omitted only for want of a micro bar. UnitPVPName -> plain UnitName (this file's header
|
||||
-- comment: PVP rank is a named deferral).
|
||||
function BenillaCharacterFrame_OnShow()
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end
|
||||
PlaySound("igCharacterInfoOpen")
|
||||
SetPortraitTexture(getglobal("BenillaCharacterFramePortrait"), "player")
|
||||
getglobal("BenillaCharacterNameText"):SetText(UnitName("player"))
|
||||
end
|
||||
|
||||
-- ref CharacterFrame_OnHide (l.95-104): everything past the PlaySound is the OnShow list's
|
||||
-- Hide-twin, OMITTED for the same reason. The subframe hide-loop is OUR OWN addition
|
||||
-- Hide-twin — UpdateMicroButtons live (MicroMenu.xml), the bar-text pair still OMITTED for
|
||||
-- the same reason as above. The subframe hide-loop is OUR OWN addition
|
||||
-- (decision 0437 phase 4): BenillaPaperDollFrame is a true engine child of this frame (its
|
||||
-- own header comment) so it hides for free on the parent's Hide-cascade, but
|
||||
-- BenillaSkillFrame is a SEPARATE top-level file — this engine has no cross-file `parent=`
|
||||
@@ -313,6 +317,7 @@
|
||||
-- UIPanel exclusivity). Looping every BENILLA_CHARACTERFRAME_SUBFRAMES entry here, once,
|
||||
-- keeps this correct as more non-nested tabs (Pet/Reputation/Honor) land the same way.
|
||||
function BenillaCharacterFrame_OnHide()
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end
|
||||
PlaySound("igCharacterInfoClose")
|
||||
for _, name in ipairs(BENILLA_CHARACTERFRAME_SUBFRAMES) do
|
||||
local subFrame = getglobal(name)
|
||||
@@ -1282,9 +1287,10 @@
|
||||
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(2)</OnClick></Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The paper-doll page (ref PaperDollFrame, ref-PaperDollFrame.xml:124-1059). No
|
||||
setAllPoints="true" (frame-level setAllPoints is a no-op in this engine, this file's
|
||||
header comment) — sized to exactly fill the parent instead, the same effect. -->
|
||||
<!-- The paper-doll page (ref PaperDollFrame, ref-PaperDollFrame.xml:124-1059). Sized to
|
||||
exactly fill the parent instead of the ref's setAllPoints="true" — the same effect
|
||||
(see this file's header: frame-level setAllPoints was a no-op when this was written
|
||||
and is wired now; the explicit size is equivalent, so it stays). -->
|
||||
<Frame name="BenillaPaperDollFrame">
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||||
|
||||
@@ -210,6 +210,170 @@
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── BenillaUIMenu: the 1.12 UIMenu kit, transcribed (ref UIMenu.lua — the chat menu's
|
||||
-- row engine: fixed 16px rows in a tooltip-backdrop box, a 2 s no-hover timeout that
|
||||
-- walks up parentMenu chains, nested submenus opened on row hover). Constants quoted.
|
||||
-- Scoped here because the chat menu is its only consumer; a second consumer graduates
|
||||
-- it to its own file.
|
||||
BENILLA_UIMENU_BUTTON_HEIGHT = 16
|
||||
BENILLA_UIMENU_BUTTON_WIDTH = 104
|
||||
BENILLA_UIMENU_BORDER_HEIGHT = 12
|
||||
BENILLA_UIMENU_BORDER_WIDTH = 12
|
||||
BENILLA_UIMENU_NUMBUTTONS = 32
|
||||
BENILLA_UIMENU_TIMEOUT = 2.0
|
||||
|
||||
function BenillaUIMenu_Initialize(menu)
|
||||
menu.numButtons = 0
|
||||
menu.subMenu = ""
|
||||
local name = menu:GetName()
|
||||
for i = 1, BENILLA_UIMENU_NUMBUTTONS do
|
||||
local button = getglobal(name .. "Button" .. i)
|
||||
button:SetWidth(BENILLA_UIMENU_BUTTON_WIDTH)
|
||||
button:SetHeight(BENILLA_UIMENU_BUTTON_HEIGHT)
|
||||
button:Hide()
|
||||
getglobal(button:GetName() .. "ShortcutText"):Hide()
|
||||
end
|
||||
menu:SetWidth(BENILLA_UIMENU_BUTTON_WIDTH + BENILLA_UIMENU_BORDER_WIDTH * 2)
|
||||
menu:SetHeight(BENILLA_UIMENU_BORDER_HEIGHT * 2)
|
||||
end
|
||||
|
||||
-- Rows past the button pool are dropped (the ref message()s; nothing we author overflows).
|
||||
function BenillaUIMenu_AddButton(menu, text, shortcut, func, nested)
|
||||
local id = menu.numButtons + 1
|
||||
if id > BENILLA_UIMENU_NUMBUTTONS then return end
|
||||
menu.numButtons = id
|
||||
local button = getglobal(menu:GetName() .. "Button" .. id)
|
||||
if text then button:SetText(text) end
|
||||
button.func = func
|
||||
button.nested = nested
|
||||
button:Show()
|
||||
if shortcut then
|
||||
local s = getglobal(button:GetName() .. "ShortcutText")
|
||||
s:SetText(shortcut)
|
||||
s:Show()
|
||||
end
|
||||
menu:SetHeight(id * BENILLA_UIMENU_BUTTON_HEIGHT + BENILLA_UIMENU_BORDER_HEIGHT * 2)
|
||||
end
|
||||
|
||||
function BenillaUIMenu_OnShow()
|
||||
this.timeleft = BENILLA_UIMENU_TIMEOUT
|
||||
this.counting = 1
|
||||
end
|
||||
|
||||
function BenillaUIMenu_OnUpdate(elapsed)
|
||||
if this.counting == 1 then
|
||||
local timeleft = (this.timeleft or BENILLA_UIMENU_TIMEOUT) - elapsed
|
||||
if timeleft <= 0 then
|
||||
this:Hide()
|
||||
return
|
||||
end
|
||||
this.timeleft = timeleft
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaUIMenu_StartCounting(menu)
|
||||
menu.counting = 1
|
||||
if menu.parentMenu then BenillaUIMenu_StartCounting(getglobal(menu.parentMenu)) end
|
||||
end
|
||||
|
||||
function BenillaUIMenu_StopCounting(menu)
|
||||
menu.counting = 0
|
||||
menu.timeleft = BENILLA_UIMENU_TIMEOUT
|
||||
if menu.parentMenu then BenillaUIMenu_StopCounting(getglobal(menu.parentMenu)) end
|
||||
end
|
||||
|
||||
function BenillaUIMenuButton_OnLoad()
|
||||
this:SetPoint("TOP", this:GetParent():GetName(), "TOP", 0,
|
||||
-((this:GetID() - 1) * BENILLA_UIMENU_BUTTON_HEIGHT) - BENILLA_UIMENU_BORDER_HEIGHT)
|
||||
end
|
||||
|
||||
function BenillaUIMenuButton_OnClick()
|
||||
if this.func then this.func() end
|
||||
this:GetParent():Hide()
|
||||
PlaySound("UChatScrollButton")
|
||||
end
|
||||
|
||||
function BenillaUIMenuButton_OnEnter()
|
||||
local nested = this.nested
|
||||
if nested then
|
||||
local menu = getglobal(nested)
|
||||
if not menu:IsVisible() then
|
||||
local oldMenu = getglobal(this:GetParent().subMenu or "")
|
||||
if oldMenu then oldMenu:Hide() end
|
||||
this:GetParent().subMenu = nested
|
||||
menu:SetPoint("BOTTOMLEFT", this:GetName(), "BOTTOMRIGHT", 10, -12)
|
||||
menu:Show()
|
||||
end
|
||||
BenillaUIMenu_StopCounting(menu)
|
||||
else
|
||||
BenillaUIMenu_StopCounting(this:GetParent())
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaUIMenuButton_OnLeave()
|
||||
BenillaUIMenu_StartCounting(this:GetParent())
|
||||
if this.nested then BenillaUIMenu_StartCounting(getglobal(this.nested)) end
|
||||
end
|
||||
|
||||
-- ── The chat menu rows (ref ChatMenu_* in ChatFrame.lua, adapted): every type row
|
||||
-- prefixes the box with the type's slash and lets the LIVE PARSE convert — the ref's
|
||||
-- own Whisper row does literally `SetText("/w "..GetText())`, and routing all rows the
|
||||
-- same way means the menu can never drift from the slash grammar the edit machine
|
||||
-- owns. Labels/shortcuts quoted from GlobalStrings. Language (benilla has no language
|
||||
-- model yet) and Macro (no macro frame) are deliberately out; this OnLoad is their
|
||||
-- return path.
|
||||
function BenillaChatMenu_Type(prefix)
|
||||
local eb = ChatFrameEditBox
|
||||
eb:Show()
|
||||
eb:SetFocus()
|
||||
eb:SetText(prefix .. (eb:GetText() or ""))
|
||||
end
|
||||
|
||||
function BenillaChatMenu_OnLoad()
|
||||
BenillaUIMenu_Initialize(this)
|
||||
BenillaUIMenu_AddButton(this, "Say", "/s", function() BenillaChatMenu_Type("/s ") end)
|
||||
BenillaUIMenu_AddButton(this, "Party Chat", "/p", function() BenillaChatMenu_Type("/p ") end)
|
||||
BenillaUIMenu_AddButton(this, "Guild Chat", "/g", function() BenillaChatMenu_Type("/g ") end)
|
||||
BenillaUIMenu_AddButton(this, "Yell", "/y", function() BenillaChatMenu_Type("/y ") end)
|
||||
BenillaUIMenu_AddButton(this, "Whisper", "/w", function() BenillaChatMenu_Type("/w ") end)
|
||||
BenillaUIMenu_AddButton(this, "Emote", "/e", function() BenillaChatMenu_Type("/e ") end, "EmoteMenu")
|
||||
BenillaUIMenu_AddButton(this, "Reply", "/r", function() BenillaChatMenu_Type("/r ") end)
|
||||
BenillaUIMenu_AddButton(this, "Voice Emote", nil, nil, "VoiceMacroMenu")
|
||||
end
|
||||
|
||||
-- An emote row submits its /command through the REAL chat pipeline (SubmitChatInput →
|
||||
-- the router's EmotesText resolve + the send-side posture gate) — the ref's DoEmote by
|
||||
-- token, reached through our one grammar. The lists are the ref's EmoteList /
|
||||
-- TextEmoteSpeechList tokens as OUR canonical /commands (the EmotesText names the typed
|
||||
-- grammar resolves), CMD1-sorted like the ref menu. Three ref labels differ
|
||||
-- (/attacktarget /followme /silly are GlobalStrings CMD aliases the typed grammar
|
||||
-- doesn't carry yet) — each row shows the command it actually runs.
|
||||
BenillaEmoteMenuList = {
|
||||
"/applaud", "/beg", "/bow", "/chicken", "/cry", "/dance", "/eat", "/flex",
|
||||
"/kiss", "/kneel", "/laugh", "/point", "/roar", "/rude", "/salute", "/shy",
|
||||
"/sit", "/sleep", "/stand", "/talk", "/wave",
|
||||
}
|
||||
BenillaVoiceMacroList = {
|
||||
"/attackmytarget", "/bye", "/charge", "/cheer", "/congratulate", "/flee",
|
||||
"/flirt", "/follow", "/healme", "/hello", "/helpme", "/incoming", "/joke",
|
||||
"/no", "/nod", "/oom", "/openfire", "/rasp", "/thank", "/train", "/wait",
|
||||
"/welcome",
|
||||
}
|
||||
|
||||
function BenillaEmoteMenu_Click(cmd)
|
||||
SubmitChatInput(cmd)
|
||||
ChatMenu:Hide()
|
||||
end
|
||||
|
||||
function BenillaOnEmoteMenuLoad(list)
|
||||
BenillaUIMenu_Initialize(this)
|
||||
this.parentMenu = "ChatMenu"
|
||||
for _, cmd in ipairs(list) do
|
||||
local c = cmd
|
||||
BenillaUIMenu_AddButton(this, cmd, nil, function() BenillaEmoteMenu_Click(c) end)
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!-- ── The dock: ChatFrame1 (General) + ChatFrame2 (Combat Log) sharing its rect ─────── -->
|
||||
@@ -466,7 +630,14 @@
|
||||
</Anchors>
|
||||
</ButtonText>
|
||||
<NormalFont inherits="GameFontNormalSmall"/>
|
||||
<!-- The ref leaves this texture unsized and leans on the BLP's NATURAL height (32) to
|
||||
seat the glow at the anchors' y −7 — centered on the tab's visible body, which sits
|
||||
in the BOTTOM 22 rows of the ChatFrameTab canvas. benilla's v1 region model has no
|
||||
natural-size law (an unpinned axis inherits the owner's edges, decision 0068), which
|
||||
parked the glow 7 units high; the explicit height states what the ref reads from the
|
||||
file. -->
|
||||
<HighlightTexture file="Interface\PaperDollInfoFrame\UI-Character-Tab-Highlight" alphaMode="ADD">
|
||||
<Size><AbsDimension x="0" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="$parentLeft"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
|
||||
<Anchor point="RIGHT" relativeTo="$parentRight"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
|
||||
@@ -474,6 +645,152 @@
|
||||
</HighlightTexture>
|
||||
</Button>
|
||||
|
||||
<!-- ── The UIMenu kit (ref UIMenu.xml, names kept for the addon surface): a row button
|
||||
with a right-aligned shortcut string, and the tooltip-backdrop menu holding the ref's
|
||||
fixed pool of 32 rows (hidden until AddButton). The label is authored LEFT-anchored:
|
||||
the ref leans on the 1.12 ButtonText default rect spanning the button, which our lazy
|
||||
label slot doesn't reproduce. ─────────────────────────────────────────────────── -->
|
||||
<Button name="UIMenuButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="128" y="16"/></Size>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
BenillaUIMenuButton_OnLoad()
|
||||
</OnLoad>
|
||||
<OnClick>
|
||||
BenillaUIMenuButton_OnClick()
|
||||
</OnClick>
|
||||
<OnEnter>
|
||||
BenillaUIMenuButton_OnEnter()
|
||||
</OnEnter>
|
||||
<OnLeave>
|
||||
BenillaUIMenuButton_OnLeave()
|
||||
</OnLeave>
|
||||
</Scripts>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<FontString name="$parentShortcutText" inherits="GameFontHighlight" hidden="true">
|
||||
<Anchors><Anchor point="RIGHT"/></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<ButtonText name="$parentText">
|
||||
<Anchors><Anchor point="LEFT"/></Anchors>
|
||||
</ButtonText>
|
||||
<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>
|
||||
<HighlightFont inherits="GameFontHighlight" justifyH="LEFT"/>
|
||||
<DisabledFont inherits="GameFontDisable" justifyH="LEFT"/>
|
||||
</Button>
|
||||
<Frame name="UIMenuTemplate" frameStrata="DIALOG" virtual="true">
|
||||
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
|
||||
<BackgroundInsets><AbsInset left="4" right="4" top="4" bottom="4"/></BackgroundInsets>
|
||||
<TileSize><AbsValue val="32"/></TileSize>
|
||||
<EdgeSize><AbsValue val="13"/></EdgeSize>
|
||||
</Backdrop>
|
||||
<Frames>
|
||||
<Button name="$parentButton1" inherits="UIMenuButtonTemplate" id="1"/>
|
||||
<Button name="$parentButton2" inherits="UIMenuButtonTemplate" id="2"/>
|
||||
<Button name="$parentButton3" inherits="UIMenuButtonTemplate" id="3"/>
|
||||
<Button name="$parentButton4" inherits="UIMenuButtonTemplate" id="4"/>
|
||||
<Button name="$parentButton5" inherits="UIMenuButtonTemplate" id="5"/>
|
||||
<Button name="$parentButton6" inherits="UIMenuButtonTemplate" id="6"/>
|
||||
<Button name="$parentButton7" inherits="UIMenuButtonTemplate" id="7"/>
|
||||
<Button name="$parentButton8" inherits="UIMenuButtonTemplate" id="8"/>
|
||||
<Button name="$parentButton9" inherits="UIMenuButtonTemplate" id="9"/>
|
||||
<Button name="$parentButton10" inherits="UIMenuButtonTemplate" id="10"/>
|
||||
<Button name="$parentButton11" inherits="UIMenuButtonTemplate" id="11"/>
|
||||
<Button name="$parentButton12" inherits="UIMenuButtonTemplate" id="12"/>
|
||||
<Button name="$parentButton13" inherits="UIMenuButtonTemplate" id="13"/>
|
||||
<Button name="$parentButton14" inherits="UIMenuButtonTemplate" id="14"/>
|
||||
<Button name="$parentButton15" inherits="UIMenuButtonTemplate" id="15"/>
|
||||
<Button name="$parentButton16" inherits="UIMenuButtonTemplate" id="16"/>
|
||||
<Button name="$parentButton17" inherits="UIMenuButtonTemplate" id="17"/>
|
||||
<Button name="$parentButton18" inherits="UIMenuButtonTemplate" id="18"/>
|
||||
<Button name="$parentButton19" inherits="UIMenuButtonTemplate" id="19"/>
|
||||
<Button name="$parentButton20" inherits="UIMenuButtonTemplate" id="20"/>
|
||||
<Button name="$parentButton21" inherits="UIMenuButtonTemplate" id="21"/>
|
||||
<Button name="$parentButton22" inherits="UIMenuButtonTemplate" id="22"/>
|
||||
<Button name="$parentButton23" inherits="UIMenuButtonTemplate" id="23"/>
|
||||
<Button name="$parentButton24" inherits="UIMenuButtonTemplate" id="24"/>
|
||||
<Button name="$parentButton25" inherits="UIMenuButtonTemplate" id="25"/>
|
||||
<Button name="$parentButton26" inherits="UIMenuButtonTemplate" id="26"/>
|
||||
<Button name="$parentButton27" inherits="UIMenuButtonTemplate" id="27"/>
|
||||
<Button name="$parentButton28" inherits="UIMenuButtonTemplate" id="28"/>
|
||||
<Button name="$parentButton29" inherits="UIMenuButtonTemplate" id="29"/>
|
||||
<Button name="$parentButton30" inherits="UIMenuButtonTemplate" id="30"/>
|
||||
<Button name="$parentButton31" inherits="UIMenuButtonTemplate" id="31"/>
|
||||
<Button name="$parentButton32" inherits="UIMenuButtonTemplate" id="32"/>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnShow>
|
||||
BenillaUIMenu_OnShow()
|
||||
PlaySound("UChatScrollButton")
|
||||
this:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b)
|
||||
this:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b)
|
||||
</OnShow>
|
||||
<OnUpdate>
|
||||
BenillaUIMenu_OnUpdate(arg1)
|
||||
</OnUpdate>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- ── The chat menu button (ref ChatFrameMenuButton): the bubble icon atop the scroll
|
||||
column; click opens ChatMenu (ref ChatFrame_OpenMenu). ──────────────────────────── -->
|
||||
<Button name="ChatFrameMenuButton" frameStrata="LOW">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM" relativeTo="ChatFrame1UpButton" relativePoint="TOP"/>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
PlaySound("igChatEmoteButton")
|
||||
ChatMenu:Show()
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
<NormalTexture file="Interface\ChatFrame\UI-ChatIcon-Chat-Up"/>
|
||||
<PushedTexture file="Interface\ChatFrame\UI-ChatIcon-Chat-Down"/>
|
||||
<DisabledTexture file="Interface\ChatFrame\UI-ChatIcon-Chat-Disabled"/>
|
||||
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" alphaMode="ADD"/>
|
||||
</Button>
|
||||
|
||||
<!-- ChatMenu + its two nested menus (ref FloatingChatFrame.xml; the ref's own OnShow
|
||||
hides only EmoteMenu — hiding both submenus is the same intent without the ref's
|
||||
Voice-menu-left-open corner). The submenus are authored as children (the ref's
|
||||
parent="ChatMenu" attr — this engine attaches by nesting, CharacterFrame.xml's note),
|
||||
so hiding ChatMenu hides them with it. -->
|
||||
<Frame name="ChatMenu" inherits="UIMenuTemplate" hidden="true">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrameMenuButton" relativePoint="TOPRIGHT"/>
|
||||
</Anchors>
|
||||
<Frames>
|
||||
<Frame name="EmoteMenu" inherits="UIMenuTemplate" hidden="true">
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
BenillaOnEmoteMenuLoad(BenillaEmoteMenuList)
|
||||
</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="VoiceMacroMenu" inherits="UIMenuTemplate" hidden="true">
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
BenillaOnEmoteMenuLoad(BenillaVoiceMacroList)
|
||||
</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
BenillaChatMenu_OnLoad()
|
||||
</OnLoad>
|
||||
<OnShow>
|
||||
BenillaUIMenu_OnShow()
|
||||
EmoteMenu:Hide()
|
||||
VoiceMacroMenu:Hide()
|
||||
this:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b)
|
||||
this:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b)
|
||||
</OnShow>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- ── The edit box (ref ChatFrameEditBox): below ChatFrame1, -5/+5 wide, 32 tall, DIALOG
|
||||
strata, hidden until ENTER opens it (ui_chat::input). The three-piece
|
||||
UI-ChatInputBorder art; the header ("Say:" until the P5 edit machine drives it) at
|
||||
@@ -484,7 +801,9 @@
|
||||
the keys dead; the director's call is the modern law — plain arrows move the caret,
|
||||
Up/Down recall history (decision 0301). The engine still honors the attr faithfully
|
||||
for any box that sets it. -->
|
||||
<EditBox name="ChatFrameEditBox" frameStrata="DIALOG" hidden="true" historyLines="32" letters="255">
|
||||
<!-- historyLines: the ref template ships 32; 64 at the director's call ("at least 50",
|
||||
2026-07-26) — the recall walk is the modern law already (decision 0301). -->
|
||||
<EditBox name="ChatFrameEditBox" frameStrata="DIALOG" hidden="true" historyLines="64" letters="255">
|
||||
<Size><AbsDimension x="0" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="ChatFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="-5" y="-2"/></Offset></Anchor>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
like the template pairs reference Lua addresses by name). This file declares the INSTANCE:
|
||||
the plate (backdrop art + colors QUOTED from ref-GameTooltip.xml/.lua, line cites inline),
|
||||
the money coin trio, and the Lua that is genuinely FrameXML's in the real client
|
||||
(GameTooltip_SetDefaultAnchor, SetTooltipMoney) — plus the P0 item-stat renderer, which
|
||||
(GameTooltip_SetDefaultAnchor, GameTooltip_AddNewbieTip, SetTooltipMoney) — plus the P0 item-stat renderer, which
|
||||
moves engine-side in 0274 P1 with the full template feed.
|
||||
|
||||
The frame's global name is the real one — GameTooltip — so transcribed reference code
|
||||
@@ -28,6 +28,15 @@
|
||||
-- bounds arg2 by it).
|
||||
NUM_SHOPPING_TOOLTIPS = 2
|
||||
|
||||
-- "Show Detailed Tooltips" — ref UIOptionsFrame.lua l.100-101, inside UIOptionsFrame_Init:
|
||||
-- SHOW_NEWBIE_TIPS = "1"; RegisterForSave("SHOW_NEWBIE_TIPS");
|
||||
-- It is a FrameXML *uvar*, not a CVar, and 1.12 ships it ON — so the detailed second line
|
||||
-- IS the default reference tooltip, not an opt-in. (An earlier note in this corpus had this
|
||||
-- backwards; decision 0661.) We ship no UIOptionsFrame and no saved variables, so the ref's
|
||||
-- initial value is the whole story; the options panel takes ownership of this line the day
|
||||
-- it lands.
|
||||
SHOW_NEWBIE_TIPS = "1"
|
||||
|
||||
-- The bracketed name out of an item hyperlink "|cff…|Hitem:117|h[Tough Jerky]|h|r"; if the
|
||||
-- string has no bracket it is already a plain name (the merchant/loot rows hand plain names).
|
||||
function BenillaTooltip_ItemName(link)
|
||||
@@ -71,6 +80,35 @@
|
||||
tooltip.default = 1
|
||||
end
|
||||
|
||||
-- The two-line UI-element tooltip (ref-GameTooltip.lua l.107-123, quoted): a white label,
|
||||
-- then the long explanation wrapped underneath in NORMAL_FONT_COLOR — the whole reason a
|
||||
-- 1.12 hover over the ping meter or a micro button is a paragraph and not four words.
|
||||
-- With detailed tips on it seats at the DEFAULT corner, not beside the owner; with them off
|
||||
-- it collapses to the bare label at the owner's right. `noNormalText` says "the explanation
|
||||
-- is the whole tooltip" (the XP bar) — with tips off such a hover shows nothing at all.
|
||||
--
|
||||
-- Signature note: the ref reads the firing frame from the `this` global; the corpus
|
||||
-- convention is to keep the ref NAME and take that frame as a leading parameter
|
||||
-- (GameTooltip_OnHide(self), GameTooltip_ClearMoney(frame) — same shift). Reference code
|
||||
-- pasted in verbatim must therefore have the owner threaded in as arg 1.
|
||||
function GameTooltip_AddNewbieTip(owner, normalText, r, g, b, newbieText, noNormalText)
|
||||
if SHOW_NEWBIE_TIPS == "1" then
|
||||
GameTooltip_SetDefaultAnchor(GameTooltip, owner)
|
||||
if normalText then
|
||||
GameTooltip:SetText(normalText, r, g, b)
|
||||
GameTooltip:AddLine(newbieText, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1)
|
||||
else
|
||||
GameTooltip:SetText(newbieText, r, g, b, 1, 1)
|
||||
end
|
||||
GameTooltip:Show()
|
||||
else
|
||||
if not noNormalText then
|
||||
GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
|
||||
GameTooltip:SetText(normalText, r, g, b)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ref GameTooltip_OnHide (ref-GameTooltip.lua l.100-104, quoted): the plate colours
|
||||
-- reset for the next owner and the default-corner flag drops. The ref's OnHide also
|
||||
-- hides ShoppingTooltip1/2 - that pair lands with the compare-tooltip slice (0274 P4).
|
||||
|
||||
@@ -423,8 +423,8 @@
|
||||
</Button>
|
||||
|
||||
<!-- The paper-doll page (ref InspectPaperDollFrame, l.47-371). The ref uses
|
||||
setAllPoints="true"; frame-level setAllPoints is a no-op in this engine
|
||||
(CharacterFrame.xml's header), so it is sized to fill the parent instead. -->
|
||||
setAllPoints="true"; this is sized to fill the parent instead — equivalent, and the
|
||||
shape CharacterFrame.xml's own paper doll takes (its header has the history). -->
|
||||
<Frame name="BenillaInspectPaperDollFrame">
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
<Ui>
|
||||
<!--
|
||||
The MICRO MENU — the row of eight small arched buttons in the main bar's right-hand recess
|
||||
(character, spellbook, talents, quest log, socials, world map, main menu, help). It was the
|
||||
last hole in the bar: ActionBar.xml's SCOPE note named "the bag/keyring/micro cluster on the
|
||||
bar's right" as the visible remaining gap, and with the bag bar landed (decision 0216 slice
|
||||
2) the micro row was the empty dark panel between the page arrows and the bags.
|
||||
|
||||
Transcribed from the real 1.12 FrameXML — ref-MainMenuBarMicroButtons.xml (the template +
|
||||
the eight buttons' geometry) and ref-MainMenuBarMicroButtons.lua (LoadMicroButtonTextures,
|
||||
UpdateMicroButtons, the CharacterMicroButton portrait, UpdateTalentButton), both extracted
|
||||
from patch.MPQ. Geometry QUOTED: each button 29x58 with `HitRectInsets top=18`; the first at
|
||||
MainMenuBarArtFrame BOTTOMLEFT +(552,2); the rest chained BOTTOMLEFT to the previous
|
||||
BOTTOMRIGHT +(-3,0) — a 26 px stride, so the row runs x 552..757 inside the 1024-wide bar.
|
||||
|
||||
NAMES are the reference's, not Benilla*-prefixed (unlike the multibars/stance bar, which are
|
||||
benilla-shaped divergences). These are straight transcriptions, and `UpdateMicroButtons` is
|
||||
the ref global that CharacterFrame/SpellBookFrame/TalentFrame already cite by name in their
|
||||
own OnShow/OnHide comments — those calls are now live instead of OMITTED, in all five
|
||||
panels the ref calls it from (+ QuestLogFrame, WorldMapFrame). They are nil-GUARDED at the
|
||||
call sites (`if UpdateMicroButtons then … end`) — not because the load order is in doubt
|
||||
(this file always precedes them), but because a dozen test harnesses load one panel without
|
||||
the action bar behind it, and an unguarded call would abort the rest of that OnShow. Same
|
||||
posture as UiPanels.xml's `CloseMenus and CloseMenus()` and the `if GameTooltip` guards.
|
||||
|
||||
Structure: the buttons are TOP-LEVEL frames anchored by name into BenillaActionBarArtFrame
|
||||
(this engine has no cross-file `parent=` attachment — SkillFrame.xml's header comment), then
|
||||
seated one frame level above the art by BenillaActionBarArt_SeatAbove (ActionBar.xml) so the
|
||||
bar plate doesn't paint over them. Same shape the bag bar next door already uses.
|
||||
|
||||
SCOPE / real-client divergences (noted, not bugs):
|
||||
- THREE buttons have no panel to open yet: Socials (no FriendsFrame), Main Menu (no
|
||||
GameMenuFrame — ESC runs BenillaOnEscape's close chain instead, UiPanels.xml) and Help (no
|
||||
HelpFrame). They are drawn, hover, and highlight like the rest, but their click is a no-op
|
||||
— the same "art only, wired the day the panel lands" call the page up/down arrows on this
|
||||
same bar already make. Deliberately NOT greyed with the ref's `-Disabled` art: three dead
|
||||
grey buttons mid-row read as a broken bar, and the point of this slice is that the bar
|
||||
reads whole. Each is one OnClick line away from live.
|
||||
- No GetBindingKey in this engine, so MicroButtonTooltipText's "(key)" suffix comes from a
|
||||
static table of the bindings we actually ship (ui_script/input.rs `bare_key_binding`:
|
||||
C/P/N/L/M) rather than from a live binding lookup — the same call ActionBar.xml makes for
|
||||
its HotKey corners. The three unbound buttons show a bare label.
|
||||
- The hover is the ref's real two-line GameTooltip_AddNewbieTip (GameTooltip.xml): the label,
|
||||
then the button's NEWBIE_TOOLTIP_* explanation, at the default screen corner. An earlier
|
||||
note here claimed detailed tips were OFF in 1.12 and open-coded the else branch — the ref
|
||||
ships them ON (UIOptionsFrame.lua l.100), so that was a one-line tooltip where the real
|
||||
client shows a paragraph. Corrected by decision 0661.
|
||||
- TalentMicroButton registers UNIT_LEVEL + PLAYER_ENTERING_WORLD but NOT the ref's
|
||||
PLAYER_LEVEL_UP (this client never fires it) — and PLAYER_LEVEL_UP's only other job there
|
||||
is SetButtonPulse, which decision 0304 already scoped out ("no microbutton pulse").
|
||||
- UpdateTalentButton's re-anchor uses -3 both ways; the ref writes -3 in the XML but -2 in
|
||||
the Lua, so a real client nudges the whole row 1 px left the first time it re-anchors at
|
||||
level 10. That's a ref typo, not a mechanism — not reproduced.
|
||||
- SetButtonState is called without the ref's second "lock" argument (this engine has no
|
||||
state lock; the pushed texture already draws from pushed-state OR a live mouse hold, so
|
||||
the rendered result is identical for a button nothing else pushes).
|
||||
- The keyring button (ref KeyRingButton, `hidden="true"` by default) stays out with the
|
||||
keyring container itself.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- ref GlobalStrings.lua — the eight labels (enUS), in the `X = X or` FALLBACK form: the
|
||||
-- app runs the real localized GlobalStrings.lua off the patch chain BEFORE this file
|
||||
-- (ui_script/mod.rs load_global_strings), so the installed locale wins and these copies
|
||||
-- only fill in for the test harnesses, which load no patch chain. CombatText.xml's shape.
|
||||
CHARACTER_BUTTON = CHARACTER_BUTTON or "Character Info"
|
||||
SPELLBOOK_ABILITIES_BUTTON = SPELLBOOK_ABILITIES_BUTTON or "Spellbook & Abilities"
|
||||
TALENTS_BUTTON = TALENTS_BUTTON or "Talents"
|
||||
QUESTLOG_BUTTON = QUESTLOG_BUTTON or "Quest Log"
|
||||
SOCIAL_BUTTON = SOCIAL_BUTTON or "Social"
|
||||
WORLDMAP_BUTTON = WORLDMAP_BUTTON or "World Map"
|
||||
MAINMENU_BUTTON = MAINMENU_BUTTON or "Main Menu"
|
||||
HELP_BUTTON = HELP_BUTTON or "Help Request"
|
||||
|
||||
-- ...and their second lines, ref GlobalStrings.lua l.2722/2766/2768/2758/2765/2788/2747/2739,
|
||||
-- verbatim (enUS). 1.12 ships detailed tooltips ON (decision 0661), so every one of these is
|
||||
-- part of the default hover, not an opt-in extra.
|
||||
NEWBIE_TOOLTIP_CHARACTER = NEWBIE_TOOLTIP_CHARACTER or "Information about your character, including equipment, statistics, skills, and reputation."
|
||||
NEWBIE_TOOLTIP_SPELLBOOK = NEWBIE_TOOLTIP_SPELLBOOK or "All of your spells and abilities. To move a spell or ability to your Action Bar, open the Spellbook & Abilities window, left-click that spell or ability, and drag it down to your Action Bar."
|
||||
NEWBIE_TOOLTIP_TALENTS = NEWBIE_TOOLTIP_TALENTS or "The array of talents available to enhance and specialize your character."
|
||||
NEWBIE_TOOLTIP_QUESTLOG = NEWBIE_TOOLTIP_QUESTLOG or "A list of all the active quests you currently have. You can have up to 20 active quests at one time."
|
||||
NEWBIE_TOOLTIP_SOCIAL = NEWBIE_TOOLTIP_SOCIAL or "Information about other people in the game. You can use the Social window to manage your friends list and ignore list, as well as see who is online."
|
||||
NEWBIE_TOOLTIP_WORLDMAP = NEWBIE_TOOLTIP_WORLDMAP or "As you explore a zone, more areas of interest will become visible on the world map. You can use left-click to zoom in, or right-click to zoom out."
|
||||
NEWBIE_TOOLTIP_MAINMENU = NEWBIE_TOOLTIP_MAINMENU or "Here you can modify your video, sound, and interface settings, or create custom hotkeys. You can also choose to log out or exit the program altogether."
|
||||
NEWBIE_TOOLTIP_HELP = NEWBIE_TOOLTIP_HELP or "Through this menu, you can report a bug. You can also use the Help Request menu to talk to one of our Game Masters (GMs) in the game if you have questions or need assistance."
|
||||
|
||||
-- ref LoadMicroButtonTextures (MainMenuBarMicroButtons.lua l.1-9): the Up/Down/Disabled trio
|
||||
-- under one art name plus the shared highlight. `art` is nil for CharacterMicroButton, whose
|
||||
-- textures don't follow the pattern (UI-MicroButtonCharacter-*, and no Disabled variant
|
||||
-- exists in the archives) — it sets its own.
|
||||
-- `newbie` is the ref's per-button `this.newbieText` (set in each button's own OnLoad there —
|
||||
-- MainMenuBarMicroButtons.lua l.95 and .xml l.101/127/149/174/193/215); Spellbook is the one
|
||||
-- the ref hands to the call site instead, because its label swaps with GetSpellBookState.
|
||||
function BenillaMicroButton_OnLoad(button, art, label, binding, newbie)
|
||||
button.label = label
|
||||
button.binding = binding
|
||||
button.newbieText = newbie
|
||||
-- Seat over the bar plate (ActionBar.xml's helper, which carries the why). Called
|
||||
-- nil-guarded for the same reason BagFrame.xml's two calls are: the shipped load order
|
||||
-- always has the bar first, but a test harness may load this file alone — and with no
|
||||
-- bar there is nothing to seat above.
|
||||
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end
|
||||
if art then
|
||||
local prefix = "Interface\\Buttons\\UI-MicroButton-" .. art
|
||||
button:SetNormalTexture(prefix .. "-Up")
|
||||
button:SetPushedTexture(prefix .. "-Down")
|
||||
button:SetDisabledTexture(prefix .. "-Disabled")
|
||||
end
|
||||
button:SetHighlightTexture("Interface\\Buttons\\UI-MicroButton-Hilight")
|
||||
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||||
end
|
||||
|
||||
-- ref MicroButtonTooltipText (l.11-18) with a static binding label — see the SCOPE note.
|
||||
-- Benilla-named, not the ref global: the ref's takes (text, bindingAction) and looks the key
|
||||
-- up live, so reusing that name for a different contract would be a trap for the next
|
||||
-- ref-quoted transcription that calls it.
|
||||
function BenillaMicroButton_TooltipText(button)
|
||||
if button.binding then
|
||||
return button.label .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. button.binding .. ")" .. FONT_COLOR_CODE_CLOSE
|
||||
end
|
||||
return button.label
|
||||
end
|
||||
|
||||
-- ref MainMenuBarMicroButton's OnEnter (l.13), verbatim bar the owner shift:
|
||||
-- GameTooltip_AddNewbieTip(this.tooltipText, 1.0, 1.0, 1.0, this.newbieText)
|
||||
function BenillaMicroButton_OnEnter(button)
|
||||
if not GameTooltip_AddNewbieTip then return end -- engine tests load file subsets
|
||||
GameTooltip_AddNewbieTip(button, BenillaMicroButton_TooltipText(button), 1.0, 1.0, 1.0,
|
||||
button.newbieText)
|
||||
end
|
||||
|
||||
-- Is a panel up? `getglobal` because every panel this asks about loads AFTER this file —
|
||||
-- the first UpdateMicroButtons can land before most of them exist.
|
||||
function BenillaMicro_PanelShown(name)
|
||||
local frame = getglobal(name)
|
||||
return frame ~= nil and frame:IsVisible()
|
||||
end
|
||||
|
||||
function BenillaMicroButton_SetPushed(button, isPushed)
|
||||
if not button then return end
|
||||
if isPushed then
|
||||
button:SetButtonState("PUSHED")
|
||||
else
|
||||
button:SetButtonState("NORMAL")
|
||||
end
|
||||
end
|
||||
|
||||
-- ref UpdateMicroButtons (l.20-84): each button reflects whether its panel is up. The ref's
|
||||
-- OptionsFrame/SoundOptionsFrame/UIOptionsFrame/KeyBindingFrame/MacroFrame arms of the
|
||||
-- MainMenu test, and its keyring arm, have no frames here; the three unwired buttons
|
||||
-- (Socials/MainMenu/Help) simply never push.
|
||||
function UpdateMicroButtons()
|
||||
BenillaMicroButton_SetPushed(CharacterMicroButton, BenillaMicro_PanelShown("BenillaCharacterFrame"))
|
||||
BenillaMicroButton_SetPushed(SpellbookMicroButton, BenillaMicro_PanelShown("BenillaSpellBookFrame"))
|
||||
BenillaMicroButton_SetPushed(TalentMicroButton, BenillaMicro_PanelShown("BenillaTalentFrame"))
|
||||
BenillaMicroButton_SetPushed(QuestLogMicroButton, BenillaMicro_PanelShown("BenillaQuestLogFrame"))
|
||||
BenillaMicroButton_SetPushed(WorldMapMicroButton, BenillaMicro_PanelShown("WorldMapFrame"))
|
||||
end
|
||||
|
||||
-- ref CharacterMicroButton_SetPushed/_SetNormal (l.109-117): the portrait shifts its window
|
||||
-- and dims while the button is down. The window is a TexCoord crop of the SAME "player"
|
||||
-- portrait bake the unit frame samples (SetPortraitTexture below) — the round stencil lives
|
||||
-- in the bake's own UV space, so cropping it yields the ref's rectangular slice of a masked
|
||||
-- face rather than an ellipse fitted to this 18x25 region.
|
||||
function CharacterMicroButton_SetPushed()
|
||||
MicroButtonPortrait:SetTexCoord(0.2666, 0.8666, 0, 0.8333)
|
||||
MicroButtonPortrait:SetAlpha(0.5)
|
||||
end
|
||||
|
||||
function CharacterMicroButton_SetNormal()
|
||||
MicroButtonPortrait:SetTexCoord(0.2, 0.8, 0.0666, 0.9)
|
||||
MicroButtonPortrait:SetAlpha(1.0)
|
||||
end
|
||||
|
||||
-- ref CharacterMicroButton's OnUpdate (its XML l.55-61) drives the portrait off the live
|
||||
-- button state — which is what makes the face dim mid-mouse-hold, a thing UpdateMicroButtons
|
||||
-- can't see. Transcribed, but EDGE-TRIGGERED: the ref re-applies both setters every frame;
|
||||
-- there is no reason to re-push identical TexCoord/alpha 60x a second.
|
||||
function CharacterMicroButton_OnUpdate(button)
|
||||
local pushed = (button:GetButtonState() == "PUSHED")
|
||||
if pushed ~= button.portraitPushed then
|
||||
button.portraitPushed = pushed
|
||||
if pushed then
|
||||
CharacterMicroButton_SetPushed()
|
||||
else
|
||||
CharacterMicroButton_SetNormal()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function CharacterMicroButton_OnLoad(button)
|
||||
-- The player's face, from the same portrait slot the unit frame binds (portrait/mod.rs
|
||||
-- keeps the "player" entry live across re-bakes, so unlike the ref there is no
|
||||
-- UNIT_PORTRAIT_UPDATE re-bind to register — the binding is by token, not by handle).
|
||||
SetPortraitTexture(MicroButtonPortrait, "player")
|
||||
button:SetNormalTexture("Interface\\Buttons\\UI-MicroButtonCharacter-Up")
|
||||
button:SetPushedTexture("Interface\\Buttons\\UI-MicroButtonCharacter-Down")
|
||||
BenillaMicroButton_OnLoad(button, nil, CHARACTER_BUTTON, "C", NEWBIE_TOOLTIP_CHARACTER)
|
||||
end
|
||||
|
||||
-- ref UpdateTalentButton (l.133-141): no talents before level 10, so the button goes away
|
||||
-- and the row closes up behind it. Guarded on BOTH frames: it re-anchors the quest-log
|
||||
-- button, which is created after the talent one, so the very first call has to be able to
|
||||
-- land before the row is fully built. (`UnitLevel` answers 0, never nil, for a unit the
|
||||
-- client hasn't got yet — so pre-login the button is simply hidden until the level lands.)
|
||||
function UpdateTalentButton()
|
||||
if not TalentMicroButton or not QuestLogMicroButton then return end
|
||||
if UnitLevel("player") < 10 then
|
||||
TalentMicroButton:Hide()
|
||||
QuestLogMicroButton:SetPoint("BOTTOMLEFT", "TalentMicroButton", "BOTTOMLEFT", 0, 0)
|
||||
else
|
||||
TalentMicroButton:Show()
|
||||
QuestLogMicroButton:SetPoint("BOTTOMLEFT", "TalentMicroButton", "BOTTOMRIGHT", -3, 0)
|
||||
end
|
||||
end
|
||||
|
||||
function TalentMicroButton_OnLoad(button)
|
||||
BenillaMicroButton_OnLoad(button, "Talents", TALENTS_BUTTON, "N", NEWBIE_TOOLTIP_TALENTS)
|
||||
button:RegisterEvent("UNIT_LEVEL")
|
||||
button:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
end
|
||||
|
||||
function TalentMicroButton_OnEvent()
|
||||
-- UNIT_LEVEL names its unit in arg1; every other unit's ding is none of our business.
|
||||
if event == "UNIT_LEVEL" and arg1 ~= "player" then return end
|
||||
UpdateTalentButton()
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!-- ref MainMenuBarMicroButton (l.4-19): 29x58, with the top 18 inset out of the HIT rect — the
|
||||
art only fills the lower ~40 px (verified against the 32x64 BLPs: the top ~22/64 rows are
|
||||
fully transparent), and without the inset that empty header would eat hover and clicks
|
||||
aimed at the bar's XP strip and the sliver of world above it. -->
|
||||
<Button name="BenillaMicroButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="29" y="58"/></Size>
|
||||
<HitRectInsets><AbsInset left="0" right="0" top="18" bottom="0"/></HitRectInsets>
|
||||
<Scripts>
|
||||
<OnEnter>BenillaMicroButton_OnEnter(self)</OnEnter>
|
||||
<OnLeave>if GameTooltip then GameTooltip:Hide() end</OnLeave>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- ref CharacterMicroButton (l.20-63): the row's head, at the art frame's BOTTOMLEFT +(552,2).
|
||||
MicroButtonPortrait is the ref's own global name (not $parent-prefixed), 18x25 hung from the
|
||||
button's TOP -28. -->
|
||||
<Button name="CharacterMicroButton" inherits="BenillaMicroButtonTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="BenillaActionBarArtFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="552" y="2"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="MicroButtonPortrait">
|
||||
<Size><AbsDimension x="18" y="25"/></Size>
|
||||
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-28"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0.2" right="0.8" top="0.0666" bottom="0.9"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>CharacterMicroButton_OnLoad(self)</OnLoad>
|
||||
<OnClick>ToggleCharacter("BenillaPaperDollFrame")</OnClick>
|
||||
<OnUpdate>CharacterMicroButton_OnUpdate(self)</OnUpdate>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Button name="SpellbookMicroButton" inherits="BenillaMicroButtonTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="CharacterMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaMicroButton_OnLoad(self, "Spellbook", SPELLBOOK_ABILITIES_BUTTON, "P", NEWBIE_TOOLTIP_SPELLBOOK)</OnLoad>
|
||||
<OnClick>ToggleSpellBook(BOOKTYPE_SPELL)</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Button name="TalentMicroButton" inherits="BenillaMicroButtonTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="SpellbookMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>TalentMicroButton_OnLoad(self)</OnLoad>
|
||||
<OnEvent>TalentMicroButton_OnEvent()</OnEvent>
|
||||
<OnClick>ToggleTalentFrame()</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Button name="QuestLogMicroButton" inherits="BenillaMicroButtonTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="TalentMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<!-- The row's initial talent-gate pass runs here, not in TalentMicroButton_OnLoad: this
|
||||
is the button UpdateTalentButton re-anchors, so it must already exist. (The ref gets
|
||||
its first pass from PLAYER_ENTERING_WORLD, which we register too — this just means
|
||||
the row is right before any event lands, e.g. under the capture harness.) -->
|
||||
<OnLoad>BenillaMicroButton_OnLoad(self, "Quest", QUESTLOG_BUTTON, "L", NEWBIE_TOOLTIP_QUESTLOG); UpdateTalentButton()</OnLoad>
|
||||
<OnClick>ToggleQuestLog()</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- No FriendsFrame in this client yet — drawn and hovering, click inert (SCOPE note). -->
|
||||
<Button name="SocialsMicroButton" inherits="BenillaMicroButtonTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="QuestLogMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, nil, NEWBIE_TOOLTIP_SOCIAL)</OnLoad>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Button name="WorldMapMicroButton" inherits="BenillaMicroButtonTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="SocialsMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaMicroButton_OnLoad(self, "World", WORLDMAP_BUTTON, "M", NEWBIE_TOOLTIP_WORLDMAP)</OnLoad>
|
||||
<OnClick>ToggleWorldMap()</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- No GameMenuFrame yet — ESC runs BenillaOnEscape's close chain instead (SCOPE note). -->
|
||||
<Button name="MainMenuMicroButton" inherits="BenillaMicroButtonTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="WorldMapMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaMicroButton_OnLoad(self, "MainMenu", MAINMENU_BUTTON, nil, NEWBIE_TOOLTIP_MAINMENU)</OnLoad>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- No HelpFrame yet (SCOPE note). -->
|
||||
<Button name="HelpMicroButton" inherits="BenillaMicroButtonTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaMicroButton_OnLoad(self, "Help", HELP_BUTTON, nil, NEWBIE_TOOLTIP_HELP)</OnLoad>
|
||||
</Scripts>
|
||||
</Button>
|
||||
</Ui>
|
||||
@@ -17,8 +17,7 @@
|
||||
DEVIATIONS / DEFERRED (phase 4+, decision 0434):
|
||||
- Pet frames, the 4 debuff buttons (48,-32 +2px chain), the 16/8 buff hover panel, and the
|
||||
dispellable-debuff Status ring: they need the party aura/pet feed (phase 4).
|
||||
- The PVP icon (UI-Group-PVP-<faction>, 32x32 at -9,-15): needs UnitFactionGroup, absent
|
||||
from the engine today (a party member always shares our faction in 1.12 — low loss).
|
||||
(The PVP icon landed with decision 0646 — UnitFactionGroup exists now.)
|
||||
- HIDE_PARTY_INTERFACE (hide-in-raid uvar): waits for the raid UI slice.
|
||||
- Portrait: our high-res model bake (decision 0105), same as every unit frame.
|
||||
- The ref's low-health portrait pulse flips alpha 127/255<->255/255 on 0.5 s half-cycles
|
||||
@@ -72,6 +71,7 @@
|
||||
frame.leaderIcon = getglobal(tf .. "LeaderIcon")
|
||||
frame.masterIcon = getglobal(tf .. "MasterIcon")
|
||||
frame.disconnect = getglobal(tf .. "Disconnect")
|
||||
frame.pvpIcon = getglobal(tf .. "PVPIcon")
|
||||
frame.flashTimer = 0
|
||||
SetPortraitTexture(frame.portrait, frame.unit)
|
||||
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
@@ -84,6 +84,7 @@
|
||||
frame:RegisterEvent("UNIT_MAXPOWER")
|
||||
frame:RegisterEvent("UNIT_DISPLAYPOWER")
|
||||
frame:RegisterEvent("UNIT_NAME_UPDATE")
|
||||
frame:RegisterEvent("UNIT_FACTION")
|
||||
frame:EnableMouse(true)
|
||||
BenillaPartyFrame_Update(frame)
|
||||
end
|
||||
@@ -163,6 +164,26 @@
|
||||
end
|
||||
|
||||
BenillaPartyFrame_HealthCheck(frame)
|
||||
BenillaPartyFrame_UpdatePvP(frame)
|
||||
end
|
||||
|
||||
-- ref PartyMemberFrame_UpdatePvPStatus (PartyMemberFrame.lua l.116-130), verbatim: the
|
||||
-- same three branches the unit frames run, but the faction leg draws the GroupFrame art
|
||||
-- (UI-Group-PVP-<group>) — only the FFA skull is shared with the big frames, because no
|
||||
-- UI-Group-PVP-FFA ships. Decision 0646 §4; closes 0434's named deferral (the icon waited
|
||||
-- on UnitFactionGroup, which the app now feeds).
|
||||
function BenillaPartyFrame_UpdatePvP(frame)
|
||||
local unit = frame.unit
|
||||
local factionGroup = UnitFactionGroup(unit)
|
||||
if UnitIsPVPFreeForAll(unit) then
|
||||
frame.pvpIcon:SetTexture("Interface\\TargetingFrame\\UI-PVP-FFA")
|
||||
frame.pvpIcon:Show()
|
||||
elseif factionGroup and UnitIsPVP(unit) then
|
||||
frame.pvpIcon:SetTexture("Interface\\GroupFrame\\UI-Group-PVP-" .. factionGroup)
|
||||
frame.pvpIcon:Show()
|
||||
else
|
||||
frame.pvpIcon:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
-- The ref's <=20% portrait alpha pulse: a 0.5 s-per-leg triangle between 127/255 and 1.
|
||||
@@ -305,6 +326,12 @@
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="32" y="0"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<!-- The group PvP icon, 32x32 TOPLEFT (-9,-15) (ref PartyFrameTemplates.xml
|
||||
l.298-309). Texture chosen at runtime — decision 0646 §4. -->
|
||||
<Texture name="$parentPVPIcon" hidden="true">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="-9" y="-15"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture name="$parentDisconnect" file="Interface\CharacterFrame\Disconnect-Icon" hidden="true">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-7" y="-1"/></Offset></Anchor></Anchors>
|
||||
|
||||
@@ -1660,9 +1660,11 @@
|
||||
kit names below are `igQuestLog*`, the shipped waves are `iQuestLogOpenA`/
|
||||
`iQuestLogCloseA` (no "g") — PlaySound takes the KIT name, so this is correct as
|
||||
written, flagged only so a future filename-based lookup doesn't "fix" it wrongly.
|
||||
Never fires at load (hidden="true"). -->
|
||||
<OnShow>PlaySound("igQuestLogOpen") BenillaQuestLogFrame_Update()</OnShow>
|
||||
<OnHide>PlaySound("igQuestLogClose")</OnHide>
|
||||
Never fires at load (hidden="true"). UpdateMicroButtons leads both, as in the ref
|
||||
(QuestLogFrame.lua:85-95's own first line) — live since the micro-menu slice
|
||||
(MicroMenu.xml). -->
|
||||
<OnShow>if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igQuestLogOpen") BenillaQuestLogFrame_Update()</OnShow>
|
||||
<OnHide>if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igQuestLogClose")</OnHide>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
|
||||
@@ -61,10 +61,12 @@
|
||||
- `maxLines="3"` on `$parentSpellName` (word-wrap line cap): no such FontString
|
||||
attribute is modeled (grepped `benilla-ui/src/loader/widgets.rs`) — the name just
|
||||
wraps unbounded inside its box width, a harmless cosmetic gap on the rare long name.
|
||||
- `UpdateMicroButtons()`/`MultiActionBar_ShowAllGrids/_HideAllGrids()` (ref
|
||||
`SpellBookFrame_OnShow`/`_OnHide`): no micro-button bar or multi-bar grid-show
|
||||
mechanism exists in this engine yet — dropped, the same class of gap CharacterFrame's
|
||||
own OnShow/OnHide already carry.
|
||||
- `MultiActionBar_ShowAllGrids/_HideAllGrids()` (ref `SpellBookFrame_OnShow`/`_OnHide`):
|
||||
no multi-bar grid-show mechanism exists in this engine yet — dropped, the same class of
|
||||
gap CharacterFrame's own OnShow/OnHide already carry. Its neighbour in that pair,
|
||||
`UpdateMicroButtons()`, is LIVE since the micro-menu slice (`MicroMenu.xml`) — nil-
|
||||
guarded at the call sites only because several test harnesses load this window without
|
||||
the action bar behind it.
|
||||
|
||||
Sounds (decision 0090): `igSpellBookOpen`/`igSpellBookClose` on show/hide (ref
|
||||
`SpellBookFrame_Update`/`_OnHide`, VERIFIED present in the real `SoundEntries.dbc` this
|
||||
@@ -137,9 +139,11 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- ref SpellBookFrame_OnShow (l.94-105): UpdateMicroButtons/MultiActionBar_ShowAllGrids
|
||||
-- OMITTED (header comment); the tab-flash UIFrameFlash call goes with LEARNED_SPELL_IN_TAB.
|
||||
-- ref SpellBookFrame_OnShow (l.94-105): MultiActionBar_ShowAllGrids OMITTED (header
|
||||
-- comment); the tab-flash UIFrameFlash call goes with LEARNED_SPELL_IN_TAB.
|
||||
-- UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
|
||||
function BenillaSpellBookFrame_OnShow()
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end
|
||||
BenillaSpellBookFrame_Update(1)
|
||||
end
|
||||
|
||||
@@ -170,10 +174,11 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- ref SpellBookFrame_OnHide (l.186-204): UpdateMicroButtons/MultiActionBar_HideAllGrids
|
||||
-- and the tab-flash cleanup OMITTED (header comment); always the spell-book close kit
|
||||
-- (no pet book to branch on).
|
||||
-- ref SpellBookFrame_OnHide (l.186-204): MultiActionBar_HideAllGrids and the tab-flash
|
||||
-- cleanup OMITTED (header comment); always the spell-book close kit (no pet book to branch
|
||||
-- on). UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
|
||||
function BenillaSpellBookFrame_OnHide()
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end
|
||||
PlaySound("igSpellBookClose")
|
||||
end
|
||||
|
||||
|
||||
@@ -89,11 +89,13 @@
|
||||
are reused as-is: both read only a Slider's own value/range/step, nothing faux-specific.
|
||||
|
||||
Named deferrals/OMITs (each re-cited at its own site below):
|
||||
- `SetButtonPulse`/`UpdateMicroButtons` (ref `TalentFrame_OnShow`/`_OnHide`/`TalentFrameTab_
|
||||
OnClick`'s pulse-clear loop, and `TalentTabTemplate`'s own `PLAYER_LEVEL_UP` level-9
|
||||
flash-hint OnLoad/OnEvent): decision 0304's own Scope-calls section, "No microbutton
|
||||
pulse — the reference pulses TalentMicroButton on point gain; benilla has no microbar
|
||||
yet." All four sites drop cleanly (none gate anything else).
|
||||
- `SetButtonPulse` (ref `TalentFrame_OnShow`/`_OnHide`/`TalentFrameTab_OnClick`'s
|
||||
pulse-clear loop, and `TalentTabTemplate`'s own `PLAYER_LEVEL_UP` level-9 flash-hint
|
||||
OnLoad/OnEvent): decision 0304's own Scope-calls section, "No microbutton pulse — the
|
||||
reference pulses TalentMicroButton on point gain". The microbar itself has since landed
|
||||
(`MicroMenu.xml`) and `UpdateMicroButtons()` is live in OnShow/OnHide below — but the
|
||||
PULSE stays out: it rides `PLAYER_LEVEL_UP`, which this client never fires. All four
|
||||
pulse sites drop cleanly (none gate anything else).
|
||||
- `message(...)` (5 sites: the `numTalents > MAX_NUM_TALENTS` reminder, `_GetArrowTexture`/
|
||||
`_GetBranchTexture`'s pool-exhaustion notices, and 3 sites in `_DrawLines`'s own layout-
|
||||
blocked guards): no engine `message()` global exists (grepped `crates/benilla-ui/src`;
|
||||
@@ -281,15 +283,18 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- ref TalentFrame_OnShow (l.95-103): SetButtonPulse/UpdateMicroButtons OMITTED — this
|
||||
-- file's header comment (decision 0304's own "No microbutton pulse" scope call).
|
||||
-- ref TalentFrame_OnShow (l.95-103): SetButtonPulse still OMITTED (decision 0304's own "No
|
||||
-- microbutton pulse" scope call — the pulse rides PLAYER_LEVEL_UP, which this client never
|
||||
-- fires). UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
|
||||
function BenillaTalentFrame_OnShow()
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end
|
||||
PlaySound("TalentScreenOpen")
|
||||
BenillaTalentFrame_Update()
|
||||
end
|
||||
|
||||
-- ref TalentFrame_OnHide (l.105-108): UpdateMicroButtons OMITTED, same citation.
|
||||
-- ref TalentFrame_OnHide (l.105-108).
|
||||
function BenillaTalentFrame_OnHide()
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end
|
||||
PlaySound("TalentScreenClose")
|
||||
end
|
||||
|
||||
@@ -1126,8 +1131,8 @@
|
||||
<Button name="BenillaTalentFrameTalent20" inherits="BenillaTalentButtonTemplate" id="20"/>
|
||||
|
||||
<!-- The arrow-art pool (ref l.380-415: TalentFrameArrowFrame,
|
||||
setAllPoints="true" — a frame-level no-op here (CharacterFrame.xml's
|
||||
own precedent), an explicit Size matching the scroll child instead. -->
|
||||
setAllPoints="true") — an explicit Size matching the scroll child
|
||||
instead, the equivalent shape CharacterFrame.xml's paper doll takes. -->
|
||||
<Frame name="BenillaTalentFrameArrowFrame">
|
||||
<Size><AbsDimension x="320" y="504"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
|
||||
@@ -41,12 +41,13 @@
|
||||
unless the statusText CVar is set; we match the default look.
|
||||
- TargetFrameNameBackground (the level plate) is faction-tinted (TargetFrame_CheckFaction,
|
||||
ref-TargetFrame.lua l.144-191). A PLAYER-controlled target takes the player branch (red when
|
||||
mutually hostile, else blue — a friendly player is BLUE, never the reaction green); an NPC
|
||||
mutually hostile, yellow attackable-not-hostile, green when we can assist them but they are
|
||||
PvP-flagged, else blue — a player never reads the *reaction* green); an NPC
|
||||
takes the reaction swatch, resolved from the same faction-template / reputation decode the
|
||||
selection ring uses (ui_unit.rs → UnitState.reaction → UnitReaction). DEFERRED (no PvP/tap
|
||||
wire): the player branch's two middle legs — yellow "attackable but not hostile" (l.158) and
|
||||
green "assist + PvP-flagged" (l.163) — and the tapped-by-other grey (l.176); they collapse
|
||||
into red/blue, correct for every same-/opposite-faction player. The ref gates the player
|
||||
selection ring uses (ui_unit.rs → UnitState.reaction → UnitReaction). The player branch's
|
||||
two middle legs — yellow "attackable but not hostile" (l.158) and green "assist +
|
||||
PvP-flagged" (l.163) — landed with the PvP wire (decision 0646); only the tapped-by-other
|
||||
grey (l.176) is still deferred, on the tap wire. The ref gates the player
|
||||
branch on UnitPlayerControlled (which also covers pets); we resolve its player half via
|
||||
UnitIsPlayer, so a player-controlled PET takes the NPC reaction branch until that flag lands.
|
||||
- The classification border swap (elite/rare art, ref-TargetFrame.lua l.205-218) is a named
|
||||
@@ -56,7 +57,16 @@
|
||||
- The level slot runs the full CheckLevel law (ref-TargetFrame.lua l.119-142, transcribed in
|
||||
Update below): corpse or "can't tell" (UnitLevel ≤ 0) → the HighLevelTexture skull;
|
||||
otherwise the number, GetDifficultyColor-tinted when attackable, gold when not.
|
||||
- OUT (no wire / future arcs): PVP/leader/master/rest/attack icons, group indicator, dropdowns,
|
||||
- The PVP icon landed with decision 0646 (the three-branch law in BenillaUnitFrame_UpdatePvP).
|
||||
Its `PlayerPVPIconHitArea` did NOT, deliberately: the ref's hit area draws nothing at the
|
||||
default settings — GameTooltip_AddNewbieTip with SHOW_NEWBIE_TIPS off and noNormalText=1
|
||||
(which is exactly how it is called) shows no tooltip at all — while an enableMouse frame
|
||||
over the portrait would eat the player frame's own hover and right-click. Zero pixels for a
|
||||
real regression is not a trade; it comes back with the newbie-tip cvar if that ever ships.
|
||||
RE-VERIFIED in 0652, both legs: ref-GameTooltip.lua l.107-123 takes the `not noNormalText`
|
||||
branch when tips are off (so `noNormalText=1` → nothing), and "newbie" does not appear
|
||||
anywhere in WoW.exe's strings, so SHOW_NEWBIE_TIPS has no cvar producer to be on.
|
||||
- OUT (no wire / future arcs): leader/master/rest/attack icons, group indicator,
|
||||
hit indicators, raid target icon, playtime, target-of-target.
|
||||
|
||||
TARGET AURA ROWS (0255's deferred slice): 5 buffs / 16 debuffs under the frame, geometry +
|
||||
@@ -71,6 +81,15 @@
|
||||
branches take the not-shown leg (wrap 6, buffs under Debuff7) until a ToT frame exists.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- The right-click-menu explanations the detailed-tooltip fork shows in place of the unit
|
||||
-- lines (BenillaUnitFrame_OnEnter below) — ref GlobalStrings.lua l.2993/3081/2754/2755
|
||||
-- (enUS), fallback form: the real localized GlobalStrings.lua runs before this file at
|
||||
-- runtime (ui_script/mod.rs), so these only fill in for the tests. CombatText.xml's shape.
|
||||
PARTY_OPTIONS_LABEL = PARTY_OPTIONS_LABEL or "Party Options"
|
||||
PLAYER_OPTIONS_LABEL = PLAYER_OPTIONS_LABEL or "Player Options"
|
||||
NEWBIE_TOOLTIP_PARTYOPTIONS = NEWBIE_TOOLTIP_PARTYOPTIONS or "If you belong to a party, you can right-click your portrait to bring up the Party Options menu. This menu will allow you to quit the party or see what looting rules the party is using. A party leader can add or remove party members, as well as change the party's looting rules."
|
||||
NEWBIE_TOOLTIP_PLAYEROPTIONS = NEWBIE_TOOLTIP_PLAYEROPTIONS or "Right-click to bring up special commands for interacting with another player. You can inspect their equipment, issue a party invite, initiate a trade, or challenge a player to a duel. A group leader can promote or remove that player from the group."
|
||||
|
||||
-- The client's PowerBarColor values, keyed by the UnitPowerType token.
|
||||
BENILLA_POWER_COLORS = {
|
||||
MANA = { r = 0.0, g = 0.0, b = 1.0 },
|
||||
@@ -122,6 +141,9 @@
|
||||
-- still loading) the circle is simply empty.
|
||||
frame.portrait = getglobal(name .. "Portrait")
|
||||
SetPortraitTexture(frame.portrait, unit)
|
||||
-- The PvP flag icon (decision 0646), under the TextureFrame like the ring art it
|
||||
-- draws over — see BenillaUnitFrame_UpdatePvP.
|
||||
frame.pvpIcon = getglobal(tf .. "PVPIcon")
|
||||
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
frame:RegisterEvent("UNIT_HEALTH")
|
||||
frame:RegisterEvent("UNIT_MAXHEALTH")
|
||||
@@ -130,6 +152,10 @@
|
||||
frame:RegisterEvent("UNIT_MAXPOWER")
|
||||
frame:RegisterEvent("UNIT_DISPLAYPOWER")
|
||||
frame:RegisterEvent("UNIT_NAME_UPDATE")
|
||||
-- The PvP icon's repaint wire (decision 0646 §2) — in the reference exactly the three
|
||||
-- frames that draw the icon register it (PlayerFrame, TargetFrame, PartyMemberFrame)
|
||||
-- and nothing else does.
|
||||
frame:RegisterEvent("UNIT_FACTION")
|
||||
frame:RegisterEvent("PLAYER_TARGET_CHANGED")
|
||||
-- The aura rows are target-only (ref TargetFrame_OnLoad registers UNIT_AURA).
|
||||
if unit == "target" then
|
||||
@@ -154,22 +180,53 @@
|
||||
-- recolor of line 1 (ref UnitFrame_OnEnter l.68-76: the FrameXML half of the split).
|
||||
-- The plate goes to the DEFAULT corner, not beside the frame — ref UnitFrame_OnEnter
|
||||
-- l.56 is GameTooltip_SetDefaultAnchor(GameTooltip, this): unit-frame tooltips live at
|
||||
-- the screen's bottom-right like world hovers. (The ref's newbie-tip fork and the
|
||||
-- TOOLTIP_UPDATE_TIME re-poll are not ours: no newbie-tip option; the engine's health
|
||||
-- watcher is the live half of the re-poll, and name/level don't drift mid-hover yet.)
|
||||
-- the screen's bottom-right like world hovers. (The TOOLTIP_UPDATE_TIME re-poll is not
|
||||
-- ours: the engine's health watcher is the live half of it, and name/level don't drift
|
||||
-- mid-hover yet.)
|
||||
--
|
||||
-- The DETAILED-TOOLTIP FORK (ref UnitFrame_OnEnter l.58-67, director-approved 0663) is the
|
||||
-- surprising half: with tips on — 1.12's default, decision 0661 — the frame explains its
|
||||
-- RIGHT-CLICK MENU instead of the unit, and returns BEFORE SetUnit, so the health/level
|
||||
-- lines never render at all. Your own portrait always takes it; another player's takes it
|
||||
-- whenever they're your target. The ref excepts PartyMemberFrame1-4 by name; ours are a
|
||||
-- separate file with their own OnEnter (PartyFrame.xml), so there is nothing to except here.
|
||||
--
|
||||
-- One divergence, named: the ref gates the second branch on UnitPlayerControlled("target"),
|
||||
-- which is true for a player OR a player's pet/charm. This engine has no such binding and no
|
||||
-- flag to build one from — UnitIsPlayer is the nearest honest read, so a charmed mob or
|
||||
-- another player's pet falls through to the unit tooltip where the reference would explain
|
||||
-- the menu. The player case, which is the one anybody hovers, is exact.
|
||||
function BenillaUnitFrame_OnEnter(frame)
|
||||
local unit = frame.unit
|
||||
if not UnitExists(unit) then return end
|
||||
GameTooltip_SetDefaultAnchor(GameTooltip, frame)
|
||||
if SHOW_NEWBIE_TIPS == "1" then
|
||||
if frame:GetName() == "BenillaPlayerFrame" then
|
||||
GameTooltip_AddNewbieTip(frame, PARTY_OPTIONS_LABEL, 1.0, 1.0, 1.0,
|
||||
NEWBIE_TOOLTIP_PARTYOPTIONS)
|
||||
return
|
||||
elseif UnitIsPlayer("target") and not UnitIsUnit("target", "player")
|
||||
and not UnitIsUnit("target", "pet") then
|
||||
GameTooltip_AddNewbieTip(frame, PLAYER_OPTIONS_LABEL, 1.0, 1.0, 1.0,
|
||||
NEWBIE_TOOLTIP_PLAYEROPTIONS)
|
||||
return
|
||||
end
|
||||
end
|
||||
if GameTooltip:SetUnit(unit) then
|
||||
getglobal("GameTooltipTextLeft1"):SetTextColor(GameTooltip_UnitColor(unit))
|
||||
end
|
||||
end
|
||||
|
||||
-- ref UnitFrame_OnLeave l.88-93: the unit-frame tooltip FADES on leave (items Hide;
|
||||
-- units fade — the same ramp the world mouseover uses).
|
||||
-- ref UnitFrame_OnLeave l.84-88: the fade is the tips-OFF branch. With detailed tips on the
|
||||
-- plate HIDES outright — the ref's own pairing, and the right one: an explanation that
|
||||
-- lingers on a ramp reads as a stuck tooltip, where a unit readout fading is the client's
|
||||
-- signature. (Items Hide, units fade — that split still holds for the world mouseover.)
|
||||
function BenillaUnitFrame_OnLeave(frame)
|
||||
GameTooltip:FadeOut()
|
||||
if SHOW_NEWBIE_TIPS == "1" then
|
||||
GameTooltip:Hide()
|
||||
else
|
||||
GameTooltip:FadeOut()
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaUnitFrame_OnEvent(frame)
|
||||
@@ -184,28 +241,68 @@
|
||||
return
|
||||
end
|
||||
BenillaUnitFrame_Update(frame)
|
||||
-- The player's flag-change sound (decision 0646 §2/§4). The reference plays
|
||||
-- igPVPUpdate inside PlayerFrame_UpdatePvPStatus's two show branches, which it only
|
||||
-- ever reaches from UNIT_FACTION; our repaint runs from the shared Update on every
|
||||
-- event, so the sound rides the (edge-fired) event instead of the repaint — one
|
||||
-- sound per real flag change, which is what the reference audibly does.
|
||||
if event == "UNIT_FACTION" and frame.unit == "player" and frame.pvpShown then
|
||||
PlaySound("igPVPUpdate")
|
||||
end
|
||||
end
|
||||
|
||||
-- The PvP flag icon (decision 0646 §4). The reference writes this law twice with
|
||||
-- identical branches — PlayerFrame_UpdatePvPStatus (ref-PlayerFrame.lua l.55-79) and the
|
||||
-- tail of TargetFrame_CheckFaction (ref-TargetFrame.lua l.193-202) — so it lives once
|
||||
-- here. Branch ORDER is the reference's and it is load-bearing: free-for-all wins
|
||||
-- outright (an FFA-flagged player shows the FFA skull even while ordinarily flagged), and
|
||||
-- the faction leg needs BOTH a resolvable side and the flag. `frame.pvpShown` is what the
|
||||
-- caller sounds off.
|
||||
function BenillaUnitFrame_UpdatePvP(frame)
|
||||
local icon = frame.pvpIcon
|
||||
if not icon then return end
|
||||
local unit = frame.unit
|
||||
local texture
|
||||
if UnitIsPVPFreeForAll(unit) then
|
||||
texture = "Interface\\TargetingFrame\\UI-PVP-FFA"
|
||||
else
|
||||
local factionGroup = UnitFactionGroup(unit)
|
||||
if factionGroup and UnitIsPVP(unit) then
|
||||
texture = "Interface\\TargetingFrame\\UI-PVP-" .. factionGroup
|
||||
end
|
||||
end
|
||||
frame.pvpShown = texture ~= nil
|
||||
if texture then
|
||||
icon:SetTexture(texture)
|
||||
icon:Show()
|
||||
else
|
||||
icon:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Combat feedback: the portrait hit indicator (decision 0576) ──────────────────────
|
||||
-- Ref CombatFeedback.lua, transcribed onto frame-param style (the ref reads `this`).
|
||||
-- Timings/colors/size factors verbatim; the word strings are the shipped enUS
|
||||
-- GlobalStrings values, hardcoded like the rest of our enUS-only data.
|
||||
-- Timings/colors/size factors verbatim. The words come from the GLOBALS, exactly as the ref
|
||||
-- builds this table (CombatFeedback.lua l.6-17: CombatFeedbackText["MISS"] = TEXT(MISS)),
|
||||
-- so a localized install shows its own — the app runs the real GlobalStrings.lua off the
|
||||
-- patch chain before this file (ui_script/mod.rs). The enUS literals are the fallback for
|
||||
-- the test harnesses, which load no patch chain.
|
||||
COMBATFEEDBACK_FADEINTIME = 0.2
|
||||
COMBATFEEDBACK_HOLDTIME = 0.7
|
||||
COMBATFEEDBACK_FADEOUTTIME = 0.3
|
||||
|
||||
CombatFeedbackText = {
|
||||
INTERRUPT = "Interrupt",
|
||||
MISS = "Miss",
|
||||
RESIST = "Resist",
|
||||
DODGE = "Dodge",
|
||||
PARRY = "Parry",
|
||||
BLOCK = "Block",
|
||||
EVADE = "Evade",
|
||||
IMMUNE = "Immune",
|
||||
DEFLECT = "Deflect",
|
||||
ABSORB = "Absorb",
|
||||
REFLECT = "Reflect",
|
||||
INTERRUPT = INTERRUPT or "Interrupt",
|
||||
MISS = MISS or "Miss",
|
||||
RESIST = RESIST or "Resist",
|
||||
DODGE = DODGE or "Dodge",
|
||||
PARRY = PARRY or "Parry",
|
||||
BLOCK = BLOCK or "Block",
|
||||
EVADE = EVADE or "Evade",
|
||||
IMMUNE = IMMUNE or "Immune",
|
||||
DEFLECT = DEFLECT or "Deflect",
|
||||
ABSORB = ABSORB or "Absorb",
|
||||
REFLECT = REFLECT or "Reflect",
|
||||
}
|
||||
|
||||
function CombatFeedback_OnCombatEvent(frame, event, flags, amount, type)
|
||||
@@ -347,20 +444,30 @@
|
||||
end
|
||||
|
||||
-- Tint the name/level plate (target only; TargetFrame_CheckFaction, ref-TargetFrame.lua
|
||||
-- l.144-191). A PLAYER-CONTROLLED target takes the player branch (l.145-174): its plate is
|
||||
-- RED when we're mutually hostile (UnitReactionColor[2]) and BLUE otherwise — a friendly,
|
||||
-- non-attackable player is blue, NOT the reaction green. Only an NPC takes the reaction
|
||||
-- swatch (l.179-190): the resolved UnitReactionColor (neutral → yellow), or blue when the
|
||||
-- reaction can't be told (nil). The ref's two middle player legs — yellow "attackable but
|
||||
-- not hostile" (l.158) and green "assist + PvP-flagged" (l.163) — plus the tapped-by-other
|
||||
-- grey (l.176) need the PvP/tap wire we don't carry; until it lands they collapse into
|
||||
-- red/blue, which is the right answer for every same-/opposite-faction player we can
|
||||
-- resolve. (The ref gates on UnitPlayerControlled, which also covers pets; UnitIsPlayer is
|
||||
-- the player half we can resolve — see the header deviation.)
|
||||
-- l.144-191). A PLAYER-CONTROLLED target takes the player branch (l.145-174); only an NPC
|
||||
-- takes the reaction swatch (l.179-190): the resolved UnitReactionColor (neutral →
|
||||
-- yellow), or blue when the reaction can't be told (nil) — a player never reads the
|
||||
-- reaction green, which is why the two paths differ at all.
|
||||
-- The player branch runs the ref's full ladder now that the
|
||||
-- PvP wire carries it (decision 0646 §4): hostile red, else attackable-but-not-hostile
|
||||
-- yellow (l.158), else "we can assist them but they're PvP-flagged" green (l.163), else
|
||||
-- blue. Only the tapped-by-other grey (l.176) is still deferred — it needs the tap wire.
|
||||
-- The ref's first leg tests mutual attackability (UnitCanAttack both ways); our
|
||||
-- UnitCanAttack carries only the player→unit direction, so UnitIsEnemy (reaction ≤ 2)
|
||||
-- stands in for it — the same substitution this branch has always made. (The ref gates on
|
||||
-- UnitPlayerControlled, which also covers pets; UnitIsPlayer is the player half we can
|
||||
-- resolve — see the header deviation.)
|
||||
if frame.nameBackground then
|
||||
if UnitIsPlayer(unit) then
|
||||
local c
|
||||
if UnitIsEnemy(unit, "player") then
|
||||
local c = BENILLA_REACTION_COLORS[2]
|
||||
c = BENILLA_REACTION_COLORS[2]
|
||||
elseif UnitCanAttack("player", unit) then
|
||||
c = BENILLA_REACTION_COLORS[4]
|
||||
elseif UnitIsPVP(unit) then
|
||||
c = BENILLA_REACTION_COLORS[6]
|
||||
end
|
||||
if c then
|
||||
frame.nameBackground:SetVertexColor(c.r, c.g, c.b)
|
||||
else
|
||||
frame.nameBackground:SetVertexColor(0.0, 0.0, 1.0)
|
||||
@@ -387,6 +494,9 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- The PvP flag icon, on both frames (decision 0646 §4).
|
||||
BenillaUnitFrame_UpdatePvP(frame)
|
||||
|
||||
-- The aura rows (target only) — the ref's TargetFrame_Update →
|
||||
-- TargetDebuffButton_Update: repainted on PLAYER_TARGET_CHANGED and UNIT_AURA alike.
|
||||
if unit == "target" then
|
||||
@@ -649,6 +759,18 @@
|
||||
<TexCoords left="1.0" right="0.09375" top="0" bottom="0.78125"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="ARTWORK">
|
||||
<!-- The PvP flag icon, 64×64 TOPLEFT (18,-20) (ref PlayerPVPIcon,
|
||||
ref-PlayerFrame.xml l.124-135) — ARTWORK over the BACKGROUND ring art,
|
||||
like the ref's. Texture chosen at runtime (decision 0646 §4); the ref's
|
||||
PlayerPVPIconHitArea is deliberately OUT (see the header). -->
|
||||
<Texture name="$parentPVPIcon" hidden="true">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="18" y="-20"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="OVERLAY">
|
||||
<!-- Name 100×10 CENTER (50,19) (ref l.58-69). -->
|
||||
<FontString name="$parentNameText" inherits="GameFontNormalSmall" text="Unknown">
|
||||
@@ -819,6 +941,17 @@
|
||||
<TexCoords left="0.09375" right="1.0" top="0" bottom="0.78125"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="ARTWORK">
|
||||
<!-- The PvP flag icon, 64×64 TOPRIGHT (3,-20) (ref TargetPVPIcon,
|
||||
ref-TargetFrame.xml l.208-219) — the player's icon mirrored to the
|
||||
frame's other edge, same law (decision 0646 §4). -->
|
||||
<Texture name="$parentPVPIcon" hidden="true">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="3" y="-20"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="OVERLAY">
|
||||
<!-- Name 100×10 CENTER (-50,19) (ref l.152-163). -->
|
||||
<FontString name="$parentNameText" inherits="GameFontNormalSmall" text="Unknown">
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
the ref's load split.
|
||||
|
||||
DEVIATIONS, deliberate (each waits for its arc):
|
||||
- NO ADDED ROWS. A "PVP" row briefly sat at the head of SELF (decision 0646 §3); decision 0652
|
||||
took it back out. 1.12.1 has no PvP toggle in any menu — `TogglePVP()`'s single caller in the
|
||||
whole shipped UI is `SlashCmdList["PVP"]`, and `UnitPopup.lua` contains the string "PVP"
|
||||
exactly zero times — so the row was a deviation that made this menu read wrong against the
|
||||
reference. `/pvp` is the toggle the client has.
|
||||
- DEFERRED ENTRIES: rows whose backing system doesn't exist yet are HIDDEN outright (the
|
||||
BENILLA_UNITPOPUP_DEFERRED set in HideButtons) rather than shown-dead: FOLLOW (its CMSG
|
||||
family is its own slice), TARGET (the friends list), GUILD_PROMOTE/GUILD_LEAVE (the guild
|
||||
|
||||
@@ -608,16 +608,20 @@
|
||||
WorldMapFrameAreaLabel:SetFont("Fonts\\FRIZQT__.TTF", 33);
|
||||
WorldMapFrame_Update();
|
||||
</OnLoad>
|
||||
<!-- UpdateMicroButtons leads both, as in the ref (WorldMapFrame.xml l.599/606) — live
|
||||
since the micro-menu slice (MicroMenu.xml). -->
|
||||
<OnShow>
|
||||
WorldMapBlackout:Show();
|
||||
SetupFullscreenScale();
|
||||
SetMapToCurrentZone();
|
||||
WorldMapFrame_Update();
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end;
|
||||
PlaySound("igQuestLogOpen");
|
||||
CloseDropDownMenus();
|
||||
</OnShow>
|
||||
<OnHide>
|
||||
WorldMapBlackout:Hide();
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end;
|
||||
PlaySound("igQuestLogClose");
|
||||
</OnHide>
|
||||
<OnEvent>
|
||||
|
||||
@@ -44,6 +44,7 @@ use crate::schedule::WorldStage;
|
||||
// probe instruments (`probes`) each live in their own file — the server-less harness (settle,
|
||||
// screenshot, perf probe) is this one's concern.
|
||||
mod fixtures;
|
||||
mod pick_probe;
|
||||
mod probe_bank;
|
||||
mod probe_castcancel;
|
||||
mod probe_charcreate;
|
||||
@@ -51,10 +52,12 @@ mod probe_crossing;
|
||||
mod probe_mail;
|
||||
mod probe_melee;
|
||||
mod probe_partner;
|
||||
mod probe_rig;
|
||||
mod probe_taxi;
|
||||
mod probes;
|
||||
mod scenarios;
|
||||
use fixtures::seed_ui_fixture;
|
||||
pub(crate) use pick_probe::PickProbePlugin;
|
||||
pub(crate) use probe_bank::ProbeBankPlugin;
|
||||
pub(crate) use probe_castcancel::ProbeCastCancelPlugin;
|
||||
pub(crate) use probe_charcreate::ProbeCharCreatePlugin;
|
||||
@@ -62,6 +65,7 @@ pub(crate) use probe_crossing::ProbeCrossingPlugin;
|
||||
pub(crate) use probe_mail::ProbeMailPlugin;
|
||||
pub(crate) use probe_melee::ProbeMeleePlugin;
|
||||
pub(crate) use probe_partner::ProbePartnerPlugin;
|
||||
pub(crate) use probe_rig::{rig_char_name_from_env, ProbeRigPlugin};
|
||||
pub(crate) use probe_taxi::ProbeTaxiPlugin;
|
||||
pub(crate) use probes::{
|
||||
LiveFpsPlugin, LiveShotPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! `WOW_PICK` — the headless **"what is at this pixel, all the way back"** probe.
|
||||
//!
|
||||
//! The flicker instruments (decisions 0653/0656) localise a defect: `benilla-visual hotspot` hands
|
||||
//! back a pixel box and says *this* is what would not hold still. Naming what is actually there was
|
||||
//! then a manual step — read the ADT placements, guess which model the report meant, hope. The
|
||||
//! interactive inspector ([`crate::interact`]) answers it with a cursor, which an unattended probe
|
||||
//! does not have.
|
||||
//!
|
||||
//! So: `WOW_PICK="<x>,<y>[;<x>,<y>…]"` (+ `WOW_PICK_AT=<secs>`, default 20) casts a ray through each
|
||||
//! pixel and logs **every** hit along it, nearest first — not just the front one.
|
||||
//!
|
||||
//! `WOW_PICK_COUNT=<n>` / `WOW_PICK_EVERY=<secs>` (0 = one per frame) repeat the cast, the same shape
|
||||
//! as the screenshot burst — because the cast honours `Visibility`, a hit that *vanishes* between
|
||||
//! adjacent casts is a surface being **culled**, not one losing a depth test. That distinction is
|
||||
//! invisible to a single cast and to the pixels alike: both look like the surface went away.
|
||||
//!
|
||||
//! Reporting the whole ray is the point. A surface that swaps with another between frames has a
|
||||
//! rival *behind* it at nearly the same depth, and the gap between hit 0 and hit 1 is the number
|
||||
//! that decides the diagnosis: an exact tie is a coplanar authoring tie (no depth precision can
|
||||
//! break it — only a deterministic order or a bias), while a few millimetres is a precision
|
||||
//! question. The nearest hit alone cannot tell those apart, and which one it is decides the fix.
|
||||
//!
|
||||
//! Coordinates are **screenshot pixels** — the same space `benilla-visual` reports boxes in — so a
|
||||
//! hotspot box can be pasted straight in. They are divided by the window's scale factor here, since
|
||||
//! `viewport_to_world` works in logical units and a Retina capture is 2× the logical window.
|
||||
|
||||
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
|
||||
use bevy::platform::collections::HashSet;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use crate::interact::WorldObject;
|
||||
use crate::player::WorldCamera;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
|
||||
pub(crate) struct PickProbePlugin;
|
||||
|
||||
impl Plugin for PickProbePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let pixels = std::env::var("WOW_PICK")
|
||||
.ok()
|
||||
.map(|s| parse_pixels(&s))
|
||||
.unwrap_or_default();
|
||||
let at = std::env::var("WOW_PICK_AT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20.0);
|
||||
let count = std::env::var("WOW_PICK_COUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1u32)
|
||||
.max(1);
|
||||
let every = std::env::var("WOW_PICK_EVERY")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
if pixels.is_empty() {
|
||||
warn!("pick: no usable pixel in WOW_PICK (want e.g. 1600,900) — inert");
|
||||
}
|
||||
app.insert_resource(PickProbe {
|
||||
pixels,
|
||||
count,
|
||||
every,
|
||||
taken: 0,
|
||||
next_at: at,
|
||||
})
|
||||
.add_systems(Update, fire_pick);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct PickProbe {
|
||||
/// Screenshot-space pixels to cast through.
|
||||
pixels: Vec<Vec2>,
|
||||
/// Casts to make (`WOW_PICK_COUNT`), `WOW_PICK_EVERY` seconds apart (0 = one per frame).
|
||||
count: u32,
|
||||
every: f32,
|
||||
taken: u32,
|
||||
next_at: f32,
|
||||
}
|
||||
|
||||
fn parse_pixels(spec: &str) -> Vec<Vec2> {
|
||||
spec.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| {
|
||||
let (x, y) = s.split_once(',')?;
|
||||
Some(Vec2::new(x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What a hit entity is asked for: its identity, the batch class it draws as, and the material
|
||||
/// carrying the WMO batch order (`Option` because a hit need not be a model batch at all).
|
||||
type HitIdentity = (
|
||||
&'static WorldObject,
|
||||
Option<&'static crate::debug_panel::ModelPart>,
|
||||
Option<&'static MeshMaterial3d<WowModelMaterial>>,
|
||||
);
|
||||
|
||||
/// Everything needed to turn a hit entity into a line of text — bundled so the system keeps one
|
||||
/// parameter for "describe this hit" rather than two that must always travel together.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct HitNames<'w, 's> {
|
||||
identity: Query<'w, 's, HitIdentity>,
|
||||
materials: Res<'w, Assets<WowModelMaterial>>,
|
||||
}
|
||||
|
||||
fn fire_pick(
|
||||
mut probe: ResMut<PickProbe>,
|
||||
time: Res<Time>,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
|
||||
objects: Query<Entity, With<WorldObject>>,
|
||||
names: HitNames,
|
||||
mut ray_cast: MeshRayCast,
|
||||
) {
|
||||
if probe.taken >= probe.count || probe.pixels.is_empty() || time.elapsed_secs() < probe.next_at
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
|
||||
return; // no world camera / window yet — try again next frame
|
||||
};
|
||||
let cast = probe.taken;
|
||||
probe.taken += 1;
|
||||
probe.next_at = time.elapsed_secs() + probe.every;
|
||||
let scale = window.scale_factor();
|
||||
let pickable: HashSet<Entity> = objects.iter().collect();
|
||||
for &pixel in &probe.pixels {
|
||||
let logical = pixel / scale;
|
||||
let Ok(ray) = camera.viewport_to_world(cam_tf, logical) else {
|
||||
warn!("pick ({}, {}): outside the viewport", pixel.x, pixel.y);
|
||||
continue;
|
||||
};
|
||||
let filter = |e: Entity| pickable.contains(&e);
|
||||
// `never_early_exit` is the whole reason this exists: the default stops at the first hit,
|
||||
// and the rival surface behind it is exactly what we came for.
|
||||
let settings = MeshRayCastSettings::default()
|
||||
.with_visibility(RayCastVisibility::VisibleInView)
|
||||
.with_filter(&filter)
|
||||
.never_early_exit();
|
||||
let hits = ray_cast.cast_ray(ray, &settings);
|
||||
info!(
|
||||
"pick#{cast} ({}, {}) [logical {:.1}, {:.1}]: {} hits",
|
||||
pixel.x,
|
||||
pixel.y,
|
||||
logical.x,
|
||||
logical.y,
|
||||
hits.len()
|
||||
);
|
||||
let mut previous: Option<f32> = None;
|
||||
for (i, (entity, hit)) in hits.iter().enumerate() {
|
||||
let gap = previous.map_or(String::new(), |p: f32| {
|
||||
format!(" (+{:.5} yd behind the last)", hit.distance - p)
|
||||
});
|
||||
previous = Some(hit.distance);
|
||||
let Ok((obj, part, mat)) = names.identity.get(*entity) else {
|
||||
info!(" {i:2} {:9.4} yd <untagged>{gap}", hit.distance);
|
||||
continue;
|
||||
};
|
||||
// The WMO authored batch order rides in the material's `sun_scale.y` (see
|
||||
// `model_render`): 0 means "no bias applied", which for a WMO batch is itself a finding.
|
||||
let batch = mat
|
||||
.and_then(|m| names.materials.get(&m.0))
|
||||
.map(|m| m.extension.sun_scale.y as i32)
|
||||
.unwrap_or(-1);
|
||||
info!(
|
||||
" {i:2} {:9.4} yd {:?} #{:<10} bias {batch:3} {:?} {}{gap}",
|
||||
hit.distance,
|
||||
obj.kind,
|
||||
obj.id,
|
||||
part.map(|p| p.blend),
|
||||
obj.label,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ const SLOT_BANK_FIRST: u8 = SLOT_PACK_FIRST + 16;
|
||||
/// The client-side purchase ladder (`BankBagSlotPrices.dbc`, decision 0604) — the fallback used
|
||||
/// only if [`BankPrices`] failed to load; index = slots already purchased (0-based).
|
||||
const PRICE_LADDER: [u32; 6] = [1_000, 10_000, 100_000, 250_000, 500_000, 1_000_000];
|
||||
/// Slack added on top of the exact shortfall when the buy-slot step funds itself — the purse can
|
||||
/// move under it (a repair, a vendor sale) between the read and the purchase.
|
||||
const FUND_MARGIN_COPPER: u32 = 10_000;
|
||||
/// The purchasable-slot ceiling (decision 0604: `GetNumBankSlots()` reports full at 6).
|
||||
const MAX_BANK_BAGS: u8 = 6;
|
||||
/// How far past the banker's service range the refusal step teleports (yd, WoW space) — well
|
||||
@@ -151,6 +154,9 @@ enum Phase {
|
||||
BuySlot {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
/// Whether this step has already granted itself the fare with `.modify money`. One shot:
|
||||
/// if the purse is still short after a grant, that is a real defect, not a permission wall.
|
||||
funded: bool,
|
||||
},
|
||||
/// Step 6 (bonus): teleport out of range, then `BuyBankSlot` the now-stale guid, expecting
|
||||
/// `SMSG_BUY_BANK_SLOT_RESULT` NOTBANKER. `teleported`/`bought` gate the two sends in order;
|
||||
@@ -378,6 +384,7 @@ fn bank_probe(
|
||||
probe.phase = Phase::BuySlot {
|
||||
since: now,
|
||||
sent: false,
|
||||
funded: false,
|
||||
};
|
||||
} else if now - since > ACTION_TIMEOUT_SECS {
|
||||
error!(
|
||||
@@ -389,10 +396,15 @@ fn bank_probe(
|
||||
probe.phase = Phase::BuySlot {
|
||||
since: now,
|
||||
sent: false,
|
||||
funded: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
Phase::BuySlot { since, sent } => {
|
||||
Phase::BuySlot {
|
||||
since,
|
||||
sent,
|
||||
funded,
|
||||
} => {
|
||||
let Some(banker) = probe.banker else {
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
@@ -425,17 +437,34 @@ fn bank_probe(
|
||||
"PROBE_BANK: (5 buy_slot) baseline purchased={purchased} money={money}c next \
|
||||
rung costs {cost}c"
|
||||
);
|
||||
if money < cost && !funded {
|
||||
// Fund the rung and come back next tick. `.modify money <n>` ADDS `n` copper
|
||||
// (vmangos `HandleModifyMoneyCommand`: the arg is `addmoney`, not a set) and
|
||||
// needs SEC_BASIC_ADMIN(4) — which probe accounts have had since they were
|
||||
// actually raised to 6 (0651). This step used to SKIP here, because the
|
||||
// accounts were gmlevel 3 and the grant would have been refused; the whole
|
||||
// buy-slot leg was therefore unverified whenever the purse ran dry.
|
||||
let grant = cost - money + FUND_MARGIN_COPPER;
|
||||
info!("PROBE_BANK: (5 buy_slot) purse {money}c < {cost}c — granting {grant}c with `.modify money`");
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: ChatKind::Say,
|
||||
text: format!(".modify money {grant}"),
|
||||
target: None,
|
||||
});
|
||||
probe.phase = Phase::BuySlot {
|
||||
since: now,
|
||||
sent: false,
|
||||
funded: true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if money < cost {
|
||||
// `.modify money` needs SEC_BASIC_ADMIN(4); probe accounts are gmlevel 3
|
||||
// (SEC_GAMEMASTER) — verified this session, module doc — so it would be
|
||||
// refused. Nothing this probe can do about it live; SKIP rather than send a
|
||||
// command known to fail.
|
||||
warn!(
|
||||
"PROBE_BANK: SKIP (5 buy_slot) — {money}c < {cost}c needed and \
|
||||
`.modify money` is SEC_BASIC_ADMIN(4), above a probe account's \
|
||||
gmlevel 3 (verified vmangos Chat.cpp; see module doc) — cannot fund \
|
||||
from here"
|
||||
error!(
|
||||
"PROBE_BANK: FAIL (5 buy_slot) — purse is still {money}c against a \
|
||||
{cost}c rung after `.modify money` — the grant did not land (check the \
|
||||
`net: server says` lines)"
|
||||
);
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::Refusal {
|
||||
since: now,
|
||||
teleported: false,
|
||||
@@ -449,6 +478,7 @@ fn bank_probe(
|
||||
probe.phase = Phase::BuySlot {
|
||||
since: now,
|
||||
sent: true,
|
||||
funded,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The sea-crossing live probe (`WOW_PROBE=crossing`) — decision 0455's instrument, inert
|
||||
//! without the env: once in-world, wait for a cross-continent transport docked on our map,
|
||||
//! GM-drop onto its deck (`.go xyz`, account `three` is gmlevel 3), then just stand there and
|
||||
//! GM-drop onto its deck (`.go xyz`; probe accounts are gmlevel 6), then just stand there and
|
||||
//! report the seam: aboard → map flip (TRANSFER_PENDING / NEW_WORLD riding branch, logged by
|
||||
//! the net layer) → still riding → arrived docked on the far continent. Every phase edge prints
|
||||
//! a `PROBE crossing:` line, so an outer `timeout`d run + grep is the whole harness. Non-combat.
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
//! `SEC_MODERATOR` (1); `.send items <name> "subject" "text" item[:count]…` and
|
||||
//! `.send money <name> "subject" "text" <copper>` are both `SEC_ADMINISTRATOR` (6)
|
||||
//! (`AccountTypes`, `shared/Common.h` l.138-145: `SEC_MODERATOR=1`, `SEC_GAMEMASTER=3`,
|
||||
//! `SEC_ADMINISTRATOR=6`). **method.md pins every probe account at gmlevel 3
|
||||
//! (`SEC_GAMEMASTER`)** — so `.send mail` succeeds (3 ≥ 1) but `.send items`/`.send money` are
|
||||
//! REFUSED server-side (3 < 6): this is a verified permission floor, not a guess, and step (c)/(d)
|
||||
//! below degrade to SKIP for exactly that reason on an unelevated probe account. All three GM
|
||||
//! `SEC_ADMINISTRATOR=6`). **Probe accounts are gmlevel 6**, so all three land. They did *not*
|
||||
//! until 2026-07-26 — 0645 recorded the raise but never applied it, and the accounts sat at 3
|
||||
//! (0651). While they did, steps (c)/(d) below degraded to SKIP against a permission floor that
|
||||
//! was real, and that SKIP hid a defect in the probe itself: it re-found the money/item row by
|
||||
//! predicate each tick, so a *successful* take — which zeroes the money and clears the attachment
|
||||
//! — made the row stop matching and read as "no row ever appeared". Both steps now remember the
|
||||
//! id they took from, and a missing row is a FAIL, because at gmlevel 6 there is no longer an
|
||||
//! innocent reason for one. All three GM
|
||||
//! sends build their `MailDraft` and call `MailDraft::SendMailTo` DIRECTLY
|
||||
//! (`HandleSendMailCommand`/`HandleSendItemsCommand`/`HandleSendMoneyCommand`, `MiscCommands.cpp`
|
||||
//! ~l.1012-1145) — never through `WorldSession::HandleSendMail` (`MailHandler.cpp`), whose
|
||||
@@ -76,8 +80,8 @@ const SETTLE_SECS: f64 = 3.0;
|
||||
const SCAN_TIMEOUT_SECS: f64 = 15.0;
|
||||
const LIST_TIMEOUT_SECS: f64 = 15.0;
|
||||
const BODY_TIMEOUT_SECS: f64 = 10.0;
|
||||
/// How long a permission/delay-blocked row (money/item mail under an unelevated probe account)
|
||||
/// is given to show up before the dependent step SKIPs — generous, but bounded (never hangs).
|
||||
/// How long a GM-sent row is given to show up before the dependent step FAILs — generous, but
|
||||
/// bounded (never hangs).
|
||||
const ROW_GRACE_SECS: f64 = 5.0;
|
||||
const ACTION_TIMEOUT_SECS: f64 = 8.0;
|
||||
|
||||
@@ -147,15 +151,21 @@ enum Phase {
|
||||
/// The letter's `(lua bag, 1-based slot)` once found and clicked.
|
||||
slot: Option<(i64, i64)>,
|
||||
},
|
||||
/// `TakeInboxMoney` on the money row, if the GM `.send money` actually landed one (step c).
|
||||
/// `TakeInboxMoney` on the money row (step c).
|
||||
TakeMoney {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
/// The message id we sent the take for, once sent. It is remembered rather than re-found
|
||||
/// each tick because **taking is what makes the row stop matching**: a successful
|
||||
/// `TakeInboxMoney` zeroes `money` (or drops the row), so a predicate re-search returns
|
||||
/// `None` on exactly the ticks that prove success. That bug reported every successful take
|
||||
/// as "no money row appeared" and hid behind the old gmlevel-3 floor, where the row really
|
||||
/// never arrived (0651).
|
||||
taken: Option<u32>,
|
||||
},
|
||||
/// `TakeInboxItem` on the item row, if the GM `.send items` actually landed one (step d).
|
||||
/// `TakeInboxItem` on the item row (step d). Same remembered-id reason as [`Self::TakeMoney`].
|
||||
TakeItem {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
taken: Option<u32>,
|
||||
},
|
||||
/// `SendMail` to the probe's own name — expects `CANNOT_SEND_TO_SELF` (step e).
|
||||
SendSelf {
|
||||
@@ -305,9 +315,8 @@ fn mail_probe(
|
||||
if !mail.mails.is_empty() {
|
||||
info!(
|
||||
"PROBE_MAIL: (a) inbox list landed — {} row(s) (3 GM sends attempted; \
|
||||
`.send items`/`.send money` need SEC_ADMINISTRATOR(6), probe accounts are \
|
||||
gmlevel 3 per method.md, so only `.send mail`(SEC_MODERATOR=1) is expected \
|
||||
to have landed — see the module doc)",
|
||||
probe accounts are gmlevel 6, so all three — `.send mail`(1), \
|
||||
`.send money`(6), `.send items`(6) — are expected to have landed)",
|
||||
mail.mails.len()
|
||||
);
|
||||
for e in &mail.mails {
|
||||
@@ -479,7 +488,7 @@ fn mail_probe(
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::TakeMoney {
|
||||
since: now,
|
||||
sent: false,
|
||||
taken: None,
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -493,7 +502,7 @@ fn mail_probe(
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::TakeMoney {
|
||||
since: now,
|
||||
sent: false,
|
||||
taken: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -508,50 +517,66 @@ fn mail_probe(
|
||||
info!("PROBE_MAIL: PASS (b3) — the reader painted the letter body (title/creator/text via ITEM_TEXT_BEGIN→READY)");
|
||||
probe.passes += 1;
|
||||
// Cleanup: close the reader, destroy the copy (else bags gain one per run).
|
||||
let _ = script.run("BenillaItemTextCloseButton:Click()");
|
||||
let _ = script.run(&format!(
|
||||
"C_Container.PickupContainerItem({bag}, {lslot}) DeleteCursorItem()"
|
||||
));
|
||||
crate::ui_script::run_or_warn(
|
||||
&script,
|
||||
"BenillaItemTextCloseButton:Click()",
|
||||
);
|
||||
crate::ui_script::run_or_warn(
|
||||
&script,
|
||||
&format!(
|
||||
"C_Container.PickupContainerItem({bag}, {lslot}) DeleteCursorItem()"
|
||||
),
|
||||
);
|
||||
probe.phase = Phase::TakeMoney {
|
||||
since: now,
|
||||
sent: false,
|
||||
taken: None,
|
||||
};
|
||||
} else if now - since > ACTION_TIMEOUT_SECS {
|
||||
error!("PROBE_MAIL: FAIL (b3) — the reader never painted the body within {ACTION_TIMEOUT_SECS}s of the click");
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::TakeMoney {
|
||||
since: now,
|
||||
sent: false,
|
||||
taken: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Phase::TakeMoney { since, sent } => {
|
||||
// A tuple match (not pattern guards) — guarded `Some(_) if …` arms can't be proven
|
||||
// exhaustive by rustc even when the guards themselves are (`sent`/`!sent`), forcing a
|
||||
// dead catch-all; matching `(row, sent)` as a pair is exhaustive on its own shape.
|
||||
match (find_by(&mail, |e| e.money > 0), sent) {
|
||||
(Some(money_id), false) => {
|
||||
let Some(idx) = index_of(&mail, money_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = script.run(&format!("TakeInboxMoney({idx})")) {
|
||||
error!("PROBE_MAIL: FAIL (c) — TakeInboxMoney({idx}) errored: {e}");
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::TakeItem {
|
||||
Phase::TakeMoney { since, taken } => {
|
||||
// The id is REMEMBERED across ticks, never re-found: a successful take is exactly what
|
||||
// makes the row stop matching `money > 0`, so re-searching would read success as
|
||||
// absence (0651). Matched as a pair rather than with guards — guarded `Some(_) if …`
|
||||
// arms can't be proven exhaustive by rustc, forcing a dead catch-all.
|
||||
let next = Phase::TakeItem {
|
||||
since: now,
|
||||
taken: None,
|
||||
};
|
||||
match taken {
|
||||
None => match find_by(&mail, |e| e.money > 0) {
|
||||
Some(money_id) => {
|
||||
let Some(idx) = index_of(&mail, money_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = script.run(&format!("TakeInboxMoney({idx})")) {
|
||||
error!("PROBE_MAIL: FAIL (c) — TakeInboxMoney({idx}) errored: {e}");
|
||||
probe.fails += 1;
|
||||
probe.phase = next;
|
||||
return;
|
||||
}
|
||||
info!("PROBE_MAIL: (c) money row found (message {money_id}) — TakeInboxMoney({idx}) sent");
|
||||
probe.phase = Phase::TakeMoney {
|
||||
since: now,
|
||||
sent: false,
|
||||
taken: Some(money_id),
|
||||
};
|
||||
return;
|
||||
}
|
||||
info!("PROBE_MAIL: (c) money row found (message {money_id}) — TakeInboxMoney({idx}) sent");
|
||||
probe.phase = Phase::TakeMoney {
|
||||
since: now,
|
||||
sent: true,
|
||||
};
|
||||
}
|
||||
(Some(money_id), true) => {
|
||||
None if now - since > ROW_GRACE_SECS => {
|
||||
error!("PROBE_MAIL: FAIL (c) — no money row within {ROW_GRACE_SECS}s. Probe accounts are gmlevel 6, so `.send money` (SEC_ADMINISTRATOR 6) should have landed one — check the `net: server says` lines for the GM send's own answer");
|
||||
probe.fails += 1;
|
||||
probe.phase = next;
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
Some(money_id) => {
|
||||
let money_now = entry_of(&mail, money_id).map(|e| e.money);
|
||||
if matches!(money_now, Some(0) | None) {
|
||||
info!(
|
||||
@@ -563,80 +588,58 @@ fn mail_probe(
|
||||
}
|
||||
);
|
||||
probe.passes += 1;
|
||||
probe.phase = Phase::TakeItem {
|
||||
since: now,
|
||||
sent: false,
|
||||
};
|
||||
probe.phase = next;
|
||||
} else if now - since > ACTION_TIMEOUT_SECS {
|
||||
error!("PROBE_MAIL: FAIL (c) — money row {money_id} never re-synced to 0 within {ACTION_TIMEOUT_SECS}s");
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::TakeItem {
|
||||
since: now,
|
||||
sent: false,
|
||||
};
|
||||
probe.phase = next;
|
||||
}
|
||||
}
|
||||
(None, _) if now - since > ROW_GRACE_SECS => {
|
||||
warn!("PROBE_MAIL: SKIP (c) — no money row appeared ({ROW_GRACE_SECS}s grace elapsed); expected — `.send money` needs SEC_ADMINISTRATOR(6), probe is gmlevel 3 (see module doc)");
|
||||
probe.phase = Phase::TakeItem {
|
||||
since: now,
|
||||
sent: false,
|
||||
};
|
||||
}
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
Phase::TakeItem { since, sent } => {
|
||||
match (find_by(&mail, |e| e.item.is_some()), sent) {
|
||||
(Some(item_id), false) => {
|
||||
let Some(idx) = index_of(&mail, item_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = script.run(&format!("TakeInboxItem({idx})")) {
|
||||
error!("PROBE_MAIL: FAIL (d) — TakeInboxItem({idx}) errored: {e}");
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::SendSelf {
|
||||
Phase::TakeItem { since, taken } => {
|
||||
let next = Phase::SendSelf {
|
||||
since: now,
|
||||
sent: false,
|
||||
baseline: events_len(&script),
|
||||
};
|
||||
match taken {
|
||||
None => match find_by(&mail, |e| e.item.is_some()) {
|
||||
Some(item_id) => {
|
||||
let Some(idx) = index_of(&mail, item_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = script.run(&format!("TakeInboxItem({idx})")) {
|
||||
error!("PROBE_MAIL: FAIL (d) — TakeInboxItem({idx}) errored: {e}");
|
||||
probe.fails += 1;
|
||||
probe.phase = next;
|
||||
return;
|
||||
}
|
||||
info!("PROBE_MAIL: (d) item row found (message {item_id}) — TakeInboxItem({idx}) sent");
|
||||
probe.phase = Phase::TakeItem {
|
||||
since: now,
|
||||
sent: false,
|
||||
baseline: events_len(&script),
|
||||
taken: Some(item_id),
|
||||
};
|
||||
return;
|
||||
}
|
||||
info!("PROBE_MAIL: (d) item row found (message {item_id}) — TakeInboxItem({idx}) sent");
|
||||
probe.phase = Phase::TakeItem {
|
||||
since: now,
|
||||
sent: true,
|
||||
};
|
||||
}
|
||||
(Some(item_id), true) => {
|
||||
None if now - since > ROW_GRACE_SECS => {
|
||||
error!("PROBE_MAIL: FAIL (d) — no item row within {ROW_GRACE_SECS}s. Probe accounts are gmlevel 6, so `.send items` (SEC_ADMINISTRATOR 6) should have landed one — check the `net: server says` lines for the GM send's own answer");
|
||||
probe.fails += 1;
|
||||
probe.phase = next;
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
Some(item_id) => {
|
||||
let has_item = entry_of(&mail, item_id).map(|e| e.item.is_some());
|
||||
if matches!(has_item, Some(false) | None) {
|
||||
info!("PROBE_MAIL: PASS (d) — item row {item_id} taken (item attachment cleared)");
|
||||
probe.passes += 1;
|
||||
probe.phase = Phase::SendSelf {
|
||||
since: now,
|
||||
sent: false,
|
||||
baseline: events_len(&script),
|
||||
};
|
||||
probe.phase = next;
|
||||
} else if now - since > ACTION_TIMEOUT_SECS {
|
||||
error!("PROBE_MAIL: FAIL (d) — item row {item_id} never re-synced within {ACTION_TIMEOUT_SECS}s");
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::SendSelf {
|
||||
since: now,
|
||||
sent: false,
|
||||
baseline: events_len(&script),
|
||||
};
|
||||
probe.phase = next;
|
||||
}
|
||||
}
|
||||
(None, _) if now - since > ROW_GRACE_SECS => {
|
||||
warn!("PROBE_MAIL: SKIP (d) — no item row appeared ({ROW_GRACE_SECS}s grace elapsed); expected — `.send items` needs SEC_ADMINISTRATOR(6), probe is gmlevel 3 (see module doc)");
|
||||
probe.phase = Phase::SendSelf {
|
||||
since: now,
|
||||
sent: false,
|
||||
baseline: events_len(&script),
|
||||
};
|
||||
}
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
Phase::SendSelf {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! The partner live probe (`WOW_PROBE=partner`) — the party arc's instrument (decision 0434):
|
||||
//! a second client that says YES. Once in-world it auto-accepts every group invite, so the
|
||||
//! director can exercise the whole party surface — invite, roster lines, member frames,
|
||||
//! leader/loot management, pings — solo, with the probe account as the other member. Non-combat
|
||||
//! (the unattended-combat ban, `method.md` "The local vmangos server", stands untouched: this
|
||||
//! probe never swings). Pair with the slot-keyed probe identity (`WOW_USER=probeN …`, method.md).
|
||||
//! leader/loot management, pings — solo, with the probe account as the other member. It also
|
||||
//! accepts **duel** challenges (decision 0637), which is what makes the duel arc's two-client
|
||||
//! questions answerable at all: whether the opponent turns hostile, whether the arbiter flag
|
||||
//! plants. Non-combat (the unattended-combat ban, `method.md` "The local vmangos server", stands
|
||||
//! untouched: this probe answers a challenge but never swings, and a duel nobody strikes in
|
||||
//! simply times out). Pair with the slot-keyed probe identity (`WOW_USER=probeN …`, method.md).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -25,6 +28,7 @@ impl Plugin for ProbePartnerPlugin {
|
||||
fn partner_probe(
|
||||
self_player: Query<(), With<SelfPlayer>>,
|
||||
mut group: ResMut<GroupState>,
|
||||
mut duel: ResMut<crate::ui_duel::DuelState>,
|
||||
net: Res<crate::net::NetCommands>,
|
||||
) {
|
||||
if self_player.is_empty() {
|
||||
@@ -34,4 +38,14 @@ fn partner_probe(
|
||||
info!("partner probe: accepting {inviter}'s group invite");
|
||||
let _ = net.0.send(crate::net::ClientCommand::GroupAccept);
|
||||
}
|
||||
// A pending challenge is answered with `CMSG_DUEL_ACCEPTED` the frame it lands. Taking the
|
||||
// challenger discharges the popup debt the same way the UI feed's DUEL_REQUESTED edge would,
|
||||
// so no dialog is left owed on the probe.
|
||||
let arbiter = duel.arbiter;
|
||||
if let Some(challenger) = duel.take_challenger() {
|
||||
info!("partner probe: accepting duel from {challenger:#018x} (arbiter {arbiter:#018x})");
|
||||
let _ = net
|
||||
.0
|
||||
.send(crate::net::ClientCommand::DuelAccepted { arbiter });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
//! The probe **rig** (`WOW_RIG="<spec>"`, decision 0651) — one command that puts this slot's probe
|
||||
//! account into a chosen **body** and hands the session a world that is ready to test.
|
||||
//!
|
||||
//! ## Why it exists
|
||||
//!
|
||||
//! Every probe account owns one level-1 character, and every session that needs anything else has
|
||||
//! been hand-assembling it out of GM commands. Mined from 507 MB of this project's own session
|
||||
//! transcripts, the standing tax is: `.go xyz` 750×, `.additem` 434×, `.modify` 345×, `.gm off`
|
||||
//! 257×, `.revive` **121×**, `.learn <one spell id>` a few dozen times — while
|
||||
//! `.character premade` (86 ready-made BiS/twink gear + talent templates sitting in this deploy's
|
||||
//! world DB, every class at 19/29/39/49/60) has been used **zero** times, because nobody knew it
|
||||
//! was there. That is the shape of the problem: the primitives exist and are undiscoverable, so
|
||||
//! each session re-derives a worse version of them.
|
||||
//!
|
||||
//! The rig is the one verb that hides all of it: name a body, get a body.
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_RIG="tauren druid 60 gear:heal-preraid-bis spec:heal-preraid-bis at:ThunderBluff"
|
||||
//! WOW_RIG="gnome mage 39 gear:dps-39-twink gm:off"
|
||||
//! WOW_RIG="60 gear:dps-preraid-bis" # the slot's own Probe<N>, no new character
|
||||
//! WOW_RIG="nightelf druid" # just a body of that shape, level 1
|
||||
//! ```
|
||||
//!
|
||||
//! ## What it does
|
||||
//!
|
||||
//! **Race + class name a character, so the rig owns the pick.** vmangos has no class-change command
|
||||
//! (`.character race` exists; there is no `.character class`), so a different class *is* a different
|
||||
//! character — which makes creating one the honest primitive rather than a fallback. The name is
|
||||
//! derived, never invented: `<Race3><Class3><slot-word>[f]`, e.g. Tauren Druid on `pool-1` →
|
||||
//! `Taudruone`. Deterministic means the *next* session reuses the same body instead of littering a
|
||||
//! second one, and the pattern is what makes eviction safe (below). Omit race+class and the rig
|
||||
//! configures whatever `WOW_CHAR`/the slot default already logs in as.
|
||||
//!
|
||||
//! **The roster is a cache with an eviction policy.** `CharactersPerRealm` is 10, and there are 40
|
||||
//! valid race/class pairs, so a busy account fills. The rig does not hardcode the limit: it tries
|
||||
//! the create, and only on `CHAR_CREATE_SERVER_LIMIT` evicts **the rig-named character on this slot
|
||||
//! that is cheapest to rebuild** — lowest level first, ties broken by oldest — then retries once.
|
||||
//! Nothing outside the rig's own naming pattern is ever deleted, so `Probe<N>` and anything a human
|
||||
//! made are untouchable.
|
||||
//!
|
||||
//! **Then it applies state, in an order that matters:** revive before anything (a ghost's commands
|
||||
//! half-apply), level before gear (`ApplyPremadeGearTemplateToPlayer` only levels *up*, never
|
||||
//! down), spells after level, teleport last (so a fixup lands where you asked, not where you were).
|
||||
//! It always revives a dead or ghost body, whether or not you asked — that is the 121× command, and
|
||||
//! there is no session that wants to keep testing on a corpse.
|
||||
//!
|
||||
//! ## The GM verbs behind it (all verified against `/Users/sam/wre/vmangos-src`)
|
||||
//!
|
||||
//! | rig token | command | needs | note |
|
||||
//! |---|---|---|---|
|
||||
//! | (always, if dead) | `.revive` | SEC_GAMEMASTER 3 | |
|
||||
//! | `<level>` | `.character level N` | SEC_DEVELOPER 5 | no name arg ⇒ self |
|
||||
//! | (with a level) | `.learn all_myclass` | SEC_DEVELOPER 5 | = all class spells **+** all talents |
|
||||
//! | `gear:<t>` | `.character premade gear <t>` | SEC_BASIC_ADMIN 4 | name or entry; levels up + equips |
|
||||
//! | `spec:<t>` | `.character premade spec <t>` | SEC_BASIC_ADMIN 4 | resets talents, learns the tree |
|
||||
//! | `at:<name>` | `.tele <name>` | SEC_TICKETMASTER 2 | 997 named rows in `game_tele` |
|
||||
//! | `at:m,x,y,z` | `.go xyz x y z m` | SEC_TICKETMASTER 2 | |
|
||||
//! | `gm:on\|off` | `.gm on\|off` | SEC_GAMEMASTER 3 | see 0649 on why `off` matters |
|
||||
//!
|
||||
//! Probe accounts are gmlevel **6**, so every one of these lands. Pass `gear:?` (or `spec:?`) to
|
||||
//! make the server *list* the templates its class has instead of applying one — the discovery path,
|
||||
//! since the catalog lives in the world DB and not in any client data.
|
||||
//!
|
||||
//! Non-combat throughout: the rig creates, configures, places and stops. It never fights, so the
|
||||
//! unattended-combat ban (method.md) is untouched.
|
||||
|
||||
use benilla_protocol::{messages, CharAction, CharCreateReq};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::{send_pick, Roster};
|
||||
use crate::net::{
|
||||
CharActionResultMessage, CharListMessage, CharPick, CharRequest, EnteredWorldMessage,
|
||||
ObjectStore, SelfPlayer,
|
||||
};
|
||||
|
||||
/// Grace after world entry before the first GM line — the descriptor and the UI VM both have to be
|
||||
/// up, and a command sent into a half-built session is silently dropped.
|
||||
const SETTLE_SECS: f32 = 3.0;
|
||||
|
||||
/// Spacing between GM lines. Each one is a server-side mutation whose result the next may depend on
|
||||
/// (level → gear → spec), and two flips inside one net drain merge to a no-op (0441's lesson).
|
||||
const STEP_SECS: f32 = 0.8;
|
||||
|
||||
/// Grace after the last GM line before reading the result back — the level-up, the equip sweep and
|
||||
/// the teleport all land as descriptor deltas a frame or two later.
|
||||
const VERIFY_SECS: f32 = 2.0;
|
||||
|
||||
pub(crate) struct ProbeRigPlugin;
|
||||
|
||||
impl Plugin for ProbeRigPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let Some(spec) = std::env::var("WOW_RIG")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(RigSpec::parse)
|
||||
else {
|
||||
return; // inert without a parseable spec (parse() has already said why)
|
||||
};
|
||||
info!("rig: {}", spec.describe());
|
||||
app.insert_resource(Rig {
|
||||
spec,
|
||||
phase: RigPhase::AwaitRoster,
|
||||
roster: Vec::new(),
|
||||
evicted: false,
|
||||
steps: Vec::new(),
|
||||
sent: 0,
|
||||
next_at: 0.0,
|
||||
})
|
||||
.add_systems(Update, drive_rig);
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed `WOW_RIG` spec. Every field is optional — the rig only touches what was asked for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
struct RigSpec {
|
||||
/// `(race id, class id, gender)` — present together or not at all; they name the character.
|
||||
body: Option<(u8, u8, u8)>,
|
||||
level: Option<u8>,
|
||||
gear: Option<String>,
|
||||
spec: Option<String>,
|
||||
/// `.tele <name>`, or `.go xyz` when the token parsed as `map,x,y,z`.
|
||||
at: Option<String>,
|
||||
gm: Option<bool>,
|
||||
/// Whether to `.learn all_myclass`. Defaults to "yes if a level was asked for" — a level-60
|
||||
/// body with a level-1 spellbook is not a level-60 body.
|
||||
learn: Option<bool>,
|
||||
}
|
||||
|
||||
impl RigSpec {
|
||||
/// Parse the whitespace-separated, order-free, case-insensitive token soup. Returns `None` (with
|
||||
/// a `warn!` naming the offending token) rather than silently rigging the wrong thing.
|
||||
fn parse(spec: &str) -> Option<Self> {
|
||||
let mut out = Self::default();
|
||||
let (mut race, mut class, mut gender) = (None, None, None);
|
||||
for tok in spec.split_whitespace() {
|
||||
let lower = tok.to_ascii_lowercase();
|
||||
if let Some((key, _)) = lower.split_once(':') {
|
||||
// The KEY is matched case-insensitively, but the VALUE is taken from the original
|
||||
// token: a premade-template name and a `game_tele` name are both looked up verbatim
|
||||
// server-side, so lowercasing them would break the lookup.
|
||||
let val = tok.split_once(':').map_or("", |(_, v)| v).to_string();
|
||||
match key {
|
||||
"gear" => out.gear = Some(val),
|
||||
"spec" => out.spec = Some(val),
|
||||
"at" => out.at = Some(val),
|
||||
"gm" => out.gm = Some(matches!(val.to_ascii_lowercase().as_str(), "on" | "1")),
|
||||
_ => {
|
||||
warn!("rig: unknown token {tok:?} — expected gear:/spec:/at:/gm:");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else if let Ok(level) = lower.parse::<u8>() {
|
||||
out.level = Some(level.clamp(1, 60));
|
||||
} else if let Some(id) = race_id(&lower) {
|
||||
race = Some(id);
|
||||
} else if let Some(id) = class_id(&lower) {
|
||||
class = Some(id);
|
||||
} else if lower == "male" || lower == "female" {
|
||||
gender = Some(u8::from(lower == "female"));
|
||||
} else if lower == "spells" || lower == "nospells" {
|
||||
out.learn = Some(lower == "spells");
|
||||
} else {
|
||||
warn!("rig: unknown token {tok:?} in WOW_RIG — nothing rigged");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
match (race, class) {
|
||||
(Some(r), Some(c)) => out.body = Some((r, c, gender.unwrap_or(0))),
|
||||
(None, None) => {
|
||||
if gender.is_some() {
|
||||
warn!(
|
||||
"rig: a gender needs a race and a class to name a character — ignoring it"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("rig: give BOTH a race and a class (they name the character), or neither");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether the class's spellbook should be filled in (`learn:` if given, else "a level implies
|
||||
/// the spells that come with it").
|
||||
fn wants_spells(&self) -> bool {
|
||||
self.learn.unwrap_or(self.level.is_some_and(|l| l > 1))
|
||||
}
|
||||
|
||||
/// The one-line echo of what was asked for — printed at startup so a mis-parse is obvious
|
||||
/// before the socket even opens.
|
||||
fn describe(&self) -> String {
|
||||
let body = self
|
||||
.body
|
||||
.map_or("this slot's probe character".into(), |(r, c, g)| {
|
||||
format!(
|
||||
"{} {} {}",
|
||||
if g == 1 { "female" } else { "male" },
|
||||
crate::ui_unit::race_names(r).map_or("?", |(d, _)| d),
|
||||
crate::ui_unit::class_names(c).map_or("?", |(d, _)| d),
|
||||
)
|
||||
});
|
||||
let mut extras = Vec::new();
|
||||
if let Some(l) = self.level {
|
||||
extras.push(format!("level {l}"));
|
||||
}
|
||||
if self.wants_spells() {
|
||||
extras.push("all class spells + talents".into());
|
||||
}
|
||||
if let Some(g) = &self.gear {
|
||||
extras.push(format!("gear {g}"));
|
||||
}
|
||||
if let Some(s) = &self.spec {
|
||||
extras.push(format!("spec {s}"));
|
||||
}
|
||||
if let Some(a) = &self.at {
|
||||
extras.push(format!("at {a}"));
|
||||
}
|
||||
if let Some(gm) = self.gm {
|
||||
extras.push(format!("GM {}", if gm { "on" } else { "off" }));
|
||||
}
|
||||
if extras.is_empty() {
|
||||
body
|
||||
} else {
|
||||
format!("{body} — {}", extras.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum RigPhase {
|
||||
/// Parked at select, waiting for a roster to look our character up in.
|
||||
AwaitRoster,
|
||||
/// Sent `Create`, waiting for its result.
|
||||
Creating,
|
||||
/// Sent `Delete` (the roster was full), waiting for its result before retrying the create.
|
||||
Deleting,
|
||||
/// Sent `Enter`, waiting to be in the world.
|
||||
Entering,
|
||||
/// In the world; the GM batch starts at this `Time::elapsed_secs`.
|
||||
Settling(f32),
|
||||
/// Sending the batch, one line per [`STEP_SECS`].
|
||||
Commanding,
|
||||
/// The batch is out; re-read the descriptor at this `Time::elapsed_secs` and report what
|
||||
/// actually landed. A command the server refused is otherwise invisible — the send always
|
||||
/// "succeeds", and the preflight banner describes the body we *arrived* in, not the rigged one.
|
||||
Verifying(f32),
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct Rig {
|
||||
spec: RigSpec,
|
||||
phase: RigPhase,
|
||||
/// The freshest roster (each successful create/delete is preceded by a new enum).
|
||||
roster: Vec<benilla_protocol::Character>,
|
||||
/// A server-limit eviction has already been spent — one is a full roster, two is a bug.
|
||||
evicted: bool,
|
||||
steps: Vec<String>,
|
||||
sent: usize,
|
||||
next_at: f32,
|
||||
}
|
||||
|
||||
/// The whole rig: find-or-create the body at select, enter as it, then apply the state batch.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drive_rig(
|
||||
mut rig: ResMut<Rig>,
|
||||
mut roster: ResMut<Roster>,
|
||||
pick: Res<CharPick>,
|
||||
time: Res<Time>,
|
||||
mut lists: MessageReader<CharListMessage>,
|
||||
mut results: MessageReader<CharActionResultMessage>,
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
) {
|
||||
if let Some(list) = lists.read().last() {
|
||||
rig.roster = list.characters.clone();
|
||||
}
|
||||
let entered_world = entered.read().next().is_some();
|
||||
|
||||
match rig.phase {
|
||||
RigPhase::AwaitRoster => {
|
||||
if rig.roster.is_empty() && rig.spec.body.is_none() {
|
||||
return; // no roster yet, and nothing to create — wait for the enum
|
||||
}
|
||||
let Some(want) = rig_char_name(&rig.spec) else {
|
||||
// No body asked for: let char_select's own WOW_CHAR/default pick stand, and just
|
||||
// configure whatever walks into the world.
|
||||
rig.phase = RigPhase::Entering;
|
||||
return;
|
||||
};
|
||||
match rig
|
||||
.roster
|
||||
.iter()
|
||||
.find(|c| c.name.eq_ignore_ascii_case(&want))
|
||||
{
|
||||
Some(c) => {
|
||||
let guid = c.guid;
|
||||
info!("rig: reusing {want} (guid {guid})");
|
||||
send_pick(&mut roster, &pick, guid);
|
||||
rig.phase = RigPhase::Entering;
|
||||
}
|
||||
None if rig.roster.is_empty() => {} // the enum hasn't landed yet
|
||||
None => {
|
||||
let (race, class, gender) = rig.spec.body.expect("name implies a body");
|
||||
info!("rig: {want} does not exist — creating it");
|
||||
let _ = pick.0.send(CharRequest::Create(CharCreateReq {
|
||||
name: want,
|
||||
race,
|
||||
class,
|
||||
gender,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
}));
|
||||
rig.phase = RigPhase::Creating;
|
||||
}
|
||||
}
|
||||
}
|
||||
RigPhase::Creating => {
|
||||
let Some(r) = results.read().find(|r| r.action == CharAction::Create) else {
|
||||
return;
|
||||
};
|
||||
match r.code {
|
||||
messages::CHAR_CREATE_SUCCESS => rig.phase = RigPhase::AwaitRoster,
|
||||
messages::CHAR_CREATE_SERVER_LIMIT if !rig.evicted => {
|
||||
rig.evicted = true;
|
||||
match crate::preflight::slot_word().and_then(|slot| {
|
||||
evictable(&rig.roster, rig_char_name(&rig.spec).as_deref(), slot)
|
||||
}) {
|
||||
Some((guid, name)) => {
|
||||
warn!(
|
||||
"rig: the account is full — evicting the cheapest rig character to rebuild, {name} \
|
||||
(guid {guid}). Only rig-named characters on this slot are ever \
|
||||
deleted; Probe<N> and hand-made characters are never touched."
|
||||
);
|
||||
let _ = pick.0.send(CharRequest::Delete(guid));
|
||||
rig.phase = RigPhase::Deleting;
|
||||
}
|
||||
None => {
|
||||
error!(
|
||||
"rig: the account is full and nothing on it is a rig character to \
|
||||
evict — delete one by hand, or rig on an existing body."
|
||||
);
|
||||
rig.phase = RigPhase::Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
code => {
|
||||
error!("rig: character create failed ({code:#04x}) — nothing rigged");
|
||||
rig.phase = RigPhase::Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
RigPhase::Deleting => {
|
||||
if results.read().any(|r| r.action == CharAction::Delete) {
|
||||
rig.phase = RigPhase::AwaitRoster;
|
||||
}
|
||||
}
|
||||
RigPhase::Entering => {
|
||||
if entered_world {
|
||||
rig.phase = RigPhase::Settling(time.elapsed_secs() + SETTLE_SECS);
|
||||
}
|
||||
}
|
||||
RigPhase::Settling(at) => {
|
||||
if time.elapsed_secs() < at {
|
||||
return;
|
||||
}
|
||||
let Ok(store) = self_q.single() else { return };
|
||||
let dead = store.0.unit_is_dead() || store.0.player_is_ghost();
|
||||
rig.steps = build_steps(&rig.spec, dead);
|
||||
if rig.steps.is_empty() {
|
||||
info!("rig: nothing to apply — the body is already what was asked for");
|
||||
rig.phase = RigPhase::Done;
|
||||
return;
|
||||
}
|
||||
rig.next_at = time.elapsed_secs();
|
||||
rig.phase = RigPhase::Commanding;
|
||||
}
|
||||
RigPhase::Commanding => {
|
||||
let Some(mut script) = script else { return };
|
||||
while rig.sent < rig.steps.len() && time.elapsed_secs() >= rig.next_at {
|
||||
let line = rig.steps[rig.sent].clone();
|
||||
info!("rig: {line}");
|
||||
script.push_chat_input(line);
|
||||
rig.sent += 1;
|
||||
rig.next_at = time.elapsed_secs() + STEP_SECS;
|
||||
}
|
||||
if rig.sent == rig.steps.len() {
|
||||
rig.phase = RigPhase::Verifying(time.elapsed_secs() + VERIFY_SECS);
|
||||
}
|
||||
}
|
||||
RigPhase::Verifying(at) => {
|
||||
if time.elapsed_secs() < at {
|
||||
return;
|
||||
}
|
||||
let Ok(store) = self_q.single() else { return };
|
||||
let equipped = (0..19)
|
||||
.filter(|&i| store.0.player_visible_item_entry(i).is_some())
|
||||
.count();
|
||||
info!(
|
||||
"rig: done — {sent} command(s), and the body now reads: level {level}, {hp}/{maxhp} hp, \
|
||||
{equipped} item(s) equipped, faction template {faction}. Re-run the same WOW_RIG to \
|
||||
get this body back.",
|
||||
sent = rig.sent,
|
||||
level = store.0.unit_level().unwrap_or(0),
|
||||
hp = store.0.unit_health().unwrap_or(0),
|
||||
maxhp = store.0.unit_max_health().unwrap_or(0),
|
||||
faction = store.0.unit_faction_template().unwrap_or(0),
|
||||
);
|
||||
// The one failure that reads as success: the batch went out, the server refused every
|
||||
// line, and the body is exactly as found. Level is the cheapest tell.
|
||||
if let Some(want) = rig.spec.level {
|
||||
let got = store.0.unit_level().unwrap_or(0);
|
||||
if got < u32::from(want) {
|
||||
warn!(
|
||||
"rig: asked for level {want} but the body is level {got} — the server \
|
||||
refused the command. Check the account's GM level (`.character level` \
|
||||
needs SEC_DEVELOPER 5): \
|
||||
SELECT gmlevel FROM realmd.account_access WHERE id = <account>."
|
||||
);
|
||||
}
|
||||
}
|
||||
rig.phase = RigPhase::Done;
|
||||
}
|
||||
RigPhase::Done => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The GM batch, in the one order that works: revive first (a ghost half-applies everything else),
|
||||
/// level before gear (a premade template only levels *up*), spells after the level they belong to,
|
||||
/// and the teleport last so a fixup lands where you asked rather than where you started.
|
||||
fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
|
||||
let mut steps = Vec::new();
|
||||
if dead {
|
||||
steps.push(".revive".into());
|
||||
}
|
||||
if let Some(gm) = spec.gm {
|
||||
steps.push(format!(".gm {}", if gm { "on" } else { "off" }));
|
||||
}
|
||||
if let Some(level) = spec.level {
|
||||
steps.push(format!(".character level {level}"));
|
||||
}
|
||||
if spec.wants_spells() {
|
||||
steps.push(".learn all_myclass".into());
|
||||
}
|
||||
for (token, verb) in [(&spec.gear, "gear"), (&spec.spec, "spec")] {
|
||||
if let Some(t) = token {
|
||||
// `?` asks the server to LIST this class's templates instead of applying one — the
|
||||
// catalog lives in the world DB, so the server is the only thing that can enumerate it.
|
||||
let arg = if t == "?" { "" } else { t.as_str() };
|
||||
steps.push(format!(".character premade {verb} {arg}").trim_end().into());
|
||||
}
|
||||
}
|
||||
if let Some(at) = &spec.at {
|
||||
steps.push(match parse_point(at) {
|
||||
Some((map, x, y, z)) => format!(".go xyz {x} {y} {z} {map}"),
|
||||
None => format!(".tele {at}"),
|
||||
});
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// `map,x,y,z` → the `.go xyz` argument order (`x y z map`). Anything else is a `game_tele` name.
|
||||
fn parse_point(at: &str) -> Option<(i32, f32, f32, f32)> {
|
||||
let mut parts = at.split(',').map(str::trim);
|
||||
let map = parts.next()?.parse().ok()?;
|
||||
let (x, y, z) = (
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
);
|
||||
parts.next().is_none().then_some((map, x, y, z))
|
||||
}
|
||||
|
||||
/// The character a `WOW_RIG` body maps to on this slot: `<Race3><Class3><slot-word>[f]`. `None` when
|
||||
/// the spec named no body (the rig then configures whatever the normal pick logs in as), or when the
|
||||
/// build is outside a pool slot (there is no slot identity to key a name to).
|
||||
pub(crate) fn rig_char_name_from_env() -> Option<String> {
|
||||
rig_char_name(&RigSpec::parse(&std::env::var("WOW_RIG").ok()?)?)
|
||||
}
|
||||
|
||||
fn rig_char_name(spec: &RigSpec) -> Option<String> {
|
||||
let (race, class, gender) = spec.body?;
|
||||
let slot = crate::preflight::slot_word()?;
|
||||
let name = format!(
|
||||
"{}{}{slot}{}",
|
||||
race_code(race)?,
|
||||
class_code(class)?,
|
||||
if gender == 1 { "f" } else { "" }
|
||||
);
|
||||
// vmangos caps player names at 12 (`MAX_PLAYER_NAME`); the longest this can build is
|
||||
// `Nel` + `Wlk` + `three` + `f` = 12. Normalized like the server does: leading capital, rest
|
||||
// lower.
|
||||
let mut chars = name.chars();
|
||||
Some(chars.next()?.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The rig-named character on this slot that costs least to lose: **lowest level first**, ties
|
||||
/// broken by oldest (the enum is ordered by `create_time` — vmangos `HandleCharEnumOpcode` — so an
|
||||
/// earlier index *is* older). Level is the proxy for invested setup: a level-1 body is 15 seconds to
|
||||
/// rebuild, a geared 60 is a minute and a talent tree. Evicting by age alone would throw away the
|
||||
/// most valuable body on the account first, which the live fill test made obvious.
|
||||
///
|
||||
/// Anything that is not a rig name for `slot` — `Probe<N>`, a hand-made character, another slot's
|
||||
/// leftovers — is invisible here, and that is what makes automatic deletion safe.
|
||||
///
|
||||
/// `slot` is a **parameter, not a `slot_word()` call inside**: reading the ambient slot here made
|
||||
/// the eviction test pass only in the worktree it was written in (`pool-1`, whose rig names end
|
||||
/// `…one`) and fail in every other slot — and in the primary checkout, where `slot_word()` is
|
||||
/// `None` and nothing matches at all. A unit test's answer must not depend on which directory the
|
||||
/// build happened in.
|
||||
fn evictable(
|
||||
roster: &[benilla_protocol::Character],
|
||||
want: Option<&str>,
|
||||
slot: &str,
|
||||
) -> Option<(u64, String)> {
|
||||
roster
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
!want.is_some_and(|w| c.name.eq_ignore_ascii_case(w)) && is_rig_name(&c.name, slot)
|
||||
})
|
||||
.min_by_key(|(age, c)| (c.level, *age))
|
||||
.map(|(_, c)| (c.guid, c.name.clone()))
|
||||
}
|
||||
|
||||
/// Whether a roster name was minted by [`rig_char_name`] for this slot: a known race code, a known
|
||||
/// class code, this slot's word, and nothing but an optional `f` after it.
|
||||
fn is_rig_name(name: &str, slot: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let Some(rest) = lower.get(3..).and_then(|r| r.get(3..)) else {
|
||||
return false;
|
||||
};
|
||||
let has_codes = RACE_CODES.iter().any(|(_, c)| lower.starts_with(c))
|
||||
&& CLASS_CODES.iter().any(|(_, c)| lower[3..].starts_with(c));
|
||||
has_codes && (rest == slot || rest == format!("{slot}f"))
|
||||
}
|
||||
|
||||
/// Race id → the 3-letter name code. `Nel`/`Und` rather than the DBC's own prefixes: unambiguous,
|
||||
/// and readable in a roster line.
|
||||
const RACE_CODES: [(u8, &str); 8] = [
|
||||
(1, "hum"),
|
||||
(2, "orc"),
|
||||
(3, "dwa"),
|
||||
(4, "nel"),
|
||||
(5, "und"),
|
||||
(6, "tau"),
|
||||
(7, "gno"),
|
||||
(8, "tro"),
|
||||
];
|
||||
|
||||
/// Class id → the 3-letter name code (`wlk` for warlock — `war` is already the warrior's).
|
||||
const CLASS_CODES: [(u8, &str); 9] = [
|
||||
(1, "war"),
|
||||
(2, "pal"),
|
||||
(3, "hun"),
|
||||
(4, "rog"),
|
||||
(5, "pri"),
|
||||
(7, "sha"),
|
||||
(8, "mag"),
|
||||
(9, "wlk"),
|
||||
(11, "dru"),
|
||||
];
|
||||
|
||||
fn race_code(id: u8) -> Option<&'static str> {
|
||||
RACE_CODES.iter().find(|(i, _)| *i == id).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
fn class_code(id: u8) -> Option<&'static str> {
|
||||
CLASS_CODES.iter().find(|(i, _)| *i == id).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
/// Spec word → `ChrRaces.dbc` id. Both the one-word and the two-word spellings, because a spec is
|
||||
/// typed by hand and `nightelf`/`night-elf` are the same intent.
|
||||
fn race_id(word: &str) -> Option<u8> {
|
||||
Some(match word {
|
||||
"human" => 1,
|
||||
"orc" => 2,
|
||||
"dwarf" => 3,
|
||||
"nightelf" | "night-elf" | "nelf" => 4,
|
||||
"undead" | "scourge" | "forsaken" => 5,
|
||||
"tauren" => 6,
|
||||
"gnome" => 7,
|
||||
"troll" => 8,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spec word → `ChrClasses.dbc` id (6 and 10 are unused in 1.12).
|
||||
fn class_id(word: &str) -> Option<u8> {
|
||||
Some(match word {
|
||||
"warrior" => 1,
|
||||
"paladin" => 2,
|
||||
"hunter" => 3,
|
||||
"rogue" => 4,
|
||||
"priest" => 5,
|
||||
"shaman" => 7,
|
||||
"mage" => 8,
|
||||
"warlock" => 9,
|
||||
"druid" => 11,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_body_spec_parses_in_any_order() {
|
||||
let a = RigSpec::parse("tauren druid 60 gear:heal-preraid-bis").unwrap();
|
||||
let b = RigSpec::parse("gear:heal-preraid-bis 60 druid tauren").unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.body, Some((6, 11, 0)));
|
||||
assert_eq!(a.level, Some(60));
|
||||
assert_eq!(a.gear.as_deref(), Some("heal-preraid-bis"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_names_keep_their_case() {
|
||||
// The token soup is matched case-insensitively, but a premade template name and a
|
||||
// `game_tele` name are looked up VERBATIM server-side — lowercasing them would break both.
|
||||
let s = RigSpec::parse("at:ThunderBluff gear:DPS-PreRaid-BiS").unwrap();
|
||||
assert_eq!(s.at.as_deref(), Some("ThunderBluff"));
|
||||
assert_eq!(s.gear.as_deref(), Some("DPS-PreRaid-BiS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_named_body_is_refused_rather_than_guessed() {
|
||||
// A race with no class (or the reverse) cannot name a character, and guessing the other
|
||||
// half would silently rig the wrong body.
|
||||
assert!(RigSpec::parse("tauren 60").is_none());
|
||||
assert!(RigSpec::parse("druid").is_none());
|
||||
assert!(RigSpec::parse("tauren druid").is_some());
|
||||
// No body at all is legal — that rigs whatever already logs in.
|
||||
assert_eq!(RigSpec::parse("60 gear:x").unwrap().body, None);
|
||||
// A typo is refused, never ignored.
|
||||
assert!(RigSpec::parse("taruen druid").is_none());
|
||||
assert!(RigSpec::parse("tauren druid lvl:60").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spells_follow_the_level_unless_asked_otherwise() {
|
||||
assert!(!RigSpec::parse("tauren druid").unwrap().wants_spells());
|
||||
assert!(RigSpec::parse("tauren druid 60").unwrap().wants_spells());
|
||||
assert!(!RigSpec::parse("tauren druid 60 nospells")
|
||||
.unwrap()
|
||||
.wants_spells());
|
||||
assert!(RigSpec::parse("tauren druid spells")
|
||||
.unwrap()
|
||||
.wants_spells());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_runs_in_the_order_that_works() {
|
||||
let spec = RigSpec::parse("tauren druid 60 gear:g spec:s at:ThunderBluff gm:off").unwrap();
|
||||
assert_eq!(
|
||||
build_steps(&spec, true),
|
||||
vec![
|
||||
".revive",
|
||||
".gm off",
|
||||
".character level 60",
|
||||
".learn all_myclass",
|
||||
".character premade gear g",
|
||||
".character premade spec s",
|
||||
".tele ThunderBluff",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_body_is_revived_even_when_nothing_was_asked_for() {
|
||||
let spec = RigSpec::parse("tauren druid").unwrap();
|
||||
assert_eq!(build_steps(&spec, true), vec![".revive"]);
|
||||
assert!(build_steps(&spec, false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_goes_to_go_xyz_and_a_name_goes_to_tele() {
|
||||
let point = RigSpec::parse("at:1,-1277.5,124.0,131.2").unwrap();
|
||||
assert_eq!(
|
||||
build_steps(&point, false),
|
||||
vec![".go xyz -1277.5 124 131.2 1"]
|
||||
);
|
||||
assert_eq!(parse_point("ThunderBluff"), None);
|
||||
assert_eq!(parse_point("1,2,3"), None); // three numbers is not a map + point
|
||||
assert_eq!(parse_point("1,2,3,4,5"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_template_list_is_reachable_through_the_spec() {
|
||||
let spec = RigSpec::parse("gear:?").unwrap();
|
||||
assert_eq!(build_steps(&spec, false), vec![".character premade gear"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_derived_name_is_deterministic_and_fits_the_server_limit() {
|
||||
// `<Race3><Class3><slot-word>[f]`, normalized the way vmangos normalizes a player name.
|
||||
let name = |spec: &str, slot: &str| {
|
||||
let s = RigSpec::parse(spec).unwrap();
|
||||
let (race, class, gender) = s.body.unwrap();
|
||||
format!(
|
||||
"{}{}{slot}{}",
|
||||
race_code(race).unwrap(),
|
||||
class_code(class).unwrap(),
|
||||
if gender == 1 { "f" } else { "" }
|
||||
)
|
||||
};
|
||||
assert_eq!(name("tauren druid", "one"), "taudruone");
|
||||
assert_eq!(name("nightelf warlock female", "three"), "nelwlkthreef");
|
||||
// The longest name this scheme can mint is exactly vmangos's MAX_PLAYER_NAME of 12 — a
|
||||
// 13th character would be refused by the server as an invalid name, and the rig would
|
||||
// recreate it every run.
|
||||
const MAX_PLAYER_NAME: usize = 12;
|
||||
let longest = RACE_CODES
|
||||
.iter()
|
||||
.flat_map(|(_, rc)| {
|
||||
CLASS_CODES.iter().flat_map(move |(_, cc)| {
|
||||
[
|
||||
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
|
||||
"nine",
|
||||
]
|
||||
.iter()
|
||||
.map(move |slot| format!("{rc}{cc}{slot}f").len())
|
||||
})
|
||||
})
|
||||
.max()
|
||||
.unwrap();
|
||||
assert_eq!(longest, MAX_PLAYER_NAME);
|
||||
}
|
||||
|
||||
/// A roster row with just the fields eviction reads.
|
||||
fn row(name: &str, level: u8, guid: u64) -> benilla_protocol::Character {
|
||||
benilla_protocol::Character {
|
||||
guid,
|
||||
name: name.into(),
|
||||
level,
|
||||
race: 0,
|
||||
class: 0,
|
||||
gender: 0,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
zone: 0,
|
||||
map: 0,
|
||||
position: benilla_protocol::wire::Vector3d {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
flags: 0,
|
||||
equipment: [Default::default(); 19],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_spends_the_body_that_is_cheapest_to_rebuild() {
|
||||
// The slot is passed in, never read from the ambient build directory — these names are
|
||||
// pool-1's, and taking the slot from `slot_word()` made this test pass in pool-1 and fail
|
||||
// in every other worktree (and in the primary checkout, where it is `None`).
|
||||
let slot = "one";
|
||||
// Roster order IS create order (vmangos enumerates by `create_time`).
|
||||
let roster = [
|
||||
row("Probeone", 60, 1), // the identity — never evictable
|
||||
row("Watcher", 60, 2), // hand-made — never evictable
|
||||
row("Taudruone", 60, 3), // oldest rig body, but a geared 60
|
||||
row("Orcwarone", 1, 4), // a level-1 filler: the cheapest to lose
|
||||
row("Undmagone", 1, 5), // same level, but younger — the tie goes to the older
|
||||
row("Nelwlkonef", 40, 6),
|
||||
];
|
||||
assert_eq!(
|
||||
evictable(&roster, Some("Tauwarone"), slot),
|
||||
Some((4, "Orcwarone".into())),
|
||||
);
|
||||
// The body we are about to create is never the one we delete to make room for it.
|
||||
assert_eq!(
|
||||
evictable(&roster, Some("Orcwarone"), slot),
|
||||
Some((5, "Undmagone".into())),
|
||||
);
|
||||
// Nothing rig-named on this slot ⇒ nothing to evict; the caller errors rather than guessing.
|
||||
assert_eq!(evictable(&roster[..2], None, slot), None);
|
||||
// And another slot's leftovers are invisible: the same roster, read as pool-3, evicts
|
||||
// nothing. This is the assertion that would have caught the ambient-slot read.
|
||||
assert_eq!(evictable(&roster, None, "three"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_only_ever_sees_rig_characters() {
|
||||
assert!(is_rig_name("Taudruone", "one"));
|
||||
assert!(is_rig_name("Nelwlkthreef", "three"));
|
||||
// The identity character, a human-made character, and another slot's rig: all invisible.
|
||||
assert!(!is_rig_name("Probeone", "one"));
|
||||
assert!(!is_rig_name("Watcher", "one"));
|
||||
assert!(!is_rig_name("Taudruthree", "one"));
|
||||
assert!(!is_rig_name("Taudru", "one"));
|
||||
assert!(!is_rig_name("Taudruoneextra", "one"));
|
||||
}
|
||||
}
|
||||
@@ -167,12 +167,14 @@ fn taxi_probe(
|
||||
Phase::Wait => {
|
||||
let [x, y, z] = FLIGHTMASTER_AT;
|
||||
info!("PROBE taxi: hopping to the Stormwind flight master at ({x}, {y}, {z})");
|
||||
// The fare cannot be granted from here: `.modify money` is SEC_BASIC_ADMIN (4) and
|
||||
// the probe accounts are gmlevel 3 (two probe runs bounced on NOT_ENOUGH_MONEY
|
||||
// before this was traced). Every probe character's fare is seeded offline in
|
||||
// `characters.money` (100000 copper; a flight spends ~110) — re-seed via
|
||||
// `UPDATE characters SET money=100000 WHERE name LIKE 'Probe%' AND online=0;` if a
|
||||
// NOT_ENOUGH_MONEY reply ever shows up again.
|
||||
// The fare is seeded offline in `characters.money` (100000 copper; a flight spends
|
||||
// ~110) — re-seed via `UPDATE characters SET money=100000 WHERE name LIKE 'Probe%'
|
||||
// AND online=0;` if a NOT_ENOUGH_MONEY reply ever shows up. The seed used to be the
|
||||
// *only* option: `.modify money` is SEC_BASIC_ADMIN(4) and the probe accounts were
|
||||
// gmlevel 3 (two runs bounced on NOT_ENOUGH_MONEY before that was traced). Since they
|
||||
// were actually raised to 6 (0651) this probe could grant its own fare in-band the way
|
||||
// `probe_bank` now does — left alone because the offline seed works and is one fewer
|
||||
// command inside the flight window.
|
||||
let _ = net
|
||||
.0
|
||||
.send(ClientCommand::SetSelection { guid: self_guid.0 });
|
||||
|
||||
@@ -16,6 +16,18 @@ use super::PROBE_WARMUP_FRAMES;
|
||||
/// server-less harness deliberately excludes). The app keeps running after the save; pair with
|
||||
/// `WOW_USER`/`WOW_CHAR` and an outer `timeout` for unattended probes. Unlike
|
||||
/// [`super::CapturePlugin`], nothing is pinned — the shot shows the scene as a player would see it.
|
||||
///
|
||||
/// **`WOW_LIVE_SHOT_COUNT=<n>` makes it a burst** — `n` shots written as `<stem>-000.png`,
|
||||
/// `<stem>-001.png`, … , spaced by `WOW_LIVE_SHOT_EVERY` seconds (**default 0 = one per frame**,
|
||||
/// i.e. *adjacent* frames). One shot still writes the bare path, so every existing invocation is
|
||||
/// unchanged.
|
||||
///
|
||||
/// The burst exists because **a flicker is a temporal artefact and a single frame cannot show one**
|
||||
/// (decision 0653). "This object's textures flicker" was un-diagnosable with the instruments we had:
|
||||
/// the reporter's stills prove nothing, and grading a live run by eye is exactly the loop rule 7
|
||||
/// forbids. Adjacent frames from a *parked* camera ([`crate::player`]'s `WOW_PROBE_CAM`) turn it
|
||||
/// into arithmetic: `benilla-visual flicker <dir>` collapses the burst to the envelope of what
|
||||
/// would not hold still, and the lit pixels are the defect.
|
||||
pub(crate) struct LiveShotPlugin;
|
||||
|
||||
impl Plugin for LiveShotPlugin {
|
||||
@@ -25,38 +37,72 @@ impl Plugin for LiveShotPlugin {
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(12.0);
|
||||
let count = std::env::var("WOW_LIVE_SHOT_COUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1u32)
|
||||
.max(1);
|
||||
let every = std::env::var("WOW_LIVE_SHOT_EVERY")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
app.insert_resource(LiveShot {
|
||||
out,
|
||||
at,
|
||||
fired: false,
|
||||
count,
|
||||
every,
|
||||
taken: 0,
|
||||
next_at: at,
|
||||
})
|
||||
.add_systems(Update, fire_live_shot);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LiveShotPlugin`] state: the output path, the fire time, and the once-latch.
|
||||
/// [`LiveShotPlugin`] state: the output path, how many shots the burst wants, their spacing
|
||||
/// (`0` = one per frame), how many have gone out, and when the next one may — seeded to
|
||||
/// `WOW_LIVE_SHOT_AT`, so the first-fire delay and the burst spacing are the same clock.
|
||||
#[derive(Resource)]
|
||||
struct LiveShot {
|
||||
out: String,
|
||||
at: f32,
|
||||
fired: bool,
|
||||
count: u32,
|
||||
every: f32,
|
||||
taken: u32,
|
||||
next_at: f32,
|
||||
}
|
||||
|
||||
/// Fire the one live screenshot once the startup delay has elapsed.
|
||||
/// `stem-007.png` for shot 7 of a burst — the bare path when the burst is one shot, so the
|
||||
/// single-shot filename (which existing probes and docs name literally) never moves.
|
||||
fn burst_path(out: &str, index: u32, count: u32) -> String {
|
||||
if count <= 1 {
|
||||
return out.to_string();
|
||||
}
|
||||
match out.rsplit_once('.') {
|
||||
Some((stem, ext)) => format!("{stem}-{index:03}.{ext}"),
|
||||
None => format!("{out}-{index:03}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the live screenshot(s) once the startup delay has elapsed — one per frame by default, so a
|
||||
/// burst samples *adjacent* frames and a per-frame instability has nowhere to hide.
|
||||
fn fire_live_shot(mut shot: ResMut<LiveShot>, time: Res<Time>, mut commands: Commands) {
|
||||
if shot.fired || time.elapsed_secs() < shot.at {
|
||||
if shot.taken >= shot.count || time.elapsed_secs() < shot.next_at {
|
||||
return;
|
||||
}
|
||||
shot.fired = true;
|
||||
let path = burst_path(&shot.out, shot.taken, shot.count);
|
||||
commands
|
||||
.spawn(Screenshot::primary_window())
|
||||
.observe(save_to_disk(shot.out.clone()));
|
||||
info!("live-shot: writing {}", shot.out);
|
||||
.observe(save_to_disk(path.clone()));
|
||||
shot.taken += 1;
|
||||
shot.next_at = time.elapsed_secs() + shot.every;
|
||||
if shot.count > 1 {
|
||||
info!("live-shot: writing {path} ({}/{})", shot.taken, shot.count);
|
||||
} else {
|
||||
info!("live-shot: writing {path}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The PROBE CHAT one-shot (`WOW_PROBE_CHAT="<line>[;<line>…]"`, delay via `WOW_PROBE_CHAT_AT`
|
||||
/// seconds, default 8): send each `;`-separated line as Say once we are in-world — the "park the
|
||||
/// probe character anywhere" instrument. The probe account (gmlevel 4) makes `.go xyz …`, `.gm on`,
|
||||
/// probe character anywhere" instrument. The probe account (gmlevel 6) makes `.go xyz …`, `.gm on`,
|
||||
/// `.additem` etc. work headlessly, which a direct `characters` DB edit does NOT (the live world
|
||||
/// server's logout save overwrites it, and the row is only re-read at login). Pair with
|
||||
/// [`LiveShotPlugin`] at a later `WOW_LIVE_SHOT_AT` so the destination has streamed in.
|
||||
@@ -97,11 +143,19 @@ struct ProbeChat {
|
||||
sent: usize,
|
||||
}
|
||||
|
||||
/// The PROBE KEY one-shots (`WOW_PROBE_KEY="<key>@<secs>[;<key>@<secs>…]"`): synthesize a key
|
||||
/// TAP — press one frame, release [`PROBE_KEY_TAP_SECS`] later — at each given time once
|
||||
/// in-world. The "press space headlessly" instrument for input-gated behavior (the mounted
|
||||
/// flourish, a jump, the X/Z toggles), which neither a chat command nor a Lua chunk can reach
|
||||
/// (1.12 has no jump Lua API; the gate lives in the controller's key read). Runs in `PreUpdate`
|
||||
/// The PROBE KEY one-shots (`WOW_PROBE_KEY="<key>@<secs>[:<hold>][;…]"`): synthesize a key press
|
||||
/// at each given time once in-world, released `<hold>` seconds later ([`PROBE_KEY_TAP_SECS`] when
|
||||
/// omitted — the tap this instrument shipped with). The "press space headlessly" instrument for
|
||||
/// input-gated behavior (the mounted flourish, a jump, the X/Z toggles), which neither a chat
|
||||
/// command nor a Lua chunk can reach (1.12 has no jump Lua API; the gate lives in the
|
||||
/// controller's key read).
|
||||
///
|
||||
/// The optional hold is what makes *sustained* locomotion reachable headlessly: a 0.25 s W tap
|
||||
/// travels ~1.2 yd, far too little to cross a liquid surface's own slope, so a swim defect that
|
||||
/// only appears while moving over water could not be reproduced without asking the director to
|
||||
/// drive (decision 0644 — the gap `WOW_PROBE_LOOK` closed for mouse-turns, on the key side).
|
||||
///
|
||||
/// Runs in `PreUpdate`
|
||||
/// after winit's input processing ([`bevy::input::InputSystems`]) so the synthetic
|
||||
/// `just_pressed` is visible to every `Update` reader that same frame — a press from inside
|
||||
/// `Update` would be cleared at the next frame's input pass before an earlier-ordered
|
||||
@@ -116,16 +170,21 @@ impl Plugin for ProbeKeyPlugin {
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| {
|
||||
let (key, at) = s.split_once('@')?;
|
||||
let (key, rest) = s.split_once('@')?;
|
||||
let (at, hold) = match rest.split_once(':') {
|
||||
Some((at, hold)) => (at, hold.trim().parse::<f32>().ok()?),
|
||||
None => (rest, PROBE_KEY_TAP_SECS),
|
||||
};
|
||||
match (probe_key_by_name(key.trim()), at.trim().parse::<f32>()) {
|
||||
(Some(key), Ok(at)) => Some(ProbeKeyTap {
|
||||
key,
|
||||
at,
|
||||
hold,
|
||||
pressed: false,
|
||||
released: false,
|
||||
}),
|
||||
_ => {
|
||||
warn!("probe-key: unparseable tap {s:?} (want e.g. Space@14) — skipped");
|
||||
warn!("probe-key: unparseable tap {s:?} (want e.g. Space@14 or W@20:6) — skipped");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -138,8 +197,8 @@ impl Plugin for ProbeKeyPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a probe tap stays held. Long enough that a `pressed`-reader (a held-key gate)
|
||||
/// sees it across several frames; short enough to stay a tap, not a hold.
|
||||
/// How long a probe press stays held when the spec gives no `:<hold>`. Long enough that a
|
||||
/// `pressed`-reader (a held-key gate) sees it across several frames; short enough to stay a tap.
|
||||
const PROBE_KEY_TAP_SECS: f32 = 0.25;
|
||||
|
||||
/// The key names [`ProbeKeyPlugin`] accepts — the controller-read set; extend as probes need.
|
||||
@@ -168,11 +227,13 @@ struct ProbeKeys {
|
||||
struct ProbeKeyTap {
|
||||
key: KeyCode,
|
||||
at: f32,
|
||||
/// Seconds the key stays down — the spec's `:<hold>`, else [`PROBE_KEY_TAP_SECS`].
|
||||
hold: f32,
|
||||
pressed: bool,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
/// Press each due tap (in-world gated, like the chat probe) and release it after the tap window.
|
||||
/// Press each due tap (in-world gated, like the chat probe) and release it after its hold window.
|
||||
fn fire_probe_key(
|
||||
mut probe: ResMut<ProbeKeys>,
|
||||
time: Res<Time>,
|
||||
@@ -185,10 +246,13 @@ fn fire_probe_key(
|
||||
let now = time.elapsed_secs();
|
||||
for tap in &mut probe.taps {
|
||||
if !tap.pressed && now >= tap.at {
|
||||
info!("probe-key: {:?} down ({now:.1}s)", tap.key);
|
||||
info!(
|
||||
"probe-key: {:?} down ({now:.1}s, hold {:.2}s)",
|
||||
tap.key, tap.hold
|
||||
);
|
||||
keys.press(tap.key);
|
||||
tap.pressed = true;
|
||||
} else if tap.pressed && !tap.released && now >= tap.at + PROBE_KEY_TAP_SECS {
|
||||
} else if tap.pressed && !tap.released && now >= tap.at + tap.hold {
|
||||
keys.release(tap.key);
|
||||
tap.released = true;
|
||||
}
|
||||
@@ -367,12 +431,18 @@ fn fire_probe_lua(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the probe lines once the delay has elapsed AND the session is in-world (the self player
|
||||
/// Submit the probe lines once the delay has elapsed AND the session is in-world (the self player
|
||||
/// exists) — a `.go` sent before world-enter would be dropped server-side.
|
||||
///
|
||||
/// Lines go in through the **chat EditBox seam**, not straight to the wire: a probe line is "what
|
||||
/// the director would type", so a client-side slash command (`/duel`, `/reaction`) is parsed by
|
||||
/// the same drain that serves the real chat box, while plain text and `.gm`/`.go` server commands
|
||||
/// still leave as Say exactly as before. Sending them as Say instead — the original shape — meant
|
||||
/// every client-side command silently went out as public chat and did nothing (decision 0637).
|
||||
fn fire_probe_chat(
|
||||
mut probe: ResMut<ProbeChat>,
|
||||
time: Res<Time>,
|
||||
net: Option<Res<crate::net::NetCommands>>,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
self_player: Query<(), With<crate::net::SelfPlayer>>,
|
||||
) {
|
||||
if probe.lines.is_empty() {
|
||||
@@ -381,7 +451,7 @@ fn fire_probe_chat(
|
||||
if self_player.is_empty() {
|
||||
return; // not in-world yet — keep waiting past the delay
|
||||
}
|
||||
let Some(net) = net else {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// With no spacing every line goes in the first eligible frame (the original burst); with
|
||||
@@ -401,11 +471,7 @@ fn fire_probe_chat(
|
||||
return;
|
||||
}
|
||||
info!("probe-chat: sending {line:?}");
|
||||
let _ = net.0.send(crate::net::ClientCommand::Chat {
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: line.to_string(),
|
||||
});
|
||||
script.push_chat_input(line.to_string());
|
||||
probe.sent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,10 @@ impl Roster {
|
||||
}
|
||||
|
||||
/// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending.
|
||||
fn send_pick(roster: &mut Roster, pick: &CharPick, guid: u64) {
|
||||
/// `pub(crate)` for the rig ([`crate::capture::ProbeRigPlugin`]), which picks a character it may
|
||||
/// have had to *create* first — going through here is what keeps `pending_pick` truthful, so 0065's
|
||||
/// reconnect re-answers with the rigged body rather than showing the roster.
|
||||
pub(crate) fn send_pick(roster: &mut Roster, pick: &CharPick, guid: u64) {
|
||||
roster.pending_pick = Some(guid);
|
||||
let _ = pick.0.send(CharRequest::Enter(guid));
|
||||
}
|
||||
@@ -160,7 +163,18 @@ fn apply_roster_policy(
|
||||
) {
|
||||
if !roster.env_read {
|
||||
roster.env_read = true;
|
||||
roster.env_char = std::env::var("WOW_CHAR").ok();
|
||||
// `WOW_RIG` with a body outranks `WOW_CHAR`: the rig picks its own derived character, and
|
||||
// it may have to CREATE it first, which this fast path (a one-shot `take()` that gives up
|
||||
// on a name it can't find) structurally cannot wait for.
|
||||
roster.env_char = match crate::capture::rig_char_name_from_env() {
|
||||
Some(name) => {
|
||||
if let Ok(ignored) = std::env::var("WOW_CHAR") {
|
||||
warn!("char select: WOW_RIG names {name} — ignoring WOW_CHAR={ignored}");
|
||||
}
|
||||
None
|
||||
}
|
||||
None => std::env::var("WOW_CHAR").ok(),
|
||||
};
|
||||
}
|
||||
for msg in msgs.read() {
|
||||
roster.chars = msg.characters.clone();
|
||||
|
||||
@@ -350,8 +350,10 @@ fn debug_panel_ui(
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
// Leave room under the scroll for the pinned hotkey footer below.
|
||||
.max_height(panel_h - 24.0)
|
||||
// Leave room under the scroll for the pinned hotkey footer below. Two lines' worth:
|
||||
// the footer keeps the panel's default wrapping, so a font whose metrics run wider
|
||||
// than ours reflows it instead of having its second line clipped away.
|
||||
.max_height(panel_h - 40.0)
|
||||
.show(ui, |ui| {
|
||||
// Disjoint borrows of the sections so each can drive its own widgets.
|
||||
let DebugState {
|
||||
@@ -552,7 +554,7 @@ fn debug_panel_ui(
|
||||
}
|
||||
});
|
||||
|
||||
// The object inspector is its own I-toggled surface (see `interact.rs`), not a
|
||||
// The object inspector is its own Ctrl+Cmd+I surface (see `interact.rs`), not a
|
||||
// section here — identifying a thing shouldn't need the whole panel open.
|
||||
egui::CollapsingHeader::new("Particles")
|
||||
.default_open(true)
|
||||
@@ -573,7 +575,7 @@ fn debug_panel_ui(
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
ui.checkbox(&mut sound_cfg.enabled, "enabled");
|
||||
ui.checkbox(&mut sound_cfg.muted, "muted (⌃⌘M)");
|
||||
ui.checkbox(&mut sound_cfg.muted, "muted (Ctrl+Cmd+M)");
|
||||
ui.add(
|
||||
egui::Slider::new(&mut sound_cfg.master, 0.0..=1.0).text("master"),
|
||||
);
|
||||
@@ -644,7 +646,7 @@ fn debug_panel_ui(
|
||||
// discoverable from the one surface people find first.
|
||||
ui.separator();
|
||||
ui.label(
|
||||
egui::RichText::new("` panel · P perf · I inspect")
|
||||
egui::RichText::new("` panel · Ctrl+Cmd: P perf · I inspect · M mute")
|
||||
.small()
|
||||
.color(OVERLAY_TEXT_DIM),
|
||||
);
|
||||
|
||||
@@ -69,6 +69,12 @@ use mount::refresh_mounts;
|
||||
/// node its root bones parent under.
|
||||
mod conform;
|
||||
|
||||
/// The per-unit collision height (decision 0645): the display id's `CreatureModelData` column,
|
||||
/// scaled — the `h` the swim/wade/splash/foam depth lines are each a verified fraction of.
|
||||
mod collision_height;
|
||||
use collision_height::stamp_collision_heights;
|
||||
pub(crate) use collision_height::CollisionHeight;
|
||||
|
||||
/// Spell-visual effect models (decision 0099 phase 3): a casting unit's attach-point `.mdx` glows,
|
||||
/// spawned under the same attach-point joints as held items, lifetime per the kit stage.
|
||||
mod missile;
|
||||
@@ -161,6 +167,12 @@ pub(crate) struct Creatures {
|
||||
}
|
||||
|
||||
impl Creatures {
|
||||
/// A display's collision height in **raw model units** — see [`CollisionHeight`] for the world
|
||||
/// value and everything that reads it. `None` for an unknown display.
|
||||
pub(crate) fn collision_height(&self, display_id: u32) -> Option<f32> {
|
||||
self.catalog.collision_height(display_id)
|
||||
}
|
||||
|
||||
/// A display's resolved blood id (decision 0137 phase 3 — see [`CreatureModel::blood`]):
|
||||
/// the UnitBloodLevels key the melee spurt chain starts from; `None` = unknown display.
|
||||
pub(crate) fn blood(&self, display_id: u32) -> Option<i32> {
|
||||
@@ -293,6 +305,11 @@ impl Plugin for EntitiesPlugin {
|
||||
// The projectile flight-loop edges (`crate::sound::missile` consumes them).
|
||||
.add_message::<MissileSound>()
|
||||
.add_systems(Startup, setup_entities.after(AssetSet::Open))
|
||||
// Every streamed unit's collision height, the frame after `apply_net_updates` spawns it
|
||||
// (that stage's Commands are what create the entity, so this cannot be earlier). Its
|
||||
// consumers all read `Option<&CollisionHeight>` against the ctor default, so a unit's
|
||||
// first frame simply uses that — see `CollisionHeight`.
|
||||
.add_systems(Update, stamp_collision_heights.after(WorldStage::Net))
|
||||
// Resolve/build display models before attaching, so a model is ready the frame attach wants it.
|
||||
// **After `WorldStage::Net`**: `apply_net_updates` spawns the entities (via Commands), so
|
||||
// these must run *after* that stage — with the sync point it forces — or they'd attach an
|
||||
|
||||
@@ -12,6 +12,8 @@ use benilla_assets::{bone_target_id, ModelSkeleton};
|
||||
use benilla_formats::CharSkinSlot;
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::animation::AnimatedBy;
|
||||
use bevy::camera::primitives::MeshAabb;
|
||||
use bevy::camera::visibility::NoFrustumCulling;
|
||||
use bevy::mesh::skinning::{SkinnedMesh, SkinnedMeshInverseBindposes};
|
||||
use bevy::mesh::MeshTag;
|
||||
use bevy::prelude::*;
|
||||
@@ -51,11 +53,17 @@ pub(super) use glue_preview::build_glue_preview;
|
||||
/// * a **state GameObject** (door/button/chest — `go_state_machine`) — the open/close state machine
|
||||
/// (`GoAnim`), which cross-fades transitions through `AnimationTransitions`;
|
||||
/// * **any other animated GameObject** (a mailbox's wind-swung flags, a banner) — *no* controller:
|
||||
/// the reference arms the model's FIRST sequence and loops it forever, the universal "loader-idle
|
||||
/// seed" (wow-re `system/animation/scratch/doodad-anim-host.md` §1 — a non-transport CGGameObject
|
||||
/// animates identically to a placed doodad; "the animation HOST does not differ"). We arm it here
|
||||
/// and let the kernel's modulo-wrap run it, ungated like the creature path (GOs are few — the
|
||||
/// doodad host's draw-gate isn't warranted for them).
|
||||
/// the reference arms the model's **Stand** sequence (animation id 0, resolved through the
|
||||
/// model's own `playableAnimationLookup`) as the universal "loader-idle seed", and loops it iff
|
||||
/// `M2Sequence.flags & 1 == 0` (wow-re `gameobject-anim-arm.md` §1/§2, byte-verified `0x71019b`
|
||||
/// / `0x714585` — decision 0637, which corrected the earlier "arms the FIRST sequence" reading
|
||||
/// this comment used to carry). We arm it here and let the kernel's modulo-wrap run it, ungated
|
||||
/// like the creature path (GOs are few — the doodad host's draw-gate isn't warranted for them).
|
||||
///
|
||||
/// The reference *also* runs a second, GameObject-specific arm off `GAMEOBJECT_STATE` +
|
||||
/// `GAMEOBJECT_ANIMPROGRESS` (same note, §1). Not modelled here: for every GO benilla renders
|
||||
/// today it resolves to the same Stand this seed already arms — a named deferral, not a claim
|
||||
/// that the overlay doesn't exist.
|
||||
///
|
||||
/// Global-sequence channels ride along regardless of flavour. Returns the joints + inverse-bindposes
|
||||
/// for the caller to bind each submesh's skinned twin, or `None` when the model has no inverse
|
||||
@@ -85,7 +93,14 @@ fn setup_skinned_instance(
|
||||
let mut player = AnimationPlayer::default();
|
||||
if idle_loop {
|
||||
if let Some(clip) = anims.first_seq.and_then(|i| anims.clips.get(i)) {
|
||||
player.play(clip.node).repeat();
|
||||
// Loop iff the sequence says so (`M2Sequence.flags & 1 == 0`) — the kernel's own
|
||||
// end-of-band law (wow-re `gameobject-anim-arm.md` §2, byte-verified at
|
||||
// `0x714585`): bit0 clear loops on the modulo wrap, bit0 set plays the window and
|
||||
// then FREEZES at `end_ms`. An unconditional repeat replayed one-shot idles.
|
||||
let active = player.play(clip.node);
|
||||
if clip.looping {
|
||||
active.repeat();
|
||||
}
|
||||
}
|
||||
}
|
||||
commands.entity(entity).insert((
|
||||
@@ -549,6 +564,21 @@ pub(super) fn attach_entity_visuals(
|
||||
// Billboard batches (the brazier/lantern glow card) collected for the world-root card
|
||||
// spawn below — inside the loop we only plant their anchor child.
|
||||
let mut billboard_parts = Vec::new();
|
||||
// The armed idle's **authored** CAaBox (decision 0637) — the mouseover picker's
|
||||
// volume for a skinned part, NOT a culling volume (skinned entity parts are never
|
||||
// frustum-culled; see the `NoFrustumCulling` note at the insert below). The bind-pose
|
||||
// box the mesh would otherwise get is only a fair stand-in while the animation keeps
|
||||
// the model near rest — the duel flag breaks it: `DuelingFlag.m2` is modelled 9 yards
|
||||
// in the air and its Stand translates the root `−9.124` to plant it, so the bind box
|
||||
// sits a whole model-height above the drawn geometry. The M2 authors a per-sequence
|
||||
// CAaBox for exactly this; for the flag's Stand it is ground-to-tip, which is also
|
||||
// what makes the planted flag hoverable where it is actually seen.
|
||||
let idle_aabb = dm.and_then(|m| m.animations.as_ref()).and_then(|a| {
|
||||
let clip = a.first_seq.and_then(|i| a.clips.get(i))?;
|
||||
(clip.bounds_max.cmpgt(clip.bounds_min).all()).then(|| {
|
||||
bevy::camera::primitives::Aabb::from_min_max(clip.bounds_min, clip.bounds_max)
|
||||
})
|
||||
});
|
||||
commands.entity(entity).with_children(|parent| {
|
||||
for part in parts {
|
||||
// Skip a geoset this character doesn't show (an unselected hair/facial/body variant).
|
||||
@@ -663,16 +693,32 @@ pub(super) fn attach_entity_visuals(
|
||||
},
|
||||
object.clone(),
|
||||
));
|
||||
// Bind this part to the instance's skeleton — all parts share the one joint set. We
|
||||
// *don't* add `NoFrustumCulling`: it suppresses the `Aabb` (`calculate_bounds` skips
|
||||
// those entities), and the mouseover picker requires an `Aabb` — so it would make
|
||||
// creatures unpickable. The bind-pose `Aabb` bounds idle/walk/run fine; a generous
|
||||
// authored-bounds AABB is the fix if an extreme animation ever pops at a screen edge.
|
||||
// Bind this part to the instance's skeleton — all parts share the one joint set.
|
||||
if let (Some((joints, ibp)), Some(_)) = (&skin, &part.skinned_mesh) {
|
||||
child.insert(SkinnedMesh {
|
||||
inverse_bindposes: ibp.clone(),
|
||||
joints: joints.clone(),
|
||||
});
|
||||
// A streamed entity's M2 is **never view-culled** — the reference registers
|
||||
// entity render records with effectively-infinite bounds (≈1e7) and the one
|
||||
// frustum cull in its machinery is map-doodad-only (wow-re
|
||||
// `unit-anim-visibility-gate.md` §2/§4: "doodads get the faithful
|
||||
// frustum/occlusion/distance cull; units do not"). Bevy's bind-pose `Aabb`
|
||||
// is the wrong stand-in — an armed idle can leave the bind box entirely
|
||||
// (the duel flag plants itself 9 yd below it, so it culled away at every
|
||||
// ground-level camera) — and a better box inserted here is stomped anyway:
|
||||
// `calculate_bounds`' update query rewrites the `Aabb` of any entity whose
|
||||
// `Mesh3d` changed (spawn tick, async mesh load) back to the bind pose.
|
||||
// So the renderer gets the faithful `NoFrustumCulling`, and the `Aabb` we
|
||||
// insert beside it serves ONE master: the mouseover picker
|
||||
// (`target/hover.rs`) — the armed idle's authored CAaBox when it has one
|
||||
// (it tracks the drawn pose), else the bind box. Both `calculate_bounds`
|
||||
// queries skip `NoFrustumCulling` entities, so this box survives.
|
||||
let picker_aabb = idle_aabb
|
||||
.or_else(|| meshes.get(&part.mesh).and_then(MeshAabb::compute_aabb));
|
||||
if let Some(aabb) = picker_aabb {
|
||||
child.insert((aabb, NoFrustumCulling));
|
||||
}
|
||||
}
|
||||
// M2 parts can light off a WMO room they stand in: a `MeshTag` + the classifier
|
||||
// pick the law by location (0354: the day/night state rides the intensity byte
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The per-unit **collision height** ([`CollisionHeight`]) — the reference's `CMovement+0xb4`,
|
||||
//! derived from the unit's display id and stamped on every streamed entity (decision 0645).
|
||||
//!
|
||||
//! It lives beside the display cache because that is where a display id becomes model facts; every
|
||||
//! *consumer* is elsewhere (`player::swim`, `sound::water`, `sound::footsteps`, `water_fx`,
|
||||
//! `net::motion::spline`), which is exactly why it is a component and not five private constants.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::NetEntity;
|
||||
|
||||
use super::Creatures;
|
||||
|
||||
/// A unit's **collision height** in world yards — the reference's `CMovement+0xb4`, and the `h`
|
||||
/// that every depth line in the client is a fraction of: swim enters and rests at `0.75·h`, the
|
||||
/// splash fires at `0.4·h`, wading ends where swimming begins (`0.75·h`), and the foam gate is
|
||||
/// `max(2·h, 1.0)`. Each of those *fractions* is byte-verified against the real client (wow-re
|
||||
/// `swim-transition.md`, `swim-mechanism.md`, `water-ripple-decal.md`); until decision 0645 the
|
||||
/// `h` they multiplied was a **constant** — [`crate::player::DEFAULT_COLLISION_HEIGHT`], the
|
||||
/// client's own empty-world ctor default — so all five lines were right for a human male (2.031,
|
||||
/// within 2 mm of it) and wrong for every other race. A gnome female stands 1.15 yd and was held
|
||||
/// 1.52 yd under: she could not reach the surface, which is how the defect finally showed (B76).
|
||||
///
|
||||
/// `CreatureModelData.collisionHeight × render scale`. The column is the model's own MD20
|
||||
/// collision-box Z extent in raw model units — machine-pinned against the shipped client
|
||||
/// (`benilla_formats` `collision_height_is_the_m2_collision_box`), which is what settles that it
|
||||
/// scales with the geometry it bounds. The scale is [`NetEntity::scale`] = `OBJECT_FIELD_SCALE_X`,
|
||||
/// the *complete* render scale the server has already folded the DBC scales into and the one value
|
||||
/// our renderer sizes the model by — so the collision box and the drawn body cannot disagree.
|
||||
///
|
||||
/// **Not** the movement capsule: that stays the constant-height tunable it has always been
|
||||
/// ([`crate::player::CAPSULE_HEIGHT`] — see its doc for why the two are deliberately separate).
|
||||
#[derive(Component, Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct CollisionHeight(pub(crate) f32);
|
||||
|
||||
impl Default for CollisionHeight {
|
||||
/// The client's own ctor default — never `0.0`, at which every depth line collapses and the
|
||||
/// unit swims on dry land. This is what makes the type safe to hold in a `Default`-derived
|
||||
/// struct ([`crate::player::Player`] does).
|
||||
fn default() -> Self {
|
||||
Self(crate::player::DEFAULT_COLLISION_HEIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit's collision height from its display id and render scale — [`CollisionHeight`]'s one
|
||||
/// derivation, shared by the ECS stamp below and the avatar's mirror of it
|
||||
/// (`player::wire_in`), so the two can never drift into different answers.
|
||||
pub(crate) fn collision_height_for(
|
||||
creatures: Option<&Creatures>,
|
||||
display_id: Option<u32>,
|
||||
scale: f32,
|
||||
) -> CollisionHeight {
|
||||
let raw = creatures
|
||||
.zip(display_id)
|
||||
.and_then(|(c, id)| c.collision_height(id))
|
||||
// A zero column is real in the data (67 rows — invisible triggers and the like); it means
|
||||
// "no authored box", not "a unit of no height", so it falls back like a missing row.
|
||||
.filter(|h| *h > 0.0)
|
||||
.unwrap_or(crate::player::DEFAULT_COLLISION_HEIGHT);
|
||||
CollisionHeight(raw * scale.max(f32::MIN_POSITIVE))
|
||||
}
|
||||
|
||||
/// Give every streamed unit its [`CollisionHeight`] the frame its display resolves. Queries the
|
||||
/// not-yet-stamped only, so it costs nothing in steady state and self-heals if `Creatures` (the DBC
|
||||
/// catalog) lands after the first units stream in. A display id that misses the DBCs — or a unit
|
||||
/// carrying none at all — is still stamped, at the ctor default, so no consumer has to tell
|
||||
/// "not resolved yet" apart from "resolved to nothing".
|
||||
///
|
||||
/// **Stamped once, never restamped** — correct today because *neither* input can change on a live
|
||||
/// entity. A live `SCALE_X` change is [`NetEntity::scale`]'s own standing deferral, and a live
|
||||
/// `UNIT_FIELD_DISPLAYID` change isn't applied either: the wire field is read **only** on the
|
||||
/// create path (`benilla_protocol`'s `display_id`, one call site, in the `ObjectCreate` arm), so a
|
||||
/// morph or a druid form leaves both the drawn model and this height at the create-time body.
|
||||
/// Verified live 2026-07-26 — `.modify morph 1564` on a swimming probe moved neither.
|
||||
///
|
||||
/// So restamping now would be strictly *worse*: it would give the collision box a body the renderer
|
||||
/// isn't drawing, the exact disagreement this component exists to stop. When the live display-id
|
||||
/// change lands (the shapeshift/morph gap, ledger F04), the model swap and this restamp are one
|
||||
/// change, not two.
|
||||
pub(super) fn stamp_collision_heights(
|
||||
mut commands: Commands,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
units: Query<(Entity, &NetEntity), Without<CollisionHeight>>,
|
||||
) {
|
||||
for (entity, net) in &units {
|
||||
let h = collision_height_for(creatures.as_deref(), net.display_id, net.scale);
|
||||
commands.entity(entity).insert(h);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,19 @@ pub(crate) struct ItemDisplays {
|
||||
pub(super) models: HashMap<(u32, ItemModelKind), DisplayModel>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ItemDisplays {
|
||||
/// The icon-only test seam: a synthetic catalog with an empty model cache — for the UI feeds
|
||||
/// that read nothing off this resource but the icon column (the action bar, the bags). They
|
||||
/// live outside this module, so `models` is not theirs to build.
|
||||
pub(crate) fn icons_for_tests(catalog: ItemDisplayCatalog) -> Self {
|
||||
ItemDisplays {
|
||||
catalog,
|
||||
models: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A player's worn-equipment display ids by **bodyslot − 2** (shirt, chest, belt, pants, boots,
|
||||
/// wrist, gloves, tabard — the armor-composite slots, decision 0074), `0` = empty. `settled` means
|
||||
/// every non-empty visible-item entry has resolved through the template cache (hit or recorded
|
||||
|
||||
@@ -406,7 +406,7 @@ fn inspect_ui(
|
||||
}
|
||||
|
||||
/// Registers the mouseover foundation (the [`MouseoverTarget`] + [`InspectMode`] resources and the
|
||||
/// per-frame pick), the standalone I-toggled inspector surface, and the cast journal (recording
|
||||
/// per-frame pick), the standalone Ctrl+Cmd+I inspector surface, and the cast journal (recording
|
||||
/// always, drawing under the same toggle).
|
||||
pub struct InteractPlugin;
|
||||
|
||||
|
||||
@@ -49,6 +49,16 @@ pub(crate) struct Items {
|
||||
/// the tooltip store (every landed template goes to the UI unprompted, so the first hover of
|
||||
/// an item whose name is already on screen never misses).
|
||||
fresh: Vec<u32>,
|
||||
/// Bumped by every landed answer ([`Self::insert_template`]), positive or negative. The
|
||||
/// **broadcast** twin of [`Self::fresh`]: `fresh` is a DRAIN (exactly one consumer can take
|
||||
/// it — the tooltip feed does), so a second consumer that caches a template-derived view
|
||||
/// needs its own signal. A consumer keeps the epoch it last resolved at and re-resolves when
|
||||
/// it advances — the modern stand-in for the ref's `DBCACHECALLBACK` redisplay (`0x6e29b0`),
|
||||
/// which is how the real client repaints a view that was drawn while the item cache was still
|
||||
/// answering. Any cache that gates a template read behind a change-flag needs this: the read
|
||||
/// itself is what ISSUES the ask-once query, so the first resolve of a cold entry ALWAYS
|
||||
/// misses (decision 0660).
|
||||
template_epoch: u64,
|
||||
}
|
||||
|
||||
impl Items {
|
||||
@@ -133,6 +143,17 @@ impl Items {
|
||||
self.fresh.push(entry);
|
||||
}
|
||||
self.templates.insert(entry, info);
|
||||
// A NEGATIVE answer bumps too: it flips the entry from "still asking" to "answered
|
||||
// unknown", which is a real display transition for anything that waits on the ask (the
|
||||
// cast-fail redisplay's `"UNKNOWN"` literal, [`Self::template_answered_unknown`]).
|
||||
self.template_epoch = self.template_epoch.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The landed-template broadcast counter — see the [`Self::template_epoch`] field. Advances on
|
||||
/// every answer; a consumer that caches a template-derived view compares it against the value
|
||||
/// it last resolved at and re-resolves when they differ.
|
||||
pub(crate) fn template_epoch(&self) -> u64 {
|
||||
self.template_epoch
|
||||
}
|
||||
|
||||
/// Drain the entries whose template landed since the last drain (see the `fresh` field).
|
||||
|
||||
@@ -494,17 +494,12 @@ pub(crate) fn water_surface_at<'a>(
|
||||
liquid_at(water.filter(|w| !w.kind.is_fullbright()), wow, indoors).map(|h| h.surface_z)
|
||||
}
|
||||
|
||||
/// Deepest water (yd) that still counts as **wading** — feet below the surface but not yet swimming.
|
||||
/// Beyond it the unit swims: footfalls go silent and the wading effects (splash sound, surface
|
||||
/// ripple) stop. Shared by every wading consumer ([`crate::sound::footsteps`], the enter-water
|
||||
/// splash, and [`crate::water_fx`]). B7 folded in (decision 0226: the real boundary is
|
||||
/// `0.75·collisionHeight` off feet-to-surface depth, not a flat constant) and the local player now
|
||||
/// runs a real WALKING↔SWIMMING mode on it (`player::swim`) — but this 2-yard proxy was never
|
||||
/// retired: it remains the only signal for units with no swim mode of their own (creatures, which
|
||||
/// still carry no wire swim flag — the `CreatureModelData.collisionHeight` plumb 0464 leaves
|
||||
/// open), AND these consumers still read it for the local player too instead of `Player::swimming`
|
||||
/// — a named follow-up in decision 0530.
|
||||
pub(crate) const WADE_MAX: f32 = 2.0;
|
||||
// The **wade ceiling** used to live here, as `WADE_MAX = 2.0` — a flat proxy for a boundary B7
|
||||
// (decision 0226) had already shown to be `0.75·collisionHeight`, kept because the per-unit height
|
||||
// it needed was 0464's un-plumbed `CreatureModelData.collisionHeight`. Decision 0645 plumbed it, so
|
||||
// the proxy is gone and there is no wade constant to re-import: wading is *the complement of
|
||||
// swimming*, one number, and its one spelling is `player::swim_enter_depth(h)`. A human's line
|
||||
// moved 2.0 → 1.52 yd, a murloc's far shallower.
|
||||
|
||||
/// Eye-submersion accept margin (VERIFIED `FUN_0069b6d0`: `eye.z < surface + 0.01`).
|
||||
const SUBMERSION_EPS: f32 = 0.01;
|
||||
@@ -1335,4 +1330,29 @@ mod real_data {
|
||||
"…but only just: the water is at the player's soles (got {surface})"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The gradient the swim law is sized against** (decision 0644): Felwood's Felfire Hill
|
||||
/// channel, along the run the live probe swam. A liquid surface is a heightfield, and *how far
|
||||
/// from flat* is exactly what decides whether the swim latch's 1/36-yd hysteresis band can
|
||||
/// absorb travelling along it — so the slope `player::swim`'s regression test drives is pinned
|
||||
/// here against the shipped ADT instead of living as a constant someone can only take on faith.
|
||||
#[test]
|
||||
fn the_felfire_channel_falls_about_a_tenth_of_a_yard_per_yard() {
|
||||
let (downstream, upstream) = ([1953.97_f32, -2866.84, 0.0], [2013.97_f32, -2866.84, 0.0]);
|
||||
let all = surfaces_near("Kalimdor", downstream);
|
||||
if all.is_empty() {
|
||||
eprintln!("skipping: no WoW client data");
|
||||
return;
|
||||
}
|
||||
let z = |at: [f32; 3]| {
|
||||
liquid_at(all.iter(), at, Some(false))
|
||||
.unwrap_or_else(|| panic!("no river at {at:?}"))
|
||||
.surface_z
|
||||
};
|
||||
let slope = (z(upstream) - z(downstream)) / (upstream[0] - downstream[0]);
|
||||
assert!(
|
||||
(slope - 0.099).abs() < 0.005,
|
||||
"the channel's gradient over 60 yd (got {slope})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,9 +204,26 @@ fn drive_policy(
|
||||
if any_set && std::env::var_os("WOW_LOGIN_SMOKE").is_none() {
|
||||
let user = std::env::var("WOW_USER").unwrap_or_else(|_| "one".into());
|
||||
let pass = std::env::var("WOW_PASS").unwrap_or_else(|_| "pone".into());
|
||||
info!("login: env fast path — auto-submitting as {user}");
|
||||
intent.creds = Some((user, pass));
|
||||
intent.retry_at = Some(now);
|
||||
// The account guard (decision 0649): a vmangos login KICKS whoever holds the account,
|
||||
// so an unattended run from a pool slot must not authenticate as the director's `one`
|
||||
// or a neighbouring slot's probe. Only the *automated* path is gated — a typed login
|
||||
// is the director's own and is never second-guessed.
|
||||
match crate::preflight::account_guard(&user) {
|
||||
Ok(()) => {
|
||||
info!("login: env fast path — auto-submitting as {user}");
|
||||
intent.creds = Some((user, pass));
|
||||
intent.retry_at = Some(now);
|
||||
}
|
||||
Err(why) if std::env::var_os("WOW_ALLOW_ACCOUNT").is_some() => {
|
||||
warn!("login: {why} — WOW_ALLOW_ACCOUNT is set, going ahead anyway");
|
||||
intent.creds = Some((user, pass));
|
||||
intent.retry_at = Some(now);
|
||||
}
|
||||
Err(why) => {
|
||||
error!("login: REFUSING the env fast path — {why} Set WOW_ALLOW_ACCOUNT=1 if the cross-account login is deliberate.");
|
||||
dialog.open_error(&why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ mod pending_item_ops;
|
||||
mod perf;
|
||||
mod player;
|
||||
mod portrait;
|
||||
mod preflight;
|
||||
mod quest_markers;
|
||||
mod raid_marks;
|
||||
mod ribbons;
|
||||
@@ -99,6 +100,7 @@ mod ui_loot;
|
||||
mod ui_loot_roll;
|
||||
mod ui_mail;
|
||||
mod ui_merchant;
|
||||
mod ui_net;
|
||||
mod ui_party;
|
||||
mod ui_pass;
|
||||
mod ui_quest;
|
||||
@@ -174,6 +176,7 @@ use ui_loot::UiLootPlugin;
|
||||
use ui_loot_roll::UiLootRollPlugin;
|
||||
use ui_mail::UiMailPlugin;
|
||||
use ui_merchant::UiMerchantPlugin;
|
||||
use ui_net::UiNetPlugin;
|
||||
use ui_party::UiPartyPlugin;
|
||||
use ui_pass::PlayerUiPlugin;
|
||||
use ui_quest::UiQuestPlugin;
|
||||
@@ -442,6 +445,10 @@ fn main() {
|
||||
.add_plugins(login::LoginPlugin)
|
||||
// The character-creation screen + its live preview booth (decision 0423).
|
||||
.add_plugins(char_create::CharCreatePlugin)
|
||||
// The session preflight (decision 0649): one banner per world entry naming the body we logged
|
||||
// into, and loud warnings for the states — dead/ghost, GM mode, server-blocked movement — that
|
||||
// silently invalidate a session's readings. Never env-gated; a warning nobody switches on isn't one.
|
||||
.add_plugins(preflight::PreflightPlugin)
|
||||
// Audio: the delegated mixer + WoW's owned selection layer (decision 0070).
|
||||
.add_plugins(SoundPlugin)
|
||||
// Targeting: left-click a unit to select it (→ CMSG_SET_SELECTION) + draw its ground ring.
|
||||
@@ -502,6 +509,9 @@ fn main() {
|
||||
// the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After
|
||||
// UiActionPlugin (shares `Spells`, the `usable` walk, and the cast tail).
|
||||
.add_plugins(UiShapeshiftPlugin)
|
||||
// The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main
|
||||
// bar's performance meter polls (decision 0658).
|
||||
.add_plugins(UiNetPlugin)
|
||||
.add_plugins(UiCastPlugin)
|
||||
// Floating combat text (decision 0137 phase 2): the WORLDTEXTSTRING law — world-anchored
|
||||
// damage numbers/outcome words projected into the UI quad pass each frame.
|
||||
@@ -577,6 +587,13 @@ fn main() {
|
||||
if std::env::var("WOW_LIVE_SHOT").is_ok() {
|
||||
app.add_plugins(capture::LiveShotPlugin);
|
||||
}
|
||||
// The probe RIG (decision 0651): `WOW_RIG="tauren druid 60 gear:heal-preraid-bis"` finds-or-
|
||||
// creates that body on this slot's probe account, logs in as it, and applies level/spells/gear/
|
||||
// spec/place — the one verb that replaces the hand-assembled GM recipe every session used to
|
||||
// re-derive (see `capture::ProbeRigPlugin`).
|
||||
if std::env::var("WOW_RIG").is_ok() {
|
||||
app.add_plugins(capture::ProbeRigPlugin);
|
||||
}
|
||||
// The probe-chat one-shot: `WOW_PROBE_CHAT=".go xyz …"` sends GM/chat lines once in-world —
|
||||
// the "park the probe character anywhere" instrument (see `capture::ProbeChatPlugin`).
|
||||
if std::env::var("WOW_PROBE_CHAT").is_ok() {
|
||||
@@ -597,6 +614,12 @@ fn main() {
|
||||
if std::env::var("WOW_PROBE_EXIT_AT").is_ok() {
|
||||
app.add_plugins(capture::ProbeExitPlugin);
|
||||
}
|
||||
// The ray pick: `WOW_PICK="<x>,<y>"` names every surface along the ray through a screenshot
|
||||
// pixel, nearest first — "what is at the spot `benilla-visual hotspot` flagged, and what is
|
||||
// right behind it" (see `capture::PickProbePlugin`).
|
||||
if std::env::var("WOW_PICK").is_ok() {
|
||||
app.add_plugins(capture::PickProbePlugin);
|
||||
}
|
||||
// The bevy_ui node census — "who owns this rectangle" for UI outside the FrameXML quad pass
|
||||
// (see `capture::NodeProbePlugin`).
|
||||
if std::env::var("WOW_NODE_PROBE").is_ok() {
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
//! - **Reaction-colored** (the A/B falsified the earlier "constant white"): the render `0x6c6e90`
|
||||
//! fetches `unit->vtable[0x2c]` = `CGUnit::GetSelectionCircleColor 0x605960` — the SAME selector
|
||||
//! as the ground selection ring. NPC: dead gray, else reaction red/orange/yellow/green; player:
|
||||
//! hostile red else the pale blue. benilla reuses the ring's byte-verified rank resolution
|
||||
//! ([`ring_reaction`]) and palette. The selector's **first-priority branch** — the combat-flash
|
||||
//! hostile red, else the `¬X∧¬Y` split — PvP-flagged green (party pale-green), unflagged the
|
||||
//! soft blue (party pale-blue). benilla does not re-derive any of that: it calls the ring's own
|
||||
//! [`ring_variant`] and paints what it answers, because "the SAME selector" above is the literal
|
||||
//! claim (decision 0659 — a hand-copied mirror had drifted and a flagged player wore a green
|
||||
//! ring under a blue name). The selector's **first-priority branch** — the combat-flash
|
||||
//! red↔orange pulse — is live too: [`CombatFlash`] (recomputed per frame from the selection +
|
||||
//! our server-echoed attack bracket, exactly the client's per-frame `[unit+0xc58]` bit 0x10)
|
||||
//! overrides the whole palette while we melee this unit.
|
||||
@@ -62,7 +65,7 @@ use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::player::WorldCamera;
|
||||
use crate::target::{ring_reaction, CombatFlash, Factions, Selection};
|
||||
use crate::target::{ring_reaction, ring_variant, CombatFlash, Factions, RingVariant, Selection};
|
||||
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
|
||||
|
||||
/// The height-scale law (`0x6c6e90`): `d > KNEE ? d/KNEE · RATE · FLOOR : FLOOR`, `d` the anchor's
|
||||
@@ -121,54 +124,31 @@ pub(crate) fn height_scale(d: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `GetSelectionCircleColor` palette (`0x605960` — shared with the selection ring, the single
|
||||
/// byte-verified source; the full color-law notes live in `target/ring.rs`).
|
||||
/// What a name line is painted with. The classification is **not** ours: the per-frame name render
|
||||
/// fetches `unit->vtable[0x2c]` = `CGUnit::GetSelectionCircleColor 0x605960` — literally the same
|
||||
/// selector as the ground ring (decision 0156) — so [`ring_variant`] IS the law and this only adds
|
||||
/// the flash seat that lives outside the palette.
|
||||
///
|
||||
/// It used to be a hand-copied mirror of that selector, and the copy went stale: decision 0453
|
||||
/// taught the ring the player path's `¬X∧¬Y` legs (PvP-flagged → green, party → pale) and the
|
||||
/// mirror kept answering plain blue, so a flagged player drew a green ring under a blue name
|
||||
/// (decision 0659). Deleting the mirror is the fix; there is one selector now.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
enum NameColor {
|
||||
Hostile,
|
||||
Unfriendly,
|
||||
Neutral,
|
||||
Friendly,
|
||||
Player,
|
||||
Dead,
|
||||
/// The combat-flash pulse — the selector's first-priority branch. Its material tint is
|
||||
/// rewritten each frame from [`CombatFlash::color`]; the variant here is the seed.
|
||||
enum NamePaint {
|
||||
/// The selector's own answer — the whole palette.
|
||||
Variant(RingVariant),
|
||||
/// The combat-flash pulse — the selector's first-priority branch, which outranks the palette.
|
||||
/// Its material tint is rewritten each frame from [`CombatFlash::color`]; this is the seed.
|
||||
Flash,
|
||||
}
|
||||
|
||||
impl NameColor {
|
||||
/// The selector, mirroring the ring's pick: players branch first and never gray on death;
|
||||
/// NPCs gray when dead, else the reaction-rank palette.
|
||||
fn of(rank: u8, is_player: bool, is_dead: bool) -> Self {
|
||||
if is_player {
|
||||
return if rank <= 1 {
|
||||
Self::Hostile
|
||||
} else {
|
||||
Self::Player
|
||||
};
|
||||
}
|
||||
if is_dead {
|
||||
return Self::Dead;
|
||||
}
|
||||
match rank {
|
||||
0..=1 => Self::Hostile,
|
||||
2 => Self::Unfriendly,
|
||||
3 => Self::Neutral,
|
||||
_ => Self::Friendly,
|
||||
}
|
||||
}
|
||||
|
||||
impl NamePaint {
|
||||
// GAMMA LANE (0161): `linear_rgb` passes the authored byte values RAW into the gamma
|
||||
// framebuffer (stock-PBR unlit writes base_color as-is; the frame decodes once at FFXGlow).
|
||||
fn color(self) -> Color {
|
||||
match self {
|
||||
Self::Hostile => Color::linear_rgb(1.0, 0.0, 0.0), // 0xFFFF0000
|
||||
Self::Unfriendly => Color::linear_rgb(1.0, 0.502, 0.0), // 0xFFFF8000
|
||||
Self::Neutral => Color::linear_rgb(1.0, 1.0, 0.0), // 0xFFFFFF00
|
||||
Self::Friendly => Color::linear_rgb(0.0, 1.0, 0.0), // 0xFF00FF00
|
||||
Self::Player => Color::linear_rgb(0.376, 0.376, 1.0), // 0xFF6060FF
|
||||
Self::Dead => Color::linear_rgb(0.498, 0.498, 0.498), // 0xFF7F7F7F
|
||||
Self::Flash => Color::linear_rgb(1.0, 0.0, 0.0), // seed: the wave's G=0 endpoint
|
||||
Self::Variant(v) => v.color(),
|
||||
Self::Flash => Color::linear_rgb(1.0, 0.0, 0.0), // seed: the wave's G=0 endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,10 +158,10 @@ impl NameColor {
|
||||
/// live per-unit plate entities.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Nameplates {
|
||||
materials: HashMap<NameColor, Handle<StandardMaterial>>,
|
||||
materials: HashMap<NamePaint, Handle<StandardMaterial>>,
|
||||
meshes: HashMap<Vec<String>, Handle<Mesh>>,
|
||||
/// unit → (plate entity, the (lines, color) it was built with).
|
||||
live: bevy::ecs::entity::EntityHashMap<(Entity, Vec<String>, NameColor)>,
|
||||
live: bevy::ecs::entity::EntityHashMap<(Entity, Vec<String>, NamePaint)>,
|
||||
}
|
||||
|
||||
impl Nameplates {
|
||||
@@ -322,6 +302,8 @@ pub(crate) fn drive_nameplates(
|
||||
Option<Res<crate::player::CameraControl>>,
|
||||
Res<crate::vplates::VPlates>,
|
||||
Res<crate::chat_bubble::BubblesActive>,
|
||||
// The selector's party roster (`0xbc6f48`) — the `¬X∧¬Y` leg's pale variants.
|
||||
Res<crate::ui_party::GroupState>,
|
||||
),
|
||||
mut names: ResMut<NameCache>,
|
||||
net_commands: Res<NetCommands>,
|
||||
@@ -340,22 +322,18 @@ pub(crate) fn drive_nameplates(
|
||||
Query<(), With<crate::entities::mount::MountChild>>,
|
||||
),
|
||||
) {
|
||||
let (selection, flash, rig, vplates, bubbles) = gates;
|
||||
let (selection, flash, rig, vplates, bubbles, group) = gates;
|
||||
let (Ok(cam_tf), Some(atlas)) = (camera.single(), atlas.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
// The palette materials, once (they need the atlas image).
|
||||
if plates.materials.is_empty() {
|
||||
let image = atlas.image();
|
||||
for kind in [
|
||||
NameColor::Hostile,
|
||||
NameColor::Unfriendly,
|
||||
NameColor::Neutral,
|
||||
NameColor::Friendly,
|
||||
NameColor::Player,
|
||||
NameColor::Dead,
|
||||
NameColor::Flash,
|
||||
] {
|
||||
for kind in RingVariant::ALL
|
||||
.map(NamePaint::Variant)
|
||||
.into_iter()
|
||||
.chain([NamePaint::Flash])
|
||||
{
|
||||
plates.materials.insert(
|
||||
kind,
|
||||
materials.add(StandardMaterial {
|
||||
@@ -388,7 +366,7 @@ pub(crate) fn drive_nameplates(
|
||||
if flash.unit.is_some() {
|
||||
if let Some(mat) = plates
|
||||
.materials
|
||||
.get(&NameColor::Flash)
|
||||
.get(&NamePaint::Flash)
|
||||
.and_then(|h| materials.get_mut(h))
|
||||
{
|
||||
mat.base_color = flash.color;
|
||||
@@ -472,12 +450,23 @@ pub(crate) fn drive_nameplates(
|
||||
self_store.single().ok(),
|
||||
);
|
||||
let is_dead = store.is_some_and(|s| s.0.unit_is_dead());
|
||||
// The player path's `¬X∧¬Y` split inputs, exactly as the ring reads them: the unit's own
|
||||
// PvP flag (`UNIT_FIELD_FLAGS` 0x1000) and roster membership by guid — here keyed on THIS
|
||||
// unit's guid, where the ring (which only ever draws the selection) uses the selected one.
|
||||
let pvp = store.is_some_and(|s| s.0.unit_flags() & 0x1000 != 0);
|
||||
let in_party = group.members.iter().any(|m| m.guid == guid.0);
|
||||
// The selector's first-priority branch (shared with the ring): the combat flash
|
||||
// overrides the whole palette while we melee this unit.
|
||||
let color = if flash.unit == Some(entity) {
|
||||
NameColor::Flash
|
||||
NamePaint::Flash
|
||||
} else {
|
||||
NameColor::of(rank, net.kind == EntityKind::Player, is_dead)
|
||||
NamePaint::Variant(ring_variant(
|
||||
rank,
|
||||
net.kind == EntityKind::Player,
|
||||
is_dead,
|
||||
pvp,
|
||||
in_party,
|
||||
))
|
||||
};
|
||||
|
||||
// (Re)build the plate entity when its content changed. The seat below is only the
|
||||
@@ -683,20 +672,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The color selector against the byte-verified `GetSelectionCircleColor` law: players branch
|
||||
/// first and never gray on death; NPCs gray dead, else red/orange/yellow/green by rank.
|
||||
/// The name paints itself from the ring's own selector — one law, not a copy of one (decision
|
||||
/// 0659). The regression this locks: a **PvP-flagged** friendly player is GREEN, the same
|
||||
/// green the ring under their feet draws. The name used to keep a private mirror of the
|
||||
/// selector that predated the `¬X∧¬Y` legs (0453), so a flagged player wore a green ring under
|
||||
/// a blue name — which is exactly what the director saw.
|
||||
#[test]
|
||||
fn name_color_matches_the_ring_selector() {
|
||||
assert_eq!(NameColor::of(0, false, false), NameColor::Hostile);
|
||||
assert_eq!(NameColor::of(2, false, false), NameColor::Unfriendly);
|
||||
assert_eq!(NameColor::of(3, false, false), NameColor::Neutral);
|
||||
assert_eq!(NameColor::of(6, false, false), NameColor::Friendly);
|
||||
assert_eq!(NameColor::of(6, false, true), NameColor::Dead);
|
||||
fn name_color_is_the_ring_selector_itself() {
|
||||
let paint = |rank, is_player, is_dead, pvp, in_party| {
|
||||
NamePaint::Variant(ring_variant(rank, is_player, is_dead, pvp, in_party))
|
||||
};
|
||||
// The regression: flagged ⇒ green, unflagged ⇒ the soft blue, and the two really differ.
|
||||
let flagged = paint(6, true, false, true, false);
|
||||
let unflagged = paint(6, true, false, false, false);
|
||||
assert_eq!(flagged, NamePaint::Variant(RingVariant::Friendly));
|
||||
assert_eq!(unflagged, NamePaint::Variant(RingVariant::Player));
|
||||
assert_ne!(flagged.color(), unflagged.color());
|
||||
assert_eq!(
|
||||
NameColor::of(6, true, true),
|
||||
NameColor::Player,
|
||||
flagged.color(),
|
||||
Color::linear_rgb(0.0, 1.0, 0.0),
|
||||
"0xFF00FF00 — the ring's own green, byte for byte"
|
||||
);
|
||||
// The party legs come along for free now.
|
||||
assert_eq!(
|
||||
paint(6, true, false, true, true),
|
||||
NamePaint::Variant(RingVariant::PartyPvp)
|
||||
);
|
||||
assert_eq!(
|
||||
paint(6, true, false, false, true),
|
||||
NamePaint::Variant(RingVariant::Party)
|
||||
);
|
||||
// And the rest of the law still holds through the shared selector.
|
||||
assert_eq!(paint(0, false, false, false, false).color(), RED);
|
||||
assert_eq!(
|
||||
paint(6, true, true, false, false),
|
||||
NamePaint::Variant(RingVariant::Player),
|
||||
"dead player never grays"
|
||||
);
|
||||
assert_eq!(NameColor::of(1, true, false), NameColor::Hostile);
|
||||
assert_eq!(paint(1, true, false, true, false).color(), RED, "hostile");
|
||||
assert_eq!(
|
||||
paint(6, false, true, false, false),
|
||||
NamePaint::Variant(RingVariant::Dead)
|
||||
);
|
||||
}
|
||||
|
||||
const RED: Color = Color::linear_rgb(1.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! The bridge runs in [`WorldStage::Net`], before `Input`, so a server teleport snaps → streams →
|
||||
//! covers in one frame. Coordinates cross from raw WoW into Bevy space here (`wow_to_bevy`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::time::Instant;
|
||||
|
||||
use benilla_protocol::{
|
||||
@@ -224,6 +224,81 @@ pub(crate) struct NetStatus {
|
||||
/// The last measured ping round trip (ms), from the `SMSG_PONG` echo against [`PingShared`]'s
|
||||
/// clock. `None` until the first pong of a connection (and again after a disconnect).
|
||||
pub(crate) latency_ms: Option<u32>,
|
||||
/// The most recent [`RTT_RING`] round trips (ms), oldest first — the real client's own RTT
|
||||
/// history (wow-re net W1: `HandlePong 0x537d60` puts each sample into a ring with "head/tail
|
||||
/// wrap 16", which `0x537f20`'s avg-RTT math averages). [`Self::avg_latency_ms`] is what the UI
|
||||
/// reports through `GetNetStats`; the *last* sample stays separate because that is the one the
|
||||
/// wire echoes back in the next `CMSG_PING`'s lastRtt field. Cleared with the connection.
|
||||
pub(crate) rtt_ring: VecDeque<u32>,
|
||||
}
|
||||
|
||||
/// How many round trips [`NetStatus::rtt_ring`] keeps — the reference ring's depth (wow-re net W1,
|
||||
/// `HandlePong 0x537d60`: "head/tail wrap 16").
|
||||
pub(crate) const RTT_RING: usize = 16;
|
||||
|
||||
impl NetStatus {
|
||||
/// Record one measured round trip: the newest sample in, the oldest out at depth.
|
||||
pub(crate) fn record_rtt(&mut self, ms: u32) {
|
||||
self.latency_ms = Some(ms);
|
||||
if self.rtt_ring.len() == RTT_RING {
|
||||
self.rtt_ring.pop_front();
|
||||
}
|
||||
self.rtt_ring.push_back(ms);
|
||||
}
|
||||
|
||||
/// Forget this connection's measurements — the next one's latency is its own.
|
||||
pub(crate) fn clear_rtt(&mut self) {
|
||||
self.latency_ms = None;
|
||||
self.rtt_ring.clear();
|
||||
}
|
||||
|
||||
/// The reported latency: the mean of the ring, truncated — `None` while it is empty (no pong
|
||||
/// since the connection came up). The reference averages its ring rather than reporting the
|
||||
/// last sample; the exact rounding is ours (`0x537f20`'s math is recorded as "avg-RTT" without
|
||||
/// a byte-level form), and at a 30 s ping cadence one sample's rounding is invisible anyway.
|
||||
pub(crate) fn avg_latency_ms(&self) -> Option<u32> {
|
||||
if self.rtt_ring.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sum: u64 = self.rtt_ring.iter().map(|&ms| u64::from(ms)).sum();
|
||||
Some((sum / self.rtt_ring.len() as u64) as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rtt_tests {
|
||||
use super::{NetStatus, RTT_RING};
|
||||
|
||||
/// The reported latency is the ring's mean, and the ring is bounded at the reference depth —
|
||||
/// so a single spike moves the meter by a sixteenth, not the whole way (which is the point of
|
||||
/// averaging at all: the ping cadence is 30 s, and one bad sample must not sit on a red bar
|
||||
/// for eight minutes).
|
||||
#[test]
|
||||
fn the_reported_latency_is_the_mean_of_a_bounded_ring() {
|
||||
let mut status = NetStatus::default();
|
||||
assert_eq!(status.avg_latency_ms(), None, "no pong yet");
|
||||
|
||||
status.record_rtt(40);
|
||||
assert_eq!(status.avg_latency_ms(), Some(40));
|
||||
status.record_rtt(60);
|
||||
assert_eq!(status.avg_latency_ms(), Some(50), "the mean, not the last");
|
||||
|
||||
// Fill past the ring: only the newest RTT_RING samples count, so the two above age out.
|
||||
for _ in 0..RTT_RING {
|
||||
status.record_rtt(100);
|
||||
}
|
||||
assert_eq!(status.rtt_ring.len(), RTT_RING);
|
||||
assert_eq!(status.avg_latency_ms(), Some(100));
|
||||
assert_eq!(
|
||||
status.latency_ms,
|
||||
Some(100),
|
||||
"the last sample stays separate"
|
||||
);
|
||||
|
||||
status.clear_rtt();
|
||||
assert_eq!(status.avg_latency_ms(), None, "a disconnect forgets it all");
|
||||
assert_eq!(status.latency_ms, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ping clock shared with the write thread's 30 s keepalive sender ([`io::PingClock`]): the
|
||||
@@ -900,6 +975,10 @@ pub(crate) enum ClientCommand {
|
||||
/// Decline / cancel / forfeit a duel (`CMSG_DUEL_CANCELLED`) — one opcode for all three; the
|
||||
/// server reads the intent from the duel's state.
|
||||
DuelCancelled { arbiter: u64 },
|
||||
/// Ask to flip our own PvP flag (`CMSG_TOGGLE_PVP`, empty body — decision 0646): `/pvp` and
|
||||
/// the unit popup's PvP row, both through the VM's intent queue. Nothing local changes; the
|
||||
/// answer is the descriptor's PvP bit (and flagging *off* waits out the server's 300 s timer).
|
||||
TogglePvp,
|
||||
// ── The taxi/flight-master family (decision 0484 phase 1; writer bodies in
|
||||
// benilla-protocol `messages::taxi`) ──────────────────────────────────────────────────
|
||||
/// Ask a nearby flight master's known status (`CMSG_TAXINODE_STATUS_QUERY`): the guid of the
|
||||
|
||||
@@ -521,7 +521,10 @@ pub(super) fn apply_net_updates(
|
||||
session::reputations(standings, &mut reputations)
|
||||
}
|
||||
SessionEvent::ReputationDelta { standings } => {
|
||||
session::reputation_delta(standings, &mut reputations)
|
||||
// A standing change is a questgiver-status input (`SatisfyQuestReputation`, and
|
||||
// the reaction gate): the reference sweeps from this handler too (0654).
|
||||
session::reputation_delta(standings, &mut reputations);
|
||||
quest.bump_reask();
|
||||
}
|
||||
SessionEvent::BindPoint { area } => home_bind.0 = Some(area),
|
||||
SessionEvent::Proficiency {
|
||||
@@ -671,8 +674,19 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::Chat(m) => {
|
||||
// The chat window (decision 0084): the feed ([`crate::ui_chat`]) formats + colors
|
||||
// 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);
|
||||
// System lines (`CHAT_MSG_SYSTEM` 0x0A, vmangos `SharedDefines.h`) are the SERVER'S
|
||||
// ANSWER to a GM dot-command — "Premade gear template N applied", "No matching
|
||||
// premade player template found", "There is no such command". For a headless probe
|
||||
// that is the only channel the server has to say *why* something did not happen, so
|
||||
// it rides at `info!`: at `debug!` it was in the log but invisible at the default
|
||||
// level, and a refused command read exactly like an applied one (decision 0651 —
|
||||
// the rig's whole batch silently no-op'd on a too-low GM level and nothing said so).
|
||||
// Ordinary chat stays at `debug!`: conversation, not diagnosis, and high volume.
|
||||
if m.chat_type == 0x0A {
|
||||
info!("net: server says — {}", m.text);
|
||||
} else {
|
||||
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
|
||||
@@ -748,6 +762,8 @@ pub(super) fn apply_net_updates(
|
||||
} => {
|
||||
let lines = group.apply_list(group_type, own_flags, members, leader, loot);
|
||||
push_group_lines(&mut chat_log, lines);
|
||||
// Roster changes move shared-quest availability — the reference sweeps here (0654).
|
||||
quest.bump_reask();
|
||||
}
|
||||
SessionEvent::PartyCommandResult {
|
||||
operation,
|
||||
@@ -1172,7 +1188,12 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::QuestDetail(d) => quest_detail(d, &mut quest),
|
||||
SessionEvent::QuestProgress(p) => quest_progress(p, &mut quest),
|
||||
SessionEvent::QuestOffer(o) => quest_offer(o, &mut quest),
|
||||
SessionEvent::QuestComplete(c) => quest_complete(c, &mut quest),
|
||||
SessionEvent::QuestComplete(c) => {
|
||||
// The turn-in result — the `SMSG_QUESTGIVER_*` demux the reference sweeps from
|
||||
// (0654): every other giver's `!`/`?` can move the moment a quest is handed in.
|
||||
quest_complete(c, &mut quest);
|
||||
quest.bump_reask();
|
||||
}
|
||||
// Quest log (decision 0088's deferred second slice): the full template feeds the log
|
||||
// window's ask-once detail cache; the `SMSG_QUESTUPDATE_*` toasts have no dedicated
|
||||
// window of their own on this server (no ErrorsFrame-style transient panel yet), so they
|
||||
@@ -1185,20 +1206,30 @@ pub(super) fn apply_net_updates(
|
||||
entry,
|
||||
count,
|
||||
required,
|
||||
} => quest_objective_kill(entry, count, required),
|
||||
} => {
|
||||
quest_objective_kill(entry, count, required);
|
||||
quest.bump_reask();
|
||||
}
|
||||
SessionEvent::QuestObjectiveItem { item_id, count } => {
|
||||
quest_objective_item(item_id, count)
|
||||
quest_objective_item(item_id, count);
|
||||
quest.bump_reask();
|
||||
}
|
||||
SessionEvent::QuestObjectivesComplete { quest_id } => {
|
||||
quest_objectives_complete(quest_id)
|
||||
// The `SMSG_QUESTUPDATE_*` family: the turn-in `?` can go gold with no quest-log
|
||||
// field change of its own, so the reference sweeps from these handlers (0654).
|
||||
quest_objectives_complete(quest_id);
|
||||
quest.bump_reask();
|
||||
}
|
||||
SessionEvent::QuestFailed { quest_id, timed } => {
|
||||
quest_failed(
|
||||
quest_id,
|
||||
timed,
|
||||
&mut quest_log,
|
||||
&net_commands,
|
||||
&mut chat_log,
|
||||
);
|
||||
quest.bump_reask();
|
||||
}
|
||||
SessionEvent::QuestFailed { quest_id, timed } => quest_failed(
|
||||
quest_id,
|
||||
timed,
|
||||
&mut quest_log,
|
||||
&net_commands,
|
||||
&mut chat_log,
|
||||
),
|
||||
SessionEvent::QuestLogFull => quest_log_full(&mut chat_log),
|
||||
SessionEvent::QuestGiverRefused { quest_id, reason } => {
|
||||
quest_giver_refused(quest_id, reason, &mut chat_log)
|
||||
|
||||
@@ -102,6 +102,18 @@ pub(super) fn object_create(
|
||||
// carries it.
|
||||
if matches!(kind, EntityKind::GameObject) {
|
||||
go_templates.request(guid, net_commands);
|
||||
// Where and how big, the moment it streams in — the readout that answers "is this prop in
|
||||
// the wrong place, or the wrong size, or just drawn wrong" without a guess (decision 0637:
|
||||
// the duel flag read as huge and mislocated, and nothing in the client could say which).
|
||||
// `RUST_LOG=benilla::net::apply::objects=debug`.
|
||||
debug!(
|
||||
"gameobject spawn: entry {:?} display {display_id:?} type {go_type:?} \
|
||||
pos [{:.2}, {:.2}, {:.2}] scale {scale}",
|
||||
fields.object_entry(),
|
||||
position[0],
|
||||
position[1],
|
||||
position[2],
|
||||
);
|
||||
}
|
||||
if let Some(&e) = index.0.get(&guid) {
|
||||
// Re-create of a tracked guid: refresh identity + pose, drop any stale path. A create
|
||||
|
||||
@@ -131,8 +131,9 @@ pub(super) fn disconnected(
|
||||
pending_transfer.0 = None;
|
||||
status.connected = false;
|
||||
// A dead socket's last RTT is stale; the write thread resets the shared ping clock itself
|
||||
// when the reconnect hands it a fresh writer.
|
||||
status.latency_ms = None;
|
||||
// when the reconnect hands it a fresh writer. The averaged ring goes with it — the next
|
||||
// connection's latency is its own, so `GetNetStats` reads unmeasured until its first pong.
|
||||
status.clear_rtt();
|
||||
// Teardown (decision 0065): despawn every streamed entity except the self avatar —
|
||||
// it stays the local puppet (controller + camera keep working); the reconnect's
|
||||
// re-create refreshes it in place. Immediate despawn, not `DespawnFade`: a
|
||||
@@ -310,8 +311,8 @@ pub(super) fn reputation_delta(standings: Vec<(u32, i32)>, reputations: &mut Rep
|
||||
|
||||
/// The keepalive echo (`SMSG_PONG`): match it against the shared ping clock to measure the
|
||||
/// round trip. Stored back on the clock (the next ping's lastRtt field — what the real client
|
||||
/// reports) and surfaced as the panel's latency readout. A stale or mismatched sequence (a pong
|
||||
/// straddling a reconnect) is dropped.
|
||||
/// reports), pushed into the RTT ring the UI's `GetNetStats` averages, and surfaced as the panel's
|
||||
/// latency readout. A stale or mismatched sequence (a pong straddling a reconnect) is dropped.
|
||||
pub(super) fn pong(sequence: u32, ping: &PingShared, status: &mut NetStatus) {
|
||||
let mut clock = ping.0.lock().expect("ping clock");
|
||||
if let Some(sent) = clock.sent_at.filter(|_| clock.sequence == sequence) {
|
||||
@@ -320,7 +321,7 @@ pub(super) fn pong(sequence: u32, ping: &PingShared, status: &mut NetStatus) {
|
||||
info!("net: pong seq={sequence} rtt={rtt}ms (keepalive live)");
|
||||
}
|
||||
clock.last_rtt_ms = Some(rtt);
|
||||
status.latency_ms = Some(rtt);
|
||||
status.record_rtt(rtt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -857,6 +857,7 @@ fn writer_loop(
|
||||
ClientCommand::SetRaidTarget { icon, guid } => w.raid_target_set(icon, guid),
|
||||
ClientCommand::DuelAccepted { arbiter } => w.duel_accepted(arbiter),
|
||||
ClientCommand::DuelCancelled { arbiter } => w.duel_cancelled(arbiter),
|
||||
ClientCommand::TogglePvp => w.toggle_pvp(),
|
||||
ClientCommand::TaxiNodeStatusQuery { guid } => w.taxi_node_status_query(guid),
|
||||
ClientCommand::TaxiQueryNodes { guid } => w.taxi_query_available_nodes(guid),
|
||||
ClientCommand::ActivateTaxi {
|
||||
|
||||
@@ -10,7 +10,9 @@ use benilla_protocol::EntityKind;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::collision::player_query_filter;
|
||||
use crate::liquid::{water_surface_at, WaterChunkInfo, WADE_MAX};
|
||||
use crate::entities::CollisionHeight;
|
||||
use crate::liquid::{water_surface_at, WaterChunkInfo};
|
||||
use crate::player::swim_enter_depth;
|
||||
|
||||
use super::super::NetEntity;
|
||||
|
||||
@@ -319,19 +321,31 @@ pub(in crate::net) fn ground_clamp_creatures(
|
||||
}
|
||||
}
|
||||
|
||||
/// The interim exit band (yd) below [`WADE_MAX`] where a marked swimmer stays marked — mirrors the
|
||||
/// player boundary's verified 1/36-yd hysteresis (decision 0226) so the regime can't flicker at the
|
||||
/// threshold. Both numbers are the INTERIM creature stand-in (see [`CreatureSwimming`]).
|
||||
/// What [`mark_swimming_creatures`] reads per unit: identity, kind, pose, its own collision height
|
||||
/// (`None` only on a unit's first frame, before the stamp), and whether it is already marked.
|
||||
type SwimMarkQuery = (
|
||||
Entity,
|
||||
&'static NetEntity,
|
||||
&'static Transform,
|
||||
Option<&'static CollisionHeight>,
|
||||
Has<CreatureSwimming>,
|
||||
);
|
||||
|
||||
/// The exit band (yd) below the swim boundary where a marked swimmer stays marked — the player
|
||||
/// boundary's **VERIFIED** 1/36-yd hysteresis (`0x7ff9d0`), an absolute distance independent of
|
||||
/// collision height, so it is subtracted from the boundary rather than scaled with it.
|
||||
const CREATURE_SWIM_EXIT_BAND: f32 = 1.0 / 36.0;
|
||||
|
||||
/// A **creature** currently past the swim boundary — the client-side derivation of the swim state
|
||||
/// the wire never carries for creatures: vmangos sets `MOVEFLAG_SWIMMING` only from player packets,
|
||||
/// and a water creature's create block + splines carry no swim marker at all (verified
|
||||
/// `vmangos-src`, 2026-07-17). The real client derives every unit's swim mode locally per frame
|
||||
/// (the `0x6030c0` boundary family — decision 0226 pinned it for the player); how exactly it does so
|
||||
/// for a *creature* (reference point, per-unit collision height) is in the dispatched swim §5 —
|
||||
/// until that verdict, this marks a `Unit` whose feet sit deeper than [`WADE_MAX`], with a small
|
||||
/// exit band ([`CREATURE_SWIM_EXIT_BAND`]) so the state can't flicker. **INTERIM.**
|
||||
/// (the `0x6030c0` boundary family — decision 0226 pinned it for the player). This derives it the
|
||||
/// same way, on the creature's **own** collision height: marked once its feet sit deeper than
|
||||
/// `0.75·h` ([`swim_enter_depth`]), unmarked once they rise a [`CREATURE_SWIM_EXIT_BAND`] above it
|
||||
/// so the state can't flicker. The flat 2.0-yd stand-in it used before decision 0645 is retired
|
||||
/// (0464's `collisionHeight` plumb was what it was waiting on); what stays open is the *reference
|
||||
/// point* the real client measures a creature's depth from.
|
||||
///
|
||||
/// Consumers: the swim-gait leg of the animation selector (`creature_anim::select::unify`), the
|
||||
/// [`ground_clamp_creatures`] exemption above, and the enter-water splash (`sound::water`).
|
||||
@@ -343,10 +357,10 @@ pub(crate) struct CreatureSwimming;
|
||||
/// clamp the same frame (Bevy inserts the deferred-command sync point for the chain).
|
||||
pub(in crate::net) fn mark_swimming_creatures(
|
||||
mut commands: Commands,
|
||||
units: Query<(Entity, &NetEntity, &Transform, Has<CreatureSwimming>)>,
|
||||
units: Query<SwimMarkQuery>,
|
||||
water: Query<&WaterChunkInfo>,
|
||||
) {
|
||||
for (e, net, t, marked) in &units {
|
||||
for (e, net, t, collision, marked) in &units {
|
||||
if net.kind != EntityKind::Unit {
|
||||
continue; // players carry the real flag on the wire; GameObjects don't swim
|
||||
}
|
||||
@@ -354,10 +368,11 @@ pub(in crate::net) fn mark_swimming_creatures(
|
||||
// `None`: no per-unit interior claim exists (CurrentWmoInterior is the local player's
|
||||
// down-ray), so both sources answer here — the pre-0634 behaviour, named in `liquid_at`.
|
||||
let depth = water_surface_at(water.iter(), wow, None).map_or(f32::MIN, |s| s - wow[2]);
|
||||
let boundary = swim_enter_depth(collision.copied().unwrap_or_default().0);
|
||||
let swimming = if marked {
|
||||
depth >= WADE_MAX - CREATURE_SWIM_EXIT_BAND
|
||||
depth >= boundary - CREATURE_SWIM_EXIT_BAND
|
||||
} else {
|
||||
depth > WADE_MAX
|
||||
depth > boundary
|
||||
};
|
||||
if swimming != marked {
|
||||
if swimming {
|
||||
|
||||
@@ -39,6 +39,7 @@ mod movement_net;
|
||||
// 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_cam;
|
||||
mod probe_look;
|
||||
mod server_ride;
|
||||
mod setup;
|
||||
@@ -64,7 +65,13 @@ use state::{
|
||||
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, PlayerCapsule, CAPSULE_HEIGHT, GRAVITY, TERMINAL_VELOCITY};
|
||||
pub(crate) use state::{
|
||||
Player, PlayerCapsule, CAPSULE_HEIGHT, DEFAULT_COLLISION_HEIGHT, GRAVITY, TERMINAL_VELOCITY,
|
||||
};
|
||||
/// The swim boundary `0.75·h` — and therefore the **wade ceiling**, since wading is the implicit
|
||||
/// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature
|
||||
/// swim marker and the footstep splash slot, which have no `Player` of their own.
|
||||
pub(crate) use swim::swim_enter_depth;
|
||||
|
||||
/// 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.)
|
||||
@@ -75,6 +82,9 @@ impl Plugin for PlayerPlugin {
|
||||
if let Some(look) = probe_look::from_env() {
|
||||
app.insert_resource(look);
|
||||
}
|
||||
if let Some(cam) = probe_cam::from_env() {
|
||||
app.insert_resource(cam);
|
||||
}
|
||||
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
|
||||
@@ -103,6 +113,17 @@ impl Plugin for PlayerPlugin {
|
||||
.run_if(resource_exists::<probe_look::ProbeLook>)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// The scripted camera park (`WOW_PROBE_CAM`, decision 0653): holds yaw/pitch/zoom so an
|
||||
// unattended burst can frame a subject that isn't on the ground. Same slot as the turn
|
||||
// above — `control` reads the rig right after — and equally inert without the env var.
|
||||
.add_systems(
|
||||
Update,
|
||||
probe_cam::drive_probe_cam
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(resource_exists::<probe_cam::ProbeCam>)
|
||||
.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).
|
||||
@@ -118,6 +139,17 @@ impl Plugin for PlayerPlugin {
|
||||
// net drain, and dropping `active` re-arms the take-control latch for the next login
|
||||
// (possibly a different character). Ungated — the message lands as the state flips.
|
||||
.add_systems(Update, wire_in::release_on_logout.in_set(WorldStage::Input))
|
||||
// Mirror our body's collision height onto `Player` for the swim arm (decision 0645).
|
||||
// A *continuous* sync rather than a one-shot at take-control, for the reason the
|
||||
// take-control branch itself records: a cross-map worldport re-streams the `SelfPlayer`
|
||||
// entity, so anything latched on that edge is lost on transfer. Before `control`, which
|
||||
// is where the swim depth lines are evaluated.
|
||||
.add_systems(
|
||||
Update,
|
||||
mirror_self_collision_height
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control),
|
||||
)
|
||||
// The self-avatar zoom-in fade rides the same `MeshTag`/material channel as the interior
|
||||
// classifier + the appear/despawn fades, so it must run *after* both to win the frame while
|
||||
// fading (and yield to them otherwise). It also writes `Visibility` (the first-person
|
||||
@@ -138,6 +170,21 @@ impl Plugin for PlayerPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the streamed `SelfPlayer`'s [`crate::entities::CollisionHeight`] onto [`Player`] — the
|
||||
/// avatar's swim depth lines are fractions of it (decision 0645), and the swim arm runs off the
|
||||
/// resource, not the entity. One entity, one copy: no work until our body streams in, and it
|
||||
/// re-syncs itself after a worldport re-streams that entity under a new one.
|
||||
fn mirror_self_collision_height(
|
||||
mut player: ResMut<Player>,
|
||||
self_player: Query<&crate::entities::CollisionHeight, With<SelfPlayer>>,
|
||||
) {
|
||||
if let Ok(&h) = self_player.single() {
|
||||
if player.collision_height != h {
|
||||
player.collision_height = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Camera + avatar controller. Free-flies until the server reports our position; then takes
|
||||
/// third-person control (WASD walks the avatar; right-drag turns it, left-drag orbits the camera,
|
||||
/// wheel zooms) and streams our movement to the server as the confirmed mover. `F` toggles free-fly.
|
||||
@@ -670,6 +717,9 @@ fn control(
|
||||
// doesn't flicker between the two physics regimes.
|
||||
let surface_y = swim::surface_over_feet(water, player.pos, indoors);
|
||||
let swimming = swim::update_swimming(&mut player, surface_y, time.elapsed_secs());
|
||||
if let Some(surface) = surface_y {
|
||||
move_trace::swim(player.pos.y, surface, swimming, player.collision_height.0);
|
||||
}
|
||||
// Space while swimming = the ref's Jump routing (decision 0487, superseding 0479),
|
||||
// fired on the PRESS EDGE only — one hop per press, a held key does not re-fire
|
||||
// (decision 0498, director-verified on the ref; 0487's held-chaining was our
|
||||
@@ -826,6 +876,7 @@ fn control(
|
||||
capsule,
|
||||
dir3 * dir_speed,
|
||||
surface,
|
||||
|feet| swim::surface_over_feet(water, feet, indoors),
|
||||
);
|
||||
// The surface redirect (decisions 0499+0505 — a NAMED DIVERGENCE, see
|
||||
// `swim::cap_redirect`): when the rise capped at the rest line, the stroke went
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! The mover's side of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]), two tags:
|
||||
//! The mover's side of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]), three 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.
|
||||
//! - **`swim`** — one line per frame *over liquid* ([`swim`]): the waterline, the feet, the depth
|
||||
//! against the two latch thresholds, and which physics regime ran. A water-feel report ("it
|
||||
//! jitters at the surface") is a question about the depth signal frame by frame, and no other
|
||||
//! tag carries it: `move` is emitted by the walk arm only, so the swim frames of a flapping
|
||||
//! latch are exactly the ones it drops (decision 0644).
|
||||
//! - **`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
|
||||
@@ -49,6 +54,41 @@ pub(super) fn sent(kind: crate::net::MoveKind, flags: u32, facing: f32, pos: [f3
|
||||
);
|
||||
}
|
||||
|
||||
/// One `swim` line per frame the avatar is over liquid: the waterline, the feet, the submersion
|
||||
/// depth and the two latch thresholds it is being compared against, plus the regime that ran. The
|
||||
/// `<` / `>` markers flag the frames where the depth crossed a threshold, so a latch that is
|
||||
/// flapping reads as a column of them rather than something to be inferred from a Z column.
|
||||
///
|
||||
/// `h` is the avatar's own collision height, and the thresholds are printed **derived from it**
|
||||
/// rather than from a constant (decision 0645) — a trace that quoted a human's 1.52 while a gnome
|
||||
/// latched at 0.86 would read as a bug in the latch instead of the height, which is precisely the
|
||||
/// misdirection that let the constant survive this long. It is echoed on the line so a capture says
|
||||
/// which body it was taken on.
|
||||
pub(super) fn swim(feet_y: f32, surface_y: f32, swimming: bool, h: f32) {
|
||||
if !dbg_trace::enabled() {
|
||||
return;
|
||||
}
|
||||
let (enter, exit) = (
|
||||
super::swim::swim_enter_depth(h),
|
||||
super::swim::swim_exit_depth(h),
|
||||
);
|
||||
let depth = surface_y - feet_y;
|
||||
let band = if depth > enter {
|
||||
'>'
|
||||
} else if depth < exit {
|
||||
'<'
|
||||
} else {
|
||||
'='
|
||||
};
|
||||
dbg_trace::line(
|
||||
"swim",
|
||||
&format!(
|
||||
"y {feet_y:9.3} surf {surface_y:9.3} depth {depth:6.3} {band} [{exit:.3}..{enter:.3}] h {h:.3} mode={}",
|
||||
if swimming { "swim" } else { "walk" }
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static PREV_GROUNDED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
pub(super) fn frame(f: Frame) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `WOW_PROBE_CAM` — the scripted **camera park**: hold the third-person rig at an absolute yaw,
|
||||
//! pitch and zoom so an unattended probe can *look at* a named thing (decision 0653).
|
||||
//!
|
||||
//! [`super::probe_look`] turns the avatar's **aim**; that is the right lever for the facing stream,
|
||||
//! and the wrong one for framing a subject — it never touches `FlyCam::pitch`, which world entry
|
||||
//! pins at `-0.45` rad (26° **down**). Everything above the horizon was therefore unreachable
|
||||
//! headlessly: a `.go` to a pinned coordinate lands with the camera staring at the dirt, so the
|
||||
//! tower / crystal / rock the report is *about* is off the top of the frame. That is why the first
|
||||
//! C4 flicker burst came back clean — the subject was never in it.
|
||||
//!
|
||||
//! Format: `WOW_PROBE_CAM="<yaw_deg>,<pitch_deg>[,<dist_yd>]@<start_s>[:<pan_deg_per_s>][;…]"`,
|
||||
//! e.g. `"180,12,25@25"` — from 25 s on, face south-ish, 12° **up**, 25 yd back. Yaw and pitch are
|
||||
//! absolute (world yaw as the rig spells it, `+pitch` = up); the distance is optional and left alone
|
||||
//! when omitted. Entries are held **every frame** once armed, not set once: a teleport, a settle, or
|
||||
//! a wire-in reset would otherwise knock the pose off between the arm and the shot. The last armed
|
||||
//! entry wins, so a `;` list is a sequence of poses.
|
||||
//!
|
||||
//! The optional **pan** turns the parked yaw at a constant rate from that pose's own start
|
||||
//! (`"140,16,22@20:8"` — park, then sweep 8°/s). It exists because a parked camera is blind to a
|
||||
//! whole class of defect: the director's read of the Far Watch Post tower is that it "mostly only
|
||||
//! flickers while moving the cam, not while still" — z-fighting's signature, and precisely the
|
||||
//! condition decision 0653's still burst cannot see (decision 0656). Pair it with `benilla-visual
|
||||
//! flicker`'s **toggle map**, the reading that survives a moving view.
|
||||
//!
|
||||
//! It writes the rig fields directly for the same reason `probe_look` writes `face_yaw` directly —
|
||||
//! faking mouse motion needs the OS cursor inside the viewport, which an unfocused probe window
|
||||
//! cannot promise, and the camera *plumbing* is not what is under test. From these fields onward
|
||||
//! this is the identical path a mouse-drag takes.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::camera::{CameraControl, FlyCam};
|
||||
|
||||
/// One parked pose: absolute yaw/pitch (radians), an optional orbit distance (yards), when it takes
|
||||
/// over, and an optional constant yaw pan (rad/s) applied from that moment.
|
||||
struct Pose {
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
distance: Option<f32>,
|
||||
at: f32,
|
||||
pan: f32,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub(super) struct ProbeCam {
|
||||
poses: Vec<Pose>,
|
||||
}
|
||||
|
||||
/// Parse the env script. Absent or unparseable entries yield no poses (with a warning), so the probe
|
||||
/// is inert unless asked for.
|
||||
pub(super) fn from_env() -> Option<ProbeCam> {
|
||||
let spec = std::env::var("WOW_PROBE_CAM").ok()?;
|
||||
let poses: Vec<Pose> = spec
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| {
|
||||
let (pose, at) = s.split_once('@')?;
|
||||
let mut parts = pose.split(',').map(str::trim);
|
||||
let yaw = parts.next()?.parse::<f32>().ok()?;
|
||||
let pitch = parts.next()?.parse::<f32>().ok()?;
|
||||
// A third field is the orbit distance; anything past it is a typo worth refusing.
|
||||
let distance = match (parts.next(), parts.next()) {
|
||||
(None, _) => Some(None),
|
||||
(Some(d), None) => d.parse::<f32>().ok().map(Some),
|
||||
(Some(_), Some(_)) => None,
|
||||
}?;
|
||||
// `@<start>` or `@<start>:<pan>` — the same `at:rate` shape `WOW_PROBE_LOOK` uses.
|
||||
let (at, pan) = match at.trim().split_once(':') {
|
||||
Some((at, pan)) => (at.trim(), pan.trim().parse::<f32>().ok()?),
|
||||
None => (at.trim(), 0.0),
|
||||
};
|
||||
Some(Pose {
|
||||
yaw: yaw.to_radians(),
|
||||
pitch: pitch.to_radians(),
|
||||
distance,
|
||||
at: at.parse::<f32>().ok()?,
|
||||
pan: pan.to_radians(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if poses.is_empty() {
|
||||
warn!("probe-cam: no usable pose in {spec:?} (want e.g. 180,12,25@25) — inert");
|
||||
}
|
||||
(!poses.is_empty()).then_some(ProbeCam { poses })
|
||||
}
|
||||
|
||||
/// Hold the camera at the latest armed pose. Runs in `WorldStage::Input` **before** `control`, so
|
||||
/// the frame that sees the pose is the frame that renders it.
|
||||
pub(super) fn drive_probe_cam(
|
||||
probe: Res<ProbeCam>,
|
||||
time: Res<Time>,
|
||||
mut rig: ResMut<CameraControl>,
|
||||
mut cam: Query<&mut FlyCam, With<super::camera::WorldCamera>>,
|
||||
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();
|
||||
// Latest armed wins, so a `;` list reads as a timeline.
|
||||
let Some(pose) = probe.poses.iter().rfind(|p| now >= p.at) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut cam) = cam.single_mut() else {
|
||||
return;
|
||||
};
|
||||
// Absolute, from the pose's own start — so the sweep is reproducible frame-for-frame across
|
||||
// runs rather than accumulating whatever frame times this run happened to get.
|
||||
cam.yaw = pose.yaw + pose.pan * (now - pose.at);
|
||||
cam.pitch = pose.pitch;
|
||||
if let Some(d) = pose.distance {
|
||||
// Both, or the wheel glide eases `distance` back toward the old target every frame and the
|
||||
// parked shot drifts through the whole zoom while the burst is running.
|
||||
rig.distance = d;
|
||||
rig.target_distance = d;
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,25 @@ pub(super) const STATIONARY_CHASE_RATE: f32 = 8.0;
|
||||
|
||||
/// Player capsule radius (yd) — the vanilla box's ±1/3 half-width.
|
||||
pub(super) const CAPSULE_RADIUS: f32 = 1.0 / 3.0;
|
||||
/// Player capsule total height (yd) — matches the vanilla 2.028-yd box (`CMovement+0xb4`'s ctor
|
||||
/// default collision height). `pub(crate)`: the water-splash depth line (`sound::water`) scales it
|
||||
/// per unit as the collision-height stand-in.
|
||||
/// Player capsule total height (yd) — the **movement** capsule avian sweeps, deliberately a
|
||||
/// constant. Numerically the vanilla ctor-default collision height it was derived from
|
||||
/// ([`DEFAULT_COLLISION_HEIGHT`]), but it is not the same quantity and no longer stands in for one:
|
||||
/// a unit's real collision height is per-model and lives on [`crate::entities::CollisionHeight`]
|
||||
/// (decision 0645). This one is a *feel knob* per the block header above — it feeds the swept box,
|
||||
/// the step-vs-fall election's reach and the head/feet offsets, where going per-race would change
|
||||
/// where every short race can walk, step and fit. That is a movement-fidelity question of its own
|
||||
/// (our kinematic capsule vs the reference's k-DOP), not the depth-line question 0645 settled, so
|
||||
/// the two are kept apart on purpose rather than by oversight.
|
||||
pub(crate) const CAPSULE_HEIGHT: f32 = 2.027_777_7;
|
||||
|
||||
/// The client's own **empty-world collision height** (yd) — the `CMovement` ctor's immediate
|
||||
/// `0x4001c71c` at `0x616fd8` (VERIFIED, wow-re `collision.md` "the collision volume"), which the
|
||||
/// per-unit param setter `0x6174b0` overwrites from the unit's model. This is the fallback every
|
||||
/// depth line takes for a unit whose display id doesn't resolve to a `CreatureModelData` row — the
|
||||
/// same role it plays in the reference, and what vmangos falls back to as well (its `2.f`).
|
||||
///
|
||||
/// It is **not** a stand-in for a real unit's height: see [`crate::entities::CollisionHeight`].
|
||||
pub(crate) const DEFAULT_COLLISION_HEIGHT: f32 = 2.027_777_7;
|
||||
/// Downward gravity (yd/s²) — binary-VERIFIED vanilla value (set on avian's `Gravity` too; matches
|
||||
/// vmangos `Movement::gravity` exactly). Shared with the remote dead-reckoner ([`crate::net`]), which
|
||||
/// integrates a relayed jump's arc under the same gravity so an observer's view matches the mover's.
|
||||
@@ -265,6 +280,14 @@ pub(crate) struct Player {
|
||||
/// swim gait and streaming it), and pitches its body to the swim heading. Hysteresis-latched
|
||||
/// (see [`super::swim::update_swimming`]) so wading the boundary doesn't flicker.
|
||||
pub(crate) swimming: bool,
|
||||
/// **Our own collision height** — the avatar's copy of [`crate::entities::CollisionHeight`],
|
||||
/// mirrored onto this resource because the swim arm runs off it and never touches the ECS
|
||||
/// entity. Every swim depth line is a fraction of it, which is why a gnome floats with her head
|
||||
/// out and a night elf sits 0.3 yd deeper (decision 0645). Kept as the component's own type
|
||||
/// rather than a bare `f32` precisely so `Player::default()` cannot seed it to **zero** — at
|
||||
/// zero every depth line collapses to 0 and the avatar swims on dry land. It defaults to
|
||||
/// [`DEFAULT_COLLISION_HEIGHT`] and is replaced once our body's display id resolves.
|
||||
pub(crate) collision_height: crate::entities::CollisionHeight,
|
||||
/// The **swim pitch** (radians, +up) — the client's persistent per-unit pitch (`CMovement+0x20`,
|
||||
/// the swim §5's TU-B): **held** when unsteered (an idle floater keeps its pitch — never
|
||||
/// auto-leveled; the only zeroing writer `0x7c6e80` fires from stop-swim/teleport, not mouse
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! The enter/exit boundary is **VERIFIED** against the real client (wow-re
|
||||
//! `collision/scratch/swim-transition.md`, resolving benilla-pins **B7**): the per-frame local decision
|
||||
//! `0x6030c0` fires `MOVEFLAG_SWIMMING 0x00200000` off `depth = liquidSurface − feetZ` vs a fraction of
|
||||
//! the unit's collision height, with a genuine 1/36-yd hysteresis band (see [`SWIM_ENTER_DEPTH`]).
|
||||
//! the unit's collision height, with a genuine 1/36-yd hysteresis band (see [`swim_enter_depth`]).
|
||||
//! There is **no** separate wade/water movement flag (`0x40000000` is HOVER — wading is just the
|
||||
//! implicit in-liquid-below-threshold state).
|
||||
//!
|
||||
@@ -15,9 +15,14 @@
|
||||
//! earlier surface-follow reading): while SWIMMING the mover routes through the client's *floating*
|
||||
//! resolver, which **bypasses gravity entirely** — an idle swimmer's depth is **frozen** (no sink, no
|
||||
//! rise, no ease), the vertical comes only from the pitched travel velocity, and the one constraint is
|
||||
//! a **hard top-cap at `surface − 0.75·collisionHeight`** (`0x632ba0` ×0.75): the feet can never rise
|
||||
//! above the resting waterline, so a surfacing swimmer stops ~three-quarters submerged, head out.
|
||||
//! There is no spring and no resting *seek* — the old `REST_SUBMERSION`/`BUOYANCY_RATE` ease is gone.
|
||||
//! a **hard top-cap at `surface − 0.75·collisionHeight`** (`0x632ba0` ×0.75): the feet can never be
|
||||
//! shallower than the resting waterline, so a surfacing swimmer stops ~three-quarters submerged, head
|
||||
//! out. That cap constrains the **position, both ways** — wow-re's Q5 states it as "feet cannot come
|
||||
//! shallower than `surface − 0.75·collisionHeight`", a hard clamp re-tested each active substep — and
|
||||
//! what leaves a *stroking* swimmer above the line is usually not a rise but the surface **descending**
|
||||
//! under them, which is why the constraint has to be re-satisfied and not merely guarded against
|
||||
//! ([`settle_to_rest`], decision 0644). It is still no spring and no resting *seek* — the old
|
||||
//! `REST_SUBMERSION`/`BUOYANCY_RATE` ease is gone, and a swimmer *below* the line is never pulled up.
|
||||
//! Reaching the cap does NOT bleed the stroke off: the capped rise redirects **level, at full
|
||||
//! speed** — surface swimming — and the presented pitch levels with it ([`cap_redirect`],
|
||||
//! decisions 0499+0505). This is a **named divergence**: the §5 (wow-re
|
||||
@@ -77,20 +82,39 @@ const SWIM_DEPTH_FRAC: f32 = 0.75;
|
||||
/// `0.75·h`, leave against `0.75·h − 1/36`, so between them the swim state holds (`0x603100`/`0x6031c0`).
|
||||
const SWIM_HYSTERESIS: f32 = 1.0 / 36.0;
|
||||
|
||||
/// Submersion depth (yd, water surface above the feet) to **start** swimming — VERIFIED `0.75·h` from the
|
||||
/// feet (`h` = the unit's collision height, our [`CAPSULE_HEIGHT`]): ≈1.52 yd for a human, water covering
|
||||
/// ~three-quarters of the box (chest/neck deep).
|
||||
const SWIM_ENTER_DEPTH: f32 = SWIM_DEPTH_FRAC * CAPSULE_HEIGHT;
|
||||
/// Submersion depth (yd) below which swimming **stops** — VERIFIED `0.75·h − 1/36` (the lower edge of the
|
||||
/// hysteresis band); also stops the instant there's no liquid over the feet.
|
||||
const SWIM_EXIT_DEPTH: f32 = SWIM_ENTER_DEPTH - SWIM_HYSTERESIS;
|
||||
/// Submersion depth (yd, water surface above the feet) to **start** swimming — VERIFIED `0.75·h`
|
||||
/// from the feet, where `h` is **the unit's own collision height**
|
||||
/// ([`crate::entities::CollisionHeight`]): water covering ~three-quarters of its collision box,
|
||||
/// chest/neck deep. ≈1.52 yd for a human male, ≈0.86 for a gnome female, ≈1.83 for a night elf male.
|
||||
///
|
||||
/// It was `0.75 × CAPSULE_HEIGHT` — one constant for every race — until decision 0645. The
|
||||
/// *fraction* was right all along; the height it multiplied was a human's, so a gnome's rest line
|
||||
/// sat below her own head and she could never surface (B76).
|
||||
///
|
||||
/// This is also the **wade ceiling**: wading is the implicit in-liquid-but-not-swimming state
|
||||
/// (there is no wade movement flag — `0x40000000` is HOVER), so "deepest water you can still wade"
|
||||
/// and "shallowest water you swim in" are one number. The consumers with no `Player` of their own —
|
||||
/// the creature swim marker and the footstep splash slot — call it through `crate::player`, rather
|
||||
/// than keeping the second spelling `liquid::WADE_MAX` was.
|
||||
pub(crate) fn swim_enter_depth(h: f32) -> f32 {
|
||||
SWIM_DEPTH_FRAC * h
|
||||
}
|
||||
/// Submersion depth (yd) below which swimming **stops** — VERIFIED `0.75·h − 1/36` (the lower edge
|
||||
/// of the hysteresis band); also stops the instant there's no liquid over the feet. The band itself
|
||||
/// is an absolute `1/36` yd, independent of `h` (wow-re: "the band is exactly 1/36 yd … independent
|
||||
/// of `h`") — so it is subtracted, never scaled.
|
||||
pub(super) fn swim_exit_depth(h: f32) -> f32 {
|
||||
swim_enter_depth(h) - SWIM_HYSTERESIS
|
||||
}
|
||||
|
||||
/// The hard **top-cap line** (yd below the surface) a rising swimmer stops at — feet at
|
||||
/// `surface − 0.75·h`, i.e. ~three-quarters submerged, head out. VERIFIED: the floating resolver's
|
||||
/// collision top-cap plane at `surface − 0.75·collisionHeight` (`0x632ba0` ×0.75, the open-bottom
|
||||
/// k-DOP's one top plane). The same `0.75·h` as the enter threshold, so a capped swimmer sits above
|
||||
/// the leave threshold and can't flicker out of the mode.
|
||||
const REST_CAP: f32 = SWIM_ENTER_DEPTH;
|
||||
fn rest_cap(h: f32) -> f32 {
|
||||
swim_enter_depth(h)
|
||||
}
|
||||
|
||||
/// The **Bevy-Y liquid surface** at the avatar's feet, if any liquid covers that XY — the shared
|
||||
/// swim/buoyancy query. [`liquid_at`] answers in WoW Z; WoW Z maps straight to Bevy Y, so the
|
||||
@@ -136,14 +160,15 @@ pub(super) fn update_swimming(player: &mut Player, surface_y: Option<f32>, now:
|
||||
return false;
|
||||
};
|
||||
let depth = surface - player.pos.y;
|
||||
let h = player.collision_height.0;
|
||||
player.swimming = if player.swimming {
|
||||
depth >= SWIM_EXIT_DEPTH
|
||||
depth >= swim_exit_depth(h)
|
||||
} else {
|
||||
let hop_blocked = player.vel_y > 0.0
|
||||
&& player
|
||||
.airborne_since
|
||||
.is_some_and(|t0| now - t0 < player.jump_zspeed / (2.0 * GRAVITY));
|
||||
depth > SWIM_ENTER_DEPTH && !hop_blocked
|
||||
depth > swim_enter_depth(h) && !hop_blocked
|
||||
};
|
||||
if player.swimming {
|
||||
player.settling = false;
|
||||
@@ -226,13 +251,28 @@ fn cap_redirect(input_vel: Vec3, cap: f32) -> (Vec3, Option<f32>) {
|
||||
)
|
||||
}
|
||||
|
||||
/// How far the feet must sink to satisfy the rest-line constraint — the excess above
|
||||
/// `surface − 0.75·h`, or zero when already at or under it. Pure so the law is pinned without a
|
||||
/// physics world; the *sweep* that carries it out is [`swim_step`]'s, which is what leaves a rising
|
||||
/// lakebed free to hold the feet higher.
|
||||
fn settle_to_rest(feet_y: f32, surface_y: f32, h: f32) -> f32 {
|
||||
(feet_y - (surface_y - rest_cap(h))).max(0.0)
|
||||
}
|
||||
|
||||
/// Advance the avatar one swim frame: the pitched travel velocity through the client's *floating*
|
||||
/// physics (VERIFIED, TU-B — gravity bypassed: an idle swimmer's depth is **frozen**, the vertical
|
||||
/// comes only from `input_vel`), a collide-and-slide against the lakebed/banks, and the hard
|
||||
/// [`REST_CAP`] top line — where a rise doesn't stop dead but **redirects level into full-speed
|
||||
/// surface swimming** ([`cap_redirect`], decision 0499). `input_vel` is the desired 3D velocity
|
||||
/// from the swim controls (the pitched travel basis + ascent); `surface_y` is the Bevy-Y waterline
|
||||
/// over the feet. Writes `player.pos`/`vel_y`/`horiz_vel`.
|
||||
/// [`rest_cap`] line — where a rise doesn't stop dead but **redirects level into full-speed
|
||||
/// surface swimming** ([`cap_redirect`], decision 0499), and where a stroke that ends up *above*
|
||||
/// the line settles back onto it ([`settle_to_rest`], decision 0644).
|
||||
///
|
||||
/// `input_vel` is the desired 3D velocity from the swim controls (the pitched travel basis +
|
||||
/// ascent); `surface_y` is the Bevy-Y waterline over the feet at the START of the frame (the rise
|
||||
/// cap is a limit on *this* frame's climb, so it belongs to the position we climb from), and
|
||||
/// `surface_at` resamples it at wherever the stroke actually lands (the settle has to satisfy the
|
||||
/// constraint where the swimmer ends up — on a river reading the entry waterline instead lags by a
|
||||
/// frame's descent, which on a steep enough surface is the whole hysteresis band). Writes
|
||||
/// `player.pos`/`vel_y`/`horiz_vel`.
|
||||
pub(super) fn swim_step(
|
||||
player: &mut Player,
|
||||
time: &Time,
|
||||
@@ -240,6 +280,7 @@ pub(super) fn swim_step(
|
||||
capsule: &Collider,
|
||||
input_vel: Vec3,
|
||||
surface_y: f32,
|
||||
surface_at: impl Fn(Vec3) -> Option<f32>,
|
||||
) -> SwimOutcome {
|
||||
let dt = time.delta_secs();
|
||||
let half_h = Vec3::Y * (CAPSULE_HEIGHT * 0.5);
|
||||
@@ -256,7 +297,7 @@ pub(super) fn swim_step(
|
||||
// `depth` drops below the leave threshold, so we walk out. dt-based so it can't overshoot the
|
||||
// rest line and dip back under the exit depth (a flicker). No other vertical force exists here —
|
||||
// no gravity, no surface-seek (the verified floating resolver).
|
||||
let rest_feet_y = surface_y - REST_CAP;
|
||||
let rest_feet_y = surface_y - rest_cap(player.collision_height.0);
|
||||
let cap = if dt > 0.0 {
|
||||
((rest_feet_y - player.pos.y) / dt).max(0.0)
|
||||
} else {
|
||||
@@ -274,7 +315,47 @@ pub(super) fn swim_step(
|
||||
&filter,
|
||||
|_hit| MoveAndSlideHitResponse::Accept,
|
||||
);
|
||||
let c = out.position;
|
||||
let mut c = out.position;
|
||||
// **Satisfy the rest line, don't merely guard it.** The cap above only ever refuses to *rise*
|
||||
// through the waterline; that is enough on a lake and wrong on everything else, because the
|
||||
// common way a swimmer ends up above the line is the line coming down to meet them. With the
|
||||
// vertical frozen (no gravity here — verified) a stroke down a river surface that falls ~10%
|
||||
// (measured: Felwood's Felfire Hill channel drops 5.94 yd across 60) loses 0.47 yd/s of depth,
|
||||
// and the whole enter/leave hysteresis band is 1/36 yd — so the latch left swim every ~0.05 s,
|
||||
// the walk mover took over and fell a few centimetres, swim re-latched, forever: the avatar
|
||||
// spent 47% of a downhill swim inside the FALL mover, flipping mode ~10×/s and alternating
|
||||
// SWIMMING/FALLING on the wire (director-reported as bouncing off the surface; live-probe
|
||||
// measured, decision 0644).
|
||||
//
|
||||
// The correction is a **swept** drop, not a position clamp: an earlier attempt clamped
|
||||
// `pos.y` after the slide, which overrode terrain collision, shoved the feet onto the rest
|
||||
// line even where a shallow bottom held them higher, and pinned `depth` at ≥ rest so the
|
||||
// shore exit could never fire. Sweeping it leaves the bottom in charge — wading out of a
|
||||
// lake, the bank holds the feet up, the depth falls under the leave threshold, and we walk
|
||||
// out exactly as before — and unlike folding the sink into `input_vel` it cannot eat the
|
||||
// horizontal stroke that climbs the bank in the first place.
|
||||
//
|
||||
// Gated on a stroke, matching the resolver's own outer gate (`0x634100 test [esi+0x40],0x200f`
|
||||
// — translation or falling, never idle): an idle floater is not resolved at all, so its depth
|
||||
// stays frozen (TU-B(c)).
|
||||
if input_vel != Vec3::ZERO {
|
||||
if let Some(surface_now) = surface_at(c - half_h) {
|
||||
let excess = settle_to_rest(c.y - half_h.y, surface_now, player.collision_height.0);
|
||||
if excess > 0.0 {
|
||||
let drop = ms
|
||||
.cast_move(
|
||||
capsule,
|
||||
c,
|
||||
Quat::IDENTITY,
|
||||
Vec3::NEG_Y * excess,
|
||||
SKIN_WIDTH,
|
||||
&filter,
|
||||
)
|
||||
.map_or(excess, |h| h.distance.min(excess));
|
||||
c.y -= drop;
|
||||
}
|
||||
}
|
||||
}
|
||||
player.pos = c - half_h;
|
||||
// Swim owns its vertical directly; leave a clean zero so exiting into a fall starts from rest and
|
||||
// horiz_vel drives the swim gait's playback rate like every other locomotion clip.
|
||||
@@ -300,9 +381,22 @@ pub(super) fn swim_step(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shipped `CreatureModelData.collisionHeight × displayScale` for three real bodies —
|
||||
/// the numbers `benilla_formats`' `collision_height_is_the_m2_collision_box` pins against the
|
||||
/// client, restated here so these pure tests need no DBCs. Human male is the one the old
|
||||
/// constant happened to match (2.031 vs 2.0278 — 2 mm apart, which is why B76 hid for so long).
|
||||
const HUMAN_MALE: f32 = 2.031;
|
||||
const GNOME_FEMALE: f32 = 1.150; // 1.000 column × 1.15 display scale
|
||||
const NIGHT_ELF_MALE: f32 = 2.438;
|
||||
|
||||
fn player_at(y: f32) -> Player {
|
||||
player_of_height(y, HUMAN_MALE)
|
||||
}
|
||||
|
||||
fn player_of_height(y: f32, h: f32) -> Player {
|
||||
Player {
|
||||
pos: Vec3::new(0.0, y, 0.0),
|
||||
collision_height: crate::entities::CollisionHeight(h),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -312,15 +406,18 @@ mod tests {
|
||||
/// the boundary can't flicker the physics regime frame to frame (wow-re swim-transition `0x6030c0`).
|
||||
#[test]
|
||||
fn swim_entry_and_exit_hysteresis() {
|
||||
// The band is exactly 1/36 yd, independent of height.
|
||||
assert!((SWIM_ENTER_DEPTH - SWIM_EXIT_DEPTH - 1.0 / 36.0).abs() < 1e-6);
|
||||
// Enter is 0.75·collisionHeight from the feet.
|
||||
assert!((SWIM_ENTER_DEPTH - 0.75 * CAPSULE_HEIGHT).abs() < 1e-6);
|
||||
// The band is exactly 1/36 yd, independent of height — so it holds at every race's line.
|
||||
for h in [HUMAN_MALE, GNOME_FEMALE, NIGHT_ELF_MALE] {
|
||||
assert!((swim_enter_depth(h) - swim_exit_depth(h) - 1.0 / 36.0).abs() < 1e-6);
|
||||
// Enter is 0.75·collisionHeight from the feet.
|
||||
assert!((swim_enter_depth(h) - 0.75 * h).abs() < 1e-6);
|
||||
}
|
||||
|
||||
let (enter, exit) = (swim_enter_depth(HUMAN_MALE), swim_exit_depth(HUMAN_MALE));
|
||||
let mut p = player_at(0.0); // feet at y=0, so the surface height *is* the submersion depth
|
||||
let mid_band = (SWIM_ENTER_DEPTH + SWIM_EXIT_DEPTH) * 0.5;
|
||||
let mid_band = (enter + exit) * 0.5;
|
||||
assert!(
|
||||
!update_swimming(&mut p, Some(SWIM_ENTER_DEPTH), 0.0),
|
||||
!update_swimming(&mut p, Some(enter), 0.0),
|
||||
"exactly at the enter depth does not yet swim (strict >)"
|
||||
);
|
||||
assert!(
|
||||
@@ -328,7 +425,7 @@ mod tests {
|
||||
"a depth in the band, entered from walking, stays walking"
|
||||
);
|
||||
assert!(
|
||||
update_swimming(&mut p, Some(SWIM_ENTER_DEPTH + 0.01), 0.0),
|
||||
update_swimming(&mut p, Some(enter + 0.01), 0.0),
|
||||
"past the enter depth starts swimming"
|
||||
);
|
||||
assert!(
|
||||
@@ -336,11 +433,11 @@ mod tests {
|
||||
"the same in-band depth, now entered from swimming, keeps swimming (hysteresis)"
|
||||
);
|
||||
assert!(
|
||||
!update_swimming(&mut p, Some(SWIM_EXIT_DEPTH - 0.01), 0.0),
|
||||
!update_swimming(&mut p, Some(exit - 0.01), 0.0),
|
||||
"dropping below the leave threshold returns to walking"
|
||||
);
|
||||
// Re-enter, then prove no-liquid stops it regardless of depth.
|
||||
assert!(update_swimming(&mut p, Some(SWIM_ENTER_DEPTH + 0.5), 0.0));
|
||||
assert!(update_swimming(&mut p, Some(enter + 0.5), 0.0));
|
||||
assert!(
|
||||
!update_swimming(&mut p, None, 0.0),
|
||||
"no liquid over the feet stops swimming (the binary's inLiquid == 0 → STOP)"
|
||||
@@ -356,14 +453,18 @@ mod tests {
|
||||
fn latching_swim_ends_the_settle_hold() {
|
||||
let mut p = player_at(0.0);
|
||||
p.settling = true;
|
||||
assert!(update_swimming(&mut p, Some(SWIM_ENTER_DEPTH + 1.0), 0.0));
|
||||
assert!(update_swimming(
|
||||
&mut p,
|
||||
Some(swim_enter_depth(HUMAN_MALE) + 1.0),
|
||||
0.0
|
||||
));
|
||||
assert!(!p.settling, "the water is the support settling waited for");
|
||||
|
||||
let mut wading = player_at(0.0);
|
||||
wading.settling = true;
|
||||
assert!(!update_swimming(
|
||||
&mut wading,
|
||||
Some(SWIM_EXIT_DEPTH - 0.5),
|
||||
Some(swim_exit_depth(HUMAN_MALE) - 0.5),
|
||||
0.0
|
||||
));
|
||||
assert!(
|
||||
@@ -386,7 +487,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_hop_relatches_at_half_launch_velocity() {
|
||||
let half_decay = SWIM_JUMP_SPEED / (2.0 * GRAVITY);
|
||||
let deep = Some(SWIM_ENTER_DEPTH + 1.0);
|
||||
let deep = Some(swim_enter_depth(HUMAN_MALE) + 1.0);
|
||||
let mut p = player_at(0.0);
|
||||
p.airborne_since = Some(0.0);
|
||||
p.jump_zspeed = SWIM_JUMP_SPEED;
|
||||
@@ -411,6 +512,120 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The rest line is a **constraint**, not a one-way cap (decision 0644): a stroking swimmer
|
||||
/// left above it settles back onto it, while one below it is never pulled up — the verified
|
||||
/// floating resolver has no upward force but your own stroke, and no downward one at all.
|
||||
#[test]
|
||||
fn the_rest_line_is_satisfied_from_above_and_never_pulls_up() {
|
||||
// The law is the same shape at every body height; run it at all three.
|
||||
for h in [HUMAN_MALE, GNOME_FEMALE, NIGHT_ELF_MALE] {
|
||||
let cap = rest_cap(h);
|
||||
// Exactly on the line: nothing to do.
|
||||
assert_eq!(settle_to_rest(10.0 - cap, 10.0, h), 0.0);
|
||||
// Below it — a dive, or a river swum upstream: no pull up at any depth.
|
||||
assert_eq!(settle_to_rest(10.0 - cap - 0.01, 10.0, h), 0.0);
|
||||
assert_eq!(settle_to_rest(10.0 - cap - 30.0, 10.0, h), 0.0);
|
||||
// Above it, which on a river is the surface having come down to meet the feet: the
|
||||
// sink is exactly the excess, so one satisfied frame lands on the line, not past it.
|
||||
let excess = 0.25;
|
||||
assert!((settle_to_rest(10.0 - cap + excess, 10.0, h) - excess).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// **B76, pinned.** The bug the director reported: a gnome could not swim up to the surface,
|
||||
/// her head stayed well under. The rest line is `0.75·h` below the waterline, so the head
|
||||
/// clears it iff `0.75·h < h` — trivially true *for the unit's own h*, and false the moment
|
||||
/// one body's h is used for another's. With the old constant a gnome female (1.15 yd tall)
|
||||
/// was held 1.52 yd down: 0.37 yd of water **above her head**, no stroke able to reach it,
|
||||
/// because the cap is a hard collision plane and the resolver has no upward force but the cap.
|
||||
///
|
||||
/// Asserted as "every race floats with its head out", which is the property that actually
|
||||
/// matters and which no single constant can satisfy for all of them.
|
||||
#[test]
|
||||
fn every_race_floats_with_its_head_out_of_the_water() {
|
||||
for (label, h) in [
|
||||
("human male", HUMAN_MALE),
|
||||
("gnome female", GNOME_FEMALE),
|
||||
("night elf male", NIGHT_ELF_MALE),
|
||||
] {
|
||||
let submerged = rest_cap(h);
|
||||
assert!(
|
||||
submerged < h,
|
||||
"{label}: rest line {submerged} is at or below the top of a {h}-yd body"
|
||||
);
|
||||
// A quarter of the body clears the water — the verified 1−0.75.
|
||||
assert!(((h - submerged) / h - 0.25).abs() < 1e-6, "{label}");
|
||||
}
|
||||
|
||||
// And the shape of the defect itself, as the loop above would have run it pre-0645: the
|
||||
// one constant applied to a gnome puts the rest line ABOVE her head, so `submerged < h`
|
||||
// fails and she is held under with water to spare. This is what the fix removes.
|
||||
let pre_0645 = rest_cap(crate::player::DEFAULT_COLLISION_HEIGHT);
|
||||
assert!(
|
||||
pre_0645 > GNOME_FEMALE,
|
||||
"the old constant rest line ({pre_0645}) must sit above a {GNOME_FEMALE}-yd gnome — \
|
||||
that was the bug, and if this ever fails the test below is no longer testing it"
|
||||
);
|
||||
assert!(
|
||||
pre_0645 - GNOME_FEMALE > 0.3,
|
||||
"…by a third of a yard of water over her head, not a rounding error"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The director's downhill-river jitter** (decision 0644), as the frame loop that produces
|
||||
/// it. `SLOPE` is Felwood's Felfire Hill channel, measured off the shipped ADT and pinned by
|
||||
/// `liquid::real_data::the_felfire_channel_falls_about_a_tenth_of_a_yard_per_yard`.
|
||||
///
|
||||
/// The two halves are the two vertical laws over the same river. Frozen feet cannot hold the
|
||||
/// latch for a tenth of a second — the surface descends through the whole 1/36-yd hysteresis
|
||||
/// band almost at once, and every crossing hands the avatar to the fall mover and back
|
||||
/// (live-probe measured at ~10 mode flips/s, 47% of the swim spent falling). Satisfying the
|
||||
/// constraint instead pins the depth on the rest line for a run longer than any river.
|
||||
#[test]
|
||||
fn a_descending_surface_does_not_flap_the_swim_latch() {
|
||||
const SLOPE: f32 = 0.099;
|
||||
const DT: f32 = 1.0 / 60.0;
|
||||
const H: f32 = HUMAN_MALE;
|
||||
let cap = rest_cap(H);
|
||||
let surface_after = |secs: f32| 100.0 - SLOPE * SWIM_SPEED * secs;
|
||||
|
||||
// (a) The pre-0644 law — the feet keep their Y while the water leaves them behind.
|
||||
let mut frozen = player_of_height(surface_after(0.0) - cap, H);
|
||||
frozen.swimming = true;
|
||||
let mut left_at = None;
|
||||
for i in 0..600 {
|
||||
let t = i as f32 * DT;
|
||||
if !update_swimming(&mut frozen, Some(surface_after(t)), t) {
|
||||
left_at = Some(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let left_at = left_at.expect("frozen feet cannot hold the latch on a slope");
|
||||
assert!(
|
||||
left_at < 0.1,
|
||||
"the whole 1/36-yd band is spent in {left_at:.3}s of downhill swimming"
|
||||
);
|
||||
|
||||
// (b) The shipped law. The settle runs where `swim_step` runs it — after the stroke, on
|
||||
// the surface at the position the stroke reached.
|
||||
let mut held = player_of_height(surface_after(0.0) - cap, H);
|
||||
held.swimming = true;
|
||||
for i in 0..600 {
|
||||
let t = i as f32 * DT;
|
||||
let surface = surface_after(t);
|
||||
held.pos.y -= settle_to_rest(held.pos.y, surface, H);
|
||||
assert!(
|
||||
update_swimming(&mut held, Some(surface), t),
|
||||
"left swim at frame {i} ({t:.2}s)"
|
||||
);
|
||||
assert!(
|
||||
(surface - held.pos.y - cap).abs() < 1e-4,
|
||||
"frame {i}: depth {} is off the rest line",
|
||||
surface - held.pos.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface redirect (decision 0499): a pitched-up stroke reaching the rest line keeps its
|
||||
/// SPEED and turns level — the ref's full-speed surface swimming — instead of sliding against
|
||||
/// the cap at `cos(pitch)·speed ≈ 0` (the director's "invisible wall"). The presented pitch
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! The session preflight (decision 0649) — one always-on banner naming **what body we just logged
|
||||
//! into**, plus loud warnings for the avatar states that silently invalidate a session's work.
|
||||
//!
|
||||
//! This is instrumentation for the *reader of the log*, not a game system. It exists because three
|
||||
//! things cost a session ten minutes each and none of them announces itself:
|
||||
//!
|
||||
//! 1. **The character is dead or a ghost**, left that way by an earlier session. Nothing can be
|
||||
//! interacted with, attacked, looted or talked to; the world renders through the death filter;
|
||||
//! movement is rooted (unreleased) or ghost-speed (released). Every symptom reads like a client
|
||||
//! bug, and the client is fine.
|
||||
//! 2. **GM mode is on** — which every probe character carries by default (method.md's guard against
|
||||
//! unattended aggro killing it). vmangos's `Player::SetGameMaster` re-templates the player to
|
||||
//! **faction template 35** and freezes the mirror timers, so nothing is hostile, nothing aggros,
|
||||
//! fall/environmental damage is skipped and breath/fatigue never tick. FactionTemplate.dbc row 35
|
||||
//! (VERIFIED from the extracted DBC): faction 31 "Friendly", own group mask 0, enemy mask 0 — it
|
||||
//! is hostile to nothing and nothing is hostile to it. Any hostility, reaction-colour, nameplate,
|
||||
//! aggro, threat, damage or drowning measurement taken with GM on is simply wrong, quietly.
|
||||
//! 3. **Movement is server-blocked** — rooted, stunned, confused, fleeing, or mid-taxi-flight. The
|
||||
//! controller ignores input and the honest report is "the mover is broken".
|
||||
//!
|
||||
//! The banner is **not env-gated**: a warning nobody knows to switch on is not a warning. It costs
|
||||
//! one line per world entry when everything is fine, and it re-fires on every re-entry (a relog, a
|
||||
//! `.character race` forced logout, a reconnect) because the state can have changed.
|
||||
//!
|
||||
//! The other half is [`account_guard`] — the pre-connect check that a session running inside a
|
||||
//! worktree pool slot is logging in as **its own** probe account. A vmangos login kicks whoever
|
||||
//! holds the account, so the default `one` credentials fire the director out of their live session
|
||||
//! and another slot's `probeN` fires a parallel session's probe out of the world mid-sample
|
||||
//! (method.md "The local vmangos server"). It gates the **env fast path only** — a human typing at
|
||||
//! the login screen is never blocked.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::area::AreaTableRes;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{EnteredWorldMessage, ObjectStore, SelfGuid, SelfPlayer};
|
||||
use crate::terrain_stream::CurrentArea;
|
||||
use crate::world_map::CurrentMap;
|
||||
|
||||
/// `PLAYER_FLAGS_GM` (vmangos `Player.h`) — set by `SetGameMaster(true)` alongside the faction-35
|
||||
/// re-template. PUBLIC, so it rides our own descriptor like any other player flag.
|
||||
const PLAYER_FLAGS_GM: u32 = 0x0000_0008;
|
||||
|
||||
/// The `UNIT_FIELD_FLAGS` bits that mean "the server is driving, or refusing to let you drive"
|
||||
/// (vmangos `UnitDefines.h`) — each is a distinct reason the mover looks broken.
|
||||
const MOVE_BLOCKERS: &[(u32, &str)] = &[
|
||||
(0x0002_0000, "PACIFIED (no melee, no pacify-blocked casts)"),
|
||||
(0x0004_0000, "STUNNED"),
|
||||
(
|
||||
0x0010_0000,
|
||||
"on a TAXI FLIGHT (input ignored for the whole ride)",
|
||||
),
|
||||
(0x0020_0000, "DISARMED (weapon abilities refuse)"),
|
||||
(0x0040_0000, "CONFUSED (the server drives the movement)"),
|
||||
(0x0080_0000, "FLEEING (the server drives the movement)"),
|
||||
(0x0100_0000, "POSSESSED (another unit holds the reins)"),
|
||||
];
|
||||
|
||||
/// `UNIT_FLAG_IN_COMBAT` — worth naming on entry: an unattended probe that logs in already fighting
|
||||
/// is the exact shape of the accident the unattended-combat ban exists for (method.md).
|
||||
const UNIT_FLAG_IN_COMBAT: u32 = 0x0008_0000;
|
||||
|
||||
/// `UNIT_FLAG_SILENCED` — casts silently refuse.
|
||||
const UNIT_FLAG_SILENCED: u32 = 0x0000_2000;
|
||||
|
||||
/// The GM faction template vmangos swaps in with GM mode (`SetFactionTemplateId(35)`).
|
||||
const GM_FACTION_TEMPLATE: u32 = 35;
|
||||
|
||||
/// How long after world entry we keep waiting for the zone name before reporting without it. The
|
||||
/// descriptor lands in a few hundred ms but the area id only resolves once the tile under us has
|
||||
/// streamed (~2 s), and "Eastern Plaguelands" is the line's whole orienting value — a raw map id
|
||||
/// and a coordinate triple do not tell a reader at a glance that this character is nowhere useful.
|
||||
const ZONE_WAIT_SECS: f32 = 4.0;
|
||||
|
||||
/// The hard backstop: report whatever we have this long after world entry. Only reached when
|
||||
/// something is badly wrong with the stream, and reporting late beats never reporting.
|
||||
const DESCRIPTOR_WAIT_SECS: f32 = 8.0;
|
||||
|
||||
pub(crate) struct PreflightPlugin;
|
||||
|
||||
impl Plugin for PreflightPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Preflight>().add_systems(
|
||||
Update,
|
||||
report_session.after(crate::schedule::WorldStage::Net),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The banner's once-per-entry latch: armed by [`EnteredWorldMessage`], disarmed when the report
|
||||
/// goes out (or when the wait expires).
|
||||
#[derive(Resource, Default)]
|
||||
struct Preflight {
|
||||
/// `Time::elapsed_secs` at the world entry we still owe a report for; `None` = nothing pending.
|
||||
armed_at: Option<f32>,
|
||||
}
|
||||
|
||||
/// Wait for the self descriptor after each world entry, then print the banner once.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn report_session(
|
||||
mut state: ResMut<Preflight>,
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
time: Res<Time>,
|
||||
self_q: Query<(&ObjectStore, &Transform), With<SelfPlayer>>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
names: Res<NameCache>,
|
||||
map: Option<Res<CurrentMap>>,
|
||||
area: Option<Res<CurrentArea>>,
|
||||
area_table: Option<Res<AreaTableRes>>,
|
||||
) {
|
||||
if entered.read().next().is_some() {
|
||||
state.armed_at = Some(time.elapsed_secs());
|
||||
}
|
||||
let Some(armed_at) = state.armed_at else {
|
||||
return;
|
||||
};
|
||||
let expired = time.elapsed_secs() - armed_at >= DESCRIPTOR_WAIT_SECS;
|
||||
let Ok((store, transform)) = self_q.single() else {
|
||||
if expired {
|
||||
state.armed_at = None;
|
||||
warn!("preflight: no self descriptor {DESCRIPTOR_WAIT_SECS:.0}s after entering the world — the avatar never streamed in");
|
||||
}
|
||||
return;
|
||||
};
|
||||
// MAXHEALTH is always in the login snapshot; an empty store means the create block hasn't been
|
||||
// applied yet, and reading it would report a level-0 dead corpse of no race.
|
||||
if store.0.unit_max_health().unwrap_or(0) == 0 && !expired {
|
||||
return;
|
||||
}
|
||||
// `CurrentArea` is the FINEST area under us ("Darrowshire"), which on its own can be unplaceable;
|
||||
// the parent zone ("Eastern Plaguelands") is what orients a reader, so print zone / subzone.
|
||||
let zone = area
|
||||
.and_then(|a| a.0)
|
||||
.zip(area_table.as_deref())
|
||||
.map(|(id, cat)| {
|
||||
let sub = cat.0.name(id);
|
||||
let top = cat.0.top_zone(id).and_then(|z| cat.0.name(z));
|
||||
match (top, sub) {
|
||||
(Some(top), Some(sub)) if top != sub => format!(" \"{top} / {sub}\""),
|
||||
(Some(n), _) | (_, Some(n)) => format!(" \"{n}\""),
|
||||
_ => String::new(),
|
||||
}
|
||||
})
|
||||
.filter(|z| !z.is_empty());
|
||||
if zone.is_none() && time.elapsed_secs() - armed_at < ZONE_WAIT_SECS {
|
||||
return; // the tile under us hasn't streamed yet — give the zone name its grace
|
||||
}
|
||||
state.armed_at = None;
|
||||
|
||||
let name = self_guid
|
||||
.0
|
||||
.and_then(|g| names.peek(g))
|
||||
.unwrap_or("<unnamed>");
|
||||
let race = store
|
||||
.0
|
||||
.unit_race()
|
||||
.and_then(|r| crate::ui_unit::race_names(r).map(|(d, _)| d))
|
||||
.unwrap_or("?");
|
||||
let class = store
|
||||
.0
|
||||
.unit_class()
|
||||
.and_then(|c| crate::ui_unit::class_names(c).map(|(d, _)| d))
|
||||
.unwrap_or("?");
|
||||
let zone = zone.unwrap_or_default();
|
||||
// Printed as the raw WoW triple, which is what `.go xyz <x> <y> <z> <map>` takes — the whole
|
||||
// point of putting the position in the banner is that it can be pasted back.
|
||||
let [wx, wy, wz] = benilla_assets::coords::bevy_to_wow(transform.translation);
|
||||
info!(
|
||||
"preflight: {name} — level {lvl} {race} {class}, {hp}/{maxhp} hp, map {map}{zone} @ [{wx:.1}, {wy:.1}, {wz:.1}], faction template {faction}",
|
||||
lvl = store.0.unit_level().unwrap_or(0),
|
||||
hp = store.0.unit_health().unwrap_or(0),
|
||||
maxhp = store.0.unit_max_health().unwrap_or(0),
|
||||
map = map.map_or(-1, |m| m.0 as i64),
|
||||
faction = store.0.unit_faction_template().unwrap_or(0),
|
||||
);
|
||||
|
||||
for line in findings(&store.0) {
|
||||
warn!("preflight: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything about this avatar that will quietly invalidate a session's work, worst first. Pure
|
||||
/// over the descriptor so the whole ladder is unit-testable.
|
||||
fn findings(fields: &benilla_protocol::messages::ObjectFields) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let unit_flags = fields.unit_flags();
|
||||
|
||||
// Dead and ghost are mutually exclusive on the wire (a released ghost's health is 1, decision
|
||||
// 0308 §1) — report whichever holds, never both.
|
||||
if fields.unit_is_dead() {
|
||||
out.push(
|
||||
"THE CHARACTER IS DEAD (health 0, corpse not released) — the mover is server-rooted \
|
||||
and nothing can be cast, attacked, looted or interacted with. Fix it before anything \
|
||||
else: WOW_PROBE_CHAT=\".revive\" (probe accounts are gmlevel 6)."
|
||||
.into(),
|
||||
);
|
||||
} else if fields.player_is_ghost() {
|
||||
out.push(
|
||||
"THE CHARACTER IS A GHOST (spirit released, corpse elsewhere) — the world renders \
|
||||
through the death filter, NPCs and objects refuse every interaction, and movement \
|
||||
runs at ghost speed on water. Fix it before anything else: WOW_PROBE_CHAT=\".revive\"."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
if fields.player_flags() & PLAYER_FLAGS_GM != 0 {
|
||||
out.push(format!(
|
||||
"GM MODE IS ON — vmangos re-templates the player to faction {GM_FACTION_TEMPLATE} \
|
||||
(\"Friendly\", enemy mask 0) and freezes the mirror timers, so NOTHING is hostile, \
|
||||
nothing aggros, fall/environmental damage is skipped and breath/fatigue never tick. \
|
||||
Any hostility, reaction-colour, nameplate, threat, aggro, damage or drowning reading \
|
||||
taken now is wrong. Send WOW_PROBE_CHAT=\".gm off\" first — and put it back on when \
|
||||
you are done, so an unwatched probe can't be killed."
|
||||
));
|
||||
}
|
||||
|
||||
let blocked: Vec<&str> = MOVE_BLOCKERS
|
||||
.iter()
|
||||
.filter(|(bit, _)| unit_flags & bit != 0)
|
||||
.map(|(_, label)| *label)
|
||||
.collect();
|
||||
if !blocked.is_empty() {
|
||||
out.push(format!(
|
||||
"MOVEMENT IS SERVER-BLOCKED — {} — the controller will look broken because the server \
|
||||
is refusing (or driving) the movement, not because the mover is.",
|
||||
blocked.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if unit_flags & UNIT_FLAG_IN_COMBAT != 0 {
|
||||
out.push(
|
||||
"IN COMBAT on arrival — something is already fighting this character. Do not leave it \
|
||||
unattended (method.md's unattended-combat ban): break the fight or move it out."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if unit_flags & UNIT_FLAG_SILENCED != 0 {
|
||||
out.push("SILENCED — spell casts will refuse for as long as the aura holds.".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pre-connect account guard, consulted by the login policy's env fast path
|
||||
/// ([`crate::login`]). Returns `Err(explanation)` when this build lives in a worktree pool slot and
|
||||
/// the fast path is about to authenticate as an account that belongs to somebody else — the
|
||||
/// director's `one` (a login KICKS their live session mid-play) or another slot's `probeN` (the
|
||||
/// kicked client's 0065 teardown despawns every net entity, so a parallel session's probe reads a
|
||||
/// unit-less world and prints garbage; it happened, method.md records it).
|
||||
///
|
||||
/// Slot identity comes from the compiled-in manifest path, because that is what the pool guarantees
|
||||
/// is unique per session: every slot has its own checkout and its own `target/`. Outside a pool slot
|
||||
/// (the primary checkout, which is the director's) the guard is inert — it has no business having an
|
||||
/// opinion about a login it cannot attribute.
|
||||
///
|
||||
/// `WOW_ALLOW_ACCOUNT=1` is the escape hatch for the rare deliberate cross-account run; it turns the
|
||||
/// refusal into a warning rather than silence, because the kick still happens.
|
||||
pub(crate) fn account_guard(user: &str) -> Result<(), String> {
|
||||
guard_for(pool_slot(env!("CARGO_MANIFEST_DIR")), user)
|
||||
}
|
||||
|
||||
/// [`account_guard`]'s decision, with the slot passed in so the ladder is testable from any
|
||||
/// checkout (the real one reads a compile-time path that differs per worktree).
|
||||
fn guard_for(slot: Option<u32>, user: &str) -> Result<(), String> {
|
||||
let Some(slot) = slot else {
|
||||
return Ok(());
|
||||
};
|
||||
let mine = format!("probe{slot}");
|
||||
let user_lc = user.to_ascii_lowercase();
|
||||
if user_lc == mine {
|
||||
return Ok(());
|
||||
}
|
||||
let whose = if user_lc == "one" {
|
||||
"the DIRECTOR's account — logging in on it kicks them out of their live session mid-play"
|
||||
} else if user_lc.starts_with("probe") && user_lc[5..].chars().all(|c| c.is_ascii_digit()) {
|
||||
"ANOTHER worktree slot's probe account — logging in on it kicks that session's probe out \
|
||||
of the world, and its next sample reads a unit-less world"
|
||||
} else {
|
||||
return Ok(()); // a bystander account (`two`, a fresh test account): not ours to police
|
||||
};
|
||||
Err(format!(
|
||||
"the env fast path is about to log in as `{user}` from pool-{slot}, and that is {whose}. \
|
||||
This slot's identity is WOW_USER=probe{slot} WOW_PASS=pprobe{slot} \
|
||||
WOW_CHAR=Probe{spelled} (method.md \"The local vmangos server\").",
|
||||
spelled = spell_digit(slot)
|
||||
))
|
||||
}
|
||||
|
||||
/// This slot's index as the word the probe identity spells it with (`pool-4` → `"four"`), or `None`
|
||||
/// outside a pool slot. The one place anything else should ask "which session am I?" — the rig keys
|
||||
/// its derived character names off it ([`crate::capture::rig_char_name_from_env`]).
|
||||
pub(crate) fn slot_word() -> Option<&'static str> {
|
||||
pool_slot(env!("CARGO_MANIFEST_DIR")).map(spell_digit)
|
||||
}
|
||||
|
||||
/// The pool slot index this build was compiled in, from a `…/benilla-wt/pool-<N>/…` manifest path.
|
||||
/// `None` for the primary checkout and any non-pool worktree.
|
||||
fn pool_slot(manifest_dir: &str) -> Option<u32> {
|
||||
let mut parts = manifest_dir.split('/');
|
||||
while let Some(part) = parts.next() {
|
||||
if part == "benilla-wt" {
|
||||
return parts.next()?.strip_prefix("pool-")?.parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The probe character's name suffix for slot `n` — vmangos names carry no digits, so the pool
|
||||
/// index is spelled (`pool-4` → `Probefour`).
|
||||
fn spell_digit(n: u32) -> &'static str {
|
||||
match n {
|
||||
0 => "zero",
|
||||
1 => "one",
|
||||
2 => "two",
|
||||
3 => "three",
|
||||
4 => "four",
|
||||
5 => "five",
|
||||
6 => "six",
|
||||
7 => "seven",
|
||||
8 => "eight",
|
||||
9 => "nine",
|
||||
_ => "<n>",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
|
||||
/// Build a player descriptor from `(field index, value)` pairs — the wire indices the
|
||||
/// accessors read.
|
||||
fn player(fields: &[(u16, u32)]) -> ObjectFields {
|
||||
ObjectFields::from_pairs(fields)
|
||||
}
|
||||
|
||||
const HEALTH: u16 = 22;
|
||||
const MAXHEALTH: u16 = 28;
|
||||
const UNIT_FLAGS: u16 = 46;
|
||||
const PLAYER_FLAGS: u16 = 190;
|
||||
|
||||
#[test]
|
||||
fn a_healthy_avatar_reports_nothing() {
|
||||
let f = player(&[(HEALTH, 60), (MAXHEALTH, 60)]);
|
||||
assert!(findings(&f).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_and_ghost_never_report_together() {
|
||||
// Dead: health 0 with a real MAXHEALTH.
|
||||
let dead = findings(&player(&[(MAXHEALTH, 60)]));
|
||||
assert_eq!(dead.len(), 1);
|
||||
assert!(dead[0].contains("IS DEAD"));
|
||||
// Ghost: health 1 and PLAYER_FLAGS_GHOST — the wire never shows both (decision 0308 §1).
|
||||
let ghost = findings(&player(&[
|
||||
(HEALTH, 1),
|
||||
(MAXHEALTH, 60),
|
||||
(PLAYER_FLAGS, 0x10),
|
||||
]));
|
||||
assert_eq!(ghost.len(), 1);
|
||||
assert!(ghost[0].contains("IS A GHOST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gm_mode_is_reported_on_a_perfectly_healthy_avatar() {
|
||||
let f = player(&[
|
||||
(HEALTH, 60),
|
||||
(MAXHEALTH, 60),
|
||||
(PLAYER_FLAGS, PLAYER_FLAGS_GM),
|
||||
]);
|
||||
let out = findings(&f);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0].contains("GM MODE IS ON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_move_blocker_is_named_in_one_line() {
|
||||
let f = player(&[
|
||||
(HEALTH, 60),
|
||||
(MAXHEALTH, 60),
|
||||
(UNIT_FLAGS, 0x0004_0000 | 0x0010_0000), // stunned + taxi
|
||||
]);
|
||||
let out = findings(&f);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0].contains("STUNNED") && out[0].contains("TAXI FLIGHT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guard_only_polices_accounts_that_belong_to_someone() {
|
||||
// Our own slot's probe: the whole point of the identity.
|
||||
assert!(guard_for(Some(4), "probe4").is_ok());
|
||||
assert!(guard_for(Some(4), "PROBE4").is_ok()); // vmangos accounts are case-insensitive
|
||||
// The director's account, and a neighbouring slot's probe: both kick a live session.
|
||||
let director = guard_for(Some(4), "one").unwrap_err();
|
||||
assert!(director.contains("DIRECTOR") && director.contains("WOW_USER=probe4"));
|
||||
// The override hint belongs to the caller that can act on it, not to the reason.
|
||||
assert!(!director.contains("WOW_ALLOW_ACCOUNT"));
|
||||
assert!(guard_for(Some(4), "probe7")
|
||||
.unwrap_err()
|
||||
.contains("ANOTHER worktree slot"));
|
||||
// A bystander account is nobody's to police, and outside a pool slot we have no standing.
|
||||
assert!(guard_for(Some(4), "two").is_ok());
|
||||
assert!(guard_for(None, "one").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_probe_character_name_spells_the_slot() {
|
||||
// vmangos player names carry no digits, so `Probe4` cannot exist — the pool index is spelled.
|
||||
assert!(guard_for(Some(0), "one")
|
||||
.unwrap_err()
|
||||
.contains("WOW_CHAR=Probezero"));
|
||||
assert!(guard_for(Some(9), "one")
|
||||
.unwrap_err()
|
||||
.contains("WOW_CHAR=Probenine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slot_is_read_off_the_manifest_path() {
|
||||
assert_eq!(
|
||||
pool_slot("/Users/sam/dev/benilla-wt/pool-7/crates/benilla"),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(pool_slot("/Users/sam/dev/benilla-wow/crates/benilla"), None);
|
||||
assert_eq!(
|
||||
pool_slot("/Users/sam/dev/benilla-wow/.claude/worktrees/x/crates/benilla"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@
|
||||
//! light-blue `?` per the status map.
|
||||
//!
|
||||
//! The data plane is the era wire: every visible creature flagged `UNIT_NPC_FLAG_QUESTGIVER`
|
||||
//! gets one `CMSG_QUESTGIVER_STATUS_QUERY`, re-asked for everyone whenever OUR quest log changes
|
||||
//! (accepting/completing/abandoning a quest changes other NPCs' answers — the server only ever
|
||||
//! answers queries, vmangos `QuestHandler.cpp:36-77`, never pushes). Answers land in
|
||||
//! [`QuestGiver`]'s per-guid status map (`net/apply`); this module renders them.
|
||||
//! gets one `CMSG_QUESTGIVER_STATUS_QUERY` — the server only ever *answers* queries (vmangos
|
||||
//! `QuestHandler.cpp:36-77`), never pushes, so every refresh point is the client's own to trigger,
|
||||
//! and a status that is never re-asked for is a marker frozen at first sight. The re-ask law —
|
||||
//! the reference's self-player descriptor **field watch**, and which of it we implement — is its
|
||||
//! own concern and lives in [`query`] (decisions 0650/0654). Answers land in [`QuestGiver`]'s
|
||||
//! per-guid status map (`net/apply`); THIS module is the render half: attach, scale, animate.
|
||||
//!
|
||||
//! The render law is byte-verified (wow-re object-layer `questgiver-marker.md`):
|
||||
//! - **Attach** (`0x6074c0`): the marker is a CHILD of the unit's own body M2 at attachment slot
|
||||
@@ -39,7 +41,11 @@
|
||||
//! the plate leg is a director-pinned deviation, rationale on [`pose_markers`], 0408/0409):
|
||||
//! cards re-arm the matching loop, hosts switch the playing clip.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
mod query;
|
||||
|
||||
use query::query_statuses;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::mesh::skinning::SkinnedMesh;
|
||||
use bevy::prelude::*;
|
||||
@@ -54,14 +60,11 @@ use crate::lighting::SharedLightBuffer;
|
||||
use crate::mesh_tag::alpha_bits;
|
||||
use crate::model_render::{m2_url, model_material, MaterialCache, ShadeSel};
|
||||
use crate::nameplates::Nameplates;
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, NetEntity, ObjectStore, SelfPlayer};
|
||||
use crate::net::GuidIndex;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
use crate::ui_quest::QuestGiver;
|
||||
use bevy::mesh::MeshTag;
|
||||
|
||||
/// `UNIT_NPC_FLAGS` questgiver service bit (vanilla; `update_object.rs`'s accessor doc).
|
||||
const NPC_FLAG_QUESTGIVER: u32 = 0x2;
|
||||
|
||||
use crate::entities::{ATTACH_OVERHEAD, ATTACH_OVERHEAD_MOUNTED};
|
||||
|
||||
/// The marker anim ids the client arms (`0x6076c0`): the low bob, and the raised bob played while
|
||||
@@ -137,42 +140,6 @@ struct MarkerSeat {
|
||||
#[derive(Component)]
|
||||
struct MarkerCardPivot(Vec3);
|
||||
|
||||
/// Ask the dialog status of every questgiver-flagged creature once — and everyone again whenever
|
||||
/// our own quest log changes (the "generation": the occupied `PLAYER_QUEST_LOG` ids+state bytes).
|
||||
#[allow(clippy::type_complexity)] // a Bevy system: each param is one resource, the app's convention
|
||||
fn query_statuses(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
units: Query<(&crate::net::Guid, &ObjectStore), (With<NetEntity>, Without<SelfPlayer>)>,
|
||||
commands: Res<NetCommands>,
|
||||
mut state: Local<(u64, HashSet<u64>)>,
|
||||
) {
|
||||
let Some(store) = self_q.iter().next() else {
|
||||
return;
|
||||
};
|
||||
// The quest-log generation: fold every occupied slot's quest id + state byte.
|
||||
let mut generation = 0xcbf2_9ce4_8422_2325u64; // FNV offset
|
||||
for slot in 0..benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS {
|
||||
if let Some(s) = store.0.player_quest_log(slot) {
|
||||
if s.quest_id != 0 {
|
||||
generation ^= u64::from(s.quest_id) ^ (u64::from(s.state) << 32);
|
||||
generation = generation.wrapping_mul(0x0000_0100_0000_01B3);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (last_gen, asked) = &mut *state;
|
||||
if *last_gen != generation {
|
||||
*last_gen = generation;
|
||||
asked.clear();
|
||||
}
|
||||
for (guid, obj) in &units {
|
||||
if obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER != 0 && asked.insert(guid.0) {
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawn one marker instance: the root (cards cascade) and its seat — which lives under the
|
||||
/// UNIT's joint, so it needs the explicit, guarded kill (a despawned unit already cascaded it).
|
||||
fn despawn_marker(commands: &mut Commands, roots: &Query<&QuestMarkerRoot>, root: Entity) {
|
||||
@@ -541,7 +508,8 @@ fn bake_seat_scale(
|
||||
|
||||
/// Re-seat every billboard card from its seat's world placement (cards write absolute world
|
||||
/// transforms, so they can't be parented under the moving seat). Runs in PostUpdate after
|
||||
/// transform propagation and before [`BillboardPlace`] writes the card transforms — the card
|
||||
/// transform propagation and before [`crate::billboard::BillboardPlace`] writes the card
|
||||
/// transforms — the card
|
||||
/// rides the SAME-frame posed seat, no trailing frame.
|
||||
fn re_seat_cards(
|
||||
seats: Query<&GlobalTransform, With<MarkerSeat>>,
|
||||
@@ -0,0 +1,417 @@
|
||||
//! The **query** half of the overhead questgiver markers: when to ask the server what an NPC's
|
||||
//! `!`/`?` should be. The rendering half is [`super`].
|
||||
//!
|
||||
//! The server only ever *answers* `CMSG_QUESTGIVER_STATUS_QUERY` (vmangos `QuestHandler.cpp`) — it
|
||||
//! never pushes — so every refresh point is the client's own to trigger, and a status that is never
|
||||
//! re-asked for is a marker frozen at whatever it was when we first saw the NPC. That made this a
|
||||
//! surprisingly deep fidelity question; the answer is decisions 0650 (the reference's trigger set,
|
||||
//! byte-enumerated) and 0654 (what benilla implements of it), and it lives on [`query_statuses`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, NetEntity, ObjectStore, SelfPlayer};
|
||||
use crate::ui_quest::QuestGiver;
|
||||
|
||||
/// `UNIT_NPC_FLAGS` questgiver service bit (vanilla; `update_object.rs`'s accessor doc).
|
||||
const NPC_FLAG_QUESTGIVER: u32 = 0x2;
|
||||
|
||||
/// Ask the dialog status of every questgiver-flagged creature, and re-ask when the answer could
|
||||
/// have changed. The server only ever *answers* queries (vmangos `QuestHandler.cpp`), so every
|
||||
/// refresh point is the client's own to trigger — a status never re-asked for is a marker frozen
|
||||
/// at whatever it was when we first saw the NPC.
|
||||
///
|
||||
/// The reference's refresh law is a **descriptor field watch**, not a hand-picked event list: it
|
||||
/// registers handlers on specific self-player fields through `0x468070`, and the handler sweeps
|
||||
/// every visible object (`0x5eb070` → `0x468380`, one query per object passing the questgiver
|
||||
/// gate). Byte-pinned in wow-re `questgiver-marker.md` §W1–W12; the benilla side, and the
|
||||
/// still-open gaps, are decision 0650 (the byte-level trigger set, superseding 0647's wrong
|
||||
/// "level is a deviation") and decision 0654 (what benilla implements of it).
|
||||
///
|
||||
/// **The sweep** — every visible questgiver is re-asked when any *self* input to the server's
|
||||
/// answer moves. The reference gets this from six descriptor watches plus four packet handlers;
|
||||
/// ours is [`self_generation`] (the descriptor half, recomputed only when our store actually
|
||||
/// changes) XOR [`QuestGiver::reask_epoch`] (the packet half, bumped from `net/apply`). A changed
|
||||
/// generation clears the whole asked set, which is the sweep.
|
||||
///
|
||||
/// **Per unit** — the reference queries one unit from its create/init path (`0x607380`) and when
|
||||
/// its own fields move: the questgiver bit (`0x60b490`), the flightmaster bit (`0x60b4c5`), and
|
||||
/// anything that could change its *reaction* to us (`0x606f0a`). Ours keys the asked set on
|
||||
/// [`unit_ask_key`], so any of those re-asks that one NPC; and the entry is dropped when the guid
|
||||
/// leaves [`GuidIndex`], which is the create-path query (the reference caches the answer *on* the
|
||||
/// unit at `unit+0xcb8`, so its cache dies with the object — ours is a map and needs the prune).
|
||||
///
|
||||
/// **The teardown leg** — an NPC whose questgiver bit goes off loses its cached status, so the
|
||||
/// marker goes with it (`0x5eb0a0`'s own branch), and is re-asked if the bit returns.
|
||||
///
|
||||
/// **Still not covered**, and why: the reference also sweeps when an *item* moves between
|
||||
/// containers (an `ITEM` typeid watch, `0x5d9375` — quest availability can depend on carried
|
||||
/// items). benilla does not stream item objects (`EntityKind` has no `Item`), so there is nothing
|
||||
/// to watch; the equipment and equipped-bag guids in our own descriptor *are* folded, which covers
|
||||
/// equipping, unequipping and swapping a bag but not moving a stack inside one. Per unit, the
|
||||
/// reference's reaction refresh also keys on charm/persuade/duel-team/`PLAYER_BYTES_3`; we key on
|
||||
/// the faction template, and catch a standing change through the reputation sweep instead.
|
||||
#[allow(clippy::type_complexity)] // a Bevy system: each param is one resource, the app's convention
|
||||
pub(super) fn query_statuses(
|
||||
self_q: Query<Ref<ObjectStore>, With<SelfPlayer>>,
|
||||
units: Query<(&crate::net::Guid, &ObjectStore), (With<NetEntity>, Without<SelfPlayer>)>,
|
||||
index: Res<GuidIndex>,
|
||||
mut quest: ResMut<QuestGiver>,
|
||||
commands: Res<NetCommands>,
|
||||
mut state: Local<QueryState>,
|
||||
) {
|
||||
let Some(store) = self_q.iter().next() else {
|
||||
return;
|
||||
};
|
||||
// The descriptor half is only recomputed when our own store actually changed — it walks 20
|
||||
// quest-log slots, 128 skill slots and 23 inventory slots, which has no business running every
|
||||
// frame. The packet half (the epoch) is a plain counter, so it is always cheap to fold.
|
||||
if store.is_changed() {
|
||||
state.fields = self_generation(&store.0);
|
||||
}
|
||||
let generation = state.fields ^ (u64::from(quest.reask_epoch()) << 32);
|
||||
if state.generation != generation {
|
||||
state.generation = generation;
|
||||
state.asked.clear();
|
||||
}
|
||||
// Object lifetime is the other half of the cache key: a guid that left the world drops both
|
||||
// its "already asked" mark and its cached status, so re-entering view re-asks from scratch.
|
||||
state.asked.retain(|guid, _| index.0.contains_key(guid));
|
||||
quest.retain_statuses(|npc| index.0.contains_key(&npc));
|
||||
for (guid, obj) in &units {
|
||||
if obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER == 0 {
|
||||
// No longer a questgiver: drop the stale answer (and the marker with it).
|
||||
if state.asked.remove(&guid.0).is_some() {
|
||||
quest.clear_status(guid.0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Re-ask when this unit's own key moves — its service bits or its faction template. The
|
||||
// reference does the same per-unit query off those field watches.
|
||||
let key = unit_ask_key(&obj.0);
|
||||
if state.asked.insert(guid.0, key) != Some(key) {
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-unit ask key: re-ask that NPC whenever this moves. `UNIT_NPC_FLAGS` carries both service
|
||||
/// bits the reference watches — questgiver `0x2` (`0x60b490`) and flightmaster `0x8` (`0x60b4c5`) —
|
||||
/// and `UNIT_FIELD_FACTIONTEMPLATE` is the reaction input we can see (`0x606f0a`); a charm or a
|
||||
/// faction swap changes what the server will answer for a hostile giver.
|
||||
fn unit_ask_key(fields: &benilla_protocol::ObjectFields) -> u64 {
|
||||
u64::from(fields.unit_npc_flags())
|
||||
| (u64::from(fields.unit_faction_template().unwrap_or(0)) << 32)
|
||||
}
|
||||
|
||||
/// Fold every *self* descriptor field the reference watches into one value — a change to any of
|
||||
/// them sweeps every visible questgiver, because each is an input the server's `GetDialogStatus`
|
||||
/// reads. The reference registers one handler per field (`0x468070`); we hash the lot, which is
|
||||
/// the same thing observed from the outside. Recomputed only when our own store changes.
|
||||
///
|
||||
/// Level (`SatisfyQuestLevel`, the grey `!`), the quest log, money, every skill
|
||||
/// (`SatisfyQuestSkill`), `PLAYER_FLAGS`, health, and our equipment/bag guids — the last standing
|
||||
/// in for the reference's `ITEM` watch, as far as our object model can see it.
|
||||
fn self_generation(fields: &benilla_protocol::ObjectFields) -> u64 {
|
||||
const FNV_PRIME: u64 = 0x0000_0100_0000_01B3;
|
||||
let mut h = 0xcbf2_9ce4_8422_2325u64; // FNV offset
|
||||
let mut fold = |v: u64| {
|
||||
h ^= v;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
};
|
||||
fold(u64::from(fields.unit_level().unwrap_or(0)));
|
||||
// Health enters as the ALIVE/DEAD bit, not the raw value — and that is the PINNED reading now,
|
||||
// not a conservative guess (wow-re `questgiver-marker.md` §W13). The `UNIT_FIELD_HEALTH` watch
|
||||
// gates on the zero CROSSING, in `0x6046f0`, one level above where the handler chain suggested:
|
||||
// `0x604774 jg` / `0x604778 jle` require `old > 0 && new <= 0` = **death** before the sweep is
|
||||
// reached, and `0x6047f0`/`0x6047f4` take `old <= 0 && new > 0` = **resurrect** down the sibling
|
||||
// branch. Ordinary damage, heal and regen ticks fall through both and reach nothing — which is
|
||||
// why the captures show no query bursts tracking combat (one carries 28 consecutive non-lethal
|
||||
// packets, 70 → 2 HP, with zero bursts; the single packet crossing to 0 has the only burst in
|
||||
// the stretch).
|
||||
//
|
||||
// One named over-refresh: our bit flips on BOTH crossings, so we sweep units on resurrect too.
|
||||
// The reference sweeps GameObject questgivers there (`0x5eb3c0`) plus one conditional unit
|
||||
// query — and benilla has no GameObject markers at all, so a unit sweep is the nearest thing we
|
||||
// can do with what we actually render. It costs one extra sweep per resurrect. 0654.
|
||||
fold(u64::from(fields.unit_health().unwrap_or(1) == 0));
|
||||
fold(u64::from(fields.player_flags()));
|
||||
fold(u64::from(fields.player_money().unwrap_or(0)));
|
||||
for slot in 0..benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS {
|
||||
if let Some(s) = fields.player_quest_log(slot) {
|
||||
if s.quest_id != 0 {
|
||||
fold(u64::from(s.quest_id) ^ (u64::from(s.state) << 32));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reference watches the skill *value* half specifically (`edx=0x84c`), so the id + current
|
||||
// value are what matter — a rank-up is the event, not a temporary bonus ticking.
|
||||
for slot in 0..benilla_protocol::messages::PLAYER_SKILL_SLOTS {
|
||||
if let Some(s) = fields.player_skill(slot) {
|
||||
if s.skill_id != 0 {
|
||||
fold(u64::from(s.skill_id) ^ (u64::from(s.value) << 32));
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..23 {
|
||||
if let Some(guid) = fields.player_inv_slot(i) {
|
||||
fold(guid);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// [`query_statuses`]'s memory across frames.
|
||||
#[derive(Default)]
|
||||
pub(super) struct QueryState {
|
||||
/// The last folded [`self_generation`] — kept separately from `generation` so the expensive
|
||||
/// descriptor walk can be skipped on frames where our own store didn't change.
|
||||
fields: u64,
|
||||
/// `fields` combined with the packet epoch; a change here is the sweep.
|
||||
generation: u64,
|
||||
/// Which guids we've asked about, and the [`unit_ask_key`] we asked at.
|
||||
asked: HashMap<u64, u64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The re-ask law (decision 0654). The server only *answers* queries, so a status we never
|
||||
/// re-ask for is a marker frozen at whatever it was when the NPC came into view. Three
|
||||
/// invalidations, all exercised here and all faithful: our own level (the ding that turns a
|
||||
/// grey `!` gold — the reference's `UNIT_FIELD_LEVEL` field watch), the object leaving and
|
||||
/// re-entering the world (its per-object query at create), and the questgiver bit going off
|
||||
/// (its teardown branch).
|
||||
#[test]
|
||||
fn the_status_is_re_asked_on_a_ding_on_re_entry_and_dropped_with_the_flag() {
|
||||
use crate::net::{Guid, NetEntity};
|
||||
use benilla_protocol::{EntityKind, ObjectFields};
|
||||
|
||||
const NPC: u64 = 0xdead_beef;
|
||||
const FIELD_LEVEL: u16 = 34; // UNIT_FIELD_LEVEL
|
||||
const FIELD_NPC_FLAGS: u16 = 147; // UNIT_NPC_FLAGS
|
||||
|
||||
let net_entity = || NetEntity {
|
||||
kind: EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
};
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.insert_resource(NetCommands(tx))
|
||||
.init_resource::<GuidIndex>()
|
||||
.init_resource::<QuestGiver>()
|
||||
.add_systems(Update, query_statuses);
|
||||
|
||||
let me = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SelfPlayer,
|
||||
net_entity(),
|
||||
Guid(1),
|
||||
ObjectStore(ObjectFields::from_pairs(&[(FIELD_LEVEL, 5)])),
|
||||
))
|
||||
.id();
|
||||
let spawn_npc = |app: &mut App| {
|
||||
let e = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
net_entity(),
|
||||
Guid(NPC),
|
||||
ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_FLAGS, 0x2)])),
|
||||
))
|
||||
.id();
|
||||
app.world_mut().resource_mut::<GuidIndex>().0.insert(NPC, e);
|
||||
e
|
||||
};
|
||||
let asked = |app: &mut App| -> usize {
|
||||
app.update();
|
||||
rx.try_iter()
|
||||
.filter(
|
||||
|c| matches!(c, ClientCommand::QuestgiverStatusQuery { npc } if *npc == NPC),
|
||||
)
|
||||
.count()
|
||||
};
|
||||
|
||||
let npc = spawn_npc(&mut app);
|
||||
assert_eq!(asked(&mut app), 1, "asked once when it comes into view");
|
||||
assert_eq!(asked(&mut app), 0, "not again while nothing has changed");
|
||||
|
||||
// The ding: `SatisfyQuestLevel` is what made the answer UNAVAILABLE, so the whole set is
|
||||
// re-asked — the marker can't learn it went gold any other way.
|
||||
*app.world_mut()
|
||||
.entity_mut(me)
|
||||
.get_mut::<ObjectStore>()
|
||||
.unwrap() = ObjectStore(ObjectFields::from_pairs(&[(FIELD_LEVEL, 6)]));
|
||||
assert_eq!(asked(&mut app), 1, "our level changed — re-ask everyone");
|
||||
assert_eq!(asked(&mut app), 0, "…once");
|
||||
|
||||
// Out of view: the cached status dies with the object (the reference caches it *on* the
|
||||
// unit), so the marker can't flash stale on the way back in.
|
||||
app.world_mut()
|
||||
.resource_mut::<QuestGiver>()
|
||||
.set_status(NPC, 5);
|
||||
app.world_mut().resource_mut::<GuidIndex>().0.remove(&NPC);
|
||||
app.world_mut().entity_mut(npc).despawn();
|
||||
assert_eq!(asked(&mut app), 0, "gone: nothing to ask");
|
||||
assert!(
|
||||
app.world().resource::<QuestGiver>().status(NPC).is_none(),
|
||||
"the cached status goes with the object"
|
||||
);
|
||||
|
||||
let npc = spawn_npc(&mut app);
|
||||
assert_eq!(asked(&mut app), 1, "back in view — asked afresh");
|
||||
|
||||
// The flag goes off: drop the answer, which drops the marker with it.
|
||||
app.world_mut()
|
||||
.resource_mut::<QuestGiver>()
|
||||
.set_status(NPC, 5);
|
||||
*app.world_mut()
|
||||
.entity_mut(npc)
|
||||
.get_mut::<ObjectStore>()
|
||||
.unwrap() = ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_FLAGS, 0x1)]));
|
||||
assert_eq!(
|
||||
asked(&mut app),
|
||||
0,
|
||||
"no longer a questgiver — nothing to ask"
|
||||
);
|
||||
assert!(
|
||||
app.world().resource::<QuestGiver>().status(NPC).is_none(),
|
||||
"and its stale answer is dropped"
|
||||
);
|
||||
}
|
||||
|
||||
/// The rest of the reference's trigger set (0654): the remaining self-descriptor watches, the
|
||||
/// packet epoch that stands in for its four packet handlers, and the per-unit key that catches
|
||||
/// a unit's own service bits or faction moving. Each leg must re-ask exactly once.
|
||||
#[test]
|
||||
fn every_recorded_trigger_re_asks_exactly_once() {
|
||||
use crate::net::{Guid, NetEntity};
|
||||
use benilla_protocol::{EntityKind, ObjectFields};
|
||||
|
||||
const NPC: u64 = 0x1234;
|
||||
const FIELD_HEALTH: u16 = 22; // UNIT_FIELD_HEALTH
|
||||
const FIELD_NPC_FLAGS: u16 = 147;
|
||||
const FIELD_FACTION: u16 = 35; // UNIT_FIELD_FACTIONTEMPLATE
|
||||
const FIELD_PLAYER_FLAGS: u16 = 190;
|
||||
const FIELD_COINAGE: u16 = 1176;
|
||||
const FIELD_SKILL_1_1: u16 = 718;
|
||||
const FIELD_INV_SLOT_0: u16 = 486; // PLAYER_FIELD_INV_SLOT_HEAD
|
||||
|
||||
let net_entity = || NetEntity {
|
||||
kind: EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
};
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.insert_resource(NetCommands(tx))
|
||||
.init_resource::<GuidIndex>()
|
||||
.init_resource::<QuestGiver>()
|
||||
.add_systems(Update, query_statuses);
|
||||
|
||||
// Our own store starts with every watched field present, so each leg below is a CHANGE to
|
||||
// one field rather than a field appearing for the first time.
|
||||
let base_self = vec![
|
||||
(34u16, 5u32),
|
||||
(FIELD_HEALTH, 100),
|
||||
(FIELD_PLAYER_FLAGS, 0),
|
||||
(FIELD_COINAGE, 500),
|
||||
(FIELD_SKILL_1_1, 186 | (1 << 16)), // skill id 186, step 1
|
||||
(FIELD_SKILL_1_1 + 1, 75 | (300 << 16)), // value 75 / max 300
|
||||
(FIELD_INV_SLOT_0, 0xaaaa),
|
||||
(FIELD_INV_SLOT_0 + 1, 0),
|
||||
];
|
||||
let me = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SelfPlayer,
|
||||
net_entity(),
|
||||
Guid(1),
|
||||
ObjectStore(ObjectFields::from_pairs(&base_self)),
|
||||
))
|
||||
.id();
|
||||
let npc_e = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
net_entity(),
|
||||
Guid(NPC),
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(FIELD_NPC_FLAGS, 0x2),
|
||||
(FIELD_FACTION, 35),
|
||||
])),
|
||||
))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(NPC, npc_e);
|
||||
|
||||
let asked = |app: &mut App| -> usize {
|
||||
app.update();
|
||||
rx.try_iter()
|
||||
.filter(
|
||||
|c| matches!(c, ClientCommand::QuestgiverStatusQuery { npc } if *npc == NPC),
|
||||
)
|
||||
.count()
|
||||
};
|
||||
// Replace one field of our own descriptor, keeping the rest — the shape a values-update
|
||||
// delta actually has.
|
||||
let set_self = |app: &mut App, field: u16, value: u32| {
|
||||
let mut pairs = base_self.clone();
|
||||
match pairs.iter_mut().find(|(f, _)| *f == field) {
|
||||
Some(p) => p.1 = value,
|
||||
None => pairs.push((field, value)),
|
||||
}
|
||||
*app.world_mut()
|
||||
.entity_mut(me)
|
||||
.get_mut::<ObjectStore>()
|
||||
.unwrap() = ObjectStore(ObjectFields::from_pairs(&pairs));
|
||||
};
|
||||
|
||||
assert_eq!(asked(&mut app), 1, "the first sight");
|
||||
assert_eq!(asked(&mut app), 0, "then quiet");
|
||||
|
||||
for (label, field, value) in [
|
||||
("death", FIELD_HEALTH, 0u32), // the alive/dead bit, not the raw value — see 0654
|
||||
("PLAYER_FLAGS", FIELD_PLAYER_FLAGS, 0x10),
|
||||
("money", FIELD_COINAGE, 900),
|
||||
("a skill rank", FIELD_SKILL_1_1 + 1, 80 | (300 << 16)),
|
||||
("an equipped item", FIELD_INV_SLOT_0, 0xbbbb),
|
||||
] {
|
||||
set_self(&mut app, field, value);
|
||||
assert_eq!(asked(&mut app), 1, "{label} changed — re-ask");
|
||||
assert_eq!(asked(&mut app), 0, "{label}: exactly once");
|
||||
}
|
||||
|
||||
// The packet half: reputation, the group roster and the quest packets all land here.
|
||||
app.world_mut().resource_mut::<QuestGiver>().bump_reask();
|
||||
assert_eq!(asked(&mut app), 1, "a swept packet — re-ask");
|
||||
assert_eq!(asked(&mut app), 0, "exactly once");
|
||||
|
||||
// Per unit: its flightmaster bit, then its faction template.
|
||||
let set_npc = |app: &mut App, pairs: &[(u16, u32)]| {
|
||||
*app.world_mut()
|
||||
.entity_mut(npc_e)
|
||||
.get_mut::<ObjectStore>()
|
||||
.unwrap() = ObjectStore(ObjectFields::from_pairs(pairs));
|
||||
};
|
||||
set_npc(
|
||||
&mut app,
|
||||
&[(FIELD_NPC_FLAGS, 0x2 | 0x8), (FIELD_FACTION, 35)],
|
||||
);
|
||||
assert_eq!(asked(&mut app), 1, "its flightmaster bit — re-ask that one");
|
||||
assert_eq!(asked(&mut app), 0, "exactly once");
|
||||
|
||||
set_npc(
|
||||
&mut app,
|
||||
&[(FIELD_NPC_FLAGS, 0x2 | 0x8), (FIELD_FACTION, 12)],
|
||||
);
|
||||
assert_eq!(asked(&mut app), 1, "its faction template — re-ask that one");
|
||||
assert_eq!(asked(&mut app), 0, "exactly once");
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,12 @@
|
||||
//! `benilla-pins.md` B11, byte-confirmed); the lookup's class-0 rows are the Ancient
|
||||
//! Protector's stomps (kit 661), reached only by that model's own nonzero class.
|
||||
//!
|
||||
//! Wading (feet below a water surface, up to [`WADE_MAX`] deep) picks the lookup's **splash**
|
||||
//! slot instead of dry (falling back to dry when the class has no splash kit); deeper the unit
|
||||
//! swims and footfalls go silent. B7 folded in (decision 0226) and the local player now runs a
|
||||
//! real WALKING↔SWIMMING mode (`player::swim`), but this depth proxy was never retired: it is
|
||||
//! still the only signal for units with no swim mode of their own (creatures — 0464's open
|
||||
//! `collisionHeight` plumb), and this system still reads it for the local player too rather than
|
||||
//! the real mode flag — a named follow-up in decision 0530.
|
||||
//! Wading (feet below a water surface, down to the unit's own swim boundary `0.75·h`) picks the
|
||||
//! lookup's **splash** slot instead of dry (falling back to dry when the class has no splash kit);
|
||||
//! deeper the unit swims and footfalls go silent. The flat 2.0-yd stand-in that boundary used to be
|
||||
//! is retired now 0464's `collisionHeight` plumb has landed (decision 0645) — a murloc's footfalls
|
||||
//! now go quiet in water that only reaches a human's knees. Still open: this reads a *depth* even
|
||||
//! for the local player rather than its real mode flag — the named follow-up in decision 0530.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -30,8 +29,10 @@ use benilla_formats::FootstepCatalog;
|
||||
|
||||
use crate::assets::{AssetSet, LockRecover, WorldAssets};
|
||||
use crate::creature_anim::{is_footstep, AnimSoundEvent};
|
||||
use crate::liquid::{water_surface_at, WaterChunkInfo, WADE_MAX};
|
||||
use crate::entities::CollisionHeight;
|
||||
use crate::liquid::{water_surface_at, WaterChunkInfo};
|
||||
use crate::net::NetEntity;
|
||||
use crate::player::swim_enter_depth;
|
||||
use crate::schedule::WorldStage;
|
||||
use crate::terrain_stream::{ground_effect_under, TerrainStreamer};
|
||||
|
||||
@@ -63,7 +64,7 @@ fn footstep_sounds(
|
||||
// GlobalTransform: a mounted unit's steps are the MOUNT model's own tags, fired by the
|
||||
// mount CHILD entity — whose local Transform is the seat-relative ~origin. World position
|
||||
// is the only correct read for both parented and top-level sources (0441 fold-back).
|
||||
units: Query<(&NetEntity, &GlobalTransform)>,
|
||||
units: Query<(&NetEntity, &GlobalTransform, Option<&CollisionHeight>)>,
|
||||
footsteps: Option<Res<Footsteps>>,
|
||||
voices: Option<Res<CreatureVoices>>,
|
||||
streamer: Res<TerrainStreamer>,
|
||||
@@ -88,7 +89,7 @@ fn footstep_sounds(
|
||||
if !is_footstep(&ev.ident) {
|
||||
continue;
|
||||
}
|
||||
let Ok((net, transform)) = units.get(ev.entity) else {
|
||||
let Ok((net, transform, collision)) = units.get(ev.entity) else {
|
||||
continue;
|
||||
};
|
||||
// The unit's footstep class (module docs): the voice row's class verbatim; zero or no
|
||||
@@ -104,7 +105,8 @@ fn footstep_sounds(
|
||||
let depth = water_surface_at(water.iter(), wow, None)
|
||||
.map(|s| s - wow[2])
|
||||
.filter(|d| *d > 0.0);
|
||||
if depth.is_some_and(|d| d > WADE_MAX) {
|
||||
let wade_max = swim_enter_depth(collision.copied().unwrap_or_default().0);
|
||||
if depth.is_some_and(|d| d > wade_max) {
|
||||
continue;
|
||||
}
|
||||
let effect = ground_effect_under(&streamer, &adt_tiles, transform.translation());
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
//!
|
||||
//! What stays INTERIM (same dispatch, still open): the **size class** — the client routes the
|
||||
//! play through the unit's own sound emitter (`[CGUnit+0xb18]`) where Small/Medium/Large
|
||||
//! resolves; that selector is not pinned, so kit 1096 Medium plays for everyone. And the
|
||||
//! per-unit **collision height** — ours is the player capsule scaled by the unit's render scale;
|
||||
//! the real per-creature value is `CreatureModelData.collisionHeight × scale` (a follow-up
|
||||
//! plumb, decision 0464).
|
||||
//! resolves; that selector is not pinned, so kit 1096 Medium plays for everyone. The per-unit
|
||||
//! **collision height** the line scales by is no longer interim: 0464's named
|
||||
//! `CreatureModelData.collisionHeight` plumb landed in decision 0645, and this reads it off the
|
||||
//! unit ([`crate::entities::CollisionHeight`]) instead of scaling the player capsule.
|
||||
|
||||
use bevy::ecs::entity::EntityHashMap;
|
||||
use bevy::prelude::*;
|
||||
@@ -24,9 +24,9 @@ use bevy::prelude::*;
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
|
||||
use crate::assets::WorldAssets;
|
||||
use crate::entities::CollisionHeight;
|
||||
use crate::liquid::{water_surface_at, WaterChunkInfo};
|
||||
use crate::net::NetEntity;
|
||||
use crate::player::CAPSULE_HEIGHT;
|
||||
use crate::schedule::WorldStage;
|
||||
|
||||
use super::kit::{play_kit_ext, source_kit_playing, KitRef, SoundCategory, SoundKits};
|
||||
@@ -42,7 +42,7 @@ const SPLASH_DEPTH_FRAC: f32 = 0.4;
|
||||
/// Play the water splash on a unit's `0.4·h` depth-line crossing, either direction (module docs).
|
||||
#[allow(clippy::too_many_arguments)] // the sound-play plumbing, one param per concern
|
||||
fn water_splashes(
|
||||
units: Query<(Entity, &Transform), With<NetEntity>>,
|
||||
units: Query<(Entity, &Transform, Option<&CollisionHeight>), With<NetEntity>>,
|
||||
water: Query<&WaterChunkInfo>,
|
||||
mut wet: Local<EntityHashMap<bool>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
@@ -55,11 +55,11 @@ fn water_splashes(
|
||||
return;
|
||||
};
|
||||
let listener = listener.pos;
|
||||
for (entity, transform) in &units {
|
||||
for (entity, transform, collision) in &units {
|
||||
let wow = bevy_to_wow(transform.translation);
|
||||
// The unit's collision height: the player capsule × the baked render scale (INTERIM for
|
||||
// creatures — module docs).
|
||||
let h = CAPSULE_HEIGHT * transform.scale.x.max(0.1);
|
||||
// The unit's own collision height (0645). `None` only on a unit's very first frame, before
|
||||
// the stamp runs — the ctor default covers it, as it does in the reference.
|
||||
let h = collision.copied().unwrap_or_default().0;
|
||||
// `None` — every unit, not just the local player; see `liquid_at`'s named gap.
|
||||
let submerged = water_surface_at(water.iter(), wow, None)
|
||||
.is_some_and(|s| s - wow[2] > SPLASH_DEPTH_FRAC * h);
|
||||
|
||||
@@ -61,7 +61,8 @@ pub(crate) use flash::CombatFlash;
|
||||
pub(crate) use scan::{can_attack, AttackNearestRequest};
|
||||
// The byte-verified reaction decode + its faction catalog, reused by the unit-frame feed to tint the
|
||||
// target's name plate (`TargetFrame_CheckFaction`) the same way the selection ring colours itself.
|
||||
pub(crate) use ring::{ring_reaction, Factions};
|
||||
// `duel_rung` is the diagnostic face of the same walk, for `/reaction` (decision 0637).
|
||||
pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant};
|
||||
|
||||
/// Our current target: the selected entity and its server guid, or `None`. Set the instant we click a
|
||||
/// unit (client-authoritative for the ring — the real client doesn't wait for the server), cleared on
|
||||
|
||||
@@ -129,11 +129,17 @@ pub(super) struct RingMaterials {
|
||||
flash: Handle<StandardMaterial>,
|
||||
}
|
||||
|
||||
/// The ring colour the selector resolves — the pure classification half of [`RingMaterials::pick`],
|
||||
/// split out so the branch logic (players vs NPCs, the `¬X∧¬Y` party split) is unit-testable
|
||||
/// without material handles.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum RingVariant {
|
||||
/// The colour `GetSelectionCircleColor` resolves — the pure classification half of
|
||||
/// [`RingMaterials::pick`], split out so the branch logic (players vs NPCs, the `¬X∧¬Y` party
|
||||
/// split) is unit-testable without material handles.
|
||||
///
|
||||
/// **This is the single source for BOTH surfaces the selector feeds** — the ground ring here and
|
||||
/// the overhead name (`nameplates.rs` fetches the same `vtable+0x2c`, decision 0156). It was a
|
||||
/// duplicated mirror until decision 0659: the ring gained the PvP/party legs with 0453 and the
|
||||
/// name's copy did not, so a flagged player drew a green ring under a blue name. One law, one
|
||||
/// function; the name maps this to its own material cache.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub(crate) enum RingVariant {
|
||||
Hostile,
|
||||
Unfriendly,
|
||||
Neutral,
|
||||
@@ -144,13 +150,43 @@ enum RingVariant {
|
||||
PartyPvp,
|
||||
}
|
||||
|
||||
impl RingVariant {
|
||||
/// Every variant, for a consumer that pre-builds one material per colour.
|
||||
pub(crate) const ALL: [Self; 8] = [
|
||||
Self::Hostile,
|
||||
Self::Unfriendly,
|
||||
Self::Neutral,
|
||||
Self::Friendly,
|
||||
Self::Player,
|
||||
Self::Dead,
|
||||
Self::Party,
|
||||
Self::PartyPvp,
|
||||
];
|
||||
|
||||
/// The selector's dword for this variant (see the palette constants above).
|
||||
pub(crate) fn color(self) -> Color {
|
||||
match self {
|
||||
Self::Hostile => RING_HOSTILE,
|
||||
Self::Unfriendly => RING_UNFRIENDLY,
|
||||
Self::Neutral => RING_NEUTRAL,
|
||||
Self::Friendly => RING_FRIENDLY,
|
||||
Self::Player => RING_PLAYER,
|
||||
Self::Dead => RING_DEAD,
|
||||
Self::Party => RING_PARTY,
|
||||
Self::PartyPvp => RING_PARTY_PVP,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ring's own colour selector (`0x605960`) on the raw reaction rank (`0..=7`): **players**
|
||||
/// branch first and never check health (a dead player doesn't gray) — hostile red on rank ≤ 1
|
||||
/// (the X/Y matrix approximation), else the `¬X∧¬Y` split: PvP-flagged → green / party
|
||||
/// pale-green, unflagged → soft blue / party pale-blue (the 4-slot party table `0xbc6f48`, our
|
||||
/// roster — self is never in it, and self reads blue/green exactly like the law's own-guid legs);
|
||||
/// **NPCs** — dead → gray, else the rank palette (0–1 red, 2 orange, 3 yellow, 4–7 green).
|
||||
fn ring_variant(
|
||||
///
|
||||
/// Shared with the overhead name (decision 0659) — see [`RingVariant`].
|
||||
pub(crate) fn ring_variant(
|
||||
rank: u8,
|
||||
is_player: bool,
|
||||
is_dead: bool,
|
||||
@@ -571,6 +607,9 @@ pub(crate) fn ring_reaction(
|
||||
if let Some(rank) = duel_reaction(&target.0, &own.0) {
|
||||
return rank;
|
||||
}
|
||||
if ffa_reaction(&target.0, &own.0) {
|
||||
return Reaction::Hostile as u8;
|
||||
}
|
||||
}
|
||||
let resolved = (|| {
|
||||
let catalog = &factions?.0;
|
||||
@@ -613,25 +652,94 @@ fn duel_reaction(
|
||||
target: &benilla_protocol::ObjectFields,
|
||||
own: &benilla_protocol::ObjectFields,
|
||||
) -> Option<u8> {
|
||||
if own.unit_flags() & UNIT_FLAG_PVP_ATTACKABLE == 0
|
||||
|| target.unit_flags() & UNIT_FLAG_PVP_ATTACKABLE == 0
|
||||
{
|
||||
return None;
|
||||
match duel_rung(target, own) {
|
||||
DuelRung::Engaged { rank, .. } => Some(rank),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The **both-FFA** rung of `UnitReaction` (`0x60632c`, the ladder 0633 §5 read in full; decision
|
||||
/// 0646 §5): two player-controlled units that are BOTH free-for-all flagged are hostile to each
|
||||
/// other, whatever their factions say. This is the only rung an ordinary PvP flag does *not* have
|
||||
/// — a same-faction player who flags for PvP stays friendly, which is why there is no `pvp` arm
|
||||
/// beside this one.
|
||||
///
|
||||
/// The flag is `PLAYER_FLAGS` bit 7 (`PLAYER_FLAGS_FFA_PVP`, vmangos `Player.h:322`), the same
|
||||
/// field and bit the unit snapshot feeds `UnitIsPVPFreeForAll` from.
|
||||
fn ffa_reaction(
|
||||
target: &benilla_protocol::ObjectFields,
|
||||
own: &benilla_protocol::ObjectFields,
|
||||
) -> bool {
|
||||
const PLAYER_FLAGS_FFA_PVP: u32 = 0x80;
|
||||
let player_controlled =
|
||||
|u: &benilla_protocol::ObjectFields| u.unit_flags() & UNIT_FLAG_PVP_ATTACKABLE != 0;
|
||||
player_controlled(target)
|
||||
&& player_controlled(own)
|
||||
&& target.player_flags() & PLAYER_FLAGS_FFA_PVP != 0
|
||||
&& own.player_flags() & PLAYER_FLAGS_FFA_PVP != 0
|
||||
}
|
||||
|
||||
/// Why [`duel_reaction`]'s rung did or did not fire, with the values it judged on. This is the
|
||||
/// diagnostic face of the same walk — `/reaction` prints it, so a duel that fails to turn the
|
||||
/// opponent red names the gate that refused instead of silently reporting "neutral". Keeping one
|
||||
/// walk with a richer return (rather than a second copy in the probe) is what stops the
|
||||
/// instrument and the law from drifting apart.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DuelRung {
|
||||
/// One or both sides lack `UNIT_FIELD_FLAGS` bit 3, so no player-vs-player rung runs at all.
|
||||
NotPlayerControlled { own: bool, target: bool },
|
||||
/// No duel under way yet: `PLAYER_DUEL_TEAM` is still `0` on one or both sides (it is set
|
||||
/// only when `Player::UpdateDuelFlag` fires at the end of the countdown).
|
||||
NoTeam { own: u32, target: u32 },
|
||||
/// Both carry a team, but they are not duelling **each other** — or the arbiter guid has not
|
||||
/// streamed to us.
|
||||
ArbiterMismatch { own: u64, target: u64 },
|
||||
/// The rung fires: `1` (hostile) on opposing teams, `4` (friendly) on the same one.
|
||||
Engaged {
|
||||
rank: u8,
|
||||
own_team: u32,
|
||||
target_team: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// The duel rung's walk, reporting the gate that decided (see [`DuelRung`]).
|
||||
pub(crate) fn duel_rung(
|
||||
target: &benilla_protocol::ObjectFields,
|
||||
own: &benilla_protocol::ObjectFields,
|
||||
) -> DuelRung {
|
||||
let own_pc = own.unit_flags() & UNIT_FLAG_PVP_ATTACKABLE != 0;
|
||||
let target_pc = target.unit_flags() & UNIT_FLAG_PVP_ATTACKABLE != 0;
|
||||
if !own_pc || !target_pc {
|
||||
return DuelRung::NotPlayerControlled {
|
||||
own: own_pc,
|
||||
target: target_pc,
|
||||
};
|
||||
}
|
||||
let own_team = own.player_duel_team();
|
||||
let target_team = target.player_duel_team();
|
||||
if own_team == 0 || target_team == 0 {
|
||||
return None;
|
||||
return DuelRung::NoTeam {
|
||||
own: own_team,
|
||||
target: target_team,
|
||||
};
|
||||
}
|
||||
let arbiter = own.player_duel_arbiter();
|
||||
if arbiter == 0 || arbiter != target.player_duel_arbiter() {
|
||||
return None;
|
||||
let target_arbiter = target.player_duel_arbiter();
|
||||
if arbiter == 0 || arbiter != target_arbiter {
|
||||
return DuelRung::ArbiterMismatch {
|
||||
own: arbiter,
|
||||
target: target_arbiter,
|
||||
};
|
||||
}
|
||||
DuelRung::Engaged {
|
||||
rank: if own_team == target_team {
|
||||
Reaction::Friendly as u8
|
||||
} else {
|
||||
Reaction::Hostile as u8
|
||||
},
|
||||
own_team,
|
||||
target_team,
|
||||
}
|
||||
Some(if own_team == target_team {
|
||||
Reaction::Friendly as u8
|
||||
} else {
|
||||
Reaction::Hostile as u8
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! [`super::feed_actions`] as a real Bevy system through a real Lua VM — the seam where the
|
||||
//! action bar's slot identity is resolved and pushed.
|
||||
//!
|
||||
//! What these pin is the **landed-template redisplay** (decision 0660): an ITEM slot's icon needs
|
||||
//! an item template that arrives asynchronously, so the resolve that first touches a cold entry is
|
||||
//! the one that ISSUES the ask-once query and necessarily reads back nothing. The regression these
|
||||
//! guard is a question mark that never goes away — the fresh character's food button.
|
||||
//!
|
||||
//! `run_system_once` is deliberately NOT used: the feed's memory is a `Local`, which that helper
|
||||
//! rebuilds per call, so a two-frame test has to run a registered system across two `app.update()`s.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_formats::{ItemDisplay, ItemDisplayCatalog};
|
||||
use benilla_protocol::messages::{ActionButton, ACTION_KIND_ITEM};
|
||||
use benilla_ui::script::UiScript;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{feed_actions, CastErrors, MountErrors, PlayerActions, UiErrorKeys};
|
||||
use crate::entities::ItemDisplays;
|
||||
use crate::items::{test_template, Items};
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
|
||||
/// The fresh human warrior's default bar (vmangos `playercreateinfo_action`): Tough Jerky, item
|
||||
/// **117**, on wire slot **83** (the Battle Stance bonus page) — Lua action id 84.
|
||||
const JERKY: u32 = 117;
|
||||
const JERKY_SLOT: u8 = 83;
|
||||
const JERKY_ACTION: u32 = JERKY_SLOT as u32 + 1;
|
||||
/// Tough Jerky's real display id and the icon that display carries (`ItemDisplayInfo.dbc`).
|
||||
const JERKY_DISPLAY: u32 = 2473;
|
||||
const JERKY_ICON: &str = "Interface\\Icons\\INV_Misc_Food_16";
|
||||
|
||||
/// An app with the feed registered and the fresh character's food button on the bar — but a cold
|
||||
/// template cache, exactly as at login.
|
||||
fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
let mut actions = PlayerActions::default();
|
||||
actions.buttons.insert(
|
||||
JERKY_SLOT,
|
||||
ActionButton {
|
||||
slot: JERKY_SLOT,
|
||||
action: JERKY,
|
||||
kind: ACTION_KIND_ITEM,
|
||||
},
|
||||
);
|
||||
// SMSG_ACTION_BUTTONS' own arm sets this; the feed's first pass is what clears it.
|
||||
actions.dirty = true;
|
||||
|
||||
let displays = HashMap::from([(
|
||||
JERKY_DISPLAY,
|
||||
ItemDisplay {
|
||||
icon: Some(JERKY_ICON.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
|
||||
app.insert_resource(actions)
|
||||
.init_resource::<Items>()
|
||||
.init_resource::<CastErrors>()
|
||||
.init_resource::<MountErrors>()
|
||||
.init_resource::<UiErrorKeys>()
|
||||
.insert_resource(ItemDisplays::icons_for_tests(
|
||||
ItemDisplayCatalog::from_displays(displays),
|
||||
))
|
||||
.insert_resource(NetCommands(tx));
|
||||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||||
app.add_systems(Update, feed_actions);
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// What the VM believes the food button's icon is — the exact read `ActionBar.xml` does before it
|
||||
/// falls back to `BENILLA_FALLBACK_ICON` (the question mark).
|
||||
fn fed_texture(app: &mut App) -> Option<String> {
|
||||
app.world_mut()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<Option<String>>(&format!("return GetActionTexture({JERKY_ACTION})"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The whole bug in one test: the first resolve issues the query and can only feed nothing; the
|
||||
/// frame the answer lands, the slot re-resolves and the real icon arrives. Before decision 0660 the
|
||||
/// second half never happened — the button kept the fallback question mark for the whole session,
|
||||
/// until some unrelated bar edit re-dirtied the feed.
|
||||
#[test]
|
||||
fn a_landed_item_template_redisplays_the_action_slot() {
|
||||
let (mut app, rx) = app_with_food_on_the_bar();
|
||||
|
||||
app.update();
|
||||
assert_eq!(
|
||||
fed_texture(&mut app),
|
||||
None,
|
||||
"the first resolve of a cold entry IS the ask — it cannot have an icon yet"
|
||||
);
|
||||
assert!(
|
||||
rx.try_iter()
|
||||
.any(|c| matches!(c, ClientCommand::ItemQuery { entry, .. } if entry == JERKY)),
|
||||
"…and it must have asked the server for the template"
|
||||
);
|
||||
|
||||
// Nothing else changes: no bar edit, no new spell, no local pickup — only the answer arriving.
|
||||
let mut info = test_template("Tough Jerky");
|
||||
info.display_info_id = JERKY_DISPLAY;
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(JERKY, Some(info));
|
||||
|
||||
app.update();
|
||||
assert_eq!(
|
||||
fed_texture(&mut app).as_deref(),
|
||||
Some(JERKY_ICON),
|
||||
"the landed template redisplays the slot — the fresh character's food loses its question mark"
|
||||
);
|
||||
}
|
||||
|
||||
/// The epoch is a *change* gate, not a per-frame re-resolve: once the answer has landed and been
|
||||
/// fed, an idle frame re-resolves nothing. (Guards the obvious over-correction — turning the feed
|
||||
/// into an every-frame rebuild of all 120 slots.)
|
||||
#[test]
|
||||
fn a_quiet_frame_after_the_answer_re_resolves_nothing() {
|
||||
let (mut app, _rx) = app_with_food_on_the_bar();
|
||||
app.update();
|
||||
let mut info = test_template("Tough Jerky");
|
||||
info.display_info_id = JERKY_DISPLAY;
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(JERKY, Some(info));
|
||||
app.update();
|
||||
|
||||
let before = app.world().resource::<Items>().template_epoch();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<Items>().template_epoch(),
|
||||
before,
|
||||
"an idle frame lands no template, so the gate stays shut"
|
||||
);
|
||||
assert_eq!(
|
||||
fed_texture(&mut app).as_deref(),
|
||||
Some(JERKY_ICON),
|
||||
"…and the fed icon is unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// A NEGATIVE answer (the server does not know the entry) also advances the epoch — it is a real
|
||||
/// transition for anything that waits on the ask — but it resolves to no icon, so the slot keeps
|
||||
/// the fallback. Pins that the gate cannot spin: one re-resolve, then quiet.
|
||||
#[test]
|
||||
fn an_unknown_entry_answers_once_and_settles() {
|
||||
let (mut app, _rx) = app_with_food_on_the_bar();
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(JERKY, None);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
fed_texture(&mut app),
|
||||
None,
|
||||
"an unknown entry has no display and so no icon"
|
||||
);
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<Items>()
|
||||
.template_answered_unknown(JERKY),
|
||||
"the negative is cached — the feed must not re-ask on the next resolve"
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user