diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index df9333ea..f49a8a86 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -364,7 +364,7 @@ fn main() { // globals are missing, and every number below is worse for a reason that has nothing to do // with the client — say so rather than letting two machines' numbers be compared in silence. println!( - " VM: our whole FrameXML + a seated session{}\n", + " VM: the stock 1.12 FrameXML off the player's chain + a seated session{}\n", if addon_harness::seated_with_global_strings() { " + the real GlobalStrings.lua" } else { diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 385d511c..76cbae11 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -8,7 +8,9 @@ //! //! ## One VM per addon, deliberately //! -//! Every addon is surveyed in a **fresh** [`UiScript`] with our own FrameXML loaded underneath it. +//! Every addon is surveyed in a **fresh** [`UiScript`] with the whole in-game interface loaded +//! underneath it — the stock 1.12 FrameXML off the player's chain plus the few files still ours +//! (1751), exactly what a live session seats. //! That costs a full UI load per addon and buys the only property that makes the report readable: //! one addon's failure cannot be another's. Loading them all into one VM means the first addon to //! leave a global in a bad state gets blamed for the next twenty, and the distribution 1188 asks @@ -1332,8 +1334,10 @@ fn source_files(root: &Path, name: &str, toc: &Toc) -> Vec { /// The FrameXML digest of the interface this survey loaded (`crate::ui_script::framexml_digest`). /// /// **Print it beside every number.** A survey run is only comparable to another survey run that -/// loaded the same interface, and in a dev build `assets/ui` is read from the source tree — so an -/// edit by anything sharing the checkout moves the headline with no rebuild and no announcement. +/// loaded the same interface, and in a dev build the manifest and the files still ours are read +/// from the source tree — so an edit by anything sharing the checkout moves the headline with no +/// rebuild and no announcement. A chain entry contributes its name only (its bytes are the +/// player's install), so migrating a window changes the digest and playing does not. pub fn framexml_digest() -> String { crate::ui_script::framexml_digest() } diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 8da8c7a4..d73fbedd 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -671,6 +671,29 @@ pub(super) fn seed_ui_fixture( seed_bag_window(&mut script, icons.as_deref()); seed_equipped_bags(&mut script, icons.as_deref()); } + UiFixture::Cooldown => { + let Some(mut script) = script else { + return; + }; + seed_cooldown_filmstrip(&mut script, icons.as_deref()); + } + UiFixture::CooldownShine => { + let Some(mut script) = script else { + return; + }; + seed_cooldown_filmstrip(&mut script, icons.as_deref()); + // The pet bar's autocast shine, raised by hand: no pet is fed here, so the bar's own + // show path never runs. The shine is the subject, not the bar's chrome. + if let Err(e) = script.run( + "PetActionBarFrame:Show()\n\ + for i = 1, 4 do\n\ + local b = getglobal(\"PetActionButton\"..i)\n\ + if b then b:Show(); getglobal(b:GetName()..\"AutoCast\"):Show() end\n\ + end", + ) { + warn!("capture: ui-cooldown-shine failed to raise the autocast shine: {e}"); + } + } UiFixture::WorldMap => { let Some(mut script) = script else { return; @@ -1442,6 +1465,79 @@ pub(super) fn seed_ui_fixture( /// fixtures. The bag is a standalone addon (no `ShowUIPanel` path): drive the container snapshot /// and purse directly, then open and paint. The feed (`crate::ui_items`) leaves bag 0 alone when /// there is no `SelfPlayer` (net is disabled in capture), so this manual snapshot is not clobbered. +/// The pinned `GetTime()` value the cooldown filmstrip parks the VM's session clock at, seconds. +/// Large, because the reference's own `CooldownFrame_SetTimer` refuses a `start <= 0` and the +/// starts here are `now − fraction · span`. +const COOLDOWN_NOW_S: f64 = 100_000.0; + +/// Each filmstrip slot's cooldown duration, seconds. Long enough that the settle window's few +/// seconds of VM clock move a phase by `~5e-4` of a step, so the shot is reproducible. +const COOLDOWN_SPAN_S: f64 = 10_000.0; + +/// **The cooldown sweep as a filmstrip** (B379): sixteen backpack slots, each parked at its own +/// fraction of one very long cooldown, so the window shows sixteen points of the 1000 ms sweep at +/// once — the instrument for "what does the indicator actually draw", which no test can reach +/// (the engine tests prove the scrub, not the picture). +/// +/// Game slot 1 renders TOP-LEFT and slot 16 bottom-right (`ContainerFrame_GenerateFrame` numbers +/// backwards — see [`seed_bag_window`]), so ascending slot is reading order and ascending +/// fraction reads as a filmstrip. +fn seed_cooldown_filmstrip( + script: &mut benilla_ui::script::UiScript, + icons: Option<&crate::entities::ItemDisplays>, +) { + // Park the session clock FIRST: `set_container` stores each triple against it, and + // `GetContainerItemCooldown`'s cold-at-expiry guard reads it. + if let Err(e) = script.run(&format!("__benilla_now = {COOLDOWN_NOW_S}")) { + warn!("capture: ui-cooldown failed to pin the session clock: {e}"); + } + const DISP_STONE: u32 = 6418; + let texture = icons + .and_then(|i| i.catalog.get(DISP_STONE)) + .and_then(|d| d.icon.clone()); + let span_ms = COOLDOWN_SPAN_S * 1000.0; + let now_ms = COOLDOWN_NOW_S * 1000.0; + let mut slots = std::collections::HashMap::new(); + for slot in 1u32..=16 { + // Sixteen phases across the sweep, biased off both ends: 0 is the uniform disc and 1 is + // the flash, and neither is what this instrument is looking at. + let fraction = (f64::from(slot) - 0.5) / 16.0; + slots.insert( + slot, + benilla_ui::script::ContainerSlot { + petition: None, + durability: None, + duration_ms: None, + bar_placeable: true, + texture: texture.clone(), + count: 1, + quality: Some(1), + item_id: 6948, + link: Some("|cffffffff|Hitem:6948|h[Hearthstone]|h|r".into()), + locked: false, + equip_slots: Vec::new(), + cooldown: Some(((now_ms - fraction * span_ms) as i64, span_ms as u32, true)), + readable: false, + creator: None, + flags: 0, + already_bound: false, + enchants: Vec::new(), + }, + ); + } + script.set_container( + 0, + Some(benilla_ui::script::ContainerState { + name: Some("Backpack".into()), + num_slots: 16, + slots, + }), + ); + if let Err(e) = script.run("OpenBag(0)") { + warn!("capture: ui-cooldown failed to open the backpack: {e}"); + } +} + fn seed_bag_window( script: &mut benilla_ui::script::UiScript, icons: Option<&crate::entities::ItemDisplays>, diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 3060863e..f73a1a97 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -64,6 +64,30 @@ pub(super) enum UiFixture { QuestLog, Loot, Bag, + /// **The cooldown sweep, at sixteen phases in one still** (B379). + /// + /// The backpack filled slot for slot, each slot's `GetContainerItemCooldown` triple parked at + /// its own fraction of one very long cooldown — so the sixteen `…Cooldown` model panes + /// (`CooldownFrameTemplate`, the reference's own `UI-Cooldown-Indicator.m2` through the tile + /// lane, decision 2019) each hold a different point of the 1000 ms sweep and the window reads + /// left-to-right, top-to-bottom as a filmstrip of it. Game slot 1 renders TOP-LEFT + /// (`ContainerFrame_GenerateFrame` numbers backwards — see `seed_bag_window`), so ascending + /// slot is reading order. + /// + /// **The clock is pinned so the phases are, too.** `GetTime()` is the VM's own accumulating + /// session clock (`__benilla_now`), and the frame count to the shutter varies run to run — so + /// an absolute start would put the sweep somewhere different every capture and the baseline + /// could never diff. The fixture parks `__benilla_now` at a large value and gives every + /// slot a very long cooldown, which makes the settle window's few seconds worth + /// `~5e-4` of one phase step. + Cooldown, + /// **The cooldown filmstrip with the pet bar's autocast SHINE up beside it** (B379). + /// + /// `UI-AutoCastButton.m2` is four additive spline emitters and no render batch at all — 300 + /// live golden particles (`GlowStar.blp`, born `(0.98, 0.87, 0.19)`) circling the button. It + /// shares the tile atlas with every other `` pane, and a particle is a world quad in + /// ATLAS space: this scenario is the one that asks whether it stays inside its own cell. + CooldownShine, /// The bag window with the GameTooltip forced open over a known slot — the tooltip look-pass /// instrument (crisp border, tiled edges, tinted plate, quality-coloured item name, snug size). Tooltip, @@ -894,6 +918,26 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, // The GameTooltip forced open over a seeded bag slot (a green-quality item). Run with // `WOW_CAPTURE=ui-tooltip`. + // The cooldown sweep as a filmstrip: sixteen bag slots, sixteen phases, one deterministic + // still (B379). Run with `WOW_CAPTURE=ui-cooldown`. + Scenario { + name: "ui-cooldown", + map: Some(MAP_AZEROTH), + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::Cooldown), + }, + // The same filmstrip with the autocast shine sharing the atlas — the cell-bleed instrument. + // Run with `WOW_CAPTURE=ui-cooldown-shine`. + Scenario { + name: "ui-cooldown-shine", + map: Some(MAP_AZEROTH), + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::CooldownShine), + }, Scenario { name: "ui-tooltip", map: Some(MAP_AZEROTH), diff --git a/crates/benilla-app/src/char_create/screen.rs b/crates/benilla-app/src/char_create/screen.rs index acc08890..d0c52211 100644 --- a/crates/benilla-app/src/char_create/screen.rs +++ b/crates/benilla-app/src/char_create/screen.rs @@ -120,38 +120,48 @@ fn spawn_screen( let empty = GlueStrings::default(); let strings = strings.unwrap_or(&empty); - let mut root = commands.spawn(( - CharCreateUi { s }, - DynTint::Backdrop, - GlobalZIndex(SCREEN_Z), - Node { - width: Val::Percent(100.0), - height: Val::Percent(100.0), - ..default() - }, - BackgroundColor(BACKDROP), - )); - root.with_children(|ui| { - // The 3D scene, full-bleed and first (everything else draws over it) — the ref's screen IS - // a fullscreen ModelFFX: the per-race background with the character standing in it (the - // booth renders both into this window-sized target). The whole pane drags to rotate, the - // ref's full-frame mouse rotation; the page tint behind it is the no-art fallback. - let mut pane = ui.spawn(( - CreateAction::Model, - Button, + let root = commands + .spawn(( + CharCreateUi { s }, + DynTint::Backdrop, + GlobalZIndex(SCREEN_Z), Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, - )); - if let Some(image) = model_image { - pane.insert(ImageNode::new(image)); - } + BackgroundColor(BACKDROP), + )) + .with_children(|ui| { + // The 3D scene, full-bleed and first (everything else draws over it) — the ref's + // screen IS a fullscreen ModelFFX: the per-race background with the character standing + // in it (the booth renders both into this window-sized target). The whole pane drags + // to rotate, the ref's full-frame mouse rotation; the page tint behind it is the + // no-art fallback. It keeps the WINDOW while the chrome below does not: a pillarbox's + // bars are the booth camera's own output clear inside this same target (1619 §3). + let mut pane = ui.spawn(( + CreateAction::Model, + Button, + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.0), + top: Val::Px(0.0), + width: Val::Percent(100.0), + height: Val::Percent(100.0), + ..default() + }, + )); + if let Some(image) = model_image { + pane.insert(ImageNode::new(image)); + } + }) + .id(); + // ...and every piece of chrome hangs off the CANVAS — the boxed scene's own rect (decision + // 2091). The race/class towers are this screen's edge-anchored chrome: against the window they + // stand over the bars, and 1587's "no void at 21:9" held only because they did (1619 §2). + let mut canvas = commands.spawn((crate::glue::glue_canvas(), ChildOf(root))); + canvas.with_children(|ui| { left_tower(ui, art, &font, s, strings); // The WoW logo (`CharacterCreateWoWLogo`, 256×128 at (3,−7)) — after the tower, like the diff --git a/crates/benilla-app/src/char_select/screen.rs b/crates/benilla-app/src/char_select/screen.rs index c572a213..337ea530 100644 --- a/crates/benilla-app/src/char_select/screen.rs +++ b/crates/benilla-app/src/char_select/screen.rs @@ -167,40 +167,51 @@ fn spawn_screen( let empty = GlueStrings::default(); let strings = strings.unwrap_or(&empty); - let mut root = commands.spawn(( - CharSelectUi { - with_art: art.button_up.is_some(), - s, - }, - GlobalZIndex(SCREEN_Z), - Node { - width: Val::Percent(100.0), - height: Val::Percent(100.0), - ..default() - }, - BackgroundColor(BACKDROP), - )); - root.with_children(|ui| { - // The 3D scene, full-bleed and first (everything else draws over it) — the ref's screen IS - // the fullscreen ModelFFX: the selected race's scene with the geared character standing in - // it. The whole pane drags to rotate (the ref's full-frame mouse rotation); the page tint - // behind it is the no-art fallback. - let mut pane = ui.spawn(( - SelectAction::Scene, - Button, + let root = commands + .spawn(( + CharSelectUi { + with_art: art.button_up.is_some(), + s, + }, + GlobalZIndex(SCREEN_Z), Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, - )); - if let Some(image) = model_image { - pane.insert(ImageNode::new(image)); - } + BackgroundColor(BACKDROP), + )) + .with_children(|ui| { + // The 3D scene, full-bleed and first (everything else draws over it) — the ref's + // screen IS the fullscreen ModelFFX: the selected race's scene with the geared + // character standing in it. The whole pane drags to rotate (the ref's full-frame mouse + // rotation); the page tint behind it is the no-art fallback. It keeps the WINDOW while + // the chrome below does not: a pillarbox's bars are the booth camera's own output + // clear inside this window-sized target (1619 §3), so the pane that samples it covers + // the window and brings the bars with it. + let mut pane = ui.spawn(( + SelectAction::Scene, + Button, + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.0), + top: Val::Px(0.0), + width: Val::Percent(100.0), + height: Val::Percent(100.0), + ..default() + }, + )); + if let Some(image) = model_image { + pane.insert(ImageNode::new(image)); + } + }) + .id(); + // ...and every piece of chrome hangs off the CANVAS — the boxed scene's own rect (decision + // 2091). Anchored to the window instead, the logo and the whole right-hand character frame + // (with Delete Character and Back) stood out in the bars at 21:9 (B377). + let mut canvas = commands.spawn((crate::glue::glue_canvas(), ChildOf(root))); + canvas.with_children(|ui| { // The WoW logo (`CharacterSelectLogo`, 256×128 at TOPLEFT (3,−7)). if let Some(logo) = &art.logo { ui.spawn((ImageNode::new(logo.clone()), abs(s, 3.0, 7.0, 256.0, 128.0))); diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index fb9edeba..48a31893 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -872,6 +872,11 @@ pub(crate) struct AnimDriver { /// the cast keeps bone 0 — where the reference's next base request overwrites it and leaves the /// character neutral. gait_flags: u32, + /// The **base-animation lock** ([`driver::play::BaseAnimLock`]) — the reference's + /// `[unit+0xd58] & 0xc0000`. While a `Knockdown`/`LiftOff`/`Land` holds it, every base request + /// is refused outright, which is how a stunned victim's knockdown survives the root's own + /// `Stand` recompute (decision 2096). + base_lock: driver::play::BaseAnimLock, /// The unit's **client-side sheath state** — the mirror of the client's committed CUR cache /// (`[unit+0xd40]`, decision 0080): what the weapon placement renders (absent a /// [`VisualSheath`] ceremony pin) and what the setter/reconcile test against. Seeded from @@ -1061,6 +1066,7 @@ impl Default for AnimDriver { mode: Mode::Gait, gait: None, gait_flags: 0, + base_lock: driver::play::BaseAnimLock::default(), sheath_cur: None, sheath_byte: None, sheath_swap: None, diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index 9468e622..329d56a6 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -42,7 +42,7 @@ use super::{ mod grip; mod mode; -mod play; +pub(super) mod play; #[cfg(test)] mod tests; mod wound; @@ -921,6 +921,12 @@ pub(super) fn drive_animations( // plays (the key-bone) evict a masked wound. A play on the *other* bone leaves the wound // decaying (the §5's inherited-swing case). Mode/gait changes proxy the mode machine's own // base plays; the flags catch the same-id re-plays the proxy can't see. + // ── The **base-animation lock**'s clearer, keyed on the FINISHED id and run before + // anything re-picks the base this frame — the reference clears at `0x5fc9c6`, above + // `OnAnimationFinished`'s own reason branch, so completion and pre-emption both release it + // ([`play::BaseAnimLock`], decision 2096). + drv.base_lock.release_finished(&player, anims, catalog); + let pre_state = (drv.mode, drv.gait); let mut base_played = false; let mut masked_played = false; @@ -1017,6 +1023,13 @@ pub(super) fn drive_animations( requests.extend(drv.deferred.take()); } for id in requests { + // **The lock, first** — `0x5fe2f0`'s head guard returns before any routing, descriptor + // build or slot election, so it precedes the fast path and the dedup below and takes + // the deferral with it: "no routing, no arm, no deferral" ([`play::BaseAnimLock`]). + // This is what keeps a stunned victim knocked flat for the whole clip. + if drv.base_lock.refuses() { + continue; + } // The COMBAT FAST-PATH (`0x5fe43c`–`0x5fe48b`, wow-re `combat-anim-fastpath.md`, // decision 0406): a combat clip requested while another combat clip is playing is // NOT armed — the CURRENT clip's rate doubles (op6 2.0f re-times its remainder, @@ -1122,7 +1135,7 @@ pub(super) fn drive_animations( } } if let Some((c, repeat)) = picked { - play_clip(&mut tr, &mut player, c, repeat, 1.0); + play_clip(&mut drv.base_lock, &mut tr, &mut player, c, repeat, 1.0); } drv.mode = Mode::Swing { id, under: special }; drv.gait = None; @@ -1213,6 +1226,7 @@ pub(super) fn drive_animations( let rate = player.animation(node).map_or(1.0, |a| a.speed()); let (c, fresh) = roll_loop(anims, head, relaxed, &mut rng); play_clip( + &mut drv.base_lock, &mut tr, &mut player, c, diff --git a/crates/benilla-app/src/creature_anim/driver/mode.rs b/crates/benilla-app/src/creature_anim/driver/mode.rs index 4b5e8b6c..824874e3 100644 --- a/crates/benilla-app/src/creature_anim/driver/mode.rs +++ b/crates/benilla-app/src/creature_anim/driver/mode.rs @@ -105,6 +105,7 @@ pub(super) fn run( // What we're entering is no longer wanted before the enter even finished — preempt // to the new Special, to the gait (a pose cut by movement), or to this one's exit. drv.mode = leave_special( + &mut drv.base_lock, sp, special, moving, @@ -120,6 +121,7 @@ pub(super) fn run( ); } else if oneshot_finished(player, anims, sp.enter(), catalog) { play( + &mut drv.base_lock, tr, player, anims, @@ -137,6 +139,7 @@ pub(super) fn run( Mode::Looping(sp) => { if special != Some(sp) { drv.mode = leave_special( + &mut drv.base_lock, sp, special, moving, @@ -157,6 +160,7 @@ pub(super) fn run( if let Some(sp) = special { // A new jump or a pose interrupts (a second jump, or sitting right after landing). drv.mode = enter_special( + &mut drv.base_lock, sp, relaxed, tr, @@ -183,6 +187,7 @@ pub(super) fn run( // A new Special interrupts the exit — re-sitting during the stand-up, say. Enter // it straight away instead of waiting the stand-up out. drv.mode = enter_special( + &mut drv.base_lock, next, relaxed, tr, @@ -229,6 +234,7 @@ pub(super) fn run( } drv.mode = if let Some(sp) = under { leave_special( + &mut drv.base_lock, sp, special, moving, @@ -244,6 +250,7 @@ pub(super) fn run( ) } else if let Some(sp) = special { enter_special( + &mut drv.base_lock, sp, relaxed, tr, @@ -270,6 +277,7 @@ pub(super) fn run( // (decision 0083 (c) — the enter never replays after an interruption). if oneshot_finished(player, anims, id, catalog) { play( + &mut drv.base_lock, tr, player, anims, @@ -347,6 +355,7 @@ pub(super) fn run( if let Some(sp) = special { // Enter a Special state. drv.mode = enter_special( + &mut drv.base_lock, sp, relaxed, tr, @@ -518,6 +527,9 @@ pub(super) fn run( let clip = cands .iter() .find_map(|&id| find_resolved(anims, id, catalog)); + // Did the base actually take a clip this pass? Only the lock can say no, and + // when it does the bookkeeping below must not run ([`play::BaseAnimLock`]). + let mut armed = true; if drv.gait == Some(target) { // The gait is already armed and stays armed — its rate is // [`play::sync_base_rate`]'s per-frame write below, which finds whichever @@ -525,55 +537,81 @@ pub(super) fn run( // sweeping every node of the id. A completed ranged Load simply clamps at // full draw and stays there; nothing promotes it (0994). } else if let Some(c) = clip { - // A looping base arm rolls its variation when relaxed (decision 0123 — - // the client's base-arm `variationIdx = −1`; a combat/cast arm keeps the - // deterministic head) AND its replay budget (decision 0516 §7d — the - // watchdog window). A re-armed Stand landing on its rare look-around - // variations IS the idle fidget. - let (c, budget) = roll_loop(anims, c, relaxed, rng); - if traced && benilla_assets::trace::enabled() { - // Every fresh gait play, including a same-clip replay (which the - // settled-state diff below cannot see) — the exact restart-from-head - // event a "frames snap" report is hunting. - benilla_assets::trace::line( - "anim", - &format!( - "{subject}PLAY gait {} (was {:?}) rate {:.2}", - c.anim_id, - drv.gait, - playback_rate(c, mv.speed, model_scale) - ), - ); + // **The two bypasses the reference keeps** (wow-re + // `base-anim-lock-knockdown.md` §6/§7.6): the death poses and the mount attach + // write the arm primitive directly and are not gated by the base-anim lock. In + // benilla both arrive as ordinary gait targets, so they say so here — a body + // knocked flat and then killed must still fall dead, and mounting during a + // `Knockdown` overrides the clip. + if target == DEATH || target == select::MOUNT { + drv.base_lock.release(); } - // The ranged Load plays ONCE and freezes at full draw: as a Forever - // gait it WRAPPED to its head at completion — the frames + cross-fade - // against the restarted reach-to-quiver were the director's "jumps - // back to the start the moment it gets fully pulled", in every build - // that replayed a pull (trace-caught, decision 0412). The clamp IS the - // drawn pose; nothing follows it (0994). - // Loot 50 is likewise authored clamp — one 0.5 s kneel-down that must - // FREEZE in the rummage pose; as Forever it would wrap back to standing - // and re-kneel every half second. - let repeat = if select::is_ranged_load(target) || target == select::LOOT { - // A deliberate freeze — no window either: a watchdog re-pull is - // exactly what the reference's missing completion dispatch rules out. - drv.loop_window = None; - bevy::animation::RepeatAnimation::Never + // Held: the selector does not even roll. The reference re-picks the base on an + // EVENT (`0x5fd9e0(-1)` from a packet handler), never once a frame, so a + // per-frame retry while the lock refuses would burn the shared variation RNG + // (decision 0114's single `_rand` stream) for the clip's whole 2 s and perturb + // every other unit's rolls with it. `drv.gait` stays `None`, so the pick + // happens exactly once — the frame the clip releases the lock. + if drv.base_lock.refuses() { + armed = false; } else { - drv.loop_window = Some((c.node, budget)); - bevy::animation::RepeatAnimation::Forever - }; - drv.gait_rate = playback_rate(c, mv.speed, model_scale); - play_clip(tr, player, c, repeat, drv.gait_rate); - drv.gait = Some(target); + // A looping base arm rolls its variation when relaxed (decision 0123 — + // the client's base-arm `variationIdx = −1`; a combat/cast arm keeps the + // deterministic head) AND its replay budget (decision 0516 §7d — the + // watchdog window). A re-armed Stand landing on its rare look-around + // variations IS the idle fidget. + let (c, budget) = roll_loop(anims, c, relaxed, rng); + if traced && benilla_assets::trace::enabled() { + // Every fresh gait play, including a same-clip replay (which the + // settled-state diff below cannot see) — the exact restart-from-head + // event a "frames snap" report is hunting. + benilla_assets::trace::line( + "anim", + &format!( + "{subject}PLAY gait {} (was {:?}) rate {:.2}", + c.anim_id, + drv.gait, + playback_rate(c, mv.speed, model_scale) + ), + ); + } + // The ranged Load plays ONCE and freezes at full draw: as a Forever + // gait it WRAPPED to its head at completion — the frames + cross-fade + // against the restarted reach-to-quiver were the director's "jumps + // back to the start the moment it gets fully pulled", in every build + // that replayed a pull (trace-caught, decision 0412). The clamp IS the + // drawn pose; nothing follows it (0994). + // Loot 50 is likewise authored clamp — one 0.5 s kneel-down that must + // FREEZE in the rummage pose; as Forever it would wrap back to standing + // and re-kneel every half second. + let repeat = if select::is_ranged_load(target) || target == select::LOOT { + // A deliberate freeze — no window either: a watchdog re-pull is + // exactly what the reference's missing completion dispatch rules out. + drv.loop_window = None; + bevy::animation::RepeatAnimation::Never + } else { + drv.loop_window = Some((c.node, budget)); + bevy::animation::RepeatAnimation::Forever + }; + drv.gait_rate = playback_rate(c, mv.speed, model_scale); + let rate = drv.gait_rate; + armed = play_clip(&mut drv.base_lock, tr, player, c, repeat, rate); + if armed { + drv.gait = Some(target); + } else { + drv.loop_window = None; + } + } } else { drv.gait = Some(target); // no clip (bind pose) — record the target so we don't churn } - // The base is now arm-consistent with this movement state — every branch above - // leaves `drv.gait == Some(target)`. A one-shot that displaces it later reads - // this, not its own arm-time flags, to know whether the movement state has - // moved on since (decision 0894). - drv.gait_flags = mv.flags; + if armed { + // The base is now arm-consistent with this movement state — every branch above + // leaves `drv.gait == Some(target)`. A one-shot that displaces it later reads + // this, not its own arm-time flags, to know whether the movement state has + // moved on since (decision 0894). + drv.gait_flags = mv.flags; + } } } } diff --git a/crates/benilla-app/src/creature_anim/driver/play.rs b/crates/benilla-app/src/creature_anim/driver/play.rs index 797587bd..830af7d0 100644 --- a/crates/benilla-app/src/creature_anim/driver/play.rs +++ b/crates/benilla-app/src/creature_anim/driver/play.rs @@ -13,18 +13,106 @@ use bevy::prelude::*; use super::super::{find_resolved, AnimDriver}; use super::select::{self, jump_land_pick, Mode, Special}; +/// The **base-animation lock** — the reference's `[unit+0xd58]` bits `0xc0000`, which are the whole +/// reason a Lashed player visibly falls over (VERIFIED, wow-re `base-anim-lock-knockdown.md`; +/// decision 2096, correcting 2085's §3). +/// +/// `CGUnit::PlayAnimation 0x5fe2f0` opens with a guard that returns having done nothing at all: +/// +/// ```text +/// 5fe396 mov eax,[ebx+0xd58] +/// 5fe39c test eax,0xc0000 +/// 5fe3a1 jne 0x5fec29 ; bare epilogue — no routing, no arm, no deferral +/// ``` +/// +/// The bits are set by `PlayAnimation`'s **own** arm helper `0x5fdba0`, in a tail keyed on the id +/// *actually armed* (`0x5fdcf7`'s byte table `0x5fdd90`): **121 `Knockdown` → `0x80000`**, **192 +/// `LiftOff` / 200 `Land` → `0x40000`**. They are cleared in `CGUnit::OnAnimationFinished +/// `0x5fc9c6`, above that function's reason branch — so on natural completion *and* on pre-emption +/// — and again on a model rebuild (`0x60adee`). +/// +/// So a stun's root **does** recompute the base and the selector **does** resolve `Stand(0)`, and +/// the `0x5fda20 call 0x5fe2f0(0)` that would overwrite the Knockdown is simply **refused**. The +/// clip runs its full 2000 ms. Order-independent, which is why it holds whatever the packet timing. +/// +/// The reference carries two independent bits; a unit can only ever hold one, because the second +/// arm would itself be refused — so this models the holder as the id, which also expresses the +/// "keyed on the FINISHED id" clearing rule directly. The two weaker relatives (`0x4` on 39 +/// `JumpEnd`, which only makes the fallback idle decline, and `0x8` on 127 `Birth`, which makes the +/// whole selector return keep-current) are separate mechanisms at their own sites and are not +/// modelled here. +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct BaseAnimLock(Option); + +impl BaseAnimLock { + /// The three ids whose arm takes the lock — `0x5fdd90`'s table, `0xc0000` half. + fn locking(id: u16) -> bool { + matches!(id, select::KNOCKDOWN | select::LIFT_OFF | select::LAND) + } + + /// The guard: is a play refused right now? An **unconditional early return**, not a priority + /// comparison and not a dedup (`base-anim-lock-knockdown.md` §7.3). + pub(super) fn refuses(self) -> bool { + self.0.is_some() + } + + /// The setter, run on the id **actually armed** and only when the arm happened. + fn took(&mut self, id: u16) { + if Self::locking(id) { + self.0 = Some(id); + } + } + + /// The clearer, keyed on the **finished** id. Called once a frame before anything re-picks the + /// base, so completion and pre-emption both release it: an overridden clip's node still reports + /// finished at the end of its own span, which is exactly the reference's "the base stays locked + /// for the rest of the clip's window" when a mount arms over a knockdown (§7.6). + pub(super) fn release_finished( + &mut self, + player: &AnimationPlayer, + anims: &ModelAnimations, + catalog: Option<&AnimDataCatalog>, + ) { + if let Some(id) = self.0 { + if oneshot_finished(player, anims, id, catalog) { + self.0 = None; + } + } + } + + /// Drop the lock outright — the reference's other two clearing sites (§7.5/§7.6). + /// + /// A **model rebuild** needs no call here: benilla's rebuild removes [`AnimDriver`] itself + /// (`entities::live_display`), so the lock goes with it and the unit comes back with the base + /// unheld — which is the reference's own outcome at `0x60adee`, reached differently. What does + /// call it is the pair of arms the reference never gates: the death pose and the mount attach. + pub(super) fn release(&mut self) { + self.0 = None; + } +} + /// Cross-fade the player into an **already-resolved** clip over its blend-in time. `repeat` sets its /// repetition (`Forever` for a loop, `Count(R)` for a rolled replay budget, `Never` for a plain /// one-shot — always set, so a reused graph node never carries a stale count from a prior play); /// `rate` sets its playback speed. The primitive [`play`] (and the gait picker, which already has /// the resolved [`AnimClip`] in hand) both funnel through this so resolution never runs twice. +/// Returns whether the clip was actually armed — `false` is the lock's refusal, and a caller whose +/// bookkeeping says "the base now holds this" must not write it on a `false` (the reference's guard +/// returns before the arm's own `[bone+0xf8] = animId`, so nothing downstream believes it played). pub(super) fn play_clip( + lock: &mut BaseAnimLock, tr: &mut AnimationTransitions, player: &mut AnimationPlayer, c: &AnimClip, repeat: RepeatAnimation, rate: f32, -) { +) -> bool { + // The guard, at the very top — before any routing, descriptor build or slot election + // ([`BaseAnimLock`]). `arm` below is the primitive the reference reaches past this guard from + // the dismount, the sheath family and the weapon attach, so it stays ungated. + if lock.refuses() { + return false; + } arm( tr, player, @@ -33,6 +121,8 @@ pub(super) fn play_clip( rate, Duration::from_secs_f32(c.blend_time.max(0.0)), ); + lock.took(c.anim_id); + true } /// Arm an already-resolved **looping** clip with the cross-fade SUPPRESSED — op4 `0x7121a0` called @@ -149,6 +239,7 @@ pub(super) fn roll_loop<'a>( /// the `Count` repeat — no window outlives the arm). #[allow(clippy::too_many_arguments)] // the resolve+roll+play primitive's full input set pub(super) fn play( + lock: &mut BaseAnimLock, tr: &mut AnimationTransitions, player: &mut AnimationPlayer, anims: &ModelAnimations, @@ -169,7 +260,7 @@ pub(super) fn play( *window = None; roll_oneshot(anims, c, rng) }; - play_clip(tr, player, c, repeat, rate); + play_clip(lock, tr, player, c, repeat, rate); } } @@ -257,6 +348,7 @@ pub(super) fn oneshot_finished( /// [`Mode::Looping`] with a looping play. #[allow(clippy::too_many_arguments)] pub(super) fn enter_special( + lock: &mut BaseAnimLock, sp: Special, relaxed: bool, tr: &mut AnimationTransitions, @@ -268,6 +360,7 @@ pub(super) fn enter_special( ) -> Mode { if sp == Special::Fall { play( + lock, tr, player, anims, @@ -283,6 +376,7 @@ pub(super) fn enter_special( } else { // Enter plays are one-shots — `relaxed` (a looping-arm concern) is moot for them. play( + lock, tr, player, anims, @@ -306,6 +400,7 @@ pub(super) fn enter_special( /// exit one-shot, which [`super::drive_animations`] then waits out. Returns the mode to adopt. #[allow(clippy::too_many_arguments)] pub(super) fn leave_special( + lock: &mut BaseAnimLock, sp: Special, special: Option, moving: bool, @@ -344,7 +439,7 @@ pub(super) fn leave_special( } } if let Some(next) = special { - enter_special(next, relaxed, tr, player, anims, catalog, rng, window) + enter_special(lock, next, relaxed, tr, player, anims, catalog, rng, window) } else if matches!(sp, Special::Jump | Special::Fall) { // The landing is a plain, freely-overwritten pick (decisions 0083/0087 (d)): the clip // is chosen from the input *at touchdown* (`flags`) by the `0x602c60` dispatcher's rule, @@ -353,7 +448,7 @@ pub(super) fn leave_special( match jump_land_pick(flags) { Some(id) => { play( - tr, player, anims, id, false, false, 1.0, catalog, rng, window, + lock, tr, player, anims, id, false, false, 1.0, catalog, rng, window, ); Mode::Land { id, flags } } @@ -364,7 +459,7 @@ pub(super) fn leave_special( } else { let exit = sp.exit(); play( - tr, player, anims, exit, false, false, 1.0, catalog, rng, window, + lock, tr, player, anims, exit, false, false, 1.0, catalog, rng, window, ); Mode::Exiting(sp, exit) } diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index 3430485c..d83ca1db 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -4180,25 +4180,34 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() { ); } -/// The **stage-2 base recompute** (decision 2085): a state kit's animation id is compared against -/// what the unit is already playing (`0x5fdb50`) and, on a difference, spent on -/// `0x5fd9e0(unit, -1)` — never played. So the recompute is an animation *cutter*, and the case -/// that names it is a spell cutting its own impact clip: Charge's kit 348 plays `Knockdown`(121), -/// kit 349 names `Stun`(14), and 121 ≠ 14 ends the Knockdown. +/// The **base-animation lock** (decision 2096, VERIFIED wow-re `base-anim-lock-knockdown.md`) — +/// the reason a Lashed player visibly falls over, and the correction to 2085's reading. /// -/// The match arm is the other half and is not decoration: `0x60f393 je 0x60f3ca` leaves the block -/// having done nothing at all, so a state kit naming the clip already running must not restart or -/// cut it. -mod stage_two_recompute { +/// A stun's root recomputes the base unconditionally and the selector resolves `Stand(0)`; what +/// stops that overwriting the victim's `Knockdown` is `0x5fe2f0`'s head guard on +/// `[unit+0xd58] & 0xc0000`, a bit the arm helper set keyed on the id it actually armed. So the +/// recompute is not skipped — **the play it asks for is refused**, and the clip runs its full +/// 2000 ms. The director watched exactly this on the reference client, fighting the Silithus worm +/// whose Lash (6607) puts nothing on its victim but that one clip. +/// +/// The same guard scopes 2085's claim that a state kit's anim is an animation *cutter*: it cannot +/// cut a clip that took the lock, so Charge does **not** cut its own Knockdown. +mod base_anim_lock { use super::*; - use crate::creature_anim::Mode; + use crate::creature_anim::{BaseAnimRecompute, Mode}; + + /// Stand, Knockdown (the locking id), and SpecialUnarmed (an ordinary one-shot that does not + /// lock) — the pair that separates the guard from a plain re-pick. + const STAND_NODE: u32 = 1; + const KNOCKDOWN_NODE: u32 = 2; fn model() -> ModelAnimations { ModelAnimations { graph: Handle::default(), clips: vec![ - clip(0, 1, true), // Stand - clip(121, 2, false), // Knockdown — the impact kit's one-shot + clip(0, STAND_NODE, true), // Stand + clip(121, KNOCKDOWN_NODE, false), // Knockdown — takes the lock + clip(118, 3, false), // SpecialUnarmed — takes nothing ], hand_close: [None, None], playable_animation_lookup: Vec::new(), @@ -4220,64 +4229,96 @@ mod stage_two_recompute { .id() } - /// A one-shot is armed, then a state kit naming a DIFFERENT id lands: the clip ends. - #[test] - fn a_differing_state_kit_anim_cuts_the_one_shot() { - let mut app = app(); - let unit = victim(&mut app); + fn main_node(app: &App, unit: Entity) -> Option { + app.world() + .entity(unit) + .get::() + .unwrap() + .get_main_animation() + } + fn oneshot(app: &mut App, unit: Entity, anim_id: u16) { app.world_mut().write_message(EmoteAnim { entity: unit, - anim_id: 121, + anim_id, seq: 1, }); app.update(); - assert!( - matches!( - app.world().entity(unit).get::().unwrap().mode, - Mode::Swing { id: 121, .. } - ), - "the impact kit's Knockdown holds the base slot" + } + + fn recompute(app: &mut App, unit: Entity, anim_id: u16) { + app.world_mut().write_message(BaseAnimRecompute { + entity: unit, + anim_id, + }); + app.update(); + } + + /// The whole report, in one assertion: a `Knockdown` on the base survives the recompute the + /// stun's own root triggers, because the `Stand` it asks for is refused. + #[test] + fn a_knockdown_survives_the_base_recompute() { + let mut app = app(); + let unit = victim(&mut app); + oneshot(&mut app, unit, 121); + assert_eq!( + main_node(&app, unit), + Some(AnimationNodeIndex::new(KNOCKDOWN_NODE as usize)), + "the impact kit's Knockdown holds the base" ); - app.world_mut() - .write_message(crate::creature_anim::BaseAnimRecompute { - entity: unit, - anim_id: 14, // Stun — what kit 349 names, and never plays - }); - app.update(); + recompute(&mut app, unit, 14); // the state kit's `Stun`, which is never played + assert_eq!( - app.world().entity(unit).get::().unwrap().mode, - Mode::Gait, - "121 != 14 recomputes the base, which is what ends the Knockdown" + main_node(&app, unit), + Some(AnimationNodeIndex::new(KNOCKDOWN_NODE as usize)), + "the recompute fires, resolves Stand, and the lock refuses it — the clip stays" + ); + assert_eq!( + app.world().entity(unit).get::().unwrap().gait, + None, + "and nothing may claim the base holds Stand: the target stays unset so the selector \ + tries again once the clip releases the lock" ); } - /// …and a state kit naming the id already playing does nothing at all. + /// …and the guard is the id's, not a blanket refusal: an ordinary one-shot takes no lock, so + /// the same recompute ends it. This is 2085's mechanism, correctly scoped. #[test] - fn a_matching_state_kit_anim_leaves_the_one_shot_alone() { + fn a_non_locking_one_shot_is_cut_by_the_same_recompute() { let mut app = app(); let unit = victim(&mut app); - - app.world_mut().write_message(EmoteAnim { - entity: unit, - anim_id: 121, - seq: 1, - }); - app.update(); - - app.world_mut() - .write_message(crate::creature_anim::BaseAnimRecompute { - entity: unit, - anim_id: 121, - }); - app.update(); + oneshot(&mut app, unit, 118); assert!( matches!( app.world().entity(unit).get::().unwrap().mode, - Mode::Swing { id: 121, .. } + Mode::Swing { id: 118, .. } ), - "`je 0x60f3ca` — already playing it, so the leg does nothing" + "SpecialUnarmed holds the base slot" + ); + + recompute(&mut app, unit, 14); + + assert_eq!( + main_node(&app, unit), + Some(AnimationNodeIndex::new(STAND_NODE as usize)), + "nothing refused the Stand, so it took the slot" + ); + } + + /// A state kit naming the id already playing does nothing at all (`0x60f393 je 0x60f3ca`). + #[test] + fn a_matching_state_kit_anim_leaves_the_base_alone() { + let mut app = app(); + let unit = victim(&mut app); + oneshot(&mut app, unit, 118); + recompute(&mut app, unit, 118); + assert!( + matches!( + app.world().entity(unit).get::().unwrap().mode, + Mode::Swing { id: 118, .. } + ), + "already playing it — the leg leaves the block having done nothing" ); } } diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index 05d23388..dc6c451e 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -108,6 +108,19 @@ pub(super) const UNIT_FLAG_LOOT_SUPPRESS: u32 = 0x1000_0000; /// and by `SMSG_MOUNTSPECIAL_ANIM` (observed riders; our own echo is dropped in the net drain). pub(crate) const MOUNT_SPECIAL: u16 = 94; +/// The three ids whose arm takes the **base-animation lock** — `0x5fdba0`'s tail keyed on the id +/// actually armed, byte table `0x5fdd90` (wow-re `base-anim-lock-knockdown.md` §2). While one of +/// these holds bone 0, `PlayAnimation` refuses every base request outright, which is what lets a +/// stunned victim's `Knockdown` play out over the root's own `Stand` recompute. +/// +/// They are the clips with a definite start and end pose — the ones a re-pick mid-flight would +/// leave the body wrong: knocked flat, or halfway off the ground. +pub(crate) const KNOCKDOWN: u16 = 121; +/// See [`KNOCKDOWN`] — the taxi/lift pair, which take the same lock under the reference's other bit. +pub(crate) const LIFT_OFF: u16 = 192; +/// See [`KNOCKDOWN`]. +pub(crate) const LAND: u16 = 200; + /// Movement direction/mode flag bits, matching the client's CMovement `MOVEMENTFLAGS` (cached at /// `unit+0x9e8`; VERIFIED wow-5875-re RF-0057 + the jump §5 cross-check). The selector tests these /// exactly as the binary does, so a streamed unit can eventually drop the server's raw `u32` straight in. diff --git a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs index 67028125..ffa27d95 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs @@ -1550,11 +1550,15 @@ fn a_shooter_with_no_ranged_weapon_resolves_no_clip_at_all() { /// watcher and this one, the impact hand-off `0x61dced` — so the state kit's id is only ever the /// right-hand side of `0x60f390`'s compare, spent on a base recompute. /// -/// The subject is the real Silithus chain, because it is also the case that names the mechanism: -/// a Dredge Striker's **Charge** (22911 → visual 3783) plays `Knockdown`(121) from impact kit 348 -/// and then **cuts its own Knockdown** with state kit 349's `Stun`(14), 121 ≠ 14 forcing the -/// recompute. Before this, benilla played the 14 as a second one-shot — a `Stun` pose the -/// reference never shows on anything. +/// The subject is the real Silithus chain: a Dredge Striker's **Charge** (22911 → visual 3783) +/// plays `Knockdown`(121) from impact kit 348, and state kit 349 names `Stun`(14). Before this, +/// benilla played the 14 as a second one-shot — a `Stun` pose the reference never shows on +/// anything. +/// +/// **Scope, corrected by decision 2096:** the recompute this emits does NOT cut the Knockdown. +/// `Knockdown` takes the base-animation lock when it arms, so the `Stand` the recompute resolves is +/// refused and the clip plays out — which is what the director sees on the reference. The recompute +/// cuts only what holds no lock. What this test pins is the router's half: one play, not two. #[test] fn a_state_kits_anim_is_a_recompute_and_never_a_second_play() { const CHARGE: u32 = 22911; diff --git a/crates/benilla-app/src/glue/mod.rs b/crates/benilla-app/src/glue/mod.rs index ee467a1a..07d576ee 100644 --- a/crates/benilla-app/src/glue/mod.rs +++ b/crates/benilla-app/src/glue/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod widgets; use bevy::ecs::entity::EntityHashSet; use bevy::prelude::*; +use bevy::ui::FocusPolicy; use art::{tc_rect, GlueArt, BTN_BG, BTN_HOVER, BUTTON_TC, GOLD}; use widgets::{ArtSwap, GlueBtn, GlueCaption, GlueDisabled, OutlineCopy}; @@ -56,6 +57,12 @@ impl Plugin for GluePlugin { Update, (backdrop::fit_backdrop_borders, seat_outline_copies), ) + // The chrome's canvas is the boxed scene (2091) — fitted before the layout that reads + // it, so a screen spawned this frame never lays out against the window first. + .add_systems( + PostUpdate, + fit_glue_canvas.before(bevy::ui::UiSystems::Layout), + ) .add_systems( Update, (art_swaps, glue_button_visuals, glue_hilights, sync_outlines).in_set(GlueVisuals), @@ -188,6 +195,81 @@ pub(crate) fn screen_scale(window: Option<&Window>) -> f32 { window.map(|w| (w.height() / 768.0).min(2.2)).unwrap_or(1.0) } +/// **The rect a glue screen's chrome lays out into: the boxed scene, never the window** (decision +/// 2091, B377). +/// +/// [`screen_scale`] answers *how big* an authored coordinate draws; this answers *what it is +/// measured from*. The two are not the same question once the scene is pillarboxed (decision +/// 1619): past the aspect its art can fill, the booth camera renders into a centred box with black +/// bars either side, and a chrome node anchored `right: 0` against the *window* lands out in the +/// bar — the logo, the version line, Realmlist/Quit, the character list and Delete/Back all did, at +/// 21:9 (Henhouse's 3440×1440 report). 1619 wrote that residue down and left it; this is the +/// answer. Anchor the chrome to the **box** — the window's height, the box's width — and the glue +/// canvas is exactly what a reference client of the box's own aspect would lay out on, which is +/// what the reporter's own 1.12 shots are: a 16:9 client, centred by the monitor, everything +/// inside it. +/// +/// The screen's root stays full-window: the black bars are the booth camera's own *output* clear +/// inside a window-sized render target (1619 §3), so the full-bleed scene pane that samples that +/// target must keep covering the window. Only the chrome moves in. +#[derive(Component)] +pub(crate) struct GlueCanvas; + +/// The canvas node itself — full height, inset to the pillarbox's bars, sized by its own insets so +/// it recentres itself. Spawn one under a screen's root and hang the chrome off it; the insets are +/// kept current by [`fit_glue_canvas`] (which also fills them in on the spawn frame, before the +/// first layout runs). +/// +/// Spawn it as a sibling **after** the screen's full-bleed scene pane, so the chrome keeps drawing +/// over the scene exactly as it did when it hung off the root. +pub(crate) fn glue_canvas() -> (GlueCanvas, FocusPolicy, Node) { + ( + GlueCanvas, + // **`Pass`, and it is load-bearing.** `ui_focus_system` treats a hovered node with no + // `FocusPolicy` as `Block` (`focus_policy.unwrap_or(&FocusPolicy::Block)`) and stops the + // walk there — so a canvas spanning the whole box would swallow every hover and press + // before they reached the full-bleed scene pane beneath it, and the select and create + // screens' drag-to-rotate would simply stop working. The canvas is a coordinate frame, not + // a surface: it passes everything its own chrome does not take. + FocusPolicy::Pass, + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.0), + right: Val::Px(0.0), + top: Val::Px(0.0), + bottom: Val::Px(0.0), + ..default() + }, + ) +} + +/// Seat every [`GlueCanvas`] on the boxed scene's rect, every frame the bars change. +/// +/// In `PostUpdate` **before** `UiSystems::Layout`, which is what keeps a screen spawned this frame +/// from laying out once against the window and snapping in the next: the tree's commands have been +/// applied by then, so a canvas born in `Update` is fitted before it is ever laid out. Writing +/// only on change keeps it off Bevy's `Changed` path on the ~every frame nothing moves. +pub(crate) fn fit_glue_canvas( + window: Query<&Window, With>, + scene: Option>, + mut canvases: Query<&mut Node, With>, +) { + if canvases.is_empty() { + return; + } + let (left, right) = crate::portrait::glue_canvas_bars( + window.single().ok(), + scene.and_then(|s| s.viewport_aspect()), + ); + let (left, right) = (Val::Px(left), Val::Px(right)); + for mut node in &mut canvases { + if node.left != left || node.right != right { + node.left = left; + node.right = right; + } + } +} + /// Mirror every outlined text's content into its black copies ([`widgets::outlined_text`]) — the /// refresh systems write only the real text entity; the copies are its `OutlineCopy` siblings. /// Registered by each glue screen under its own state (the trees only exist there). @@ -350,7 +432,8 @@ mod tests { FallbackFace, GlueBtn, GlueCaption, GlueDisabled, Hilight, LockHighlight, }; use super::{ - glue_button_visuals, glue_clicks, glue_hilights, screen_scale, GlueArt, GlueClicks, GOLD, + glue_button_visuals, glue_canvas, glue_clicks, glue_hilights, screen_scale, FocusPolicy, + GlueArt, GlueClicks, GOLD, }; use bevy::prelude::*; use bevy::window::WindowResolution; @@ -492,6 +575,25 @@ mod tests { assert_ne!(colour(&app), GOLD, "it grays (the ref's GlueFontDisable)"); } + /// **The chrome canvas passes what it does not take** (decision 2091). `ui_focus_system` + /// reads a hovered node with no `FocusPolicy` as `Block` and stops the walk there, so a canvas + /// spanning the whole boxed scene would eat every hover and press before the full-bleed scene + /// pane under it saw one — and drag-to-rotate on the select and create screens would go dead + /// with nothing on screen to show for it. Pinned here because the failure is invisible to + /// every other check we run. + #[test] + fn the_chrome_canvas_never_swallows_the_scenes_drags() { + let (_, policy, node) = glue_canvas(); + assert_eq!(policy, FocusPolicy::Pass); + // …and it is sized by its own four insets, so [`fit_glue_canvas`] can move it by writing + // two of them (a width would have to be recomputed, and could disagree with the camera). + assert_eq!(node.position_type, PositionType::Absolute); + assert_eq!((node.width, node.height), (Val::Auto, Val::Auto)); + for inset in [node.left, node.right, node.top, node.bottom] { + assert_eq!(inset, Val::Px(0.0)); + } + } + /// The authored layout must FIT the window at every height — the B120 regression. /// [`screen_scale`] is the only thing standing between a 768-unit-tall tree and a shorter /// window, so a floor there silently amputates the bottom of every glue screen (the create diff --git a/crates/benilla-app/src/login/screen.rs b/crates/benilla-app/src/login/screen.rs index 49e345d0..bfe241c6 100644 --- a/crates/benilla-app/src/login/screen.rs +++ b/crates/benilla-app/src/login/screen.rs @@ -160,26 +160,37 @@ fn spawn_screen( let empty = GlueStrings::default(); let strings = strings.unwrap_or(&empty); - let mut root = commands.spawn(( - LoginUi { - with_art: art.button_up.is_some(), - s, - }, - GlobalZIndex(SCREEN_Z), - Node { - width: Val::Percent(100.0), - height: Val::Percent(100.0), - ..default() - }, - BackgroundColor(BACKDROP), - )); - root.with_children(|ui| { - // The 3D scene, full-bleed and first — the ref's screen IS the fullscreen ModelFFX - // (`UI_MainMenu`); the page tint behind it is the no-art fallback. - if let Some(image) = scene_image { - ui.spawn((ImageNode::new(image), overlay())); - } + let root = commands + .spawn(( + LoginUi { + with_art: art.button_up.is_some(), + s, + }, + GlobalZIndex(SCREEN_Z), + Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + ..default() + }, + BackgroundColor(BACKDROP), + )) + .with_children(|ui| { + // The 3D scene, full-bleed and first — the ref's screen IS the fullscreen ModelFFX + // (`UI_MainMenu`); the page tint behind it is the no-art fallback. It stays on the + // WINDOW while the chrome below does not: the pillarbox's black bars are the booth + // camera's own output clear *inside* this window-sized target (1619 §3), so the pane + // that samples the target covers the window and brings the bars with it. + if let Some(image) = scene_image { + ui.spawn((ImageNode::new(image), overlay())); + } + }) + .id(); + // ...and every piece of chrome hangs off the CANVAS — the boxed scene's own rect (decision + // 2091). Anchored to the window instead, the logo, the version line and Realmlist/Quit stood + // out in the bars at 21:9 (B377). + let mut canvas = commands.spawn((crate::glue::glue_canvas(), ChildOf(root))); + canvas.with_children(|ui| { // The WoW logo (`AccountLoginLogo`, 256×128 at TOPLEFT (3,−7), OVERLAY). if let Some(logo) = &art.logo { ui.spawn((ImageNode::new(logo.clone()), abs(s, 3.0, 7.0, 256.0, 128.0))); diff --git a/crates/benilla-app/src/pipe_warm/mod.rs b/crates/benilla-app/src/pipe_warm/mod.rs index 18f8a1e6..1af7e9ab 100644 --- a/crates/benilla-app/src/pipe_warm/mod.rs +++ b/crates/benilla-app/src/pipe_warm/mod.rs @@ -659,6 +659,7 @@ fn warm_effect_lane( no_depth_test: false, main_entity: cam, light: None, + clip: None, }, ); } @@ -694,6 +695,7 @@ fn warm_effect_lane( no_depth_test: true, main_entity: cam, light: None, + clip: None, }, ); } diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 20e14152..45c506d7 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -74,6 +74,9 @@ pub(super) fn control( // The tutorial system's world-input sites (1976): Movement acknowledged + the 10 s // Targeting popup on a movement input, Cameras acknowledged on a mouse-look. crate::tutorial::InputHooks, + // The loot window's move-start close (decision 2097): the reference's movement-START + // guard `0x60e990`, reported here beside the cast bar's edge and consumed by `ui_loot`. + ResMut, ), // Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`). speed_capsule: ( @@ -1089,6 +1092,21 @@ pub(super) fn control( { net.7 .0 = true; } + // The loot window's own walk-away (decision 2097) — the reference's movement-START guard + // `0x60e990`, called first by every player-initiated START emitter: forward/back, strafe, + // keyboard-turn and pitch START, pitch STOP, `SetPitch` and Jump pass `arg2 = 0` and close + // an open loot at distance zero; mouse-look `SetFacing` passes `arg2 = 1` and does not. + // A different mask from the cast's `0x10f0` above — TURN is in this one — and the same + // `IsActivePlayer(this)` gate: a possessed mover's steps close nothing of ours. + if steering_ourselves + && (move_flags_now + & (move_flags::ANY_MOVE | move_flags::TURN_LEFT | move_flags::TURN_RIGHT) + & !player.move_flags + != 0 + || jumped) + { + net.14 .0 = true; + } // Stream this frame's movement to the server — a `MSG_MOVE_*` per movement-axis transition, the // jump/fall lifecycle, and a ~500 ms heartbeat, each carrying the live `MovementInfo` (decisions diff --git a/crates/benilla-app/src/portrait/framing.rs b/crates/benilla-app/src/portrait/framing.rs index a9b43d04..5da0e4ed 100644 --- a/crates/benilla-app/src/portrait/framing.rs +++ b/crates/benilla-app/src/portrait/framing.rs @@ -178,6 +178,59 @@ pub(super) fn glue_zoom_floor(fov: f32) -> f32 { (diag_to_vert(fov, REFERENCE_PANEL) * 0.5).tan() } +/// **Where the pillarbox's box lands on the window**, in *physical* pixels: `(x, width)` of the +/// centred box, or `None` when the framing fills the window +/// ([`GlueFraming::viewport_aspect`] is `None`). +/// +/// One formula, two readers, and that is the point of it being a function. The booth camera's +/// `Viewport` ([`super::glue_booth::pillarbox_glue_scene`]) renders the scene into this rect; the +/// glue screens' chrome is laid out *inside the same rect* ([`crate::glue::GlueCanvas`], decision +/// 2091). They have to agree to the pixel — a rounding difference between two copies of this +/// arithmetic is the version line or the Quit button sitting a pixel out over the bar. +pub(crate) fn glue_box_physical( + full_w: u32, + full_h: u32, + viewport_aspect: Option, +) -> Option<(u32, u32)> { + let aspect = viewport_aspect?; + let (full_w, full_h) = (full_w.max(1), full_h.max(1)); + // A NaN/negative aspect saturates to 0 through `as u32` and clamps to 1: degenerate, finite, + // and never a panic — the same posture [`glue_scene_framing`] takes on a mid-resize window. + let box_w = ((full_h as f32 * aspect).round() as u32).clamp(1, full_w); + Some(((full_w - box_w) / 2, box_w)) +} + +/// The pillarbox's two **bars** in *logical* px — how far the glue chrome's canvas is inset from +/// the left and right of the window (decision 2091). `(0.0, 0.0)` when the scene fills the window, +/// which is every window at or below the aspect its scene's art can fill. +/// +/// **Two numbers, not one halved.** A box whose leftover width is odd is one pixel off centre — +/// the login gate at 3440×1440 is `330 | 2779 | 331`, which is what the report measured — and the +/// canvas has to be the box, not a symmetric approximation of it. +/// +/// Logical, because Bevy UI's `Val::Px` is. The box itself is decided in physical pixels (it is a +/// camera viewport), so the bars are measured there and divided by the window's scale factor; +/// insetting by a logical bar computed from the logical width would round a second time and could +/// disagree with the camera by a pixel. +pub(crate) fn glue_canvas_bars( + window: Option<&Window>, + viewport_aspect: Option, +) -> (f32, f32) { + let Some(w) = window else { + return (0.0, 0.0); + }; + let full_w = w.physical_width().max(1); + let Some((x, box_w)) = glue_box_physical(full_w, w.physical_height(), viewport_aspect) else { + return (0.0, 0.0); + }; + let sf = if w.scale_factor() > 0.0 { + w.scale_factor() + } else { + 1.0 + }; + (x as f32 / sf, full_w.saturating_sub(x + box_w) as f32 / sf) +} + /// The real client's portrait/model **projection**: gxumath `0x5c3cc0`, a *diagonal-FOV* /// perspective — half-angle `θ = (fov/2)/√(aspect²+1)`, `m11 = 1/tan θ`, `m00 = m11/aspect`. /// @@ -909,6 +962,93 @@ mod tests { .is_none()); } + /// **B377 / decision 2091 — the chrome's canvas IS the camera's box.** + /// + /// At the reporter's own 3440×1440 the login gate boxes at 1.93:1 (1619 §3): `330 | 2779 | + /// 331` physical pixels, the bars he measured off the screenshot (332/331). The canvas insets + /// by exactly those two numbers — not by a symmetric half, which would hang a pixel of chrome + /// over the right bar — and it converts to Bevy UI's *logical* px on the way, which is the + /// half that a 2× display makes visible. + #[test] + fn the_chrome_canvas_is_the_cameras_own_box() { + use bevy::window::WindowResolution; + + let (x, box_w) = glue_box_physical(3440, 1440, Some(1.93)).expect("21:9 boxes the gate"); + assert_eq!((x, box_w), (330, 2779)); + assert_eq!( + 3440 - (x + box_w), + 331, + "an odd leftover leaves the box one pixel off centre — the canvas follows it" + ); + + let win = |sf: Option| Window { + resolution: match sf { + Some(sf) => WindowResolution::new(3440, 1440).with_scale_factor_override(sf), + None => WindowResolution::new(3440, 1440), + }, + ..default() + }; + assert_eq!( + glue_canvas_bars(Some(&win(None)), Some(1.93)), + (330.0, 331.0), + "at scale 1 the logical bars are the physical ones" + ); + assert_eq!( + glue_canvas_bars(Some(&win(Some(2.0))), Some(1.93)), + (165.0, 165.5), + "on a 2x display Val::Px is logical: half the physical bar" + ); + // Unboxed, and no window at all: no inset — the chrome is the window's, as it was at 4:3 + // and 16:9 before any of this. + assert_eq!(glue_canvas_bars(Some(&win(None)), None), (0.0, 0.0)); + assert_eq!(glue_canvas_bars(None, Some(1.93)), (0.0, 0.0)); + // A degenerate aspect boxes to a 1 px scene rather than panicking or wrapping, the same + // posture `glue_scene_framing` takes on a mid-resize window. + assert_eq!( + glue_box_physical(3440, 1440, Some(f32::NAN)), + Some((1719, 1)) + ); + assert_eq!(glue_box_physical(0, 0, Some(1.93)), Some((0, 1))); + } + + /// The two readers of the box agree by construction — [`pillarbox_glue_scene`]'s viewport and + /// the chrome canvas both come off [`glue_box_physical`], so the frame the camera renders and + /// the frame the chrome lays out in are the same rect at every window the law boxes. + #[test] + fn the_box_the_camera_renders_is_the_box_the_chrome_lays_out_in() { + let t0 = (diag_to_vert(1.0, GLUE_AUTHORED_ASPECT) * 0.5).tan(); + // The login gate's shape: art that runs out of width at 1.54·t0 (MainMenu, 1619 §2). + let art = Some(ArtExtent { + half_w: t0 * 1.54, + half_h: 10.0, + }); + for (w, h) in [(3440, 1440), (2560, 1080), (5120, 1440), (3439, 1440)] { + let f = glue_scene_framing(1.0, w as f32 / h as f32, art); + let boxed = f + .viewport_aspect + .expect("every 21:9-and-wider window is boxed"); + let (x, box_w) = glue_box_physical(w, h, Some(boxed)).expect("boxed"); + let (left, right) = glue_canvas_bars( + Some(&Window { + resolution: bevy::window::WindowResolution::new(w, h), + ..default() + }), + Some(boxed), + ); + assert_eq!((left as u32, right as u32), (x, w - x - box_w), "{w}x{h}"); + assert!( + left + right > 0.0 && (box_w as f32) < w as f32, + "{w}x{h}: the bars are real" + ); + // The scene the camera draws and the canvas the chrome draws on are one rect. + assert_eq!( + x + box_w + (right as u32), + w, + "{w}x{h}: no pixel unaccounted" + ); + } + } + /// The authored box is never given up: art that does not even reach it (a `0.0` extent, or /// one inside 4:3) leaves the 4:3 framing exactly where 1587 put it — on both legs. #[test] diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index eb2a4ee3..84c73775 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -26,8 +26,8 @@ use benilla_assets::m2_url; use benilla_assets::materials::WowModelMaterial; use super::framing::{ - attachment_point, diag_to_vert, glue_scene_framing, ArtExtent, GLUE_AUTHORED_ASPECT, - PORTRAIT_ASPECT, + attachment_point, diag_to_vert, glue_box_physical, glue_scene_framing, ArtExtent, + GLUE_AUTHORED_ASPECT, PORTRAIT_ASPECT, }; use super::{ aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth, @@ -259,6 +259,15 @@ pub(crate) struct CreateScene { pending: Option, } +impl CreateScene { + /// The framing law's pillarbox for the window it last ran on: `Some(aspect)` while the scene + /// is boxed, `None` while it fills the window (decision 1619). Read by the glue screens' + /// chrome canvas, which lays out inside that box rather than over its bars (decision 2091). + pub(crate) fn viewport_aspect(&self) -> Option { + self.viewport_aspect + } +} + /// A requested stage swap, waiting for its half of the pair. /// /// **The two halves of a glue booth swap independently, and that is the whole defect.** Clicking a @@ -1229,15 +1238,18 @@ pub(super) fn pillarbox_glue_scene( let Ok(w) = window.single() else { return; }; - let (full_w, full_h) = (w.physical_width().max(1), w.physical_height().max(1)); - let viewport = scene.viewport_aspect.map(|aspect| { - let box_w = ((full_h as f32 * aspect).round() as u32).clamp(1, full_w); - bevy::camera::Viewport { - physical_position: UVec2::new((full_w - box_w) / 2, 0), - physical_size: UVec2::new(box_w, full_h), - ..default() - } - }); + let full_h = w.physical_height().max(1); + // The SAME arithmetic the chrome's canvas insets by ([`glue_box_physical`], decision 2091) — + // one function, so the frame the camera renders and the frame the chrome lays out in cannot + // round apart. + let viewport = + glue_box_physical(w.physical_width(), full_h, scene.viewport_aspect).map(|(x, box_w)| { + bevy::camera::Viewport { + physical_position: UVec2::new(x, 0), + physical_size: UVec2::new(box_w, full_h), + ..default() + } + }); for (booth, mut cam) in cams.iter_mut() { if booth.0 != GLUE_SLOT { continue; diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 505f5845..bc85e370 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -79,7 +79,8 @@ use benilla_assets::materials::WowModelMaterial; mod framing; pub(crate) use framing::{ - attachment_point, head_anchor, pane_projection, PortraitAnchors, WowPortraitProjection, + attachment_point, glue_canvas_bars, head_anchor, pane_projection, PortraitAnchors, + WowPortraitProjection, }; use framing::{body_frame, frame, PORTRAIT_FOV}; mod booth; @@ -95,13 +96,14 @@ mod dressup; pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview}; mod glue_booth; pub(crate) use glue_booth::{ - CreateLook, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook, - PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT, + CreateLook, CreateScene, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, + GlueScene, PetLook, PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, + GLUE_SLOT, }; mod light; pub(crate) use light::{material_variant, VariantLane}; use light::{model_pane_light, studio_light, BoothLight}; -mod test_bake; +pub(crate) mod test_bake; /// The portrait slots we bake, each with its own render layer/camera: the player + target unit /// frames, `"pet"` (decision 0990's frame), and `"npc"` — the NPC an interaction window (gossip / diff --git a/crates/benilla-app/src/portrait/test_bake.rs b/crates/benilla-app/src/portrait/test_bake.rs index 9fad9054..2972afc8 100644 --- a/crates/benilla-app/src/portrait/test_bake.rs +++ b/crates/benilla-app/src/portrait/test_bake.rs @@ -319,7 +319,7 @@ pub(super) fn dump_booth_target( /// /// `None` on an unexpected format, so a future target-format change is a loud warning rather than a /// garbled PNG. -fn encode_target_readback(shot: &Image) -> Option { +pub(crate) fn encode_target_readback(shot: &Image) -> Option { use bevy::asset::RenderAssetUsages; use bevy::render::render_resource::{TextureDimension, TextureFormat}; diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 981688fc..0bcaa294 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -1396,15 +1396,33 @@ pub(super) fn clear_target_requests( mut selection: ResMut, mut seam: crate::creature_anim::AttackSeam, engaged: Query<(), (With, With)>, + mut guid_asks: MessageReader, ) { + // The engine-side teardown ask (`0x493910(guid, 1)`): a no-op unless the selection IS that + // guid — the loot window's move-start close is its one producer today (decision 2097). + let asked = guid_asks.read().any(|ask| selection.guid == Some(ask.0)); let Some(mut script) = script else { + if asked { + clear(&mut selection, &mut seam, !engaged.is_empty()); + } return; }; - if script.take_target_clear() { + // Both drained every frame: a `||` that short-circuited on `asked` would leave the VM's + // ClearTarget flag armed for the next frame and clear whatever was targeted by then. + let vm_clear = script.take_target_clear(); + if asked || vm_clear { clear(&mut selection, &mut seam, !engaged.is_empty()); } } +/// Ask for the selection teardown **if the selection is this guid** — the reference's +/// `CGGameUI::SelectionTeardown 0x493910(guid, ecx=1)`, which no-ops unless a selection exists +/// and equals the argument. The loot window's move-start close raises it for a dead corpse +/// (`0x48f369`, decision 2097); drained by [`clear_target_requests`], so the attack-stop and the +/// wire clear stay the teardown's. +#[derive(bevy::ecs::message::Message, Clone, Copy, Debug)] +pub(crate) struct DeselectGuid(pub(crate) u64); + /// Drain the UI's **selection asks** — `TargetUnit(token)`, `AssistUnit(token)` and /// `TargetLastEnemy()` — and commit each through the shared SetSelection path ([`scan::commit`]). /// diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index c6810ac4..0493d7ab 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -118,6 +118,8 @@ pub(crate) use by_name::{AssistRequest, TargetByNameRequest}; // `duel_rung` is the diagnostic face of the same walk, for `/reaction` (decision 0637). pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant}; +pub(crate) use click::DeselectGuid; + /// Our current target: the selected entity and its server guid, or `None`. Set the instant we click a /// unit (client-authoritative for the ring — the real client doesn't wait for the server), cleared on /// deselect or when the target streams out. The guid is what we send in `CMSG_SET_SELECTION` and, later, @@ -355,6 +357,7 @@ impl Plugin for TargetPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_systems( Startup, ( diff --git a/crates/benilla-app/src/ui_loot.rs b/crates/benilla-app/src/ui_loot.rs index ad643a85..f1cc6f62 100644 --- a/crates/benilla-app/src/ui_loot.rs +++ b/crates/benilla-app/src/ui_loot.rs @@ -193,8 +193,9 @@ pub(crate) struct LootState { receives: Vec, /// A wire removal just emptied the open window (last item taken / coin line cleared with no /// items left) — the client-authoritative auto-close is due: the real client closes the loot - /// itself when the last slot clears (the server never initiates a creature-loot release — - /// vmangos `LootHandler.cpp` releases only in `HandleLootReleaseOpcode`; and the 1.12 + /// itself when the last slot clears (the server never initiates the last-slot release — + /// vmangos `LootHandler.cpp` releases only in `HandleLootReleaseOpcode`, and its one + /// unprompted release is the movement handler's, see [`LootMoveStart`]; the 1.12 /// `LootFrame.lua` `LOOT_SLOT_CLEARED` handler only hides buttons, so the close is engine-side). /// Set only on the *transition* to empty via [`LootState::remove_slot`]/[`LootState::clear_money`], /// never at open — an empty-at-open window stays up (the reference `LootFrame_OnShow` even has a @@ -222,6 +223,10 @@ pub(crate) struct LootState { /// rather than writing `master_candidates` directly is what keeps one window's list from /// leaking into the next window opened under a different loot method. pending_master_candidates: Vec, + /// The wire `loot_type` the window opened with (`SMSG_LOOT_RESPONSE`'s byte, the reference's + /// `0x4c2740` read). The move-start close reads it: a **non-empty DISENCHANT window** (type 4) + /// survives movement (`0x48f24a`–`0x48f25a`, decision 2097). + loot_type: u8, } /// The master-loot candidate array's fixed width — 40 slots of 8 bytes at `0xc4dc38`, zeroed at @@ -259,6 +264,7 @@ impl LootState { self.taken.clear(); self.auto_release = false; // empty-at-open stays open — only a removal auto-closes self.pending_bind_confirm = None; // ref `0x4c1df5`: the copier resets the stash to -1 + self.loot_type = loot_type; self.fishing = loot_type == benilla_protocol::messages::loot_type::FISHING; // The master-loot candidate list arrives just AHEAD of this response (the server sends it // from inside `SendLoot`), so the window claims whatever was staged and leaves the staging @@ -299,10 +305,7 @@ impl LootState { /// Arm the client-authoritative auto-close when a removal just left the open window with /// nothing lootable — every item taken and no coin left (see [`LootState::auto_release`]). fn arm_auto_release(&mut self) { - if self.source.is_some() - && !self.has_coin() - && self.items.iter().all(|it| self.taken.contains(&it.slot)) - { + if self.source.is_some() && self.is_empty() { self.auto_release = true; } } @@ -341,6 +344,7 @@ impl LootState { self.taken.clear(); self.auto_release = false; self.fishing = false; + self.loot_type = 0; self.pending_bind_confirm = None; self.master_candidates.clear(); self.pending_master_candidates.clear(); @@ -374,6 +378,12 @@ impl LootState { self.source } + /// Nothing lootable left — every item taken and no coin (the reference's `0x4c2a70` + /// empty-check). The auto-close edge and the disenchant exemption both read it. + fn is_empty(&self) -> bool { + !self.has_coin() && self.items.iter().all(|it| self.taken.contains(&it.slot)) + } + /// Resolve a clicked 1-based display row to its action: the coin pile (position 1 when the /// layout has a coin slot) or the item at the corresponding **wire** loot slot. Positions are /// the FIXED open-time layout; a slot already looted (the coin gone, an item in `taken`) @@ -589,6 +599,23 @@ impl LootLatch { } } +/// The controller's report that a **loot-closing movement START** happened this frame — the +/// reference's guard `0x60e990`, which every player-initiated movement-START emitter calls first +/// and whose tail (`arg2 == 0`) closes an open loot: forward/back, strafe, keyboard-turn and pitch +/// START, pitch STOP, `SetPitch`, and Jump. Mouse-look `SetFacing` passes `arg2 = 1` and does not. +/// Written by `player::control` beside the cast bar's [`crate::ui_cast::LocalMoveStart`] (a +/// different mask — the cast's `0x10f0` excludes TURN, this one includes it), consumed and cleared +/// by [`drain_loot`] the next frame, which runs `CloseInteraction 0x48f200(cl=1, dl=1, 0)`: the +/// kneel latch clears, `CMSG_LOOT_RELEASE` goes out, the frame closes, and a dead corpse that is +/// also the selection is deselected — at distance zero, on the first step. Decision 2097. +/// +/// **The loot window has no distance leash at all** (wow-re `loot-window-leash.md`, a §5 that +/// refuted 2094 and the 1741 census row it rested on: that row is a dead lottery kiosk). vmangos +/// happens to release on every movement opcode too (`MovementHandler.cpp:1108`), which is why the +/// missing client-side close was invisible on the local server and plain on cmangos (B381). +#[derive(Resource, Default)] +pub(crate) struct LootMoveStart(pub(crate) bool); + pub(crate) struct UiLootPlugin; impl Plugin for UiLootPlugin { @@ -597,6 +624,7 @@ impl Plugin for UiLootPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -1084,6 +1112,46 @@ fn bind_confirm_required(items: &mut Items, commands: &NetCommands, item_id: u32 .is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP && t.quality >= BIND_CONFIRM_MIN_QUALITY) } +/// `CloseInteraction 0x48f200(cl=1, dl=1, 0)` off the movement-START guard (decision 2097), +/// transcribed per loot-target type from wow-re `loot-window-leash.md` §5: +/// +/// | open loot | on the first movement start | +/// |---|---| +/// | nothing open (`0x48f23f`) | nothing | +/// | wire type 4 DISENCHANT **and** rows left (`0x48f24a`, `0x48f25a`) | survives — the window stays | +/// | an ITEM guid — a lockbox (`0x48f2ab`–`0x48f2b5`) | survives, unreleased | +/// | a creature corpse, player bones, a GameObject chest/node, a fishing bobber | latch cleared (`0x48f2c9`), `CMSG_LOOT_RELEASE` sent (`0x48f2da`), frame closed (`0x48f33d`) | +/// | …and a dead UNIT that is also the selection (`0x48f34f`–`0x48f369`) | deselected too — `0x493910(guid, 1)` | +/// +/// The deselect is asked of the target module ([`crate::target::DeselectGuid`]) rather than done +/// here: the teardown owns the attack-stop and the wire clear. A PICKPOCKET target is a live +/// unit (health above the `<= 1` gate) and keeps its selection. +fn close_on_move_start( + loot: &mut LootState, + latch: &mut LootLatch, + commands: &NetCommands, + deselect: &mut MessageWriter, +) { + use benilla_protocol::guid; + use benilla_protocol::messages::loot_type; + let Some(source) = loot.source() else { + return; + }; + if loot.loot_type == loot_type::DISENCHANTING && !loot.is_empty() { + return; + } + if guid::is_item(source) { + return; + } + debug!("ui_loot: movement started with {source:#x} open — release"); + latch.clear_for(source); + let _ = commands.0.send(ClientCommand::LootRelease { guid: source }); + if guid::is_creature_or_pet(source) && loot.loot_type != loot_type::PICKPOCKETING { + deselect.write(crate::target::DeselectGuid(source)); + } + loot.clear(); +} + /// Drain the Lua intents: a picked row → coin (`CMSG_LOOT_MONEY`) or the item's wire slot /// (`CMSG_AUTOSTORE_LOOT_ITEM`); a close → `CMSG_LOOT_RELEASE` + a client-authoritative local clear /// (the window is already hidden by its `OnHide`; the release is fire-and-forget, and the server's @@ -1096,6 +1164,7 @@ fn bind_confirm_required(items: &mut Items, commands: &NetCommands, item_id: u32 /// `CLootButton`'s arm) and `LootSlot` is the LOOT_BIND confirmation continuation (`flag == 1`, /// which sends only for the pending slot). Keeping them apart is what makes a second click on a /// bind-on-pickup row re-raise the confirm instead of looting behind it. +#[allow(clippy::too_many_arguments)] // one Bevy system's full input set fn drain_loot( script: Option>, mut loot: ResMut, @@ -1108,7 +1177,14 @@ fn drain_loot( // by then in every reachable case — the snapshot asks for it to put a NAME on the row, and a // row with no name is a row nobody has clicked. mut items: ResMut, + // The controller's move-start report (decision 2097) and the selection teardown the close + // asks for — ahead of the VM check below, because neither depends on Lua. + mut move_start: ResMut, + mut deselect: MessageWriter, ) { + if std::mem::take(&mut move_start.0) { + close_on_move_start(&mut loot, &mut latch, &commands, &mut deselect); + } let Some(mut script) = script else { return; }; @@ -1376,8 +1452,10 @@ mod tests { let (tx, rx) = crossbeam_channel::unbounded(); let mut app = App::new(); app.add_message::() + .add_message::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .insert_resource(NetCommands(tx)); @@ -2425,4 +2503,102 @@ mod tests { // 4 items ⇒ a single page of 4, no pager. assert_eq!(if 4u32 > 4 { 3 } else { 4 }, 4); } + + /// **Movement closes the loot** (2097): the controller's move-start report makes the drain + /// run `CloseInteraction` — latch cleared, the release on the wire once, the window gone, a + /// dead-corpse selection torn down — with no server help and no VM. The exemptions the bytes + /// carve stay open: an item-guid loot (a lockbox) and a disenchant window with rows left; an + /// emptied disenchant closes like the rest. A GameObject (a fishing bobber) closes too — it + /// takes the same path as a corpse. + #[test] + fn a_movement_start_closes_and_releases_the_open_loot() { + use bevy::ecs::message::Messages; + const CORPSE: u64 = 0xF130_0000_0000_0042; + const BOBBER: u64 = 0xF110_0000_0000_0011; + const LOCKBOX: u64 = 0x4000_0000_0000_0007; + + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_message::() + .add_message::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)) + .add_systems(Update, drain_loot); + // No UiScript mounted: the move-start leg must not depend on the VM. + let deselects = |app: &mut App| -> Vec { + app.world_mut() + .resource_mut::>() + .drain() + .map(|d| d.0) + .collect() + }; + let open = |app: &mut App, guid: u64, kind: u8, rows: Vec| { + app.world_mut() + .resource_mut::() + .open(guid, kind, 0, rows); + app.world_mut().resource_mut::().0 = Some(guid); + }; + let step = |app: &mut App| { + app.world_mut().resource_mut::().0 = true; + app.update(); + }; + + // A corpse, standing still: nothing happens. + open(&mut app, CORPSE, loot_type::CORPSE, vec![item(0, 117, 1)]); + app.update(); + assert_eq!(app.world().resource::().source(), Some(CORPSE)); + assert!( + rx.try_recv().is_err(), + "nothing goes out without a move start" + ); + + // The first step: closed, released once, unlatched, the corpse deselected. + step(&mut app); + assert_eq!(app.world().resource::().source(), None); + assert_eq!(app.world().resource::().0, None); + assert!( + matches!(rx.try_recv(), Ok(ClientCommand::LootRelease { guid }) if guid == CORPSE), + "the move-start close owes the wire the release" + ); + assert!(rx.try_recv().is_err(), "and sends it once"); + assert_eq!(deselects(&mut app), vec![CORPSE]); + + // A fishing bobber is a GameObject: the same path, minus the unit deselect. + open(&mut app, BOBBER, loot_type::FISHING, vec![]); + step(&mut app); + assert_eq!(app.world().resource::().source(), None); + assert!(matches!(rx.try_recv(), Ok(ClientCommand::LootRelease { guid }) if guid == BOBBER)); + assert!(deselects(&mut app).is_empty()); + + // A lockbox survives movement, unreleased and still latched-as-it-was. + open(&mut app, LOCKBOX, loot_type::CORPSE, vec![item(0, 117, 1)]); + step(&mut app); + assert_eq!(app.world().resource::().source(), Some(LOCKBOX)); + assert!(rx.try_recv().is_err()); + app.world_mut().resource_mut::().clear(); + + // A disenchant with rows left survives; once emptied, the step closes it. + open( + &mut app, + CORPSE, + loot_type::DISENCHANTING, + vec![item(0, 117, 1)], + ); + step(&mut app); + assert_eq!(app.world().resource::().source(), Some(CORPSE)); + assert!(rx.try_recv().is_err()); + app.world_mut().resource_mut::().remove_slot(0); + // The emptying armed the auto-release; take it so the step's close is the one measured. + assert!(app + .world_mut() + .resource_mut::() + .take_auto_release()); + step(&mut app); + assert_eq!(app.world().resource::().source(), None); + assert!(matches!(rx.try_recv(), Ok(ClientCommand::LootRelease { guid }) if guid == CORPSE)); + } } diff --git a/crates/benilla-app/src/ui_models/mod.rs b/crates/benilla-app/src/ui_models/mod.rs index 6c0638fb..419acaf7 100644 --- a/crates/benilla-app/src/ui_models/mod.rs +++ b/crates/benilla-app/src/ui_models/mod.rs @@ -510,6 +510,11 @@ struct Tile { clips: HashMap, /// Which id the player is currently arming (to re-arm only on change). armed: Option, + /// The mat-anim row this tile's materials read their **cell clip** from + /// (`anim_slots.w`, decision 2093): `[min.x, min.y, max.x, max.y]` in atlas texels, written + /// every frame from the cell. `None` only when the table was full at build — the tile then + /// draws unclipped, which is the pre-2093 picture rather than a missing widget. + clip_slot: Option, alpha_parts: Vec, uv_parts: Vec, emitters: Vec, @@ -528,6 +533,9 @@ impl Tile { for p in &self.uv_parts { p.free(table); } + if let Some(slot) = self.clip_slot { + table.free(slot); + } commands.entity(self.root).despawn(); } } @@ -570,7 +578,8 @@ impl Plugin for UiModelsPlugin { // The composite: this frame's cells, appended in the lane the minimap fill uses — // after the cells are packed, before the mesh rebuild reads the lane. .add_systems(Update, compose_tiles.in_set(UiQuadAppend).after(sync_tiles)) - .add_systems(Update, reap_tile_variants); + .add_systems(Update, reap_tile_variants) + .add_systems(Update, dump_atlas.after(sync_tiles)); } } @@ -904,6 +913,7 @@ fn sync_tiles( cam_slot, clips: HashMap::new(), armed: None, + clip_slot: None, alpha_parts: Vec::new(), uv_parts: Vec::new(), emitters: Vec::new(), @@ -959,6 +969,7 @@ fn sync_tiles( } } tile.clips = built.clips; + tile.clip_slot = built.clip_slot; tile.alpha_parts = built.alpha_parts; tile.uv_parts = built.uv_parts; tile.emitters = built.emitters; @@ -1261,9 +1272,26 @@ fn sync_tiles( } else { req.star_px_per_unit }; + // …and the CELL the cloud may draw in (decision 2093). A tile's particles are quads in + // the ATLAS's own space, so without this a cloud that reaches past its cell lands in the + // cell the shelf packed beside it — which the composite hands to a different widget + // (B379). `Cell::origin` is already in atlas texels, top-left origin, which is exactly + // the framebuffer coordinate the fragment tests. + let clip = Vec4::new( + cell.origin.x as f32, + cell.origin.y as f32, + (cell.origin.x + cell.size.x) as f32, + (cell.origin.y + cell.size.y) as f32, + ); + // The MESH half of the same clip: the tile's own row, read by every one of its materials + // through `anim_slots.w`. + if let Some(slot) = tile.clip_slot { + render.table.set(slot, clip.to_array()); + } for &e in &tile.emitters { if let Ok(mut em) = emitters.get_mut(e) { em.set_size_scale(star); + em.set_clip(Some(clip)); } } } @@ -1416,6 +1444,66 @@ fn tile_layer(rig: &TileRig, cam_slot: Option) -> RenderLayers { } } +/// `WOW_TILE_DUMP=:` — **shoot the tile atlas itself**, once, `secs` of app time in. +/// +/// The composited frame is the wrong place to read a `` widget: over an action button or a +/// bag slot the pane sits on the button's own art, so every measurement of what the widget drew is +/// a measurement of the icon underneath it plus a sub-pixel alignment guess. The atlas cell is the +/// widget ALONE, on transparent, at exactly the size the pane asked for — and the trace's +/// `cell=(x,y WxH)` says where each pane's is. Together they answer "what did this widget +/// actually paint", which nothing else here can (B379: the cooldown's sweep read as a filmstrip, +/// one cell per phase). +/// +/// The camera needs no waking, unlike the booths' twin (`portrait::test_bake::dump_booth_target`): +/// the orthographic tile camera is the one that CLEARS the atlas, so it is active whenever any +/// cell is packed — which is exactly when there is something to shoot. A dump that comes back +/// uniformly transparent means no pane was drawing, not a broken widget. +fn dump_atlas( + mut commands: Commands, + bridge: Res, + time: Res>, + mut done: Local, +) { + static SPEC: std::sync::OnceLock> = std::sync::OnceLock::new(); + let Some((path, secs)) = SPEC.get_or_init(|| { + let v = std::env::var("WOW_TILE_DUMP").ok()?; + let (path, secs) = v.rsplit_once(':')?; + Some((path.to_string(), secs.parse().ok()?)) + }) else { + return; + }; + if *done || time.elapsed_secs() < *secs { + return; + } + let Some(atlas) = bridge.atlas.clone() else { + return; // no atlas yet — wait for the first tile rather than shoot nothing + }; + *done = true; + use bevy::render::view::window::screenshot::{Screenshot, ScreenshotCaptured}; + info!( + "WOW_TILE_DUMP: shooting the {}x{} tile atlas ({} cell(s)) -> {path}", + bridge.atlas_size.x, + bridge.atlas_size.y, + bridge.cells.len() + ); + let out = std::path::PathBuf::from(path.clone()); + commands + .spawn(Screenshot::image(atlas)) + .observe(move |shot: On| { + let Some(img) = crate::portrait::test_bake::encode_target_readback(&shot.image) else { + warn!("WOW_TILE_DUMP: unexpected target format, nothing saved"); + return; + }; + match img.try_into_dynamic() { + Ok(dyn_img) => match dyn_img.save(&out) { + Ok(()) => info!("WOW_TILE_DUMP: saved {}", out.display()), + Err(e) => warn!("WOW_TILE_DUMP: save failed: {e}"), + }, + Err(e) => warn!("WOW_TILE_DUMP: convert failed: {e}"), + } + }); +} + #[allow(clippy::type_complexity)] // the system's own query, borrowed fn set_camera_active( cams: &mut Query< @@ -1570,6 +1658,8 @@ fn shelf_pack(sizes: &[UVec2], edge: u32) -> Vec { /// What [`build_tile`] made. struct BuiltTile { clips: HashMap, + /// The tile's cell-clip row — see [`Tile::clip_slot`]. + clip_slot: Option, alpha_parts: Vec, uv_parts: Vec, emitters: Vec, @@ -1603,6 +1693,11 @@ fn build_tile( let built = forms.slices(handle); let (stat_forms, skin_forms) = (built.stat, built.skin.unwrap_or(&[])); + // The tile's **cell clip** row (decision 2093): one row per tile, `anim_slots.w` on every + // one of its materials, the cell rect written into it each frame. It is why a tile's batches + // are all its OWN clones rather than the shared twins — the twin is per (material, light), + // and the clip is per PANE. + let clip_slot = render.table.alloc(); // Materials first — every one must be resident before anything spawns, or a retry would // leave half a tree behind. let mut part_mats: Vec> = Vec::with_capacity(model.submeshes.len()); @@ -1638,33 +1733,34 @@ fn build_tile( || sub.uv_seq.is_some() || sub.uv_rot_seq.is_some() || sub.uv_scale_seq.is_some(); - if animated { + { let mut own = render.mats.materials().get(&twin).cloned()?; + own.extension.anim_slots.w = clip_slot.map_or(0.0, f32::from); let seed = [own.extension.sun_scale.z, own.extension.sun_scale.w]; - let trans = (sub.uv_anim.is_some() || sub.uv_seq.is_some()) + let trans = (animated && (sub.uv_anim.is_some() || sub.uv_seq.is_some())) .then(|| render.table.alloc()) .flatten() .map(|slot| { own.extension.anim_slots.x = f32::from(slot); (slot, seed) }); - let affine = (sub.uv_rot_seq.is_some() || sub.uv_scale_seq.is_some()) + let affine = (animated && (sub.uv_rot_seq.is_some() || sub.uv_scale_seq.is_some())) .then(|| render.table.alloc()) .flatten() .inspect(|&slot| own.extension.anim_slots.z = f32::from(slot)); let handle = render.mats.materials().add(own); - uv_parts.push(UvPart { - material: handle.clone(), - trans, - affine, - uv_anim: sub.uv_anim.clone(), - uv_seq: sub.uv_seq.clone(), - uv_rot: sub.uv_rot_seq.clone(), - uv_scale: sub.uv_scale_seq.clone(), - }); + if animated { + uv_parts.push(UvPart { + material: handle.clone(), + trans, + affine, + uv_anim: sub.uv_anim.clone(), + uv_seq: sub.uv_seq.clone(), + uv_rot: sub.uv_rot_seq.clone(), + uv_scale: sub.uv_scale_seq.clone(), + }); + } part_mats.push(handle); - } else { - part_mats.push(twin); } } @@ -1784,6 +1880,7 @@ fn build_tile( let _ = spawn_anim_host; Some(BuiltTile { clips, + clip_slot, alpha_parts, uv_parts, emitters, diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 3a8e161d..183dce09 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -610,6 +610,22 @@ fn vertex(vertex: WowVertex) -> WowVsOut { @fragment fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { + // **The UI model tile's cell clip** (decision 2093). A `` pane's batches render into + // that pane's cell of ONE shared atlas, and a model may draw outside its widget rect — + // `ForcedBackpackItem.mdx`'s card is authored 45..83 layout units above an origin that sits + // at the rect's bottom-left. The reference cannot leak: it draws each pane straight into the + // back buffer with the widget's rect as the VIEWPORT (`modelframe-render-law.md` §6). One + // shared atlas camera cannot carry a viewport per pane, so the tile hands its cell down as a + // mat-anim row (`anim_slots.w`, the row's `[min.x, min.y, max.x, max.y]` in ATLAS TEXELS — + // which is what `@builtin(position)` is here) and the fragment is the scissor. `w == 0` is + // the pinned-zero row: every world material, no clip, one comparison. + if (m.anim_slots.w > 0.5) { + let r = wow_light.matanim[u32(m.anim_slots.w)]; + if (in.position.x < r.x || in.position.y < r.y + || in.position.x > r.z || in.position.y > r.w) { + discard; + } + } // HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl. Per-pixel discard beyond the // projection far plane (planar eye-Z), so distant buildings/trees reveal closest-part-first and the // sky/WDL shows behind. `wow_light.fog_params.w` = farclip (0 ⇒ disabled). Clutter (≤70 yd) never hits it. diff --git a/crates/benilla-ui/src/script/slider.rs b/crates/benilla-ui/src/script/slider.rs index 9002f84a..19f11e4d 100644 --- a/crates/benilla-ui/src/script/slider.rs +++ b/crates/benilla-ui/src/script/slider.rs @@ -10,11 +10,14 @@ //! faithful to the *documented* widget model, not byte-pinned (same posture as StatusBar's fill and //! ScrollFrame's scroll; decisions 0112/0250). //! -//! `SetValue` fires `OnValueChanged` **only on an actual change** ([`SliderState::store_value`]). -//! That is load-bearing, not an optimization: the real scrollbar template wires -//! `OnValueChanged → this:GetParent():SetVerticalScroll(arg1)` and the ScrollFrame's -//! `OnVerticalScroll → scrollbar:SetValue(arg1)` back the other way, so a fire-always `SetValue` -//! would recurse forever (`UIPanelTemplates.xml`). The change-gate breaks the loop after one hop. +//! `SetValue` fires `OnValueChanged` on the **first-ever** value and after that **only on an +//! actual change** ([`SliderState::store_value`] — the client's `+0x314` bit2, wow-re +//! `slider-mouse-law.md` §6). The change-gate is load-bearing, not an optimization: the real +//! scrollbar template wires `OnValueChanged → this:GetParent():SetVerticalScroll(arg1)` and the +//! ScrollFrame's `OnVerticalScroll → scrollbar:SetValue(arg1)` back the other way, so a fire-always +//! `SetValue` would recurse forever (`UIPanelTemplates.xml`); the gate breaks the loop after one +//! hop. `SetMinMaxValues` re-clamps through the same gate only once a value exists, so a range set +//! from `` never runs a handler the addon has not armed yet. //! //! The methods live in their own registry table, consulted by the frame `__index` dispatcher only //! for Slider frames — so duck-typing addons (`if frame:GetThumbTexture() then …`) see `nil` on @@ -109,13 +112,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "SetMinMaxValues", lua.create_function(|lua, (this, min, max): (Table, f32, f32)| { // Unlike StatusBar, a reversed pair is NOT swapped (the Slider LoadXML stores min + - // (max−min) and does no swap, RF-28). The held value re-clamps into the new range; a - // move fires OnValueChanged — a value change the caller didn't set explicitly is still a - // change (the store_value clamp guards a degenerate max because our SetMinMaxValues clamped the + // zero-init value into (0.25, 1) and ran a handler the addon had not armed yet. + let s = script(); + s.run( + r#" + seen = {} + local sl = CreateFrame("Slider", "SlBits") + sl:SetScript("OnValueChanged", function() table.insert(seen, arg1) end) + + sl:SetValue(7) -- bit1 clear: a complete no-op + assert(sl:GetValue() == 0, "rangeless SetValue stores nothing: " .. sl:GetValue()) + assert(table.getn(seen) == 0, "rangeless SetValue fires nothing") + + sl:SetMinMaxValues(0.25, 1) -- bit2 clear: the range excludes 0, still no fire + assert(table.getn(seen) == 0, "SetMinMaxValues on a fresh slider fires nothing") + assert(sl:GetValue() == 0, "and does not clamp a value that does not exist") + + sl:SetMinMaxValues(0, 10) + sl:SetValue(0) -- first-ever value == zero-init: fires anyway + assert(table.getn(seen) == 1 and seen[1] == 0, "the first SetValue always fires") + sl:SetValue(0) -- now the change-gate holds + assert(table.getn(seen) == 1, "an equal value after the first does not fire") + + sl:SetValue(8) + sl:SetMinMaxValues(0, 5) -- bit2 set: re-clamp 8 -> 5 fires + assert(table.getn(seen) == 3 and seen[3] == 5, "a held value re-clamps and fires") + sl:SetMinMaxValues(0, 20) -- 5 stays 5: no fire + assert(table.getn(seen) == 3, "a re-range that moves nothing fires nothing") + "#, + ) + .unwrap(); +} + +#[test] +fn slider_onload_range_does_not_run_an_unarmed_onvaluechanged() { + // Atlas's exact shape (AtlasOptions.xml): the slider's sets a range whose low end is + // above zero, and its indexes a saved-variables global that only exists after + // ADDON_LOADED. In the reference the load raises nothing; before 2095 ours raised + // "attempt to index global 'AtlasOptions' (a nil value)" out of the OnLoad. + let mut s = script(); + let doc = crate::framexml::parse( + r#" + + + this:SetMinMaxValues(0.25, 1); this:SetValueStep(0.05) + SlAtlasOpts.alpha = this:GetValue() + + + "#, + ) + .unwrap(); + let report = crate::loader::load(&s, &doc, &|_| None); + assert!( + report.errors.is_empty() && s.take_errors().is_empty(), + "an OnLoad range on a fresh slider must not run OnValueChanged: {:?}", + report.errors + ); + s.run( + r#" + SlAtlasOpts = {} + SlAtlasAlpha:SetValue(0.5) + assert(SlAtlasOpts.alpha == 0.5, "the first real SetValue reaches the handler") + "#, + ) + .unwrap(); +} diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index d5bc8b3c..31097332 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -1366,10 +1366,27 @@ pub struct SliderState { pub min: f32, /// `SetMinMaxValues` high bound (XML `maxValue`). pub max: f32, - /// The current value (`SetValue`/XML `defaultValue`), clamped into `[min, max]`. + /// The current value (`SetValue`/XML `defaultValue`), clamped into `[min, max]`. Zero-initialised + /// like the client's `+0x320`, and readable through `GetValue` before anything set it. pub value: f32, + /// **Has a range** — the client's `+0x314` bit1, set by `SetMinMaxValues` and never cleared + /// (wow-re `slider-mouse-law.md` §3/§6). Until it is set, `SetValue` is a complete no-op: no + /// store, no `OnValueChanged`. + pub range_valid: bool, + /// **Has a value** — the client's `+0x314` bit2, set by the first successful `SetValue` and + /// never cleared. Two things hang off it, both byte-verified in wow-re (`0x789930`, + /// `0x7898f0`): the first-ever `SetValue` **always** fires `OnValueChanged`, even when the new + /// value equals the zero-init one; and `SetMinMaxValues` re-clamps the held value through + /// `SetValue` **only** once a value exists. Before this flag, `SetMinMaxValues` on a fresh + /// slider clamped the zero-init value into the new range and fired the handler — which is how + /// Atlas's option sliders raised `attempt to index global 'AtlasOptions'` from their own + /// ``, a handler the reference never runs at that point. + pub has_value: bool, /// `SetValueStep` (XML `valueStep`) — the step the arrow keys / step buttons move by. Stored and - /// returned; `SetValue` does **not** snap to it (the client's SetValue sets the raw value). + /// returned. **Known divergence:** the client's `SetValue` (`0x789930`) round-half quantises the + /// value by this step before the change compare (wow-re `item9-firing34-merge.md`, `ui.md` + /// "clamped/step-quantized"); ours stores the raw value. Not yet reconciled — every scrollbar + /// rides this path, so it is its own change with its own falsifier. pub step: f32, /// `true` = VERTICAL (the ctor default; value maps along the track's height, min at the top), /// `false` = HORIZONTAL (`orientation`; shared enum `0x811b00` HORIZONTAL=0/VERTICAL=1). @@ -1388,6 +1405,8 @@ impl Default for SliderState { min: 0.0, max: 0.0, value: 0.0, + range_valid: false, + has_value: false, step: 0.0, vertical: true, enabled: true, @@ -1465,17 +1484,38 @@ impl SliderState { } } - /// Clamp `v` into the live range and store it; returns `Some(new_value)` iff it actually changed - /// (the caller fires `OnValueChanged` — firing only on a real change is what keeps the reference - /// scrollbar wiring `OnValueChanged → SetVerticalScroll → scrollbar:SetValue` from recursing - /// forever). A degenerate range (`max <= min`) pins to `min`. + /// `SetValue` (`0x789930`): clamp `v` into the live range and store it; returns `Some(value)` + /// when the caller must fire `OnValueChanged`. The fire law is the client's, emitted-jcc read + /// in wow-re `slider-mouse-law.md` §6: **no range yet → a complete no-op**; **first-ever value → + /// always store and fire**; after that, **fire iff the clamped value differs** from the stored + /// one. The change-gate is load-bearing, not an optimisation: the reference scrollbar wires + /// `OnValueChanged → SetVerticalScroll → scrollbar:SetValue` back on itself, and the gate is + /// what stops that after one hop. A degenerate range (`max <= min`) pins to `min`. pub fn store_value(&mut self, v: f32) -> Option { + if !self.range_valid { + return None; + } let clamped = v.clamp(self.min, self.max.max(self.min)); - (clamped != self.value).then(|| { + let first = !self.has_value; + self.has_value = true; + (first || clamped != self.value).then(|| { self.value = clamped; clamped }) } + + /// `SetMinMaxValues` (`0x7898f0`): set the range, mark it valid, and re-clamp the held value + /// **only if one exists** (bit2) — a fresh slider's zero-init value is not a value, so a range + /// that excludes zero fires nothing. Returns what [`Self::store_value`] would for the re-clamp. + pub fn set_min_max(&mut self, min: f32, max: f32) -> Option { + (self.min, self.max) = (min, max); + self.range_valid = true; + if self.has_value { + self.store_value(self.value) + } else { + None + } + } } /// A `ColorSelect`'s colour (`CSimpleColorSelect`, ctor `0x78b220`) — **three HSV `f32`s**, hue in diff --git a/crates/benilla-world/src/ground_fx.rs b/crates/benilla-world/src/ground_fx.rs index a28cd0ce..479473b6 100644 --- a/crates/benilla-world/src/ground_fx.rs +++ b/crates/benilla-world/src/ground_fx.rs @@ -269,6 +269,7 @@ pub(crate) fn update_ground_fx_decals( no_depth_test: false, main_entity: entity, light: None, + clip: None, }, ); } diff --git a/crates/benilla-world/src/liquid/drift.rs b/crates/benilla-world/src/liquid/drift.rs index a78a6a88..1b2ddbcb 100644 --- a/crates/benilla-world/src/liquid/drift.rs +++ b/crates/benilla-world/src/liquid/drift.rs @@ -545,6 +545,7 @@ fn push_drift( no_depth_test: false, main_entity: Entity::PLACEHOLDER, light: None, + clip: None, }, ); cloud.submitted = submitted; diff --git a/crates/benilla-world/src/particles.rs b/crates/benilla-world/src/particles.rs index 3ebc9195..c8b39d8c 100644 --- a/crates/benilla-world/src/particles.rs +++ b/crates/benilla-world/src/particles.rs @@ -277,6 +277,9 @@ pub struct ParticleEmitter { /// [`quads::DrawFrame::size_scale`]. `1.0` (a yard) for every world lane; a UI model tile /// sets its pixels-per-unit through [`Self::set_size_scale`]. size_scale: f32, + /// The render-target rectangle this cloud is confined to, target pixels — see + /// [`Self::set_clip`]. `None` = the whole target (every world emitter). + pub(crate) clip: Option, } /// One wired CHILD emitter (see [`ParticleEmitter::children`]): the recursion model's own @@ -381,6 +384,21 @@ impl ParticleEmitter { /// (`gated && gate_inputs_still && !fade.is_changed()`, decision 1979's floor) reads none of /// the owner's inputs: a world-lane cloud left gated here on a still camera could never /// re-enter its own draw set. One full gate evaluation on the thaw edge is the price. + /// **Confine this cloud's quads to a rectangle of the render target**, in target pixels — + /// the UI model tiles' cell ([`crate::particles::buffer::EffectDrawSpec::clip`], decision + /// 2093). `None` (the default, and every world emitter) draws over the whole target. + /// + /// A tile's cloud is quads in the shared atlas's own space, so without this a cloud that + /// reaches past its cell lands in the cell the shelf packed beside it — which the composite + /// hands to a different widget (B379: the autocast shine's golden `GlowStar` down the left + /// edge of a bag slot's cooldown). The reference has no atlas and no such reach: it draws + /// each `` with the widget's rect as the VIEWPORT. + pub fn set_clip(&mut self, clip: Option) { + if self.clip != clip { + self.clip = clip; + } + } + pub fn set_frozen(&mut self, frozen: bool) { self.frozen = frozen; if !frozen { @@ -773,6 +791,7 @@ pub fn spawn_emitter( geometry: emitter.geometry.clone(), model_instances: Vec::new(), size_scale: 1.0, + clip: None, }, )); if emitter.recursion.is_some() { diff --git a/crates/benilla-world/src/particles/buffer.rs b/crates/benilla-world/src/particles/buffer.rs index 412174ce..43106b9d 100644 --- a/crates/benilla-world/src/particles/buffer.rs +++ b/crates/benilla-world/src/particles/buffer.rs @@ -275,6 +275,8 @@ pub struct EffectDraw { /// [`EffectLightOverride`]'s buffer, when the producer carries one (`None` = the world's /// shared light buffer). pub(crate) light: Option, + /// The target-pixel rectangle this draw is clipped to — see [`EffectDrawSpec::clip`]. + pub(crate) clip: Option, } /// The frame's shared stream. Cleared at the top of `PostUpdate`'s effect set @@ -370,6 +372,23 @@ pub struct EffectDrawSpec { pub no_depth_test: bool, pub main_entity: Entity, pub light: Option, + /// **Clip this draw to a rectangle of its RENDER TARGET, in target pixels** — `(min.x, + /// min.y, max.x, max.y)`, `None` for the whole target, which is every world family. + /// + /// It exists for the UI model tiles (decision 2008): every visible `` pane renders + /// into its own cell of ONE shared atlas, and a pane's particles are quads in that atlas's + /// space — so a cloud that reaches past its cell lands in the cell **next to** it, which the + /// composite hands to a different widget. The reference cannot have this: it draws each + /// `` straight into the back buffer with the widget's own rect as the VIEWPORT + /// (wow-re `modelframe-render-law.md` §6), and the scissor eats the overflow. One shared + /// camera cannot carry a viewport per pane, so the clip rides the draw instead and the + /// fragment discards outside it — the same picture, at the same rank. + /// + /// B379: the pet bar's autocast shine (`UI-AutoCastButton.m2` — four additive spline + /// emitters, no render batch at all) circles its button on the very edge with an ~8 px + /// half-extent, which spilled six pixels of golden `GlowStar` across the 2-texel gutter and + /// down the LEFT edge of whatever cooldown pane the shelf packed beside it. + pub clip: Option, } impl EffectQuads { @@ -420,6 +439,7 @@ impl EffectQuads { range: start..end, main_entity: spec.main_entity, light: spec.light, + clip: spec.clip, }); } } @@ -535,6 +555,7 @@ mod tests { no_depth_test: false, main_entity: Entity::PLACEHOLDER, light: None, + clip: None, }, ); } @@ -634,6 +655,7 @@ impl<'w> WorldEffectDraw<'w> { no_depth_test: false, main_entity: Entity::PLACEHOLDER, light: None, + clip: None, }, quads: &mut self.quads, } diff --git a/crates/benilla-world/src/particles/render.rs b/crates/benilla-world/src/particles/render.rs index 9b1fa1d0..0febbfc3 100644 --- a/crates/benilla-world/src/particles/render.rs +++ b/crates/benilla-world/src/particles/render.rs @@ -95,6 +95,8 @@ struct ExtractedDraw { range: Range, /// A booth's scene-light override (0539 §5); `None` = the world's shared light buffer. light: Option, + /// The target-pixel clip rect — see [`super::buffer::EffectDrawSpec::clip`]. + clip: Option, } /// One GPU draw call after the merge walk: a contiguous index range plus the bind-group @@ -103,7 +105,9 @@ struct MergedDraw { index_range: Range, texture: AssetId, light: Option, - fog: EffectFog, + /// The dynamic offset of this draw's row in [`EffectMeta::params`] — its fog policy and its + /// clip rect together, resolved by [`EffectMeta::build_params`]. + params_offset: u32, } /// The lane's per-frame GPU state: the shared vertex stream (rebased camera-relative in @@ -117,8 +121,21 @@ pub struct EffectMeta { draws: Vec, merged: Vec, view_bind_group: Option, - params: DynamicUniformBuffer, - params_offsets: Option<[u32; 6]>, + params: DynamicUniformBuffer, + /// The six canonical fog rows' dynamic offsets, in [`EffectFog::slot`] order. + params_offsets: [u32; 6], + /// The params buffer as it was last written — the bind-group cache holds a binding into it, + /// so a re-created buffer has to invalidate that cache ([`prepare_effect_bind_groups`]). + params_buffer: Option, +} + +/// One row of the lane's per-draw uniform: the fog policy the shader reads, and the target-pixel +/// rectangle it clips to. `clip.z <= clip.x` means **no clip**, which is every world draw and +/// every canonical fog row. +#[derive(Clone, Copy, bevy::render::render_resource::ShaderType)] +struct EffectParams { + fog: Vec4, + clip: Vec4, } impl Default for EffectMeta { @@ -130,11 +147,74 @@ impl Default for EffectMeta { merged: Vec::new(), view_bind_group: None, params: DynamicUniformBuffer::default(), - params_offsets: None, + params_offsets: [0; 6], + params_buffer: None, } } } +/// How many CLIPPED params rows one frame may carry. A clip is a UI model tile's cell, and a +/// pane whose row does not fit degrades to the unclipped row — so this only has to cover the +/// panes that can actually spill, which is the ones with emitters (the autocast shines, the two +/// pings, the bag's item animation): a whole pet bar plus both pings is ten. +const MAX_CLIP_ROWS: usize = 64; + +impl EffectMeta { + /// Write this frame's per-draw uniform rows and return the clipped ones' offsets, keyed by + /// `(fog slot, the clip rect's bits)`. The six canonical fog rows are always rows 0..6, in + /// [`EffectFog::slot`] order — `params.fog.x` carries the shader's fog COLOUR policy (the + /// `0x70baf0` table), `params.fog.y` the forced-fog enable with `zw` its start/end (rain's + /// verified 70..75 window — the constants live with their law in `weather::precip`), and + /// `params.clip` the target-pixel rectangle (`z <= x` = no clip). + fn build_params( + &mut self, + device: &RenderDevice, + queue: &RenderQueue, + ) -> HashMap<(u32, [u32; 4]), u32> { + const NO_CLIP: Vec4 = Vec4::ZERO; + let fog_rows = [ + Vec4::new(0.0, 0.0, 0.0, 0.0), + Vec4::new(1.0, 0.0, 0.0, 0.0), + Vec4::new(2.0, 0.0, 0.0, 0.0), + Vec4::new(3.0, 0.0, 0.0, 0.0), + Vec4::new(4.0, 0.0, 0.0, 0.0), + Vec4::new( + 0.0, + 1.0, + crate::weather::RAIN_FOG_START, + crate::weather::RAIN_FOG_END, + ), + ]; + self.params.clear(); + let mut offsets = [0u32; 6]; + for (i, fog) in fog_rows.iter().enumerate() { + offsets[i] = self.params.push(&EffectParams { + fog: *fog, + clip: NO_CLIP, + }); + } + self.params_offsets = offsets; + let mut clip_rows: HashMap<(u32, [u32; 4]), u32> = HashMap::default(); + for i in 0..self.draws.len() { + let (Some(clip), slot) = (self.draws[i].clip, self.draws[i].fog.slot()) else { + continue; + }; + let key = (slot, clip.to_array().map(f32::to_bits)); + if clip_rows.contains_key(&key) || clip_rows.len() >= MAX_CLIP_ROWS { + continue; + } + let off = self.params.push(&EffectParams { + fog: fog_rows[slot as usize], + clip, + }); + clip_rows.insert(key, off); + } + self.params.write_buffer(device, queue); + self.params_buffer = self.params.buffer().map(|b| b.id()); + clip_rows + } +} + /// Per-(texture, light-buffer) bind groups (texture + sampler + light blob + params uniform), /// cached across frames — invalidated by that image's asset events, mirroring bevy_ui_render's /// cache. The light key is `None` for the world's shared buffer (startup-created, never @@ -142,6 +222,9 @@ impl Default for EffectMeta { #[derive(Resource, Default)] pub struct EffectBindGroups { images: HashMap<(AssetId, Option), BindGroup>, + /// The params buffer the cached groups were built against — see + /// [`prepare_effect_bind_groups`]. + params_buffer: Option, } /// The lane's pipeline: layouts + shader, specialized per (blend, raster bias, msaa, hdr). @@ -171,7 +254,7 @@ pub fn init_effect_pipeline(mut commands: Commands, asset_server: Res(true), + uniform_buffer::(true), ), ), ); @@ -377,6 +460,7 @@ fn extract_effects( no_depth_test: d.no_depth_test, range: d.range.clone(), light: d.light.clone(), + clip: d.clip, })); } @@ -454,7 +538,8 @@ fn queue_effects( } /// The run identity the merge walk groups by: everything two adjacent items must share to be -/// one GPU draw — pipeline (blend/bias/msaa/hdr), bind group (texture + light), params row. +/// one GPU draw — pipeline (blend/bias/msaa/hdr), bind group (texture + light), and the params +/// row (the fog policy AND the clip rect, which is why two panes' clouds never fold together). type RunKey = ( bevy::render::render_resource::CachedRenderPipelineId, AssetId, @@ -545,6 +630,11 @@ fn prepare_effects( // shadows here for exactly that reason). let trace = std::env::var_os("WOW_EFFECT_TRACE").is_some(); let mut trace_lines: Vec = Vec::new(); + // The per-draw uniform rows, THIS frame: the six canonical fog policies plus one row per + // distinct (fog, clip) a clipped draw asks for. It is rebuilt every frame rather than once + // because the clip set is per-frame — the rows are a handful of `vec4` pairs, and the walk + // below needs the offsets to key its runs on. + let clip_rows = meta.build_params(&device, &queue); meta.indices.clear(); meta.merged.clear(); let mut n_items = 0u32; @@ -614,11 +704,19 @@ fn prepare_effects( draw.texture, )); } + let params_offset = match draw.clip { + None => meta.params_offsets[draw.fog.slot() as usize], + // A clipped draw whose row did not fit the frame's budget degrades to the + // unclipped one — the pre-2093 picture, never a wrong pixel elsewhere. + Some(clip) => *clip_rows + .get(&(draw.fog.slot(), clip.to_array().map(f32::to_bits))) + .unwrap_or(&meta.params_offsets[draw.fog.slot() as usize]), + }; let key: RunKey = ( pipeline, draw.texture, draw.light.as_ref().map(|b| b.id()), - draw.fog.slot(), + params_offset, ); match &mut open { Some((_, run_len, open_key)) if *open_key == key => { @@ -635,7 +733,7 @@ fn prepare_effects( index_range: index_start..index_end, texture: draw.texture, light: draw.light.clone(), - fog: draw.fog, + params_offset, }); open = Some((i, 1, key)); } @@ -660,28 +758,6 @@ fn prepare_effects( if !meta.indices.is_empty() { meta.indices.write_buffer(&device, &queue); } - - if meta.params_offsets.is_none() { - // Slot order = `EffectFog::slot`: off, scene, black, white, grey, rain-forced — - // `params.x` carries the shader's fog COLOUR policy (the `0x70baf0` table), `params.y` - // the forced-fog enable with `zw` its start/end (rain's verified 70..75 window — the - // constants live with their law in `weather::precip`). - let offsets = [ - meta.params.push(&Vec4::new(0.0, 0.0, 0.0, 0.0)), - meta.params.push(&Vec4::new(1.0, 0.0, 0.0, 0.0)), - meta.params.push(&Vec4::new(2.0, 0.0, 0.0, 0.0)), - meta.params.push(&Vec4::new(3.0, 0.0, 0.0, 0.0)), - meta.params.push(&Vec4::new(4.0, 0.0, 0.0, 0.0)), - meta.params.push(&Vec4::new( - 0.0, - 1.0, - crate::weather::RAIN_FOG_START, - crate::weather::RAIN_FOG_END, - )), - ]; - meta.params.write_buffer(&device, &queue); - meta.params_offsets = Some(offsets); - } } /// Build the view bind group and any missing per-texture groups for this frame's draws. @@ -708,6 +784,13 @@ fn prepare_effect_bind_groups( let Some(params_binding) = meta.params.binding() else { return; }; + // The cached groups hold a binding INTO the params buffer, and that buffer is rebuilt every + // frame now (the clip rows are per-frame) — so a re-created buffer, which is what a growing + // row count produces, has to invalidate the cache or the groups bind freed memory. + if bind_groups.params_buffer != meta.params_buffer { + bind_groups.images.clear(); + bind_groups.params_buffer = meta.params_buffer; + } for draw in &meta.draws { let key = (draw.texture, draw.light.as_ref().map(|b| b.id())); if bind_groups.images.contains_key(&key) { @@ -789,14 +872,11 @@ impl RenderCommand

for DrawEffectBatch { let Some(image_bind_group) = bind_groups.into_inner().images.get(&key) else { return RenderCommandResult::Skip; }; - let (Some(vertices), Some(indices), Some(offsets)) = ( - meta.vertices.buffer(), - meta.indices.buffer(), - meta.params_offsets, - ) else { + let (Some(vertices), Some(indices)) = (meta.vertices.buffer(), meta.indices.buffer()) + else { return RenderCommandResult::Failure("effect lane buffers not available"); }; - pass.set_bind_group(1, image_bind_group, &[offsets[draw.fog.slot() as usize]]); + pass.set_bind_group(1, image_bind_group, &[draw.params_offset]); pass.set_vertex_buffer(0, vertices.slice(..)); pass.set_index_buffer(indices.slice(..), IndexFormat::Uint32); // The `$WOW_EFFECT_TRACE` tail: what draw_indexed ACTUALLY ran — read against the diff --git a/crates/benilla-world/src/particles/sim.rs b/crates/benilla-world/src/particles/sim.rs index 3ccb0d5b..22884d89 100644 --- a/crates/benilla-world/src/particles/sim.rs +++ b/crates/benilla-world/src/particles/sim.rs @@ -796,6 +796,7 @@ pub(super) fn simulate_particles( model_instances, gated: _, frozen: _, + clip, } = &mut *emitter; // The water-interleave MODEL frame, captured before the draw-anchor local shadows the // `anchor` field below: the cloud anchor is "the MODEL, never the bone" — its transform @@ -1390,6 +1391,9 @@ pub(super) fn simulate_particles( no_depth_test: false, main_entity: entity, light: light_override.map(|l| l.0.clone()), + // The pane cell a UI model tile's cloud is confined to (`set_clip`); `None` for + // every world emitter, which owns the whole target. + clip: *clip, }, ); // CHILD pools: their own texture/blend/fog identity, the PARENT's anchor and rung @@ -1433,6 +1437,8 @@ pub(super) fn simulate_particles( no_depth_test: false, main_entity: entity, light: light_override.map(|l| l.0.clone()), + // A child cloud draws where its parent does — same cell, same clip. + clip: *clip, }, ); } diff --git a/crates/benilla-world/src/ribbons.rs b/crates/benilla-world/src/ribbons.rs index 84217b14..0292036a 100644 --- a/crates/benilla-world/src/ribbons.rs +++ b/crates/benilla-world/src/ribbons.rs @@ -708,6 +708,7 @@ pub(crate) fn simulate_ribbons( no_depth_test: false, main_entity: entity, light: None, // trails never carry a light override (world lane only) + clip: None, // …and never ride a UI model pane's atlas cell }, ); } diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl index beb577d2..1b945d5a 100644 --- a/crates/benilla-world/src/shaders/wow_effect.wgsl +++ b/crates/benilla-world/src/shaders/wow_effect.wgsl @@ -53,7 +53,19 @@ struct WowLight { // table M2 batches take, `0x70baf0`). y = forced-fog mode with zw = its start/end (the rain // lanes — unused until precipitation joins the lane in slice P2, kept so the struct is P2's // already). -@group(1) @binding(3) var wow_ext_params: vec4; +// +// `clip` is the draw's **render-target rectangle in target pixels** — `(min.x, min.y, max.x, +// max.y)`, and `z <= x` means the whole target, which is every world draw. The UI model tiles +// are the one customer (decision 2093): every visible `` pane renders into its own cell +// of ONE shared atlas, so a cloud reaching past its cell would land in the cell beside it and +// be composited onto a different widget. The reference clips this with the widget's own rect as +// the VIEWPORT (`modelframe-render-law.md` §6); one shared camera cannot carry a viewport per +// pane, so the rect rides the draw and the fragment discards outside it. +struct EffectParams { + fog: vec4, + clip: vec4, +}; +@group(1) @binding(3) var wow_params: EffectParams; // The rain pass's forced fog colour: 0x80808080 → grey (render-state 0x0d). const RAIN_FOG_GREY: vec3 = vec3(0.50196078, 0.50196078, 0.50196078); @@ -112,6 +124,16 @@ fn vertex(v: Vertex) -> VertexOutput { @fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { + // The draw's target-rect clip (see `EffectParams::clip`). `@builtin(position)` is the + // framebuffer pixel, which for the UI tile atlas IS the atlas texel — so this is the + // scissor the reference gets from the widget's viewport, per fragment. + if (wow_params.clip.z > wow_params.clip.x) { + let p = in.clip_position.xy; + if (p.x < wow_params.clip.x || p.y < wow_params.clip.y + || p.x > wow_params.clip.z || p.y > wow_params.clip.w) { + discard; + } + } // `$WOW_PARTICLE_FLAT` (B16 instrument): solid magenta, no inputs — see render.rs. #ifdef WOW_PARTICLE_FLAT return vec4(1.0, 0.0, 1.0, 1.0); @@ -191,22 +213,22 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { // geometry — same start/end, same day-night colour, applied in gamma space BEFORE blend, // with NO additive special-case. The blend equation then does the whole night split by // itself. Missing fog was the "night smoke glows" bug. (Planar eye-Z, like terrain fog.) - if (wow_light.fog_color.w > 0.5 && wow_ext_params.x > 0.5) { + if (wow_light.fog_color.w > 0.5 && wow_params.fog.x > 0.5) { let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001); let factor = clamp((wow_light.fog_params.y - in.view_z) / denom, 0.0, 1.0); - // The fog COLOUR follows the policy in wow_ext_params.x (see its declaration). + // The fog COLOUR follows the policy in wow_params.fog.x (see its declaration). var fog_rgb = wow_light.fog_color.xyz; - if (wow_ext_params.x > 1.5 && wow_ext_params.x < 2.5) { fog_rgb = vec3(0.0); } - else if (wow_ext_params.x > 2.5 && wow_ext_params.x < 3.5) { fog_rgb = vec3(1.0); } - else if (wow_ext_params.x > 3.5) { fog_rgb = RAIN_FOG_GREY; } + if (wow_params.fog.x > 1.5 && wow_params.fog.x < 2.5) { fog_rgb = vec3(0.0); } + else if (wow_params.fog.x > 2.5 && wow_params.fog.x < 3.5) { fog_rgb = vec3(1.0); } + else if (wow_params.fog.x > 3.5) { fog_rgb = RAIN_FOG_GREY; } rgb = mix(fog_rgb, rgb, factor); } // The FORCED fog (rain weather): grey-0.5 over the params' own start/end, regardless of // the scene fog state — under Mod2x the grey is neutral, so this IS the streak/patter // distance fade (rf-weather-render Q3). - if (wow_ext_params.y > 0.5) { - let denom = max(wow_ext_params.w - wow_ext_params.z, 0.001); - let factor = clamp((wow_ext_params.w - in.view_z) / denom, 0.0, 1.0); + if (wow_params.fog.y > 0.5) { + let denom = max(wow_params.fog.w - wow_params.fog.z, 0.001); + let factor = clamp((wow_params.fog.w - in.view_z) / denom, 0.0, 1.0); rgb = mix(RAIN_FOG_GREY, rgb, factor); } #ifdef BLEND_ADD diff --git a/crates/benilla-world/src/water_fx/mod.rs b/crates/benilla-world/src/water_fx/mod.rs index bdfaaca5..73978607 100644 --- a/crates/benilla-world/src/water_fx/mod.rs +++ b/crates/benilla-world/src/water_fx/mod.rs @@ -619,6 +619,7 @@ fn push_water_foam( no_depth_test: false, main_entity: Entity::PLACEHOLDER, light: None, + clip: None, }, ); } diff --git a/crates/benilla-world/src/weather/precip/mod.rs b/crates/benilla-world/src/weather/precip/mod.rs index 1d55f4f2..3304841f 100644 --- a/crates/benilla-world/src/weather/precip/mod.rs +++ b/crates/benilla-world/src/weather/precip/mod.rs @@ -670,6 +670,7 @@ fn push_precip( no_depth_test: false, main_entity: Entity::PLACEHOLDER, light: None, + clip: None, }; let start = quads.begin(); push_streaks(&mut quads.verts, &precip.rain.drops, wind.tilt, cam_pos);