From c1ba444119d954ca1946baa62368e847e38bc2c5 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 26 Jul 2026 19:17:47 +0700 Subject: [PATCH] the micro menu, the ping meter, and the PvP flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/benilla-assets/src/m2.rs | 65 +- crates/benilla-assets/src/model/anims.rs | 20 +- .../benilla-formats/examples/dump_emitters.rs | 52 ++ .../examples/dump_model_anim.rs | 102 +++ crates/benilla-formats/examples/list_chain.rs | 26 + .../benilla-formats/examples/what_is_here.rs | 68 ++ .../benilla-formats/examples/wmo_coplanar.rs | 208 +++++ .../src/bin/benilla-extract/main.rs | 12 + .../src/bin/benilla-extract/scan.rs | 509 ++++++++++- crates/benilla-formats/src/creatures.rs | 133 ++- crates/benilla-formats/src/items.rs | 6 + crates/benilla-formats/src/models/anim.rs | 467 +++++++--- crates/benilla-formats/src/models/key_anim.rs | 13 +- .../tests/m2_loader_idle_is_stand.rs | 104 +++ .../examples/cooldown_probe.rs | 2 +- .../src/bin/benilla-world/main.rs | 18 +- .../src/bin/benilla-world/probes/aura.rs | 2 +- .../bin/benilla-world/probes/worldstate.rs | 2 +- crates/benilla-protocol/src/messages/mod.rs | 5 +- .../benilla-protocol/src/messages/opcode.rs | 8 + .../benilla-protocol/src/messages/roster.rs | 5 + .../benilla-protocol/src/world/writer/mod.rs | 2 + .../benilla-protocol/src/world/writer/pvp.rs | 23 + crates/benilla-ui/src/loader/geometry.rs | 17 + crates/benilla-ui/src/loader/tests.rs | 34 + crates/benilla-ui/src/order.rs | 25 + crates/benilla-ui/src/script/editbox.rs | 10 +- .../benilla-ui/src/script/editbox/methods.rs | 29 +- crates/benilla-ui/src/script/editbox/tests.rs | 61 ++ crates/benilla-ui/src/script/extract.rs | 20 +- crates/benilla-ui/src/script/messageframe.rs | 56 ++ crates/benilla-ui/src/script/mod.rs | 13 + crates/benilla-ui/src/script/model.rs | 11 + crates/benilla-ui/src/script/net_stats.rs | 40 + .../src/script/object/frame_state.rs | 26 + crates/benilla-ui/src/script/pointer.rs | 21 +- crates/benilla-ui/src/script/pvp.rs | 46 + crates/benilla-ui/src/script/tests/input.rs | 56 ++ crates/benilla-ui/src/script/unit/bindings.rs | 16 + crates/benilla-ui/src/script/unit/mod.rs | 10 + crates/benilla-ui/src/script/unit/tests.rs | 46 + crates/benilla-ui/src/widget/kinds/editbox.rs | 9 +- crates/benilla-ui/src/widget/mod.rs | 11 + crates/benilla-ui/src/widget/propagation.rs | 16 + crates/benilla-visual/src/lib.rs | 699 ++++++++++++++- crates/benilla-visual/src/main.rs | 280 +++++- crates/benilla/assets/ui/ActionBar.xml | 175 +++- crates/benilla/assets/ui/BagFrame.xml | 81 +- crates/benilla/assets/ui/CharacterFrame.xml | 40 +- crates/benilla/assets/ui/ChatFrame.xml | 321 ++++++- crates/benilla/assets/ui/GameTooltip.xml | 40 +- crates/benilla/assets/ui/InspectFrame.xml | 4 +- crates/benilla/assets/ui/MicroMenu.xml | 339 ++++++++ crates/benilla/assets/ui/PartyFrame.xml | 31 +- crates/benilla/assets/ui/QuestLogFrame.xml | 8 +- crates/benilla/assets/ui/SpellBookFrame.xml | 23 +- crates/benilla/assets/ui/TalentFrame.xml | 25 +- crates/benilla/assets/ui/UnitFrames.xml | 205 ++++- crates/benilla/assets/ui/UnitPopup.xml | 5 + crates/benilla/assets/ui/WorldMapFrame.xml | 4 + crates/benilla/src/capture/mod.rs | 4 + crates/benilla/src/capture/pick_probe.rs | 178 ++++ crates/benilla/src/capture/probe_bank.rs | 50 +- crates/benilla/src/capture/probe_crossing.rs | 2 +- crates/benilla/src/capture/probe_mail.rs | 199 ++--- crates/benilla/src/capture/probe_partner.rs | 20 +- crates/benilla/src/capture/probe_rig.rs | 804 ++++++++++++++++++ crates/benilla/src/capture/probe_taxi.rs | 14 +- crates/benilla/src/capture/probes.rs | 128 ++- crates/benilla/src/char_select/mod.rs | 18 +- crates/benilla/src/debug_panel/mod.rs | 12 +- crates/benilla/src/entities.rs | 17 + crates/benilla/src/entities/attach/mod.rs | 68 +- .../benilla/src/entities/collision_height.rs | 89 ++ crates/benilla/src/entities/equipment.rs | 13 + crates/benilla/src/interact.rs | 2 +- crates/benilla/src/items.rs | 21 + crates/benilla/src/liquid.rs | 42 +- crates/benilla/src/login/mod.rs | 23 +- crates/benilla/src/main.rs | 23 + crates/benilla/src/nameplates.rs | 156 ++-- crates/benilla/src/net.rs | 81 +- crates/benilla/src/net/apply.rs | 59 +- crates/benilla/src/net/apply/objects.rs | 12 + crates/benilla/src/net/apply/session.rs | 11 +- crates/benilla/src/net/io.rs | 1 + crates/benilla/src/net/motion/spline.rs | 39 +- crates/benilla/src/player.rs | 53 +- crates/benilla/src/player/move_trace.rs | 42 +- crates/benilla/src/player/probe_cam.rs | 118 +++ crates/benilla/src/player/state.rs | 29 +- crates/benilla/src/player/swim.rs | 279 +++++- crates/benilla/src/preflight.rs | 428 ++++++++++ .../mod.rs} | 60 +- crates/benilla/src/quest_markers/query.rs | 417 +++++++++ crates/benilla/src/sound/footsteps.rs | 24 +- crates/benilla/src/sound/water.rs | 20 +- crates/benilla/src/target/mod.rs | 3 +- crates/benilla/src/target/ring.rs | 144 +++- crates/benilla/src/ui_action/feed_tests.rs | 167 ++++ crates/benilla/src/ui_action/mod.rs | 37 +- crates/benilla/src/ui_chat/edit.rs | 32 +- crates/benilla/src/ui_chat/frames.rs | 7 +- crates/benilla/src/ui_chat/input.rs | 100 +++ crates/benilla/src/ui_chat/tests.rs | 4 + crates/benilla/src/ui_duel.rs | 14 +- crates/benilla/src/ui_net.rs | 34 + crates/benilla/src/ui_party/feed.rs | 27 +- crates/benilla/src/ui_quest.rs | 33 + .../benilla/src/ui_script/action_bar_tests.rs | 77 +- crates/benilla/src/ui_script/bag_tests.rs | 89 +- crates/benilla/src/ui_script/extract/mod.rs | 13 +- crates/benilla/src/ui_script/extract/text.rs | 136 ++- .../benilla/src/ui_script/micro_menu_tests.rs | 315 +++++++ crates/benilla/src/ui_script/mod.rs | 24 + .../benilla/src/ui_script/perf_bar_tests.rs | 327 +++++++ .../src/ui_script/tooltip_anchor_tests.rs | 78 +- .../benilla/src/ui_script/unit_frame_tests.rs | 170 +++- crates/benilla/src/ui_text/layout/mod.rs | 5 +- crates/benilla/src/ui_text/mod.rs | 2 +- crates/benilla/src/ui_unit.rs | 162 +++- crates/benilla/src/water_fx/mod.rs | 36 +- 122 files changed, 9518 insertions(+), 779 deletions(-) create mode 100644 crates/benilla-formats/examples/dump_emitters.rs create mode 100644 crates/benilla-formats/examples/dump_model_anim.rs create mode 100644 crates/benilla-formats/examples/list_chain.rs create mode 100644 crates/benilla-formats/examples/what_is_here.rs create mode 100644 crates/benilla-formats/examples/wmo_coplanar.rs create mode 100644 crates/benilla-formats/tests/m2_loader_idle_is_stand.rs create mode 100644 crates/benilla-protocol/src/world/writer/pvp.rs create mode 100644 crates/benilla-ui/src/script/net_stats.rs create mode 100644 crates/benilla-ui/src/script/pvp.rs create mode 100644 crates/benilla/assets/ui/MicroMenu.xml create mode 100644 crates/benilla/src/capture/pick_probe.rs create mode 100644 crates/benilla/src/capture/probe_rig.rs create mode 100644 crates/benilla/src/entities/collision_height.rs create mode 100644 crates/benilla/src/player/probe_cam.rs create mode 100644 crates/benilla/src/preflight.rs rename crates/benilla/src/{quest_markers.rs => quest_markers/mod.rs} (92%) create mode 100644 crates/benilla/src/quest_markers/query.rs create mode 100644 crates/benilla/src/ui_action/feed_tests.rs create mode 100644 crates/benilla/src/ui_net.rs create mode 100644 crates/benilla/src/ui_script/micro_menu_tests.rs create mode 100644 crates/benilla/src/ui_script/perf_bar_tests.rs diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index d38eb0ea..6349ffcd 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -150,6 +150,38 @@ pub struct ModelEmitter { pub geometry: Option>, } +/// 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 diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index 19dac74d..5ceeef49 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -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, - /// 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, } diff --git a/crates/benilla-formats/examples/dump_emitters.rs b/crates/benilla-formats/examples/dump_emitters.rs new file mode 100644 index 00000000..a82788c9 --- /dev/null +++ b/crates/benilla-formats/examples/dump_emitters.rs @@ -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 "))?; + 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(()) +} diff --git a/crates/benilla-formats/examples/dump_model_anim.rs b/crates/benilla-formats/examples/dump_model_anim.rs new file mode 100644 index 00000000..883fec1f --- /dev/null +++ b/crates/benilla-formats/examples/dump_model_anim.rs @@ -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 "))?; + 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 = 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::parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}")) +} diff --git a/crates/benilla-formats/examples/list_chain.rs b/crates/benilla-formats/examples/list_chain.rs new file mode 100644 index 00000000..a54ffee0 --- /dev/null +++ b/crates/benilla-formats/examples/list_chain.rs @@ -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 [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(()) +} diff --git a/crates/benilla-formats/examples/what_is_here.rs b/crates/benilla-formats/examples/what_is_here.rs new file mode 100644 index 00000000..57f4df8c --- /dev/null +++ b/crates/benilla-formats/examples/what_is_here.rs @@ -0,0 +1,68 @@ +//! What is actually standing at a world coordinate: +//! `cargo run -p benilla-formats --example what_is_here -- [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 [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(()) +} diff --git a/crates/benilla-formats/examples/wmo_coplanar.rs b/crates/benilla-formats/examples/wmo_coplanar.rs new file mode 100644 index 00000000..3d8c7945 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_coplanar.rs @@ -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 -- [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 { + 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 { + let mut best: Option = 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 [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> = 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(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index f8d60e3c..281f9308 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -188,6 +188,17 @@ enum Command { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, + /// 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, + }, /// 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) diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 2511987b..5e2abbf4 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -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, + vals: Vec<[f32; 4]>, + comps: usize, +} + +impl RawBoneTrack { + fn read(b: &[u8], o: usize, comps: usize) -> Option { + let u32_at = |p: usize| -> Option { + b.get(p..p + 4) + .map(|s| u32::from_le_bytes(s.try_into().unwrap())) + }; + let u16_at = |p: usize| -> Option { + b.get(p..p + 2) + .map(|s| u16::from_le_bytes(s.try_into().unwrap())) + }; + let f32_at = |p: usize| -> Option { + 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 = (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::() < 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::() + .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::() + .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 = 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 = BTreeSet::new(); + let mut gseq_restrict_models: BTreeSet = 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 = 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 = 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 = (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 = (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(()) +} diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 6c944f3b..6349e4e3 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -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, - /// CreatureModelData: modelId → (model path, model scale, BloodID — see [`CreatureModel::blood`]). - models: HashMap, + /// CreatureModelData: modelId → row. + models: HashMap, /// CreatureDisplayInfoExtra: extendedDisplayInfoId → character-model NPC appearance. extra: HashMap, } @@ -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 { + 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 { 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 { 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" + ); + } } diff --git a/crates/benilla-formats/src/items.rs b/crates/benilla-formats/src/items.rs index 38811f2c..b10bd25e 100644 --- a/crates/benilla-formats/src/items.rs +++ b/crates/benilla-formats/src/items.rs @@ -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) -> 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) diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index e6e06ba2..a190af7c 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -324,103 +324,176 @@ pub struct ModelAnimation { pub events: Vec, } -/// 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( +/// 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 { + /// `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( 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 { 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 ChannelTrack { + /// 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 { 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 = (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 { } 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 + ); + } + } + } + } + } } diff --git a/crates/benilla-formats/src/models/key_anim.rs b/crates/benilla-formats/src/models/key_anim.rs index b9eb4bb9..2dfc6a77 100644 --- a/crates/benilla-formats/src/models/key_anim.rs +++ b/crates/benilla-formats/src/models/key_anim.rs @@ -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 KeyAnim { /// 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( +pub(super) fn sample_window( keys: &[(u32, V)], step: bool, lo: usize, diff --git a/crates/benilla-formats/tests/m2_loader_idle_is_stand.rs b/crates/benilla-formats/tests/m2_loader_idle_is_stand.rs new file mode 100644 index 00000000..a5170358 --- /dev/null +++ b/crates/benilla-formats/tests/m2_loader_idle_is_stand.rs @@ -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 `/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" + ); +} diff --git a/crates/benilla-protocol/examples/cooldown_probe.rs b/crates/benilla-protocol/examples/cooldown_probe.rs index 7895db1a..1f9696e5 100644 --- a/crates/benilla-protocol/examples/cooldown_probe.rs +++ b/crates/benilla-protocol/examples/cooldown_probe.rs @@ -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}; diff --git a/crates/benilla-protocol/src/bin/benilla-world/main.rs b/crates/benilla-protocol/src/bin/benilla-world/main.rs index 61bae220..5f7dcea3 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/main.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/main.rs @@ -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 `$w`/`$e` tokens read): teleport to /// Elwynn, hop to Stormwind to force a zone-change init, and `.debug send worldstate` one /// synthetic pair to require back. Prints every state received. Needs a **SEC_DEVELOPER** - /// account: `.debug send worldstate` is gmlevel 5, one above the 3 the slot-keyed probe + /// 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, diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs index 03b7b959..5ae2e33c 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs @@ -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).", )?; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs index ce6ce052..7cb17b50 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs @@ -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. //! diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 6f0f148f..03ad89ff 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index a374b008..27ff401e 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/roster.rs b/crates/benilla-protocol/src/messages/roster.rs index cbf7c3c2..313e145a 100644 --- a/crates/benilla-protocol/src/messages/roster.rs +++ b/crates/benilla-protocol/src/messages/roster.rs @@ -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; diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index cb541dc4..e72a79ee 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/world/writer/pvp.rs b/crates/benilla-protocol/src/world/writer/pvp.rs new file mode 100644 index 00000000..aa4a7874 --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/pvp.rs @@ -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, &[]) + } +} diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 5deda8ae..69912ce7 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -42,6 +42,23 @@ impl Loader<'_> { if el.attr_bool("enableMouse") { self.call(wrapper, "EnableMouse", true, dbg); } + // `` (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::().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 [ diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index a088ceeb..a0f81c8b 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -813,6 +813,40 @@ mod loader_tests { "the text region is anchored by the insets" ); } + + /// `` 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#" + + "#, + ); + 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::("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 { diff --git a/crates/benilla-ui/src/order.rs b/crates/benilla-ui/src/order.rs index d5059d48..a056c311 100644 --- a/crates/benilla-ui/src/order.rs +++ b/crates/benilla-ui/src/order.rs @@ -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 `` child is their font instance, and a font string with no + /// `` 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 { diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index 366abd17..7dcf1462 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -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 { diff --git a/crates/benilla-ui/src/script/editbox/methods.rs b/crates/benilla-ui/src/script/editbox/methods.rs index dc67314f..c82788ea 100644 --- a/crates/benilla-ui/src/script/editbox/methods.rs +++ b/crates/benilla-ui/src/script/editbox/methods.rs @@ -32,9 +32,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "SetText", lua.create_function(|lua, (this, s): (Table, Option)| { 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)| { + 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::() + .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( diff --git a/crates/benilla-ui/src/script/editbox/tests.rs b/crates/benilla-ui/src/script/editbox/tests.rs index 1e57944c..2d3c4b41 100644 --- a/crates/benilla-ui/src/script/editbox/tests.rs +++ b/crates/benilla-ui/src/script/editbox/tests.rs @@ -526,6 +526,33 @@ fn typing_ends_the_history_browse() { assert_eq!(s.eval::("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::("return E:GetText()").unwrap(), "/g two"); + s.run(r#"E:SetText("two")"#).unwrap(); + s.editbox_action(EditAction::HistoryPrev); + assert_eq!(s.eval::("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::("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 diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index daf42c90..c4ab81bf 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -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 diff --git a/crates/benilla-ui/src/script/messageframe.rs b/crates/benilla-ui/src/script/messageframe.rs index 8b6dfb36..7e247067 100644 --- a/crates/benilla-ui/src/script/messageframe.rs +++ b/crates/benilla-ui/src/script/messageframe.rs @@ -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(); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index f35e3671..97cd89a0 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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 { diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index e1680fa3..806df3f4 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, + /// 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, diff --git a/crates/benilla-ui/src/script/net_stats.rs b/crates/benilla-ui/src/script/net_stats.rs new file mode 100644 index 00000000..eb81e418 --- /dev/null +++ b/crates/benilla-ui/src/script/net_stats.rs @@ -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) { + 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::().expect("model app_data"); + Ok((0.0_f64, 0.0_f64, i64::from(model.net_latency_ms))) + })?, + ) +} diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index c8989006..59b3ba9c 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -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::() + .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::().expect("model"); + let i = model.arena.hit_rect_insets(h); + Ok((i[0], i[1], i[2], i[3])) + })?, + )?; Ok(()) } diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 852d35f7..70b38c9b 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -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), + } +} diff --git a/crates/benilla-ui/src/script/pvp.rs b/crates/benilla-ui/src/script/pvp.rs new file mode 100644 index 00000000..18a8162d --- /dev/null +++ b/crates/benilla-ui/src/script/pvp.rs @@ -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::().expect("model app_data"); + model.pvp_toggles += 1; + Ok(()) + })?, + ) +} diff --git a/crates/benilla-ui/src/script/tests/input.rs b/crates/benilla-ui/src/script/tests/input.rs index 395a69cf..9804bc5f 100644 --- a/crates/benilla-ui/src/script/tests/input.rs +++ b/crates/benilla-ui/src/script/tests/input.rs @@ -205,3 +205,59 @@ fn mouse_wheel_passes_delta_to_the_captured_frame() { assert!((s.eval::("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::("return A:GetHeight()").unwrap(), 100.0); + assert_eq!(s.eval::("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::("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::("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()); +} diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 3f78ea51..39edff82 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -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-` 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| { + 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 diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index b07487a9..998d85ba 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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| (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-` 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, /// 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. diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 0b294f0e..7b8f3eb5 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -518,3 +518,49 @@ fn unit_race_class_sex_report_the_snapshot_or_the_absent_shape() { .eval::(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::(r#"local a, b = UnitFactionGroup("target") return a == nil and b == nil"#) + .unwrap()); + // An absent unit answers the same way. + assert!(s + .eval::(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"); +} diff --git a/crates/benilla-ui/src/widget/kinds/editbox.rs b/crates/benilla-ui/src/widget/kinds/editbox.rs index 431dd32f..8d58508b 100644 --- a/crates/benilla-ui/src/widget/kinds/editbox.rs +++ b/crates/benilla-ui/src/widget/kinds/editbox.rs @@ -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, /// The live line stashed when browsing starts, restored when DOWN walks past the newest entry. pub history_draft: Option, @@ -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; diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index dc40df9f..7e73ca72 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -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 `` — 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 — ``/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, diff --git a/crates/benilla-ui/src/widget/propagation.rs b/crates/benilla-ui/src/widget/propagation.rs index cdff660e..2e0bf42b 100644 --- a/crates/benilla-ui/src/widget/propagation.rs +++ b/crates/benilla-ui/src/widget/propagation.rs @@ -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)? diff --git a/crates/benilla-visual/src/lib.rs b/crates/benilla-visual/src/lib.rs index 8f8f923f..08ed4676 100644 --- a/crates/benilla-visual/src/lib.rs +++ b/crates/benilla-visual/src/lib.rs @@ -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 anyhow::Result { + 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, +} + +/// 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 { + 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 { + frames + .windows(2) + .map(|pair| { + let mut from = 0.0; + let mut rgb = [0.0f64; 3]; + let deltas: Vec = 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::() / 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) -> 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, + /// 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 = 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 { + 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 { + (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![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 = (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 + } } diff --git a/crates/benilla-visual/src/main.rs b/crates/benilla-visual/src/main.rs index 0290e53a..c1ad1b0f 100644 --- a/crates/benilla-visual/src/main.rs +++ b/crates/benilla-visual/src/main.rs @@ -3,21 +3,54 @@ //! Usage: //! benilla-visual diff [--out ] [--fail ] [--amplify ] //! benilla-visual diff-dir [--out ] [--fail ] [--amplify ] +//! benilla-visual flicker [--out ] [--fail ] [--amplify ] //! //! `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, fail: Option, 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 ")?; + 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 { + let names: Vec = 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=)", + dir.display(), + names.len() + ); + } + let frames: Vec = names + .iter() + .map(|n| load(&dir.join(n))) + .collect::>()?; + + 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 = pngs(dir)?.into_iter().collect(); + let frames: Vec = names + .iter() + .map(|n| load(&dir.join(n))) + .collect::>()?; + 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="" names the surfaces at these exact pixels, front to back. + let pick = r + .samples() + .iter() + .map(|(x, y)| format!("{x},{y}")) + .collect::>() + .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> = 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 = 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 = (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, flag: &str) -> Result { 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) -> bool { fail.is_some_and(|t| m.mae > t) } @@ -240,8 +512,14 @@ fn print_usage() { USAGE:\n \ benilla-visual diff [--out ] [--fail ] [--amplify ]\n \ benilla-visual diff-dir [--out ] [--fail ] [--amplify ]\n \ + benilla-visual flicker [--out ] [--fail ] [--amplify ] [--toggle-delta ]\n \ + benilla-visual hotspot --out [--toggle-delta ] [--pad ]\n \ benilla-visual compose-dir --out (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." ); } diff --git a/crates/benilla/assets/ui/ActionBar.xml b/crates/benilla/assets/ui/ActionBar.xml index ae6fd4d1..5d7ecf72 100644 --- a/crates/benilla/assets/ui/ActionBar.xml +++ b/crates/benilla/assets/ui/ActionBar.xml @@ -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). --> - + + @@ -587,6 +705,8 @@ BenillaExpBar_OnLoad(self) BenillaExpBar_OnEvent(self) + BenillaExpBar_OnEnter(self) + BenillaExpBar_OnLeave() @@ -723,6 +843,53 @@ + + + + + + + + + + + + + + + + + + + + BenillaPerformanceBar_OnLoad(self) + BenillaPerformanceBar_OnUpdate(self, arg1) + + BenillaActionBar_OnLoad(self) diff --git a/crates/benilla/assets/ui/BagFrame.xml b/crates/benilla/assets/ui/BagFrame.xml index b62f45bf..6c832866 100644 --- a/crates/benilla/assets/ui/BagFrame.xml +++ b/crates/benilla/assets/ui/BagFrame.xml @@ -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 @@ - BenillaBagBar_SeatAboveActionBar(self) + BenillaActionBarArt_SeatAbove's comment). --> + if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end BenillaBagToggle_OnClick() + BenillaBagToggle_OnEnter(self) + BenillaBagBarButton_OnLeave() @@ -1196,6 +1235,8 @@ BenillaBagBarSlot_OnLoad(self, 1) BenillaBagBarSlot_OnClick(self) BenillaBagBarSlot_OnEvent(self) + BenillaBagBarSlot_OnEnter(self) + BenillaBagBarButton_OnLeave() @@ -1219,6 +1260,8 @@ BenillaBagBarSlot_OnLoad(self, 2) BenillaBagBarSlot_OnClick(self) BenillaBagBarSlot_OnEvent(self) + BenillaBagBarSlot_OnEnter(self) + BenillaBagBarButton_OnLeave() @@ -1242,6 +1285,8 @@ BenillaBagBarSlot_OnLoad(self, 3) BenillaBagBarSlot_OnClick(self) BenillaBagBarSlot_OnEvent(self) + BenillaBagBarSlot_OnEnter(self) + BenillaBagBarButton_OnLeave() @@ -1265,6 +1310,8 @@ BenillaBagBarSlot_OnLoad(self, 4) BenillaBagBarSlot_OnClick(self) BenillaBagBarSlot_OnEvent(self) + BenillaBagBarSlot_OnEnter(self) + BenillaBagBarButton_OnLeave() diff --git a/crates/benilla/assets/ui/CharacterFrame.xml b/crates/benilla/assets/ui/CharacterFrame.xml index 52b4a9bc..ba820389 100644 --- a/crates/benilla/assets/ui/CharacterFrame.xml +++ b/crates/benilla/assets/ui/CharacterFrame.xml @@ -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 `` (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 `` 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 `` 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 @@ BenillaCharacterFrameTab_OnClick(2) - + diff --git a/crates/benilla/assets/ui/ChatFrame.xml b/crates/benilla/assets/ui/ChatFrame.xml index 405ec980..2e21ba97 100644 --- a/crates/benilla/assets/ui/ChatFrame.xml +++ b/crates/benilla/assets/ui/ChatFrame.xml @@ -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 ]]> @@ -466,7 +630,14 @@ + + @@ -474,6 +645,152 @@ + + + + + + + + + + + + + + + + + + + + + BenillaOnEmoteMenuLoad(BenillaEmoteMenuList) + + + + + + + BenillaOnEmoteMenuLoad(BenillaVoiceMacroList) + + + + + + + BenillaChatMenu_OnLoad() + + + 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) + + + + - diff --git a/crates/benilla/assets/ui/SpellBookFrame.xml b/crates/benilla/assets/ui/SpellBookFrame.xml index c99d0e52..2881804a 100644 --- a/crates/benilla/assets/ui/SpellBookFrame.xml +++ b/crates/benilla/assets/ui/SpellBookFrame.xml @@ -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 diff --git a/crates/benilla/assets/ui/TalentFrame.xml b/crates/benilla/assets/ui/TalentFrame.xml index e934bd0c..8dbfb254 100644 --- a/crates/benilla/assets/ui/TalentFrame.xml +++ b/crates/benilla/assets/ui/TalentFrame.xml @@ -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 @@