glue chrome in the box, a knockdown holds, and loot closes on a step

- on a 21:9 display the login, select and create chrome lays out on the boxed scene instead of the black bars beside it; at 16:9 nothing moves (B377, 2091)
- a <Model> tile draws only inside its own cell of the shared atlas, so the pet bar's autocast shine no longer bleeds a golden bar onto a bag slot's cooldown (B379, 2093); a slider's first value always fires its handler, so Atlas's options load (2095)
- a knockdown plays out: the reference locks the base animation for Knockdown, LiftOff and Land until the clip ends, and benilla re-picked Stand ten milliseconds in (2096)
- the loot window closes and releases the corpse on your first step, strafe, turn or jump, the reference's movement-start guard; a distance leash shipped first and was a mislabelled census row (2094 → 2097, B381)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:24:48 +07:00
co-authored by Claude Fable 5.1
parent b81e481348
commit 057a3061c4
39 changed files with 1527 additions and 287 deletions
+1 -1
View File
@@ -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 {
+7 -3
View File
@@ -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<String> {
/// 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()
}
@@ -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>,
@@ -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 `<Model>` 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),
+36 -26
View File
@@ -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
+39 -28
View File
@@ -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)));
+6
View File
@@ -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,
+16 -2
View File
@@ -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,
@@ -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;
}
}
}
}
@@ -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<u16>);
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<Special>,
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)
}
@@ -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<AnimationNodeIndex> {
app.world()
.entity(unit)
.get::<AnimationTransitions>()
.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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().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::<AnimDriver>().unwrap().mode,
Mode::Swing { id: 118, .. }
),
"already playing it — the leg leaves the block having done nothing"
);
}
}
@@ -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.
@@ -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;
+103 -1
View File
@@ -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<Node>` path on the ~every frame nothing moves.
pub(crate) fn fit_glue_canvas(
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
scene: Option<Res<crate::portrait::CreateScene>>,
mut canvases: Query<&mut Node, With<GlueCanvas>>,
) {
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
+30 -19
View File
@@ -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)));
+2
View File
@@ -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,
},
);
}
@@ -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<crate::ui_loot::LootMoveStart>,
),
// 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
+140
View File
@@ -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<f32>,
) -> 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, 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<f32>| 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]
+23 -11
View File
@@ -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<PendingSwap>,
}
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<f32> {
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;
+6 -4
View File
@@ -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 /
+1 -1
View File
@@ -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<Image> {
pub(crate) fn encode_target_readback(shot: &Image) -> Option<Image> {
use bevy::asset::RenderAssetUsages;
use bevy::render::render_resource::{TextureDimension, TextureFormat};
+19 -1
View File
@@ -1396,15 +1396,33 @@ pub(super) fn clear_target_requests(
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
mut guid_asks: MessageReader<DeselectGuid>,
) {
// 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`]).
///
+3
View File
@@ -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::<AttackNearestRequest>()
.add_message::<TargetByNameRequest>()
.add_message::<AssistRequest>()
.add_message::<click::DeselectGuid>()
.add_systems(
Startup,
(
+182 -6
View File
@@ -193,8 +193,9 @@ pub(crate) struct LootState {
receives: Vec<PendingReceive>,
/// 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<u64>,
/// 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::<LootConfig>()
.init_resource::<LootLatch>()
.init_resource::<LootKneel>()
.init_resource::<LootMoveStart>()
.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<crate::target::DeselectGuid>,
) {
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<NonSendMut<UiScript>>,
mut loot: ResMut<LootState>,
@@ -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<Items>,
// 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<LootMoveStart>,
mut deselect: MessageWriter<crate::target::DeselectGuid>,
) {
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::<crate::sound::LootPickupSound>()
.add_message::<crate::target::DeselectGuid>()
.init_resource::<LootState>()
.init_resource::<LootLatch>()
.init_resource::<LootMoveStart>()
.init_resource::<GroupState>()
.init_resource::<Items>()
.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::<crate::sound::LootPickupSound>()
.add_message::<crate::target::DeselectGuid>()
.init_resource::<LootState>()
.init_resource::<LootLatch>()
.init_resource::<LootMoveStart>()
.init_resource::<GroupState>()
.init_resource::<Items>()
.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<u64> {
app.world_mut()
.resource_mut::<Messages<crate::target::DeselectGuid>>()
.drain()
.map(|d| d.0)
.collect()
};
let open = |app: &mut App, guid: u64, kind: u8, rows: Vec<LootItem>| {
app.world_mut()
.resource_mut::<LootState>()
.open(guid, kind, 0, rows);
app.world_mut().resource_mut::<LootLatch>().0 = Some(guid);
};
let step = |app: &mut App| {
app.world_mut().resource_mut::<LootMoveStart>().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::<LootState>().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::<LootState>().source(), None);
assert_eq!(app.world().resource::<LootLatch>().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::<LootState>().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::<LootState>().source(), Some(LOCKBOX));
assert!(rx.try_recv().is_err());
app.world_mut().resource_mut::<LootState>().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::<LootState>().source(), Some(CORPSE));
assert!(rx.try_recv().is_err());
app.world_mut().resource_mut::<LootState>().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::<LootState>()
.take_auto_release());
step(&mut app);
assert_eq!(app.world().resource::<LootState>().source(), None);
assert!(matches!(rx.try_recv(), Ok(ClientCommand::LootRelease { guid }) if guid == CORPSE));
}
}
+112 -15
View File
@@ -510,6 +510,11 @@ struct Tile {
clips: HashMap<u16, (AnimationNodeIndex, usize)>,
/// Which id the player is currently arming (to re-arm only on change).
armed: Option<u16>,
/// 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<u16>,
alpha_parts: Vec<AlphaPart>,
uv_parts: Vec<UvPart>,
emitters: Vec<Entity>,
@@ -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<usize>) -> RenderLayers {
}
}
/// `WOW_TILE_DUMP=<path>:<secs>` — **shoot the tile atlas itself**, once, `secs` of app time in.
///
/// The composited frame is the wrong place to read a `<Model>` 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<UiModelTiles>,
time: Res<Time<bevy::time::Real>>,
mut done: Local<bool>,
) {
static SPEC: std::sync::OnceLock<Option<(String, f32)>> = 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<ScreenshotCaptured>| {
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<Cell> {
/// What [`build_tile`] made.
struct BuiltTile {
clips: HashMap<u16, (AnimationNodeIndex, usize)>,
/// The tile's cell-clip row — see [`Tile::clip_slot`].
clip_slot: Option<u16>,
alpha_parts: Vec<AlphaPart>,
uv_parts: Vec<UvPart>,
emitters: Vec<Entity>,
@@ -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<Handle<WowModelMaterial>> = 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,
@@ -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 `<Model>` 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.
+12 -12
View File
@@ -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 `<OnLoad>` 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<min range).
let changed = with_slider(lua, &this, |s| {
(s.min, s.max) = (min, max);
s.store_value(s.value)
})?;
// (max−min) and does no swap, RF-28). A held value re-clamps into the new range and a
// move fires OnValueChanged — but only once a value EXISTS (`SliderState::set_min_max`,
// the client's bit2): on a fresh slider this sets the range and fires nothing.
let changed = with_slider(lua, &this, |s| s.set_min_max(min, max))?;
fire_value_changed(lua, &this, changed)
})?,
)?;
@@ -302,3 +302,76 @@ fn slider_scrollbar_wiring_does_not_recurse() {
)
.unwrap();
}
#[test]
fn slider_value_bits_gate_the_first_fire_and_the_range_reclamp() {
// The client's `+0x314` bit1 (has a range) and bit2 (has a value), wow-re
// `slider-mouse-law.md` §3/§6, byte-read off `SetValue 0x789930` / `SetMinMaxValues 0x7898f0`:
// rangeless SetValue is a no-op; the FIRST SetValue always fires, even at the zero-init value;
// SetMinMaxValues re-clamps through SetValue only once a value exists. Decision 2095 — Atlas's
// option sliders raised from their own <OnLoad> 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 <OnLoad> sets a range whose low end is
// above zero, and its <OnValueChanged> 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#"<Ui>
<Slider name="SlAtlasAlpha">
<Scripts>
<OnLoad>this:SetMinMaxValues(0.25, 1); this:SetValueStep(0.05)</OnLoad>
<OnValueChanged>SlAtlasOpts.alpha = this:GetValue()</OnValueChanged>
</Scripts>
</Slider>
</Ui>"#,
)
.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();
}
+47 -7
View File
@@ -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
/// `<OnLoad>`, 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<f32> {
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<f32> {
(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
+1
View File
@@ -269,6 +269,7 @@ pub(crate) fn update_ground_fx_decals(
no_depth_test: false,
main_entity: entity,
light: None,
clip: None,
},
);
}
+1
View File
@@ -545,6 +545,7 @@ fn push_drift(
no_depth_test: false,
main_entity: Entity::PLACEHOLDER,
light: None,
clip: None,
},
);
cloud.submitted = submitted;
+19
View File
@@ -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<Vec4>,
}
/// 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 `<Model>` with the widget's rect as the VIEWPORT.
pub fn set_clip(&mut self, clip: Option<Vec4>) {
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() {
@@ -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<Buffer>,
/// The target-pixel rectangle this draw is clipped to — see [`EffectDrawSpec::clip`].
pub(crate) clip: Option<Vec4>,
}
/// 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<Buffer>,
/// **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 `<Model>` 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
/// `<Model>` 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<Vec4>,
}
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,
}
+116 -36
View File
@@ -95,6 +95,8 @@ struct ExtractedDraw {
range: Range<u32>,
/// A booth's scene-light override (0539 §5); `None` = the world's shared light buffer.
light: Option<Buffer>,
/// The target-pixel clip rect — see [`super::buffer::EffectDrawSpec::clip`].
clip: Option<Vec4>,
}
/// 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<u32>,
texture: AssetId<Image>,
light: Option<Buffer>,
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<ExtractedDraw>,
merged: Vec<MergedDraw>,
view_bind_group: Option<BindGroup>,
params: DynamicUniformBuffer<Vec4>,
params_offsets: Option<[u32; 6]>,
params: DynamicUniformBuffer<EffectParams>,
/// 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<BufferId>,
}
/// 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<Image>, Option<BufferId>), BindGroup>,
/// The params buffer the cached groups were built against — see
/// [`prepare_effect_bind_groups`].
params_buffer: Option<BufferId>,
}
/// 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<AssetServe
// time; the WGSL struct pins the layout.
storage_buffer_read_only_sized(false, None),
// The per-draw fog-params `vec4`, dynamic-offset into the canonical rows.
uniform_buffer::<Vec4>(true),
uniform_buffer::<EffectParams>(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<Image>,
@@ -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<String> = 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<P: PhaseItem> RenderCommand<P> 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
@@ -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,
},
);
}
+1
View File
@@ -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
},
);
}
@@ -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<uniform> wow_ext_params: vec4<f32>;
//
// `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 `<Model>` 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<f32>,
clip: vec4<f32>,
};
@group(1) @binding(3) var<uniform> wow_params: EffectParams;
// The rain pass's forced fog colour: 0x80808080 → grey (render-state 0x0d).
const RAIN_FOG_GREY: vec3<f32> = vec3<f32>(0.50196078, 0.50196078, 0.50196078);
@@ -112,6 +124,16 @@ fn vertex(v: Vertex) -> VertexOutput {
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// 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<f32>(1.0, 0.0, 1.0, 1.0);
@@ -191,22 +213,22 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// 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<f32>(0.0); }
else if (wow_ext_params.x > 2.5 && wow_ext_params.x < 3.5) { fog_rgb = vec3<f32>(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<f32>(0.0); }
else if (wow_params.fog.x > 2.5 && wow_params.fog.x < 3.5) { fog_rgb = vec3<f32>(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
+1
View File
@@ -619,6 +619,7 @@ fn push_water_foam(
no_depth_test: false,
main_entity: Entity::PLACEHOLDER,
light: None,
clip: None,
},
);
}
@@ -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);