meeting stones join, the claw trail draws, and the gun flashes

- a right-click on a meeting stone joins its queue: the stone's own join packet with its four refusals, and the stone you are queued at goes dark on the minimap frame (2283)
- the druid's Swipe draws its claw trail: spell effects run their authored texture scroll, each on its own cast's clock (2282)
- a gun's muzzle blast plays: the weapon model runs its own release animation, whose emitters were authored on the rifle and never had a clock (2281, 2286)
- README: battlegrounds are the one big feature left on the roadmap, now that meeting stones are in

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:40:49 +07:00
co-authored by Claude Fable 5.1
parent bc404ca24b
commit a893a7205c
47 changed files with 3946 additions and 190 deletions
+2 -2
View File
@@ -51,8 +51,8 @@ benilla is done when a 1.12.1 player can do everything here that they could in t
client, it looks and feels the same, and it runs from a download on Windows, Linux and macOS.
No dates; the order is what is likely, not a promise.
- Battlegrounds and meeting stones, then the long tail of small features that separates a
working client from a finished one.
- Battlegrounds, then the long tail of small features that separates a working client from a
finished one.
- Addons, options and performance, ongoing.
- The no-brainer fixes from VanillaFixes, SuperWoW and the like.
- Playable downloads for Windows, Linux and macOS. Linux first.
+28 -11
View File
@@ -7,10 +7,14 @@
//!
//! Benilla's transcription draws the two segments through Bevy's gizmo lines — the same
//! immediate-mode screen-space-width primitive class as the client's GX lines. Named
//! deviations (decision record): the string color/width are INFERRED (the callback emits a
//! packed vertex color the round didn't decode — a dark cord is used); the bow prop is not yet
//! animated (BowPull/BowRelease bend the limbs and carry the tips with them in the ref — our
//! anchors ride the static prop frame, so the limbs stay straight while the middle draws).
//! deviation (decision record): the string color/width are INFERRED (the callback emits a
//! packed vertex color the round didn't decode — a dark cord is used).
//!
//! **The tips are POSED** (decision 2281). The bow prop animates — `$BWP` arms BowPull(160) on the
//! prop's own model and `$BWR` returns it to Stand(0) — so the `$WTT`/`$WTB` markers ride limb
//! bones that bend through the draw. Composing them through the prop's rigid root frame instead
//! (which is all this file could do while the prop rested at bind pose) leaves the chord pinned to
//! the un-bent limb tips while the mesh around it moves: the string would visibly leave the bow.
use bevy::prelude::*;
@@ -28,15 +32,21 @@ const HAND_ARROW: u16 = 0x23;
pub(crate) struct Bowstring {
/// The unit wearing the bow — the HandArrow middle vertex and nock latch live on it.
pub(crate) owner: Entity,
/// The `$WTT` top / `$WTB` bottom anchors, model-local Bevy space (the prop root's frame).
pub(crate) top: Vec3,
pub(crate) bottom: Vec3,
/// The `$WTT` top / `$WTB` bottom anchors as `(bone, model-local Bevy offset)` — the bone is
/// load-bearing on an animated prop (see the module note): the limb tips move with the draw.
pub(crate) top: (u16, Vec3),
pub(crate) bottom: (u16, Vec3),
}
/// Draw every visible bow's string (per frame, post-propagation so the prop/joint frames are
/// this frame's). Two segments: tip → middle → tip.
fn draw_bowstrings(
bows: Query<(&Bowstring, &GlobalTransform, &InheritedVisibility)>,
bows: Query<(
&Bowstring,
&GlobalTransform,
&InheritedVisibility,
Option<&benilla_world::rig_anim::RigPose>,
)>,
owners: Query<(
&BoneAttach,
&benilla_world::rig_anim::RigPose,
@@ -47,12 +57,19 @@ fn draw_bowstrings(
) {
// A dark waxed-cord tone; the ref's packed vertex color is not decoded (INFERRED, §G2).
const STRING_COLOR: Color = Color::srgb(0.12, 0.10, 0.08);
for (bs, prop, vis) in &bows {
for (bs, prop, vis, flex) in &bows {
if !vis.get() {
continue;
}
let top = prop.transform_point(bs.top);
let bottom = prop.transform_point(bs.bottom);
// The prop's own pose when it flexes (`joints_root` IS this entity, so its
// `GlobalTransform` is the frame `posed_point` wants); its rigid frame otherwise, which is
// the same answer for a bow standing at bind pose.
let tip = |(bone, offset): (u16, Vec3)| {
flex.and_then(|p| p.posed_point(prop, bone, offset))
.unwrap_or_else(|| prop.transform_point(offset))
};
let top = tip(bs.top);
let bottom = tip(bs.bottom);
// The middle control point: the owner's HandArrow attach world position while the nock
// latch holds (the drawn string follows the hand), else the relaxed straight chord.
let middle = owners
+9 -3
View File
@@ -12,7 +12,7 @@ use super::{FxViewRequest, FxViewState, FXVIEW_POS};
use crate::creature_anim::FxStage;
use crate::entities::display::{empty_shell, ModelHandle};
use crate::entities::spell_fx::{
attach_effect_visuals, EffectHost, FxTintAnims, SpellFx, FALLBACK_SPAN,
attach_effect_visuals, EffectHost, FxMaterials, FxTintAnims, SpellFx, FALLBACK_SPAN,
};
/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) — a high-word `0xF130` creature guid
@@ -38,6 +38,8 @@ pub(crate) fn drive_fx_view(
time: Res<Time>,
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
spatial: avian3d::prelude::SpatialQuery,
@@ -215,8 +217,12 @@ pub(crate) fn drive_fx_view(
} else {
FxStage::OneShot
}),
&mut wow_materials,
&mut tint_reg,
&mut FxMaterials {
store: &mut wow_materials,
tint: &mut tint_reg,
uv: &mut uv_reg,
table: &mut anim_table,
},
&ibps,
&mut palettes,
None, // the fixture previews a kit effect on its model's own `Stand`
+2
View File
@@ -111,6 +111,7 @@ mod probe_model_camera;
mod probe_partner;
mod probe_rig;
mod probe_service;
mod probe_stone;
mod probe_taxi;
mod probe_vendor_swap;
mod probes;
@@ -141,6 +142,7 @@ pub(crate) use probe_model_camera::ProbeModelCameraPlugin;
pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::ProbeRigPlugin;
pub(crate) use probe_service::ProbeServicePlugin;
pub(crate) use probe_stone::ProbeStonePlugin;
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probe_vendor_swap::ProbeVendorSwapPlugin;
pub(crate) use probes::{
@@ -268,6 +268,11 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
purpose: "1 — teleport to the Old Town plaque and measure what the item-text reader costs per frame, closed vs open (B240)",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_STONE",
purpose: "1 or <x>,<y>,<z>[,<map>] — join a real meeting stone on the click's own route and read the queue back out of the live VM",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_CHEST",
purpose: "1 or <x>,<y>,<z>[,<map>] — open a real chest on the click's own route and report the self anim id before/during/after",
@@ -0,0 +1,432 @@
//! The meeting-stone live probe (`WOW_PROBE_STONE=1`) — decision 2283's instrument: **can a
//! player actually get into the LFG queue, and does everything downstream of that light up?**
//!
//! Until 2283 a right-click on a meeting stone sent `CMSG_GAMEOBJ_USE`, which vmangos' own
//! `GameObject::Use` answers with an explicit `return` for type 23. So the whole receiving half —
//! the two globals, the five-way status table, `MEETINGSTONE_CHANGED`, the stock
//! `MiniMapMeetingStoneFrame` — had been built and tested against synthetic packets and had never
//! once run off a real one. This probe is what closes that: it drives the click's own route and
//! then reads the answer out of the **live VM**, not out of our own state.
//!
//! **Why a probe and not a capture:** none of this is a picture. Every reading here is a number or
//! a Lua truth value — the queued area id, `IsInMeetingStoneQueue()`, whether the stock minimap
//! button is shown — so the verdict needs no eye in the loop (`method.md` step 4).
//!
//! **Where it enters the chain, exactly.** It writes the very
//! [`MeetingStoneUse`] the click ladder writes, so everything **downstream** of that seam runs
//! for real — the validator, `CMSG 0x292`, the server, `SMSG 0x295`, the feed, the two globals,
//! the stock frame. It does **not** cover the mouse hit-test or the shared gates *above* the seam
//! (highlightable, mounted, range); those are the click test's
//! (`target::click::tests::a_right_click_on_a_meeting_stone_joins_it_and_sends_no_gameobj_use`).
//! Neither alone closes the chain; together they do.
//!
//! ## The five legs, in order
//!
//! 1. **CONTROL** — parked at the stone, nothing clicked: `IsInMeetingStoneQueue()` must be `nil`
//! and the stock icon hidden. A probe that only ever sees the queued state cannot tell a fix
//! from a stuck flag.
//! 2. **REFUSED** — the body demoted **below** the stone's `data[0]`, then clicked: the client's
//! own level refusal must fire, and **nothing** may reach the wire. This is the one of the four
//! refusals the server does not duplicate — `HandleMeetingStoneJoinOpcode` never looks at the
//! stone's level band — so if our copy is wrong, the server queues a level-1 character for
//! Deadmines and nobody notices.
//! 3. **JOINED** — the body levelled **into** the band, then clicked: `SMSG 0x295` must arrive with
//! this stone's own `data[2]`, `IsInMeetingStoneQueue()` must answer the number `1`,
//! `GetMeetingStoneStatusText()` a real string, and `MiniMapMeetingStoneFrame` must be shown.
//! 4. **DARKENED** — with that queue held, the stone's own `highlightable` slot must now refuse it
//! ([`crate::target::cursor_mode::meeting_stone_queued`] over the live area): the stone you are
//! queued at stops being clickable, which is the whole of `0x5f6990`.
//! 5. **LEFT** — `CancelMeetingStoneRequest()` through the live VM: back to `nil`, icon hidden.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_UNATTENDED=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
//! WOW_PROBE_STONE=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
//! "The local vmangos server"). `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere.
//!
//! **It leaves the probe body at the stone's minimum level**, deliberately and without putting it
//! back: the level is the probe's own instrument (leg 2 needs one side of the band and leg 3 the
//! other), and a probe character's level is scratch. Grep `PROBE_STONE:` for the verdict; the
//! probe self-exits when it lands.
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::go_templates::{GameObjectTemplates, MeetingStoneTemplate};
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::target::cursor_mode::meeting_stone_queued;
use crate::ui_dialog_verbs::{MeetingStone, MeetingStoneUse};
/// The probe's default object: the **Stockade** meeting stone in Stormwind (`gameobject.guid`
/// 26635, template 179595 — `data[0..2] = 24, 32, 717`). Chosen because it is a plain unpooled
/// spawn in a capital, its level band is narrow enough that a level-1 body is comfortably outside
/// it, and area 717 resolves to a real `AreaTable` row, so the status text comes out a sentence
/// ("Looking for more for The Stockade") rather than the `UNKNOWN` fallback — which makes the
/// JOINED leg's `text=yes` mean the whole rebuild ran, not just that a string exists.
const STONE_AT: [f32; 4] = [-8810.5, 798.0, 98.2, 0.0];
/// `GAMEOBJECT_TYPE_MEETINGSTONE`.
const GO_TYPE_MEETINGSTONE: i32 = 23;
/// The level leg 2 demotes to — below every shipped stone's `data[0]`.
const LEVEL_BELOW: u32 = 1;
const SCAN_RANGE: f32 = 30.0;
const SETTLE_SECS: f64 = 6.0;
const SCAN_TIMEOUT_SECS: f64 = 30.0;
const LEVEL_TIMEOUT_SECS: f64 = 20.0;
/// How long a refusal is given to *not* happen. The join round trip below is the same server, so
/// anything that was going to arrive has arrived.
const REFUSAL_WINDOW_SECS: f64 = 6.0;
const QUEUE_TIMEOUT_SECS: f64 = 20.0;
type NearbyObject = (
&'static Guid,
&'static NetEntity,
&'static ObjectStore,
&'static Transform,
);
pub(crate) struct ProbeStonePlugin;
impl Plugin for ProbeStonePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StoneProbe>()
.add_systems(Update, stone_probe);
}
}
#[derive(Resource, Default)]
struct StoneProbe {
phase: Phase,
stone: Option<u64>,
tmpl: Option<MeetingStoneTemplate>,
fails: u32,
exited: bool,
/// What each leg actually read, for the DONE line.
joined_area: u32,
status_text: Option<String>,
icon_shown: bool,
darkened: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Wait,
Settling {
sent_at: f64,
},
/// The stone is in range; waiting for its ask-once template to answer.
WaitTemplate {
since: f64,
},
Control,
/// `.character level` issued; waiting for the store to report it. `join` says which leg the
/// click that follows belongs to — **not** the level value, because a stone whose `data[0]`
/// is 1 would make the two indistinguishable and loop the probe forever.
WaitLevel {
want: u32,
since: f64,
join: bool,
},
/// Clicked below the band; waiting out the window in which nothing may happen.
Refused {
since: f64,
},
/// Clicked in the band; waiting for `SMSG 0x295`.
WaitQueue {
since: f64,
},
Joined,
/// `CancelMeetingStoneRequest()` issued; waiting for the queue to clear.
WaitLeft {
since: f64,
},
Done,
}
/// Where the probe is aimed: `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]`, else [`STONE_AT`]. Anything
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
fn target() -> [f32; 4] {
let Ok(raw) = std::env::var("WOW_PROBE_STONE") else {
return STONE_AT;
};
let parts: Vec<f32> = raw
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
match parts.len() {
3 => [parts[0], parts[1], parts[2], STONE_AT[3]],
4 => [parts[0], parts[1], parts[2], parts[3]],
_ => STONE_AT,
}
}
/// One leg's verdict line. `got == want` is the whole test; the line says both either way, so a
/// failure reads as a measurement rather than as an assertion name.
fn check(label: &str, want: &str, got: &str) -> u32 {
if want == got {
info!("PROBE_STONE: {label:<9} PASS — {got}");
0
} else {
error!("PROBE_STONE: {label:<9} FAIL — wanted {want}, read {got}");
1
}
}
/// The three live VM readings every leg is written in, asked of the **stock files**: the two
/// bindings `Minimap.xml` calls, and whether its button is actually up.
fn vm_state(script: &UiScript) -> (bool, Option<String>, bool) {
let queued = script
.eval::<bool>("return IsInMeetingStoneQueue() and true or false")
.unwrap_or(false);
let text = script
.eval::<String>("return GetMeetingStoneStatusText()")
.ok();
let shown = script
.eval::<bool>(
"return MiniMapMeetingStoneFrame and MiniMapMeetingStoneFrame:IsShown() and true or false",
)
.unwrap_or(false);
(queued, text, shown)
}
#[allow(clippy::too_many_arguments)]
fn stone_probe(
time: ProbeClock,
mut probe: ResMut<StoneProbe>,
script: Option<NonSendMut<UiScript>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
player: Res<Player>,
objects: Query<NearbyObject, Without<SelfPlayer>>,
templates: Res<GameObjectTemplates>,
queue: Res<MeetingStone>,
mut uses: MessageWriter<MeetingStoneUse>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
let Ok(store) = self_store.single() else {
return; // not in-world yet
};
let Some(script) = script else {
return; // no UI VM this build — nothing this probe can read
};
let now = time.elapsed_secs_f64();
let level = store.0.unit_level().unwrap_or(0);
match probe.phase {
Phase::Wait => {
let [x, y, z, map] = target();
info!("PROBE_STONE: heading to the meeting stone ({x} {y} {z} map {map})");
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
});
probe.phase = Phase::Settling { sent_at: now };
}
Phase::Settling { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let me = player.pos;
let found = objects.iter().find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::GameObject
&& store.0.gameobject_type_id() == GO_TYPE_MEETINGSTONE
&& tf.translation.distance(me) < SCAN_RANGE
});
if let Some((guid, ..)) = found {
info!("PROBE_STONE: stone {:#x} in range", guid.0);
probe.stone = Some(guid.0);
probe.phase = Phase::WaitTemplate { since: now };
} else if now - sent_at > SCAN_TIMEOUT_SECS {
error!(
"PROBE_STONE: FAIL — no type-{GO_TYPE_MEETINGSTONE} GameObject within \
{SCAN_RANGE} yd in {SCAN_TIMEOUT_SECS}s. This is NOT a passing run"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::WaitTemplate { since } => {
let Some(guid) = probe.stone else {
probe.phase = Phase::Done;
return;
};
if let Some(t) = templates.get(guid).and_then(|t| t.meeting_stone) {
info!(
"PROBE_STONE: template answered — levels {}..={}, area {}",
t.min_level, t.max_level, t.area
);
probe.tmpl = Some(t);
probe.phase = Phase::Control;
} else if now - since > SCAN_TIMEOUT_SECS {
error!(
"PROBE_STONE: FAIL — the stone's GAMEOBJECT_QUERY never answered in \
{SCAN_TIMEOUT_SECS}s; the level band cannot be read"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Control => {
let (queued, _, shown) = vm_state(&script);
probe.fails += check(
"CONTROL",
"unqueued icon=false",
&format!("{}queued icon={shown}", if queued { "" } else { "un" }),
);
info!("PROBE_STONE: demoting to level {LEVEL_BELOW} for the refusal leg");
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".character level {LEVEL_BELOW}"),
});
probe.phase = Phase::WaitLevel {
want: LEVEL_BELOW,
since: now,
join: false,
};
}
Phase::WaitLevel { want, since, join } => {
if level == want {
let guid = probe.stone.unwrap_or_default();
uses.write(MeetingStoneUse { go_guid: guid });
if join {
info!("PROBE_STONE: level {level} — clicking INSIDE the band");
probe.phase = Phase::WaitQueue { since: now };
} else {
info!(
"PROBE_STONE: level {level} — clicking BELOW the band; nothing may go out"
);
probe.phase = Phase::Refused { since: now };
}
} else if now - since > LEVEL_TIMEOUT_SECS {
error!(
"PROBE_STONE: FAIL — `.character level {want}` never took (still {level}) in \
{LEVEL_TIMEOUT_SECS}s. Check the account's GM level: the command needs \
SEC_DEVELOPER 5 and a probe account is 6"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Refused { since } => {
if now - since < REFUSAL_WINDOW_SECS {
return;
}
let (queued, _, shown) = vm_state(&script);
probe.fails += check(
"REFUSED",
"area=0 unqueued icon=false",
&format!(
"area={} {}queued icon={shown}",
queue.area,
if queued { "" } else { "un" }
),
);
// `.character level 0` is not a level; a template that ships `data[0] = 0` means
// "no floor", which for this leg is simply level 1.
let want = probe.tmpl.map_or(1, |t| t.min_level.max(1));
info!("PROBE_STONE: levelling to {want} for the join leg");
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".character level {want}"),
});
probe.phase = Phase::WaitLevel {
want,
since: now,
join: true,
};
}
Phase::WaitQueue { since } => {
if queue.area != 0 {
probe.joined_area = queue.area;
probe.phase = Phase::Joined;
} else if now - since > QUEUE_TIMEOUT_SECS {
error!(
"PROBE_STONE: FAIL — no SMSG 0x295 within {QUEUE_TIMEOUT_SECS}s of the join. \
Either CMSG 0x292 never left, or the server refused it (it re-checks leader / \
raid / full and interact distance, never the level band)"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Joined => {
let (queued, text, shown) = vm_state(&script);
probe.status_text = text.clone();
probe.icon_shown = shown;
let area = probe.tmpl.map_or(0, |t| t.area);
probe.fails += check(
"JOINED",
&format!("area={area} queued icon=true text=yes"),
&format!(
"area={} {}queued icon={shown} text={}",
probe.joined_area,
if queued { "" } else { "un" },
if text.is_some() { "yes" } else { "no" }
),
);
// Leg 4 — `0x5f6990` over the LIVE queue: this stone is now the one we are queued at,
// so its highlightable slot must refuse it.
probe.darkened = meeting_stone_queued(Some(area), queue.area);
probe.fails += check("DARKENED", "true", &probe.darkened.to_string());
info!("PROBE_STONE: leaving through the live VM's own CancelMeetingStoneRequest()");
let _ = script.eval::<()>("CancelMeetingStoneRequest()");
probe.phase = Phase::WaitLeft { since: now };
}
Phase::WaitLeft { since } => {
if queue.area == 0 {
let (queued, _, shown) = vm_state(&script);
probe.fails += check(
"LEFT",
"area=0 unqueued icon=false",
&format!(
"area={} {}queued icon={shown}",
queue.area,
if queued { "" } else { "un" }
),
);
probe.phase = Phase::Done;
} else if now - since > QUEUE_TIMEOUT_SECS {
error!(
"PROBE_STONE: FAIL — still queued at {} {QUEUE_TIMEOUT_SECS}s after \
CancelMeetingStoneRequest()",
queue.area
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_STONE: DONE stone={:#x} band={:?} area={} icon={} darkened={} text={:?} fail={}",
probe.stone.unwrap_or_default(),
probe.tmpl.map(|t| (t.min_level, t.max_level)),
probe.joined_area,
probe.icon_shown,
probe.darkened,
probe.status_text,
probe.fails,
);
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_STONE: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
+23
View File
@@ -1263,6 +1263,26 @@ impl Plugin for CreatureAnimPlugin {
// entity-visuals chain so the arrow appears/vanishes the frame the keyframe
// lands, not one behind.
drive_nock_latch,
// …and, off the same two keys, the RANGED PROP's own clip (decision 2281):
// the bow's limbs bend on `$BWP`, and on `$BWR` a gun fires the muzzle blast
// its BowRelease(161) sequence carries. Beside the latch because the reference
// arms both from one handler each, and ahead of the entity-visuals chain so the
// prop's pose is this frame's before the rider lane reads it.
crate::ranged_flex::flex_ranged_props
// The per-sequence material samplers (`SeqHosts`) ask an anim host which
// slot it is playing; a prop that armed 161 this frame must be answering
// for 161, not for last frame's Stand. The chain's own `.before(
// EntityVisualsSet)` does not reach them — they hang off `ModelVisSet`.
.before(benilla_world::model_render::ModelVisSet),
// …and the un-nock's reset (`0x60f59d`), which the reference reaches from the
// SAME `$BWR` it just armed on. It must run AFTER the arm, or its skip-while-
// releasing guard has nothing to read and a gun's blast is cancelled on the
// frame it starts — the chain gives it that order by construction.
crate::ranged_flex::reset_ranged_props_on_unnock
// Same edge as the arm above, and for the same reason: this writes the
// prop's player too, and the per-sequence material samplers must answer
// for the clip it leaves armed.
.before(benilla_world::model_render::ModelVisSet),
// …and the `$BTH` puff off the same scan: another SpellKitFx writer, so it
// belongs ahead of the entity-visuals chain like its `arm_*_fx` siblings.
fire_breath,
@@ -1390,6 +1410,7 @@ mod nock_latch_tests {
entity: unit,
ident: *b"$BWP",
data: 0,
anim_id: 0,
pos: None,
});
app.update();
@@ -1401,6 +1422,7 @@ mod nock_latch_tests {
entity: unit,
ident: *b"$BWR",
data: 0,
anim_id: 0,
pos: None,
});
app.update();
@@ -1415,6 +1437,7 @@ mod nock_latch_tests {
entity: bare,
ident: *b"$BWP",
data: 0,
anim_id: 0,
pos: None,
});
app.update();
@@ -21,6 +21,15 @@ pub(crate) struct AnimSoundEvent {
pub(crate) ident: [u8; 4],
/// The tag payload (a SoundEntries id for `$SND`/`$DSL`/`$DSO`; 0 otherwise).
pub(crate) data: u32,
/// The `AnimationData.dbc` id of the **clip that fired the key**.
///
/// This is the reference's `0x5fdb50` answer at the instant a handler runs: that helper reads
/// the model's currently-playing animation id, and a key fires from a clip the model is
/// playing, at that key's own time in it. Two dispatch arms branch on it — the `$BWR` weapon
/// family fork (`0x5fcfb0` bow {46,105,109} vs `0x5fcfd0` rifle {49,106,110}, decision 2281) —
/// and carrying it here is what lets them ask without re-deriving "what is this unit playing"
/// from a player whose one-shot overlays outrank its base clip in weight.
pub(crate) anim_id: u16,
/// **Where the key fired** — the reference's own `placementMatrix · (boneMatrix[event.bone] ·
/// event.position)`, resolved here at the fire and carried by value exactly as the M2 event
/// kernel `0x719370` snapshots it into the deferred callback record that every dispatcher
@@ -361,6 +370,7 @@ pub(crate) fn scan_events(
entity,
ident: e.ident,
data: e.data,
anim_id: clip.anim_id,
pos: Some(pos),
});
}
@@ -318,6 +318,7 @@ mod tests {
entity,
ident,
data: 0,
anim_id: 0,
pos: None,
});
app.update();
@@ -157,6 +157,10 @@ pub(super) struct InspectStores<'w, 's> {
/// The ask-once GO template cache — the readable head a TEXT object's line reports
/// (decision 1105), and the highlight column + name the tooltip ladder reports (2246).
go_templates: Res<'w, crate::go_templates::GameObjectTemplates>,
/// `[0xb72038]` — the meeting-stone queue, the other half of MEETINGSTONE(23)'s own
/// highlightable term (decision 2283), so the card's `interact` verdict reads the same
/// predicate the cursor and the click do.
stone: Option<Res<'w, crate::ui_dialog_verbs::MeetingStone>>,
/// **The published GameObject mouseover** — the one the tooltip actually reads
/// ([`crate::target::HoveredObject`]). The card's own pick is a dev pick and does not go
/// through the publish, so without this the card can show an object the game is not hovering
@@ -261,6 +265,7 @@ pub(super) fn inspect_ui(
let (reputations, plates, plate_mode) =
(&*stores.reputations, &stores.plates.0, &*stores.plate_mode);
let go_templates = &*stores.go_templates;
let queued_area = stores.stone.as_deref().map_or(0, |s| s.area);
let hovered_go = &*stores.hovered_go;
// The picked submesh's shading payload — off the hit entity itself (see the field's doc).
let tag_line = mouseover
@@ -416,7 +421,8 @@ pub(super) fn inspect_ui(
self_store, go_guid,
),
meeting_stone_queued: crate::target::cursor_mode::meeting_stone_queued(
go_guid.and_then(|g| go_templates.get(g)?.meeting_stone_area),
go_guid.and_then(|g| Some(go_templates.get(g)?.meeting_stone?.area)),
queued_area,
),
};
let interact = if crate::target::cursor_mode::go_highlightable(s, reaction, overrides) {
+7
View File
@@ -387,6 +387,13 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_BOOK").is_ok() {
app.add_plugins(crate::capture::ProbeBookPlugin);
}
// The meeting-stone live probe: `WOW_PROBE_STONE=1` parks at a real stone, clicks it
// on the click's own route below and inside its level band, and reads the queue back
// out of the live VM — decision 2283's instrument, the end-to-end answer to "can a
// player get into the LFG queue" (see `capture::ProbeStonePlugin`).
if std::env::var("WOW_PROBE_STONE").is_ok() {
app.add_plugins(crate::capture::ProbeStonePlugin);
}
// The chest live probe: `WOW_PROBE_CHEST=1` parks at a real chest spawn, opens it on the
// click's own route and reports the self unit's base anim id before/during/after — B84's
// instrument, the numeric answer to "does the player kneel at a chest" (decision 1471;
+4
View File
@@ -1631,6 +1631,10 @@ mod display_mirror_tests {
}),
alpha_anim: None,
rgb_anim: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
@@ -456,6 +456,10 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
@@ -364,6 +364,10 @@ mod tests {
welded_billboard: false,
alpha_anim: None,
rgb_anim: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
@@ -398,6 +398,10 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
+9 -3
View File
@@ -42,7 +42,7 @@ use crate::creature_anim::{SpellKitSound, SpellVisuals};
use crate::net::{NetEntity, ObjectStore};
use benilla_protocol::EntityKind;
use super::spell_fx::{attach_effect_visuals, ensure_model, SpellFx};
use super::spell_fx::{attach_effect_visuals, ensure_model, FxMaterials, SpellFx};
/// The client's hardcoded shard-model table (`0x870e24`, 7 entries — wow-re
/// `dynobject-visual-machine.md` Q-A1). `CharParamZero`'s decoded small int indexes it.
@@ -275,6 +275,8 @@ pub(super) fn attach_ground_fx_models(
asset_server: Res<AssetServer>,
mut wow_materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
@@ -298,8 +300,12 @@ pub(super) fn attach_ground_fx_models(
// The dest one-shot is not a `CEffect` on a unit: it plants at the packet's point and
// runs its own span clock, so it keeps the plain single-clip arm.
None,
&mut wow_materials,
&mut tint_reg,
&mut FxMaterials {
store: &mut wow_materials,
tint: &mut tint_reg,
uv: &mut uv_reg,
table: &mut anim_table,
},
&ibps,
&mut palettes,
None,
@@ -127,6 +127,23 @@ pub(super) struct EntityPart {
/// the **first key** seeded as the material tint (pixel-identical to the old static bake);
/// the effect lane clones + ticks the tint per instance. `None` for constant tints.
pub(super) rgb_anim: Option<std::sync::Arc<benilla_formats::RgbAnim>>,
/// The part's **texture-transform (UV) loop** — the translation track that scrolls this
/// batch's stage UVs (decision 0130 phase 3, wow-re `m2-texanim-uv`). The **effect lane**
/// ([`super::spell_fx`]) samples it per instance on its own clip clock, through a material
/// clone (decision 2282); the unit/GameObject lane does not consume it yet. `None` for the
/// ~98.6% of batches with no texture transform, and for all WMO parts.
pub(super) uv_anim: Option<std::sync::Arc<benilla_formats::UvAnim>>,
/// The per-file-sequence-slot form of [`Self::uv_anim`], carried only where the slots
/// disagree (decision 1408) — an effect that advances `Stand` → `Hold` → `Decay` reads the
/// slot it is actually playing. `None` for every batch whose slots agree.
pub(super) uv_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// The texture transform's **rotation** loop per file sequence slot (decision 2019) and its
/// scaling twin. Three effect models in the whole 1.12 corpus author them — Shield Wall's
/// halo turns *and* scales, Grounding Totem's glow is a scale-only transform — which is
/// precisely why the effect lane has to read them from the asset rather than from memory
/// (`benilla-extract fxuvscan`). `None` everywhere else.
pub(super) uv_rot_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
pub(super) uv_scale_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// The part's flat **ground-plane quad** shape (detected at M2 load — see
/// [`benilla_formats::GroundQuad`]). On a base-anchored spell effect the fx attach renders it
/// as a projected surface decal ([`crate::ground_fx`]) instead of free geometry, so it drapes
@@ -537,6 +554,10 @@ pub(super) fn build_parts(
welded_billboard: sub.geometry.welded_billboard,
alpha_anim: sub.alpha_anim.clone(),
rgb_anim: sub.rgb_anim.clone(),
uv_anim: sub.uv_anim.clone(),
uv_seq: sub.uv_seq.clone(),
uv_rot_seq: sub.uv_rot_seq.clone(),
uv_scale_seq: sub.uv_scale_seq.clone(),
ground_quad: sub.ground_quad,
}
})
@@ -587,6 +608,10 @@ pub(super) fn build_parts(
welded_billboard: false, // …so nothing can be welded to one
alpha_anim: None, // …nor colour/weight loops
rgb_anim: None,
uv_anim: None, // …nor texture transforms (WMO has none)
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None, // the fx decal lane is M2-only
})
.collect()
@@ -352,8 +352,9 @@ fn spawn_slot(
if let Some([top, bottom]) = dm.string_anchors {
commands.entity(root).insert(crate::bowstring::Bowstring {
owner: entity,
top: top.1,
bottom: bottom.1,
// Bone AND offset: the prop flexes, so the tips move (decision 2281).
top,
bottom,
});
}
}
@@ -382,6 +383,23 @@ fn spawn_slot(
.insert(crate::fishing_line::FishingPoleTip { owner: entity, tip });
}
}
// **The RANGED PROP's own animation** (decision 2281) — benilla's `[CGUnit+0xd24]`. The
// reference keeps the equipped ranged weapon's M2 *instance* and re-arms its animation from the
// body's `$BWP`/`$BWR` keyframes: BowPull(160) on the pull, and on the release either Stand(0)
// (a bow's limbs relax) or **BowRelease(161)** — which on a firearm is the muzzle blast, seven
// particle emitters keyed to zero in Stand and to a 0.2 s burst in 161. See
// [`crate::ranged_flex`] for the byte law and the corpus measurement.
//
// So this one held model is NOT the lane's "rests at bind pose" case, and it earns the three
// things a resting attach model is denied: a pose buffer, a player, and bone ANCHORS for its
// emitters (bone 2 of every firearm is keyed +90° in 161 — that rotation is what aims the blast
// down the barrel). The gate is the asset: 46 of the 571 shipped weapon models author 160 or
// 161, all of them bows, crossbows and firearms, and nothing else in the corpus does.
let flexes = slot_idx == 2
&& !dm.skeleton.joints.is_empty()
&& dm.animations.as_ref().is_some_and(|a| {
a.owns(crate::ranged_flex::BOW_PULL) || a.owns(crate::ranged_flex::BOW_RELEASE)
});
// **The item rig** (decisions 0841, withdrawn by 0847, RESTORED by 0854) — the one case an
// attach model runs a joint palette. 0847 pulled it believing a spherical billboard swept the
// spikes through the plate; that was wrong (0853: the spikes run ALONG their bone, worst vertex
@@ -454,7 +472,14 @@ fn spawn_slot(
benilla_world::rig_palette::RigSkin::allocate_bones(
palettes,
dm.skeleton.joints.len() as u32,
Handle::default(),
// A resting rider needs no inverse bindposes — at bind pose every row collapses to
// the placement and 1609's write never consults them. A FLEXING prop's rows are
// `F × model[b] × ibp[b]`, so its slot carries the real handle.
if flexes {
dm.inverse_bindposes.clone().unwrap_or_default()
} else {
Handle::default()
},
)
})
.flatten()
@@ -472,6 +497,34 @@ fn spawn_slot(
slot
}),
};
// The flexing prop's own pose buffer and clock (see `flexes` above). The collapsed lane
// ([`benilla_world::rig_anim::RigPose`] — no joint entities; bone anchors are minted on demand
// by the emitter loop below), armed on the model-load bootstrap the reference gives every M2
// instance: animation id 0 through the model's own `playableAnimationLookup`. From there
// [`crate::ranged_flex`] re-arms it per keyframe.
//
// Its palette rows stay the RIDER lane's — `RigRider` is still on this root, and 2281 taught
// that lane to compose a pose rather than repeat one frame. That is deliberate: composing this
// prop from its own absolute `GlobalTransform` instead would hand the bow back the 0.98 mm
// one-ULP staircase 1609 exists to have removed (the attach point is a ~1 yd quantity summed
// onto a ~9500 yd one, recomputed every frame).
let mut prop_pose = (flexes && rider.is_some()).then(|| {
let anims = dm.animations.as_ref().expect("flexes ⇒ animations");
let mut player = AnimationPlayer::default();
if let Some(idle) = anims.idle_clip() {
let active = player.play(idle.node);
if idle.looping {
active.repeat();
}
}
commands.entity(root).insert((
player,
AnimationGraphHandle(anims.graph.clone()),
anims.clone(),
crate::ranged_flex::RangedProp { owner: entity },
));
benilla_world::rig_anim::RigPose::new(root, &dm.skeleton)
});
// The instance slot every part below carries in its `MeshTag`. The WEARER's only when the item
// has no palette slot of its own — that is what puts a tinted body's colour on a boneless
// helm (decision 0812). An item WITH a slot must carry its own, because the vertex stage
@@ -583,7 +636,14 @@ fn spawn_slot(
// strength — the Hungering Cold's five glow cards blaze at 1.0 where the file says
// 0.30 (decision 0836).
if let Some(anim) = &part.alpha_anim {
child.insert(benilla_world::doodad_anim::MatAnim::resting(anim.clone()));
// `resting` is the attach model's pinned read — right for every held item but the
// one that plays sequences (decision 2281): a flexing prop's per-sequence alpha
// loops follow its own player, like a creature's do.
child.insert(if flexes {
benilla_world::doodad_anim::MatAnim::following(anim.clone(), root)
} else {
benilla_world::doodad_anim::MatAnim::resting(anim.clone())
});
}
effective.dress(&mut child, &set);
}
@@ -698,6 +758,19 @@ fn spawn_slot(
// frame is realized as a mesh-less billboard card the emitter OWNS-follows
// (`BillboardCard::frame_following`). Nothing else in the chain is live: of the
// 95 item models whose emitters ride a billboard bone, none animates its chain.
//
// …and ONE exception to *that*: a **flexing ranged prop** (decision 2281) does have a rig,
// so its emitter rides its BONE's anchor. The firearm muzzle bank hangs off bone 2, which
// BowRelease(161) keys a constant +90° about Y against Stand's 0° — on the root frame the
// blast sprays out of the receiver, on the bone it goes down the barrel. `bone_pivot`
// rebases the authored model-space origin into that bone's frame, exactly as the doodad and
// widget lanes do. A billboard chain still wins the fork above: it is camera-replaced, and
// no flexing model in the corpus has one.
let posed = prop_pose
.as_mut()
.filter(|_| em.billboard.is_none())
.and_then(|p| p.anchor_for(commands, root, em.def.bone))
.map(|anchor| (anchor, em.bone_pivot));
let owner = match em.billboard {
Some(benilla_assets::EmitterBillboard { kind, pivot, .. }) => {
let frame = commands
@@ -725,7 +798,7 @@ fn spawn_slot(
em,
spawn_tf,
benilla_world::particles::EmitterFrames {
owner: Some(owner),
owner: Some(posed.unwrap_or(owner)),
// A held item is an attached model — the flame fans with the swing.
// The cloud anchors at the MODEL; the bone composes births only.
anchor: Some(root),
@@ -745,9 +818,16 @@ fn spawn_slot(
// batches already classify under (`BodyBakeCenter`).
light_node: Some(entity),
},
// A held item spawns no rig; its emitters run the item model's own slot-0
// loop on the spawn clock (the torch burns always — the doodad law).
benilla_world::particles::EmitClock::Pinned,
// A held item spawns no rig; its emitters run the item model's own **loader-idle**
// slot on the spawn clock (the torch burns always — the doodad law). A flexing ranged
// prop does have a player, and its rate/gate tracks read the sequence it is actually
// on: the gun's blast is keyed to zero in Stand and to a 0.2 s burst in BowRelease,
// so a pinned clock would emit nothing on it for ever (decision 2281).
if flexes {
benilla_world::particles::EmitClock::Host(root)
} else {
benilla_world::particles::EmitClock::Pinned
},
);
}
// The item's own M2 point light — **the held torch's glow** (decision 0016's law on the
@@ -780,6 +860,16 @@ fn spawn_slot(
None,
);
}
// Last, so every `anchor_for` above is already registered in it.
if let Some(pose) = prop_pose {
debug!(
"ranged flex: unit {entity} display {} → prop clock ({} bones, {} anchors)",
hs.display,
pose.locals.len(),
pose.anchors.len()
);
commands.entity(root).insert(pose);
}
debug!(
"held attach: unit {entity} display {} → attach {} (bone {bone}, {} parts)",
hs.display,
@@ -821,6 +911,10 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
@@ -1136,6 +1230,173 @@ mod tests {
assert_eq!(a.joints, 0, "a rigid item builds no joint rig");
}
/// **The ranged prop gets a clock, and only the ranged prop** (decision 2281).
///
/// Four things have to be true together or the gun's muzzle blast never fires, and each one
/// alone is a silent no-op: the prop root carries a [`crate::ranged_flex::RangedProp`] naming
/// its wearer (that is how a `$BWR` key finds it), a [`benilla_world::rig_anim::RigPose`] and a
/// player (so 161 can be armed and posed at all), its emitter is **hosted on that root** rather
/// than pinned to the loader idle (the blast's gate track is keyed only in 161 — a pinned
/// emitter reads Stand's flat zero for ever), and the emitter rides its BONE's anchor rather
/// than the model root (bone 2 is what aims it down the barrel).
///
/// The control is the same display in the MAINHAND slot: nothing is ever passed to the
/// reference's `[+0xd24]` arms but the ranged weapon, so a melee item keeps the resting lane —
/// no player, no pose, a pinned clock.
#[test]
fn a_flexing_ranged_prop_gets_a_pose_a_clock_and_hosted_emitters() {
fn attach(slot_idx: usize, attach: u16) -> (App, Option<Entity>) {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
app.init_resource::<Assets<bevy::image::Image>>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
let mut dm = empty_display();
let mut p = part(false);
p.skinned_mesh = Some(skinned_handle());
dm.parts = Some(vec![p]);
dm.skeleton = benilla_assets::ModelSkeleton {
joints: vec![
benilla_assets::ModelJoint {
parent: -1,
local_translation: Vec3::ZERO,
billboard: None,
parent_arm: None,
},
// The muzzle bone the emitters hang off.
benilla_assets::ModelJoint {
parent: 0,
local_translation: Vec3::X,
billboard: None,
parent_arm: None,
},
],
spine_bone: None,
head_bone: None,
};
dm.inverse_bindposes = Some(Handle::default());
// A firearm's animation shape: Stand + BowRelease, and no BowPull.
dm.animations = Some(benilla_assets::ModelAnimations {
graph: Handle::default(),
clips: Vec::new(),
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
// `owns(161)` — the gate, the reference's own `0x711960` question.
animation_lookup: {
let mut v = vec![0xffffu16; 162];
v[0] = 0;
v[crate::ranged_flex::BOW_RELEASE as usize] = 1;
v
},
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
});
let mut em = benilla_assets::ModelEmitter {
def: benilla_world::testing::plain_particle_def(),
texture: Some(Handle::default()),
bone_pivot: [0.0; 3],
billboard: None,
recursion: None,
geometry: None,
owner_reach: 0.0,
water_bound: (Vec3::ZERO, 0.0),
idle_seq: 0,
};
em.def.bone = 1;
dm.emitters = vec![em];
displays.models.insert((7, ItemModelKind::Weapon), dm);
app.insert_resource(displays);
let bones = BoneAttach {
points: HashMap::from([(attach, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
let mut items = HeldItems::default();
items.slots[slot_idx] = Some(HeldSlot {
display: 7,
kind: ItemModelKind::Weapon,
attach,
visual: NO_GLOW,
});
let wearer = app
.world_mut()
.spawn((items, bones, Transform::default()))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
app.world_mut().entity_mut(wearer).insert(pose);
app.add_systems(Update, attach_held_items);
app.update();
(app, Some(wearer))
}
// The ranged slot (2), drawn in the right hand.
let (mut app, wearer) = attach(2, attach_id::HAND_RIGHT);
let wearer = wearer.unwrap();
let (prop, owner) = app
.world_mut()
.query::<(Entity, &crate::ranged_flex::RangedProp)>()
.iter(app.world())
.map(|(e, p)| (e, p.owner))
.next()
.expect("the ranged prop is marked");
assert_eq!(owner, wearer, "…and names its wearer, like `[+0xd24]` does");
assert!(
app.world().entity(prop).contains::<AnimationPlayer>()
&& app
.world()
.entity(prop)
.contains::<benilla_world::rig_anim::RigPose>(),
"a flexing prop carries a player and a pose of its own"
);
let hosts: Vec<Option<Entity>> = app
.world_mut()
.query::<&benilla_world::particles::ParticleEmitter>()
.iter(app.world())
.map(|e| e.emit_host())
.collect();
assert_eq!(
hosts,
vec![Some(prop)],
"its emitter reads the sequence the PROP is playing"
);
// The emitter's owner frame is the muzzle bone's anchor, not the model root.
let anchors: Vec<(Entity, u16)> = app
.world_mut()
.query::<&benilla_world::rig_anim::RigAnchor>()
.iter(app.world())
.map(|a| (a.rig, a.bone))
.collect();
assert!(
anchors.contains(&(prop, 1)),
"the emitter minted the muzzle bone's anchor: {anchors:?}"
);
// The control: the SAME display in the mainhand keeps the resting lane.
let (mut app, _) = attach(0, attach_id::HAND_RIGHT);
assert_eq!(
app.world_mut()
.query::<&crate::ranged_flex::RangedProp>()
.iter(app.world())
.count(),
0,
"a melee item is never armed — the reference only ever re-anims the ranged prop"
);
let hosts: Vec<Option<Entity>> = app
.world_mut()
.query::<&benilla_world::particles::ParticleEmitter>()
.iter(app.world())
.map(|e| e.emit_host())
.collect();
assert_eq!(
hosts,
vec![None],
"…and its emitter stays on the pinned clock"
);
}
/// **What a booth can see of an equipped item** (decision 0822, `#bugs` B118's paper-doll half).
/// An item model's camera-facing batch spawns as a world-ROOT card and its emitters as free
/// owner-followed entities — neither is a unit descendant, so the portrait / paper-doll booths,
+9 -3
View File
@@ -55,7 +55,7 @@ use benilla_assets::m2_url;
use benilla_assets::materials::WowModelMaterial;
use super::equipment::{ItemDisplays, ItemModelKind};
use super::spell_fx::{attach_effect_visuals, EffectHost, FxTintAnims};
use super::spell_fx::{attach_effect_visuals, EffectHost, FxMaterials, FxTintAnims};
use super::{DisplayModel, ModelHandle};
/// The glow chain's data + the effect-model cache: the joined `ItemVisuals`/`ItemVisualEffects`
@@ -185,6 +185,8 @@ pub(super) fn attach_item_glows(
time: Res<Time>,
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
// One breadcrumb per session the first time a glow actually spawns — the machine-readable
@@ -306,8 +308,12 @@ pub(super) fn attach_item_glows(
// An `ItemVisuals` glow is armed by a different leg than `PlaySpellVisualKit`
// (0805) and carries no kit stage — the plain single-clip arm, as before.
None,
&mut wow_materials,
&mut tint_reg,
&mut FxMaterials {
store: &mut wow_materials,
tint: &mut tint_reg,
uv: &mut uv_reg,
table: &mut anim_table,
},
&ibps,
&mut palettes,
None, // the glow models author one looping sequence — the default pick
+13 -3
View File
@@ -92,7 +92,7 @@ use crate::creature_anim::{
use benilla_assets::m2_url;
use super::equipment::ItemDisplays;
use super::spell_fx::{attach_effect_visuals, ensure_model, EffectHost, SpellFx};
use super::spell_fx::{attach_effect_visuals, ensure_model, EffectHost, FxMaterials, SpellFx};
use super::{BoneAttach, DisplayModel, ModelHandle};
/// The anim-event idents that release queued missiles — the dispatcher's drain arms (`0x5ffbd0`:
@@ -679,6 +679,8 @@ pub(super) fn attach_missile_models(
time: Res<Time>,
mut wow_materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
@@ -719,8 +721,12 @@ pub(super) fn attach_missile_models(
// A projectile is the separate `CMissile` TU, not a `CEffect`: it has no kit stage and
// no Birth/Hold/Decay lifecycle — it flies its one sequence and dies on arrival.
None,
&mut wow_materials,
&mut tint_reg,
&mut FxMaterials {
store: &mut wow_materials,
tint: &mut tint_reg,
uv: &mut uv_reg,
table: &mut anim_table,
},
&ibps,
&mut palettes,
Some(INFLIGHT_ANIM),
@@ -887,6 +893,7 @@ mod tests {
entity: caster,
ident: *b"$CSL",
data: 0,
anim_id: 0,
pos: None,
});
step(&mut app, 0.05);
@@ -920,6 +927,7 @@ mod tests {
entity: caster,
ident: *b"$CSL",
data: 0,
anim_id: 0,
pos: None,
});
step(&mut app, 0.016);
@@ -1006,6 +1014,7 @@ mod tests {
entity: caster,
ident: *b"$CSL",
data: 0,
anim_id: 0,
pos: None,
});
step(&mut app, 0.016);
@@ -1086,6 +1095,7 @@ mod tests {
entity: caster,
ident: *b"$CSL",
data: 0,
anim_id: 0,
pos: None,
});
step(&mut app, 0.016);
+97 -29
View File
@@ -37,10 +37,14 @@
//! shield's pulse comes from), and run their **material
//! animation**: each part's colour-alpha × transparency-weight loops sample per instance on the
//! attach clock (a [`MatAnim`](benilla_world::doodad_anim::MatAnim) that owns the part's render-alpha
//! tag — Battle Shout's staggered crescent pulses), and an animated M2Color RGB ticks a
//! tag — Battle Shout's staggered crescent pulses), an animated M2Color RGB ticks a
//! **per-instance material clone**'s tint uniform ([`FxTintAnims`] — the white-hot flash cooling
//! to red; per instance because one cast = one phase, unlike the doodad lane's shared-clock
//! loops in [`benilla_world::doodad_anim::TintAnimMaterials`]).
//! loops in [`benilla_world::doodad_anim::TintAnimMaterials`]), and a **texture transform** scrolls
//! that same clone's UVs off the instance's own clip — translation, rotation and scale, through
//! [`register_fx_uv`](benilla_world::doodad_anim::register_fx_uv)'s effect lane (decision 2282;
//! the druid's claw trail is nothing but that scroll, so until it landed the trail did not draw
//! at all).
//!
//! Effect instances **fire their model's event track** ([`fire_fx_anim_events`], decision 0304):
//! the playing clip's `$SND`-family keyframes emit the same [`AnimSoundEvent`] stream creatures
@@ -49,10 +53,12 @@
//! WATCHER — the `UNIT_FIELD_LEVEL` edge that spawns the pillar — lives with its lootable-edge
//! sibling in `creature_anim::spell_visual::arm_level_up_fx`, the SpellKitFx writers' home.)
//!
//! Approximations, named: the UV-scroll channel still doesn't run here (0130's scope was placed
//! doodads); the span-based self-termination stands in for the client's model-event completion
//! callback; and the kit sound keeps playing unconditionally where the client gates it on
//! no-visual-attached.
//! Approximations, named: a ground-anchored effect's flat quad parts carry no UV offset — their
//! draw identity rides a `GroundFxDecal` record rather than a `WowModelMaterial` (0733), and no
//! transform-animating effect model authors one, so the population is empty (`benilla-extract
//! fxuvscan` is where that stays measured rather than remembered); the span-based
//! self-termination stands in for the client's model-event completion callback; and the kit sound
//! keeps playing unconditionally where the client gates it on no-visual-attached.
mod lifecycle;
@@ -160,32 +166,85 @@ pub(crate) fn tick_fx_tint(
});
}
/// Resolve one part's material for a NEW effect instance: the shared handle, unless the part's
/// M2Color RGB animates — then a per-instance clone seeded at the loop's first key and registered
/// for the per-frame tint tick ([`FxTintAnims`]).
/// The material side of an effect attach, in one parameter: the store a part's clone is made
/// from, and the three places that clone can be registered for per-instance animation — the tint
/// loop ([`FxTintAnims`], decision 0271), the UV scroll ([`UvAnimMaterials`]' effect lane,
/// decision 2282) and the delta table both scrolls write their rows into. They always travel
/// together, and a caller that threaded some of them silently dropped a channel.
pub(crate) struct FxMaterials<'a> {
pub(crate) store: &'a mut Assets<WowModelMaterial>,
pub(crate) tint: &'a mut FxTintAnims,
pub(crate) uv: &'a mut benilla_world::doodad_anim::UvAnimMaterials,
pub(crate) table: &'a mut benilla_world::mat_anim_table::MatAnimTable,
}
/// This part's four baked texture-transform channels, in the shape the engine's effect lane takes
/// them (`FxUvLoops::any` is the "does it animate at all" test — a scale-only transform and a
/// dead-slot-0 translation each answer `None` to the obvious channel).
fn part_uv_loops(part: &EntityPart) -> benilla_world::doodad_anim::FxUvLoops {
benilla_world::doodad_anim::FxUvLoops {
seqs: part.uv_seq.clone(),
single: part.uv_anim.clone(),
rot: part.uv_rot_seq.clone(),
scale: part.uv_scale_seq.clone(),
}
}
/// Resolve one part's material for a NEW effect instance. The shared handle, unless a channel has
/// to run **per instance** — an animated M2Color RGB (decision 0271) or a texture transform
/// (decision 2282) — and then a clone of it, seeded at each live loop's first key and registered
/// for the per-frame ticks. `host` is the instance root, whose `AnimationPlayer` the UV lane reads
/// its clip off (the sequence source `MatAnim::following_host` takes beside it).
fn fx_part_material(
part: &EntityPart,
now: f32,
wow_materials: &mut Assets<WowModelMaterial>,
tint_reg: &mut FxTintAnims,
host: Entity,
mats: &mut FxMaterials,
) -> Handle<WowModelMaterial> {
let Some(anim) = &part.rgb_anim else {
let loops = part_uv_loops(part);
if part.rgb_anim.is_none() && !loops.any() {
return part.material.clone();
};
}
// The shared steady may still be parked (`model_render::lazy`): this copy is made before
// anything binds it.
benilla_world::model_render::lazy::realize(wow_materials, part.material.id());
let Some(mut mat) = wow_materials.get(part.material.id()).cloned() else {
benilla_world::model_render::lazy::realize(mats.store, part.material.id());
let Some(mut mat) = mats.store.get(part.material.id()).cloned() else {
return part.material.clone(); // shared material not built yet — parts were checked ready
};
let t0 = anim.sample(0.0);
mat.extension.tint = Vec4::new(t0[0], t0[1], t0[2], 1.0);
// The clone must leave the shared mat-anim table (decision 1381): its tint is ticked per
// INSTANCE right here (the whole point of the clone), and a carried world slot would add the
// shared delta on top — a double animation the old asset-mutating lane could never produce.
if let Some(anim) = &part.rgb_anim {
let t0 = anim.sample(0.0);
mat.extension.tint = Vec4::new(t0[0], t0[1], t0[2], 1.0);
}
// The clone must leave the shared mat-anim table (decision 1381): its channels are ticked per
// INSTANCE, and a carried world slot would add the shared delta on top — a double animation
// the old asset-mutating lane could never produce. `register_fx_uv` below hands it rows of
// its own.
mat.extension.anim_slots = Vec4::ZERO;
let handle = wow_materials.add(mat);
tint_reg.0.insert(handle.id(), (anim.clone(), now));
if loops.any() {
// **Seed the clone at the loop's own t = 0**, not at the entity lane's flat zero
// (`entity_variants` builds every effect batch with `play_uv = false`, so the shared
// steady this was cloned from carries no offset at all). The delta table is measured from
// whatever `sun_scale.zw` holds at registration (1381), so seeding here is what makes the
// registration's own opening row land on the scroll's first frame.
let t0 = loops.open_offset();
mat.extension.sun_scale.z = t0[0];
mat.extension.sun_scale.w = t0[1];
}
let handle = mats.store.add(mat);
if let Some(anim) = &part.rgb_anim {
mats.tint.0.insert(handle.id(), (anim.clone(), now));
}
if loops.any() {
benilla_world::doodad_anim::register_fx_uv(
mats.uv,
mats.table,
mats.store,
handle.id(),
loops,
host,
now,
);
}
handle
}
@@ -236,8 +295,7 @@ pub(crate) fn attach_effect_visuals(
ground_anchor: bool,
host: EffectHost,
stage: Option<FxStage>,
wow_materials: &mut Assets<WowModelMaterial>,
tint_reg: &mut FxTintAnims,
mats: &mut FxMaterials,
ibps: &Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>,
palettes: &mut benilla_world::rig_palette::RigPalettes,
preferred_anim: Option<u16>,
@@ -272,7 +330,7 @@ pub(crate) fn attach_effect_visuals(
if is_ground_decal(p) {
p.material.clone()
} else {
fx_part_material(p, now, wow_materials, tint_reg)
fx_part_material(p, now, root, mats)
}
})
.collect();
@@ -418,8 +476,8 @@ pub(crate) fn attach_effect_visuals(
};
// The material's packed fog byte, handed over raw — the lane owns what it maps to.
// `7` (Scene) is the no-material fallback the packer's own default agrees with.
benilla_world::model_render::lazy::realize(wow_materials, part.material.id());
let (texture, fog_bits) = match wow_materials.get(part.material.id()) {
benilla_world::model_render::lazy::realize(mats.store, part.material.id());
let (texture, fog_bits) = match mats.store.get(part.material.id()) {
Some(mat) => (
mat.base.base_color_texture.clone(),
(mat.extension.clutter_fade.z as u32 >> 4) & 7,
@@ -933,6 +991,8 @@ pub(super) fn attach_spell_fx(
time: Res<Time>,
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
mut emitters: Query<&mut benilla_world::particles::ParticleEmitter>,
@@ -1082,8 +1142,12 @@ pub(super) fn attach_spell_fx(
EffectHost { parent: Some(unit) },
// A kit effect IS a `CEffect`: it runs the stage's animation lifecycle.
Some(inst.stage),
&mut wow_materials,
&mut tint_reg,
&mut FxMaterials {
store: &mut wow_materials,
tint: &mut tint_reg,
uv: &mut uv_reg,
table: &mut anim_table,
},
&ibps,
&mut palettes,
None, // an attach-point kit effect opens on its model's own `Stand`
@@ -1216,6 +1280,8 @@ mod tests {
.init_asset::<SkinnedMeshInverseBindposes>()
.init_resource::<SpellFx>()
.init_resource::<FxTintAnims>()
.init_resource::<benilla_world::doodad_anim::UvAnimMaterials>()
.init_resource::<benilla_world::mat_anim_table::MatAnimTable>()
.init_resource::<benilla_world::rig_palette::RigPalettes>()
.add_systems(Update, attach_spell_fx);
let roots: Vec<Entity> = persistent
@@ -1441,6 +1507,8 @@ mod tests {
.init_resource::<Time>()
.init_resource::<SpellFx>()
.init_resource::<FxTintAnims>()
.init_resource::<benilla_world::doodad_anim::UvAnimMaterials>()
.init_resource::<benilla_world::mat_anim_table::MatAnimTable>()
.init_resource::<benilla_world::rig_palette::RigPalettes>()
.add_systems(Update, attach_spell_fx);
+49 -2
View File
@@ -695,13 +695,60 @@ pub(crate) mod schedule_tests {
///
/// Raising a ceiling is a claim that a new undeclared order is acceptable; make it with the
/// reason, or declare the order instead (`.after`, a set, a `chain`).
const UPDATE_CEILING: usize = 16_437;
/// **16,538 (decision 2283), 660 systems** — `ui_dialog_verbs::drain_meeting_stone_joins`, the
/// meeting stone's use slot. +101, of which 91 are VM-only: it holds `NonSendMut<UiScript>` to
/// raise its refusal line, and the paragraph above is the reason that number is what it is —
/// every new feed or drain collides with every other VM holder by construction.
/// `UPDATE_OTHER_CEILING` below carries the ten that are actionable.
///
/// **+4 (decision 2281)** — `ranged_flex`'s two systems, the `$BWP`/`$BWR` arm and the
/// un-nock's reset. Both write `AnimationPlayer` on the ranged weapon prop, so each joins the
/// accepted class its chain-mates already sit in: ambiguous against
/// `portrait::booth::drive_booth_turn` and `quest_markers::pose_markers` for exactly the
/// reason `driver::drive_animations` and `driver::grip::drive_hand_grip` are — four lanes
/// driving players on populations that never intersect (a held weapon, a booth model, a quest
/// marker), and the filter algebra cannot see the disjointness. Their *real* orders are
/// declared at the registration instead: against each other by the chain, and before the
/// per-sequence material samplers, which must answer for the clip armed this frame.
///
/// **+6 (decision 2282), all the same shape:** `{tick_anim_materials, matanim_probe} ×
/// {attach_spell_fx, attach_missile_models, attach_item_glows}`, fighting over the two
/// mat-anim resources (`MatAnimTable`, `UvAnimMaterials`) alone — the three fx attaches now
/// register a per-instance UV clone where before they touched neither. Declared acceptable
/// rather than ordered,
/// because the order is immaterial *by construction* and an `.after` would assert a
/// dependency that does not exist: `register_fx_uv` writes the new entry's rows itself, at
/// attach, precisely so a tick that already ran this frame costs the instance nothing — and a
/// tick that runs after re-derives the same numbers from the same clock (sampling is
/// clock-indexed; the registration and the tick read one `AnimationPlayer`). The probe is a
/// one-shot diagnostic behind an env var, off in every ordinary run. The cost of declaring it
/// would be real: `tick_anim_materials` is engine-private, so the game would have to name a
/// new engine `SystemSet` — a `world_api_wall` crossing bought for no behavioural difference.
const UPDATE_CEILING: usize = 16_548;
const UPDATE_SLACK: usize = 100;
/// `PostUpdate`, 181 systems: `GlobalTransform` and the particle `EffectQuads` are most of it.
const POST_UPDATE_CEILING: usize = 351;
const POST_UPDATE_SLACK: usize = 20;
/// The pairs in `Update` that are NOT explained by the VM or the audio layer.
const UPDATE_OTHER_CEILING: usize = 4_717;
///
/// **4,727 (decision 2283)** — the ten `drain_meeting_stone_joins` adds, every one of them
/// against a sibling `.after(UiInput)` drain (`drain_queue_verbs`, `drain_latch_verbs`,
/// `drain_battlefield`, `drain_talent_wipe`, `drain_party`, `drain_tabard`, the two chat-input
/// drains, `idle_handler`, `movement_clears_afk`). What they share is the append-only
/// chat/error sink and the outbound command channel — never a fact one reads and another
/// writes — and each pair is two independent player verbs that a single click cannot both
/// fire. Raised rather than ordered because a declared order here would assert a relationship
/// between, say, leaving a meeting-stone queue and typing in chat that does not exist; the
/// pair it *would* have been worth ordering, against the click that writes its message, is
/// declared (`.after(TargetUpdate)`) and so is not in this list.
///
/// **+4 (decision 2281)** — the same four pairs as above; all four are actionable-column, and
/// the reason they are raised rather than ordered is the one stated there.
///
/// **+6 (decision 2282)** — all six of 2282's new pairs land here too (they fight over
/// ordinary resources, not the VM), so both ceilings move by the same six; the reason is at
/// [`UPDATE_CEILING`].
const UPDATE_OTHER_CEILING: usize = 4_737;
const UPDATE_OTHER_SLACK: usize = 50;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
+32 -9
View File
@@ -39,7 +39,7 @@ pub(crate) struct GoTemplate {
/// The display name — the hover tooltip's gold first line (decision 0276's GO law).
pub(crate) name: String,
/// The vanilla **highlight** column, for the two types whose mouseover-eligibility slot reads
/// it instead of running a predicate (decision 1106): GENERIC(5)'s `data[1]` (`0x5f4830`,
/// it instead of running a predicate (decision 1110): GENERIC(5)'s `data[1]` (`0x5f4830`,
/// decision 0762 — nonzero on 1387 of the 1870 shipped type-5 templates, which is why a road
/// signpost hovers and the scenery beside it never does) and CAPTURE_POINT(29)'s `data[19]`
/// (`0x5f6d80` — byte-for-byte the same shape, a different slot). `false` for every other type,
@@ -61,9 +61,10 @@ pub(crate) struct GoTemplate {
/// and the fork are byte-verified, and the data is cross-checked against the reference client's
/// own `gameobjectcache.wdb` — 1890 entries, zero disagreements with the server's.)
pub(crate) floating_tooltip: bool,
/// MEETINGSTONE (type 23) only: the template's `data[2]` = **areaID**, the sole input of that
/// type's own `highlightable` slot (`0x5f6990` — decision 1106). `None` for every other type.
pub(crate) meeting_stone_area: Option<u32>,
/// MEETINGSTONE (type 23) only: the three template slots the stone's own strategy reads —
/// `data[0]`/`data[1]` = minLevel/maxLevel and `data[2]` = areaID (decisions 1110, 2283).
/// `None` for every other type.
pub(crate) meeting_stone: Option<MeetingStoneTemplate>,
/// MO_TRANSPORT (type 15) path parameters — `Some` only for boats/zeppelins (decision 0438):
/// the template's `data0..2` = (taxiPathId, moveSpeed, accelRate), the inputs the transport
/// timetable is built from.
@@ -79,6 +80,23 @@ pub(crate) struct GoTemplate {
pub(crate) quest_material: Option<u32>,
}
/// A MEETINGSTONE (type 23) template's three slots, all of them read by the stone's own strategy
/// through the per-type attribute table `0x621b00`: keys `0x33`/`0x34`/`0x35` → indices 0/1/2
/// (vmangos `GameObjectInfo::meetingstone` = `minLevel, maxLevel, areaID`; decision 2283).
///
/// The **areaID** is the input of the type's own `highlightable` slot `0x5f6990`
/// ([`crate::target::cursor_mode::meeting_stone_queued`]); the **level pair** is the input of the
/// use-slot validator's level refusal (`ERR_MEETING_STONE_INVALID_LEVEL`).
#[derive(Clone, Copy)]
pub(crate) struct MeetingStoneTemplate {
/// `data[0]` — the lowest level the stone accepts.
pub(crate) min_level: u32,
/// `data[1]` — the highest level the stone accepts.
pub(crate) max_level: u32,
/// `data[2]` — the `AreaTable` id the stone queues for.
pub(crate) area: u32,
}
/// A MO_TRANSPORT template's path tuple (`gameobject_template.data0..2`, decision 0438).
#[derive(Clone, Copy)]
pub(crate) struct MoTransport {
@@ -131,7 +149,7 @@ impl GameObjectTemplates {
.and_then(|slot| data.get(slot))
.map(|&v| v.max(0) as u32)
.unwrap_or(0);
// The highlight column, at the slot its type reads it from (decision 1106): GENERIC(5)
// The highlight column, at the slot its type reads it from (decision 1110): GENERIC(5)
// `data[1]`, CAPTURE_POINT(29) `data[19]`. Both slots are resolved by the same
// `0x621b00(type, semantic 0x12)` lookup in the reference; the two shipped answers are
// inlined here for the same reason [`go_lock_slot`] inlines the lock's.
@@ -144,9 +162,14 @@ impl GameObjectTemplates {
// the highlight column above and deliberately beside it — they are adjacent slots that
// answer different questions, and reading them as one is the mistake 0766 made.
let floating_tooltip = type_id == 5 && data[0] != 0;
// MEETINGSTONE (23): data[2] = areaID (vmangos `gameobject_template`), the one input of
// that type's own highlightable slot.
let meeting_stone_area = (type_id == 23).then(|| data[2].max(0) as u32);
// MEETINGSTONE (23): data[0..2] = minLevel, maxLevel, areaID (vmangos
// `gameobject_template`) — the area feeds that type's own highlightable slot, the level
// pair the use-slot validator's level refusal (decision 2283).
let meeting_stone = (type_id == 23).then(|| MeetingStoneTemplate {
min_level: data[0].max(0) as u32,
max_level: data[1].max(0) as u32,
area: data[2].max(0) as u32,
});
// MO_TRANSPORT (15): data0..2 = taxiPathId / moveSpeed / accelRate (vmangos
// `GameObjectInfo::moTransport`; decision 0438).
let mo_transport = (type_id == 15).then(|| MoTransport {
@@ -171,7 +194,7 @@ impl GameObjectTemplates {
name,
highlight_column,
floating_tooltip,
meeting_stone_area,
meeting_stone,
mo_transport,
text_page,
quest_material,
+1
View File
@@ -102,6 +102,7 @@ mod preflight;
mod probe_shield;
mod quest_markers;
mod raid_marks;
mod ranged_flex;
mod realm_select;
mod realmlist;
mod run_mode;
+5
View File
@@ -1352,6 +1352,11 @@ pub(crate) enum ClientCommand {
LeaveBattlefield {
map_id: u32,
},
/// A right-click on a `GAMEOBJECT_TYPE_MEETINGSTONE` that passed the type's four client-side
/// refusals — `CMSG 0x292`, `u64 goGuid` (decision 2283).
MeetingStoneJoin {
go_guid: u64,
},
/// `CancelMeetingStoneRequest()` — `CMSG 0x293`, empty.
MeetingStoneLeave,
/// The enter-world meeting-stone status query — `CMSG 0x296`, empty (decision 1974).
+1
View File
@@ -1393,6 +1393,7 @@ fn writer_loop(
w.request_battlefield_score_data()
}
ClientCommand::LeaveBattlefield { map_id } => w.leave_battlefield(map_id),
ClientCommand::MeetingStoneJoin { go_guid } => w.meeting_stone_join(go_guid),
ClientCommand::MeetingStoneLeave => w.meeting_stone_leave(),
ClientCommand::MeetingStoneStatusQuery => w.meeting_stone_status_query(),
ClientCommand::TutorialFlag { id } => w.tutorial_flag(id),
+627
View File
@@ -0,0 +1,627 @@
//! **The ranged weapon prop's own animation** (decision 2281) — the bow's limbs bend, the gun
//! fires its muzzle blast.
//!
//! An equipped ranged weapon is not just a mesh in a hand. The reference keeps its M2 *instance*
//! at `[CGUnit+0xd24]` (written by the ranged-attach helper `0x611e10` @`0x611eca`, populated on
//! any sheath→2) and re-arms **that model's own animation** from two keyframes on the body clip:
//!
//! - **`$BWP`** (BowPull, keyed only in LoadBow 105 @0.434 s / LoadRifle 106 @0.433 s) →
//! `0x624cc0`: if the prop's model *owns* animation **160 BowPull** (`0x624cee push 0xa0; call
//! 0x711960` — a presence test it bails on), play it on the prop at a rate that fits its
//! authored length into the body clip's REMAINING time (`0x624e31`'s computed float arg).
//! - **`$BWR`** (BowRelease, keyed only in AttackBow 46 @0.033 s / AttackRifle 49 @0.000 s) →
//! `0x600159`, which **forks on the weapon family** and arms a different clip for each:
//! - the body is playing a **bow** clip (`0x5fcfb0`: 46 AttackBow / 105 LoadBow / 109 HoldBow) →
//! the prop plays **animation 0 (Stand)** (`0x600209`) — the drawn limbs relax;
//! - the body is playing a **rifle** clip (`0x5fcfd0`: 49 AttackRifle / 106 LoadRifle / 110
//! HoldRifle — guns AND crossbows) → the prop plays **animation 161 (BowRelease)**
//! (`0x600273`, `push 0xa1`).
//!
//! **That second arm is the gun's muzzle blast, and it is why this file exists.** A firearm M2
//! authors exactly two sequences — Stand(0) and BowRelease(161) — and hangs its whole flash on the
//! second: `Firearm_2H_Rifle_A_01.m2` carries **seven** particle emitters, each holding a flat rate
//! in every sequence (100/s, 50/s, 40/s, 30/s, 20/s) and keying only its **enabled gate** — shut
//! across Stand, open from the head of 161 for a window of its own (34 ms, 134 ms, 4 × 200 ms,
//! 334 ms: a spark, a flare, the body of the flash, a smoke tail). Bone 2, the parent of all seven
//! emitter bones, is keyed a constant **+90° about Y** in 161 against 0° in Stand — which is what
//! aims the blast down the barrel instead of straight up out of the receiver. 21 of the 22 firearms
//! in the 5875 chain author 161; 25 bows and crossbows author 160 *and* 161; nothing else in the
//! 571-model weapon corpus authors either. Without this arm the emitters build, pool and tick for
//! ever on a sequence whose gate is shut, and a gun shot is silent art.
//!
//! **It is the GATE that is keyed, never the rate** — the track at `def+0x1dc`, outside the ten
//! scalar ones, `u8`-valued, step-interpolated (decision 2286). A consumer that asks "does this
//! emitter emit anything?" of the *rate* sees a non-zero constant in every sequence, for a bank
//! that has never once been switched on; that false negative is why this shipped broken and why a
//! wow-re round briefly published the opposite reading.
//!
//! **wow-re recorded the opposite**, flagged INFERRED: `ranged-shot-anim.md` read the mechanism as
//! "bow-only by asset … gun/crossbow/thrown/wand have **no** analogous prop re-anim call anywhere
//! (exhaustive search)". The rifle arm is in the same function as the bow arm, eleven instructions
//! apart. The §5 correction round landed as wow-re `ranged-prop-reanim-two-arm.md`: the fork is
//! CONFIRMED in every detail, and its census finds **exactly four** sites that arm `[+0xd24]` —
//! `0x624e31` (160), `0x600209` (0), `0x600273` (161) and `0x60f59d` (0, the reset below). The
//! surviving, precise form of "no analog" is **thrown and wand**: a third family predicate
//! `0x5fcf90` = {107, 111, 112} exists and the `$BWR` handler never calls it, so those two match
//! neither arm and get no prop animation at all.
//!
//! Three of that round's corrections are built in below, and one is not mine to build: the arms
//! sit under a gate on `[CGUnit+0xac]` (the unit's in-flight cast/visual record list) whose
//! **writer is still underived** — wow-re parked it. Nothing here models it; see the record.
//!
//! What this file does NOT own: the prop's rig and palette rows (the rider lane —
//! [`benilla_world::rig_rider`], given a posed arm by 2281), the emitters' clock (`EmitClock::Host`
//! on the prop root, wired at the attach), or the bowstring chord ([`crate::bowstring`], which now
//! spans the *posed* limb tips because of this).
use bevy::prelude::*;
use benilla_assets::ModelAnimations;
use crate::creature_anim::AnimSoundEvent;
/// `AnimationData.dbc` **160 BowPull** — the prop clip `$BWP` arms (`0x624e2a push 0xa0`).
pub(crate) const BOW_PULL: u16 = 160;
/// `AnimationData.dbc` **161 BowRelease** — the prop clip the RIFLE arm of `$BWR` plays
/// (`0x60026c push 0xa1`). On a firearm this is the muzzle blast; on a bow it is authored and,
/// per the byte fork above, never reached from here.
pub(crate) const BOW_RELEASE: u16 = 161;
/// The model-load bootstrap id — animation **0 Stand**, what the BOW arm of `$BWR` plays
/// (`0x600205 push 0x0`) and what the un-nock's reset re-arms (`0x60f59d`): the drawn bow returns
/// to rest.
const STAND: u16 = 0;
/// `0x5fcfb0` verbatim — the body clips that make the wearer a **bow** shooter.
const BOW_FAMILY: [u16; 3] = [46, 105, 109]; // AttackBow · LoadBow · HoldBow
/// `0x5fcfd0` verbatim — the body clips that make the wearer a **rifle** shooter (gun + crossbow).
const RIFLE_FAMILY: [u16; 3] = [49, 106, 110]; // AttackRifle · LoadRifle · HoldRifle
/// The unit's equipped **ranged weapon prop** — benilla's `[CGUnit+0xd24]`. On the prop root, put
/// there by the equipment attach for the ranged slot when the display's model authors a flex clip
/// (160 or 161); absent for every other held item, which is the reference's own population (nothing
/// else is ever passed to `0x624cc0`/`0x600209`/`0x600273`).
#[derive(Component)]
pub(crate) struct RangedProp {
/// The unit wearing it — the `$BWP`/`$BWR` keys arrive on *its* timeline, not the prop's.
pub(crate) owner: Entity,
}
/// Arm the prop's clip from the wearer's `$BWP`/`$BWR` keys.
///
/// Registered in the creature-anim chain immediately after
/// [`crate::creature_anim::drive_nock_latch`], so a key lands the frame it is crossed: the two are
/// the same handler pair in the reference (`$BWP` is `0x624cc0`'s prop arm *then* `0x624b2f`'s ammo
/// attach; `$BWR` is `0x600209`/`0x600273`'s prop arm *then* `0x600299`'s detach), and the prop's
/// pose has to be this frame's before the rider lane composes its palette rows from it.
pub(crate) fn flex_ranged_props(
mut events: MessageReader<AnimSoundEvent>,
props: Query<(Entity, &RangedProp)>,
// Disjoint from `props_mut` by the filter: a unit is never its own ranged prop.
wearers: Query<(&AnimationPlayer, &ModelAnimations), Without<RangedProp>>,
mut props_mut: Query<(&mut AnimationPlayer, &ModelAnimations), With<RangedProp>>,
) {
for ev in events.read() {
let want = match &ev.ident {
b"$BWP" => Some(BOW_PULL),
b"$BWR" => {
if BOW_FAMILY.contains(&ev.anim_id) {
Some(STAND)
} else if RIFLE_FAMILY.contains(&ev.anim_id) {
Some(BOW_RELEASE)
} else {
// Neither family: the reference falls through to `0x60027a` and touches no
// prop at all. Nothing in the shipped character models keys `$BWR` outside
// 46/49, so this arm is the reference's own defensive one.
None
}
}
_ => continue,
};
let Some(want) = want else { continue };
// The prop is found from the WEARER, exactly as `[+0xd24]` is: one ranged prop per unit.
let Some((prop, _)) = props.iter().find(|(_, p)| p.owner == ev.entity) else {
continue;
};
let Ok((mut player, anims)) = props_mut.get_mut(prop) else {
continue;
};
// **The two asks are not the same ask, and the asymmetry is real** (wow-re
// `ranged-prop-reanim-two-arm.md`, claim B). `$BWP` is guarded by `0x711960` — the
// *direct, unsubstituted* "does this model author id X" test — and bails when it fails:
// that is what confines BowPull to its 25 models, and what a firearm exits on, its whole
// cycle being the `$BWR` blast. `$BWR`'s arms carry no such guard, and `0x7121a0` does
// **not** silently no-op on a miss: `0x711bf0` substitutes through the model's own
// `PlayableAnimationLookup` first, and every weapon model that authors neither flex clip
// bakes both ids to Stand(0) (measured there: 571/571 resolve). So a rifle-family body
// holding a model without 161 re-arms Stand, it does not leave the prop alone.
let want = if want == BOW_PULL {
if !anims.owns(BOW_PULL) {
continue;
}
BOW_PULL
} else {
anims
.playable_animation_lookup
.get(want as usize)
.map_or(want, |row| row.resolved_id)
};
let Some(clip) = anims.clips.iter().find(|c| c.anim_id == want) else {
continue;
};
// `$BWP`'s rate: the reference fits BowPull's authored length into the body clip's
// REMAINING time (`0x624e31`'s computed float), so the draw completes exactly as the Load
// clip ends rather than finishing early and holding. Read off the wearer's own player at
// the clip the key fired from — `duration − seek` is the same quantity the reference
// builds from the block's window. Everything else plays at the literal 1.0 both `$BWR`
// arms push (`0x3f800000`).
let speed = if want == BOW_PULL {
// **`n <= 0` is NO FLEX THAT SHOT, not a flex at 1.0** (`0x624d91 jle`, claim C): the
// reference abandons the whole arm when the body clip has no time left to fill.
let Some(left) = wearers
.get(ev.entity)
.ok()
.and_then(|(p, a)| remaining(p, a, ev.anim_id))
.filter(|left| *left > 0.0)
else {
continue;
};
(clip.duration / left).clamp(0.05, 20.0)
} else {
1.0
};
// op4 ARMS a model, it does not layer — the prop plays exactly one clip. Without the
// stop, Bevy leaves the previous node active and `playing_seq` (which the emitters' rate
// track reads) picks by weight between two equal claims.
player.stop_all();
let active = player.play(clip.node);
active.replay();
active.set_speed(speed);
// **The instrument** (`WOW_MOVE_TRACE_TAGS=flex`), on the same clock as the `aev` key that
// asked for it. Whether a prop armed its clip is otherwise unobservable from outside the
// renderer — and "the blast did not fire" has three distinct causes (the key never
// arrived, the fork went the other way, the model owns no such clip) that look identical
// on screen.
if benilla_assets::trace::enabled_for("flex") {
benilla_assets::trace::line(
"flex",
&format!(
"{} unit={} body={} prop={prop} arm={want} speed={speed:.3}",
String::from_utf8_lossy(&ev.ident),
ev.entity,
ev.anim_id,
),
);
}
// The authored flags decide the wrap, as everywhere else: a firearm's Stand loops and its
// BowRelease clamps, a bow's three clips all clamp. A clamped flex holds its last frame
// until the next key re-arms it, which is the reference's own steady state.
if clip.looping {
active.repeat();
} else {
active.set_repeat(bevy::animation::RepeatAnimation::Never);
}
}
}
/// **The un-nock's reset — `0x60f59d`, and its condition is INVERTED from what wow-re's prose
/// said.** `ranged-shot-anim.md` read it as "re-arms the prop to Stand(0) **after** release"; the
/// bytes say **UNLESS** it is releasing:
///
/// ```text
/// 60f578 push -1 ; call 0x712090 ; the prop's CURRENT requested animation id
/// 60f57f cmp eax,0xa1 ; 161 BowRelease
/// 60f584 je 0x60f5a2 ; …then SKIP the reset
/// ```
///
/// **And the order is the law, not an implementation detail.** The reset lives inside the un-nock
/// `0x60f530`, which `$BWR` itself reaches every shot (`0x600294` → `0x60c940` → `0x60c951`) —
/// *after* the arm at `0x600273`. So the arm runs first and is exactly what that `cmp` reads back;
/// build the two independently and a gun's muzzle blast is cancelled on the frame it starts. The
/// guard exists for precisely this collision (wow-re `ranged-prop-reanim-two-arm.md`, claim D).
///
/// benilla reproduces the shape rather than the call graph: the un-nock's observable here is
/// [`NockLatch`] leaving the wearer, which `$BWR` does every shot and every cancel path does too
/// ([`crate::creature_anim::cancel_auto_repeat_local`], leaving the ranged sheath, a weapon
/// change). Removals are visible a frame after the commands that make them apply, by which time a
/// gun's prop is already on its 2 s BowRelease and the guard declines — which is the reference's
/// outcome by the reference's own reasoning.
///
/// What it is actually *for* is the case no release covers: **a cancel while the bow is drawn.**
/// BowPull(160) is a clamp, so a volley stopped mid-pull leaves the limbs bent at full draw for as
/// long as the weapon stays in hand; this is what relaxes them.
pub(crate) fn reset_ranged_props_on_unnock(
mut unnocked: RemovedComponents<crate::creature_anim::NockLatch>,
props: Query<(Entity, &RangedProp)>,
mut players: Query<(&mut AnimationPlayer, &ModelAnimations), With<RangedProp>>,
) {
for owner in unnocked.read() {
let Some((prop, _)) = props.iter().find(|(_, p)| p.owner == owner) else {
continue;
};
let Ok((mut player, anims)) = players.get_mut(prop) else {
continue;
};
// `0x60f57f cmp eax,0xa1` — the prop's current requested id, not its clip time: a
// BowRelease that has already finished still reads 161 and is still skipped.
let releasing = player
.playing_animations()
.filter_map(|(node, _)| anims.clips.iter().find(|c| c.node == *node))
.any(|c| c.anim_id == BOW_RELEASE);
if releasing {
continue;
}
let Some(stand) = anims.clips.iter().find(|c| c.anim_id == STAND) else {
continue;
};
player.stop_all();
let active = player.play(stand.node);
active.replay();
active.set_speed(1.0);
if stand.looping {
active.repeat();
} else {
active.set_repeat(bevy::animation::RepeatAnimation::Never);
}
}
}
/// Seconds left in the wearer's playing clip for `anim_id` — the `$BWP` rate's denominator.
fn remaining(player: &AnimationPlayer, anims: &ModelAnimations, anim_id: u16) -> Option<f32> {
let clip = anims
.clips
.iter()
.filter(|c| c.anim_id == anim_id)
.find_map(|c| Some((c, player.animation(c.node)?)))?;
Some(clip.0.duration - clip.1.seek_time())
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_assets::AnimClip;
use bevy::animation::graph::AnimationNodeIndex;
fn clip(anim_id: u16, node: u32, duration: f32, looping: bool) -> AnimClip {
AnimClip {
anim_id,
seq_index: 0,
node: AnimationNodeIndex::new(node as usize),
looping,
duration,
move_speed: 0.0,
blend_time: 0.0,
bounds_center: Vec3::ZERO,
bounds_radius: 0.0,
bounds_min: Vec3::ZERO,
bounds_max: Vec3::ZERO,
events: Vec::new().into(),
arm_nodes: None,
upper_node: None,
frequency: 0,
replay: (0, 0),
poses_bones: true,
}
}
/// A model's animation table, with the **two lookups a real M2 carries** built from the clips:
/// `animation_lookup` (the reference's direct `0x711960` test, which gates `$BWP`) and
/// `playable_animation_lookup` (the substitution `0x711bf0` runs before every arm — every id
/// the model does not author resolves to Stand, as the shipped weapon corpus bakes it).
/// Leaving either empty is what a synthetic fixture gets wrong: `owns()` then answers `false`
/// for a sequence the model plainly has.
fn anims(clips: Vec<AnimClip>) -> ModelAnimations {
let top = clips.iter().map(|c| c.anim_id).max().unwrap_or(0) as usize;
let mut animation_lookup = vec![0xffffu16; top + 1];
let mut playable = vec![
benilla_formats::PlayableAnim {
resolved_id: STAND,
dir_flags: 0,
};
top + 1
];
for (i, c) in clips.iter().enumerate() {
animation_lookup[c.anim_id as usize] = i as u16;
playable[c.anim_id as usize].resolved_id = c.anim_id;
}
ModelAnimations {
graph: Handle::default(),
clips,
hand_close: [None, None],
playable_animation_lookup: playable,
animation_lookup,
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
/// A firearm prop: Stand(0) looping + BowRelease(161) clamped, and **no** BowPull — the real
/// `Firearm_2H_Rifle_A_01.m2` shape (see the real-asset pin below).
fn gun() -> ModelAnimations {
anims(vec![
clip(STAND, 1, 0.333, true),
clip(BOW_RELEASE, 2, 2.0, false),
])
}
/// A bow prop: Stand(0), BowPull(160), BowRelease(161) — `Bow_1H_Standard_A_01.m2`'s shape.
fn bow() -> ModelAnimations {
anims(vec![
clip(STAND, 1, 0.033, false),
clip(BOW_PULL, 2, 1.0, false),
clip(BOW_RELEASE, 3, 0.166, false),
])
}
/// Spawn a wearer playing `body` and its prop, run the arm over one key, and report which
/// node the prop ended up playing (with its speed).
fn fire(
prop_anims: ModelAnimations,
body: u16,
body_clip: Option<(AnimClip, f32)>,
tag: &[u8; 4],
) -> Option<(AnimationNodeIndex, f32)> {
let mut app = App::new();
app.add_message::<AnimSoundEvent>();
app.add_systems(Update, flex_ranged_props);
let mut wearer_player = AnimationPlayer::default();
let wearer_anims = match &body_clip {
Some((c, seek)) => {
let active = wearer_player.play(c.node);
active.seek_to(*seek);
anims(vec![c.clone()])
}
None => anims(Vec::new()),
};
let wearer = app.world_mut().spawn((wearer_player, wearer_anims)).id();
let prop = app
.world_mut()
.spawn((
AnimationPlayer::default(),
prop_anims,
RangedProp { owner: wearer },
))
.id();
app.world_mut().write_message(AnimSoundEvent {
entity: wearer,
ident: *tag,
data: 0,
anim_id: body,
pos: None,
});
app.update();
let player = app.world().entity(prop).get::<AnimationPlayer>().unwrap();
let armed = player
.playing_animations()
.map(|(n, a)| (*n, a.speed()))
.next();
armed
}
/// **The fork the whole file exists for** (`0x600159`): the SAME `$BWR` key arms a different
/// clip on the prop depending on which weapon family the body is playing. A gun asked for
/// BowRelease(161) — its muzzle blast — and a bow for Stand(0), its limbs relaxing.
///
/// The gun leg is the director's report: before this, nothing anywhere played 161, so the
/// seven emitters that carry the blast sat for ever on Stand's authored rate of zero.
#[test]
fn bwr_forks_the_prop_clip_on_the_bodys_weapon_family() {
// AttackRifle(49) → 161 on the gun.
assert_eq!(
fire(gun(), 49, None, b"$BWR"),
Some((AnimationNodeIndex::new(2), 1.0)),
"a rifle-family body clip fires the prop's BowRelease at the literal 1.0"
);
// AttackBow(46) → Stand(0) on the bow.
assert_eq!(
fire(bow(), 46, None, b"$BWR").map(|(n, _)| n),
Some(AnimationNodeIndex::new(1)),
"a bow-family body clip relaxes the prop to Stand, NOT to its authored 161"
);
// The Load/Hold ids are in the same two sets (`0x5fcfb0`/`0x5fcfd0` each test three).
assert_eq!(
fire(gun(), 106, None, b"$BWR").map(|(n, _)| n),
Some(AnimationNodeIndex::new(2)),
"LoadRifle is in the rifle family"
);
// Neither family: the reference touches no prop at all.
assert_eq!(
fire(gun(), 0, None, b"$BWR"),
None,
"a body clip in neither family leaves the prop alone"
);
}
/// `$BWP` arms BowPull — and only on a model that HAS it. The reference makes that a literal
/// presence test it bails on (`0x624cee push 0xa0; call 0x711960`), and a firearm is exactly
/// the model it bails for: 21 of the 22 shipped firearms author 161 and none authors 160.
#[test]
fn bwp_pulls_only_a_prop_that_owns_bowpull() {
// BOTH legs supply a body clip with time left in it, so the firearm's `None` can only be
// the presence test — not the separate `n <= 0` bail the leg below covers.
let load = |id: u16| Some((clip(id, 9, 1.0, false), 0.433));
assert_eq!(
fire(bow(), 105, load(105), b"$BWP").map(|(n, _)| n),
Some(AnimationNodeIndex::new(2)),
"a bow draws on the pull key"
);
assert_eq!(
fire(gun(), 106, load(106), b"$BWP"),
None,
"a firearm owns no BowPull — the pull key arms nothing on it"
);
// `0x624d91 jle`: no time left in the body clip ⇒ no flex that shot, not a flex at 1.0.
assert_eq!(
fire(bow(), 105, Some((clip(105, 9, 1.0, false), 1.0)), b"$BWP"),
None,
"a pull key at the very end of its Load clip arms nothing"
);
}
/// The pull's RATE: the reference stretches BowPull's authored length over the body clip's
/// REMAINING time (`0x624e31`'s computed float), so the draw completes as the Load clip ends
/// instead of finishing early and holding. HumanMale LoadBow(105) is 1.0 s and keys `$BWP` at
/// 0.434 s, so a 1.0 s BowPull has to run at ~1.767×.
#[test]
fn the_pull_is_rate_matched_to_the_rest_of_the_load_clip() {
let load = clip(105, 9, 1.0, false);
let (node, speed) = fire(bow(), 105, Some((load, 0.434)), b"$BWP").expect("armed");
assert_eq!(node, AnimationNodeIndex::new(2));
assert!(
(speed - 1.0 / 0.566).abs() < 1e-3,
"BowPull fits the 0.566 s left of LoadBow, got {speed}"
);
}
/// **The reset's guard, which is what the round corrected** (`0x60f584 je`): the un-nock
/// re-arms Stand **unless** the prop is releasing. Taken the other way round — "reset after
/// release", which is how wow-re's prose read — a gun's muzzle blast is cancelled on the frame
/// it starts, because the reference reaches this reset from the very `$BWR` that armed 161.
#[test]
fn the_unnock_resets_a_drawn_prop_but_never_a_releasing_one() {
fn unnock(prop_anims: ModelAnimations, armed: u16) -> Option<AnimationNodeIndex> {
let mut app = App::new();
app.add_systems(Update, reset_ranged_props_on_unnock);
let wearer = app.world_mut().spawn(crate::creature_anim::NockLatch).id();
let mut player = AnimationPlayer::default();
let node = prop_anims
.clips
.iter()
.find(|c| c.anim_id == armed)
.unwrap()
.node;
player.play(node);
app.world_mut()
.spawn((player, prop_anims, RangedProp { owner: wearer }));
// The un-nock edge: the latch leaves the wearer.
app.world_mut()
.entity_mut(wearer)
.remove::<crate::creature_anim::NockLatch>();
app.update();
let prop = app
.world_mut()
.query_filtered::<Entity, With<RangedProp>>()
.iter(app.world())
.next()
.unwrap();
let player = app.world().entity(prop).get::<AnimationPlayer>().unwrap();
let armed = player.playing_animations().map(|(n, _)| *n).next();
armed
}
// A bow left at full draw by a cancel relaxes.
let bow_stand = bow().clips[0].node;
assert_eq!(
unnock(bow(), BOW_PULL),
Some(bow_stand),
"a cancel mid-draw returns the limbs to rest"
);
// A gun mid-blast is NOT touched — the whole point of the guard.
let gun_release = gun().clips[1].node;
assert_eq!(
unnock(gun(), BOW_RELEASE),
Some(gun_release),
"the reset skips a prop that is releasing — otherwise the blast dies at birth"
);
}
/// **The asset half, on the real chain** — the fact the whole feature rests on, and the one a
/// data-reading regression would break silently: a firearm's muzzle blast is authored ENTIRELY
/// on BowRelease(161), with every emitter's rate track keyed to zero on the loader idle.
///
/// This is what makes "the prop never plays 161" invisible rather than obviously broken — the
/// emitters build, pool and tick, and pour nothing.
#[test]
fn a_firearms_muzzle_blast_is_authored_only_on_bowrelease() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let path = "Item\\ObjectComponents\\Weapon\\Firearm_2H_Rifle_A_01.m2";
let bytes = chain.read_file(path).expect("the rifle model");
let seqs = benilla_formats::parse_m2_animations(&bytes);
let ids: Vec<u16> = seqs.iter().map(|s| s.anim_id).collect();
assert_eq!(
ids,
vec![STAND, BOW_RELEASE],
"the rifle authors exactly Stand and BowRelease"
);
assert!(!seqs[1].looping, "BowRelease clamps — one blast per arm");
let emitters = benilla_formats::parse_m2_particle_emitters(&bytes).expect("emitters");
assert_eq!(emitters.len(), 7, "the muzzle bank");
// **The discriminator is the GATE, not the rate** (decision 2286). Every one of the seven
// holds a constant rate in every sequence (100/s, 50/s, 40/s, 30/s, 20/s) and keys only
// `enabled` — the eleventh track at `def+0x1dc`, `u8`, step: flat 0 across Stand, and
// `0.000 = 1 → <its own window> = 0` in BowRelease. That is precisely why this defect was
// silent — a "does this emitter emit anything?" check on the rate says yes, on every
// sequence, for a bank that has never once been switched on.
for (i, em) in emitters.iter().enumerate() {
assert!(
em.timing.rate(Some(0), 0.0, 0.0) > 0.0,
"emitter {i} holds a rate on Stand — it is the gate that is shut"
);
for step in 0..8 {
let t = step as f32 * 0.04;
assert!(
!em.timing.emitting(Some(0), t, 0.0),
"emitter {i} is gated shut on Stand (t={t})"
);
}
assert!(
em.timing.emitting(Some(1), 0.0, 0.0),
"emitter {i} opens at the head of BowRelease — this is the blast"
);
// 1.0 s clears every window in the bank — the widest is the 334 ms smoke tail, and
// the assertion is deliberately the property rather than a per-emitter figure (2286
// corrected 2281's "0.2 s", which was the modal window read as the only one).
assert!(
!em.timing.emitting(Some(1), 1.0, 0.0),
"emitter {i} is over well before BowRelease's 2 s band ends"
);
}
}
/// The other half of the corpus law, so the `flexes` gate at the attach is pinned to data and
/// not to a guess: bows and crossbows author BOTH flex clips, an ordinary melee weapon
/// authors NEITHER (and so never gets a pose, a player or a hosted emitter clock).
#[test]
fn only_ranged_weapon_models_author_the_flex_clips() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let ids = |path: &str, chain: &mut benilla_formats::Chain| -> Vec<u16> {
let bytes = chain
.read_file(path)
.unwrap_or_else(|e| panic!("{path}: {e}"));
let mut v: Vec<u16> = benilla_formats::parse_m2_animations(&bytes)
.iter()
.map(|s| s.anim_id)
.collect();
v.sort_unstable();
v.dedup();
v
};
assert_eq!(
ids(
"Item\\ObjectComponents\\Weapon\\Bow_1H_Standard_A_01.m2",
&mut chain
),
vec![STAND, BOW_PULL, BOW_RELEASE],
"a bow draws AND releases"
);
assert_eq!(
ids(
"Item\\ObjectComponents\\Weapon\\Bow_2H_Crossbow_A_01.m2",
&mut chain
),
vec![STAND, BOW_PULL, BOW_RELEASE],
"so does a crossbow — the rifle family plays its 161"
);
let sword = ids(
"ITEM\\ObjectComponents\\WEAPON\\Sword_2H_AhnQiraj_D_01.m2",
&mut chain,
);
assert!(
!sword.contains(&BOW_PULL) && !sword.contains(&BOW_RELEASE),
"a melee weapon authors no flex clip: {sword:?}"
);
}
}
+130 -2
View File
@@ -339,10 +339,21 @@ pub(super) fn act_on_right_click(
// `CastLadder` (it would reach `Items`/`CastErrors` twice), so the lock chain's verdict
// travels to `ui_action::drain::drain_go_openers` instead of becoming a packet here.
ResMut<crate::ui_action::GoOpenerCasts>,
// The meeting stone's use slot (decision 2283), here for the same reason: its four
// refusals read the roster and the template cache, so the click hands the object to
// `ui_dialog_verbs::drain_meeting_stone_joins` rather than validating and sending here.
MessageWriter<crate::ui_dialog_verbs::MeetingStoneUse>,
),
) {
let (mut ui_error_keys, mut cast_errors, mut loot_latch, mut mail, mut item_text, mut openers) =
ui_feedback;
let (
mut ui_error_keys,
mut cast_errors,
mut loot_latch,
mut mail,
mut item_text,
mut openers,
mut stone_uses,
) = ui_feedback;
if clicks.read().last().is_none() {
return;
}
@@ -488,6 +499,36 @@ pub(super) fn act_on_right_click(
}
return;
}
// MEETINGSTONE (GO type 23): **not** the shared use-sender. `[0x80bf40+0x1c]`
// is `0x5f69d0`, this type's own validator — four client-side refusals and then
// `CMSG 0x292 {u64 goGuid}` from `0x4c9ff0` (decision 2283; wow-re's §5 round on
// that function). The shared sender `0x5f33e0` is **unreachable** from here: it
// has zero direct callers and 29 `.rdata` refs, every one at some vtable's
// `+0x1c`, and `0x80bf5c` is not among them — so a meeting stone cannot emit
// `CMSG_GAMEOBJ_USE` in the reference at all. Sending `0xB1` anyway is what left
// every meeting stone dead here: vmangos' `GameObject::Use` has an explicit
// type-23 arm that does nothing ("Should never be called for this type of
// object", `GameObject.cpp:1836`), so the packet was answered with silence.
//
// Four types override `+0x1c`, not one — 9 TEXT, 19 MAILBOX, 23 and 28 — which
// corrects the wow-re sentence this file used to rest on (`+0x1c` is `0x5f33e0`
// "for every type but MAILBOX"); the round landed that correction too.
//
// Unlike those two it is NOT a local open — it is a real send, and it therefore
// sits *below* the mounted gate above (a stone is lock-less, and only MAILBOX is
// exempted there) and behind the same `unable` range suppression every sending
// arm takes. The refusals themselves need the roster, the ask-once template and
// the wire, which this system cannot also hold, so the verdict travels as a
// message to `ui_dialog_verbs::drain_meeting_stone_joins` — the 2199 shape.
if go.is_some_and(|(s, _)| {
s.0.gameobject_type_id() == cursor_mode::GO_TYPE_MEETINGSTONE
}) {
if !cursor.unable {
debug!("right-click meeting stone: {guid:#x}");
stone_uses.write(crate::ui_dialog_verbs::MeetingStoneUse { go_guid: guid });
}
return;
}
// Branch on the lock (decisions 0239 / 0545 / 0752): a lockless GameObject is USEd;
// a lockable one casts the opener (a known OPEN_LOCK spell, or a carried key's own
// ON_USE) at it; an unopenable lock shows the client-local red toast — "The door is
@@ -2266,6 +2307,8 @@ mod tests {
const F_HEALTH: u16 = 22;
const F_MAXHEALTH: u16 = 28;
/// `GAMEOBJECT_TYPE_ID`, absolute field 21 — the store the GO arms fork on.
const GO_TYPE_FIELD: u16 = 21;
const BOAR: u64 = 0xB0A2;
const ME: u64 = 0x5E1F;
@@ -2300,6 +2343,7 @@ mod tests {
world.init_resource::<crate::ui_mail::MailOpen>();
world.init_resource::<crate::ui_item_text::ItemTextOpen>();
world.init_resource::<crate::ui_action::GoOpenerCasts>();
world.init_resource::<Messages<crate::ui_dialog_verbs::MeetingStoneUse>>();
world.spawn((SelfPlayer, Guid(ME)));
let boar = world
.spawn((Guid(BOAR), store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)])))
@@ -2347,4 +2391,88 @@ mod tests {
"the release must act on the unit whose plate the press was over"
);
}
/// **A meeting stone is JOINED, not USEd** (decision 2283). The GO type's own use slot
/// (`0x5f69d0`) replaces the shared `CMSG_GAMEOBJ_USE` sender, so the click must hand the
/// object to the join validator and put **no** `0xB1` on the wire — which is what left every
/// stone dead, since vmangos' `GameObject::Use` has an explicit do-nothing arm for type 23.
///
/// The falsification is the assertion that matters: with the arm removed this test sees a
/// `GameObjUse` command and no `MeetingStoneUse` message.
#[test]
fn a_right_click_on_a_meeting_stone_joins_it_and_sends_no_gameobj_use() {
const STONE: u64 = 0x5701;
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
let (mut world, _boar) = right_click_world();
world.insert_resource(NetCommands(tx));
let stone = world
.spawn((Guid(STONE), store(&[(GO_TYPE_FIELD, 23)])))
.id();
*world.resource_mut::<PressPick>() = PressPick {
object: HoveredObject {
target: Some(stone),
guid: Some(STONE),
distance: 5.0,
},
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Interact,
unable: false,
},
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
let uses: Vec<_> = world
.resource::<Messages<crate::ui_dialog_verbs::MeetingStoneUse>>()
.iter_current_update_messages()
.copied()
.collect();
assert_eq!(
uses,
vec![crate::ui_dialog_verbs::MeetingStoneUse { go_guid: STONE }],
"the stone must reach the join validator"
);
assert!(
!rx.try_iter()
.any(|c| matches!(c, ClientCommand::GameObjUse { .. })),
"a meeting stone must never send CMSG_GAMEOBJ_USE — the server drops it on the floor"
);
}
/// Out of interact range the click is suppressed with no toast and no packet, exactly as the
/// shared arms are (the reference auto-walks instead; `0x610300`, no packet).
#[test]
fn an_out_of_range_meeting_stone_click_sends_nothing() {
const STONE: u64 = 0x5702;
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
let (mut world, _boar) = right_click_world();
world.insert_resource(NetCommands(tx));
let stone = world
.spawn((Guid(STONE), store(&[(GO_TYPE_FIELD, 23)])))
.id();
*world.resource_mut::<PressPick>() = PressPick {
object: HoveredObject {
target: Some(stone),
guid: Some(STONE),
distance: 50.0,
},
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Interact,
unable: true,
},
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
assert!(world
.resource::<Messages<crate::ui_dialog_verbs::MeetingStoneUse>>()
.iter_current_update_messages()
.next()
.is_none());
assert!(rx.try_iter().next().is_none());
}
}
+29 -22
View File
@@ -374,7 +374,7 @@ pub(crate) struct GoOverrides {
/// (`0x5f6990`), alongside FISHINGNODE(17). It never calls `0x5f2f80`: no faction term, no
/// `GAMEOBJECT_FLAGS`, no INTERACT_COND, no DYN_FLAGS. It is exactly
/// `template.data[2] (areaID) != [0xb72038]` — see [`highlightable_flags`]'s `meeting_stone_queued`.
const GO_TYPE_MEETINGSTONE: i32 = 23;
pub(super) const GO_TYPE_MEETINGSTONE: i32 = 23;
/// The client's GameObject **highlightable** predicate (decision 0243, wow-re cursor-system §4a,
/// `0x5f2f80`) over its wire flags: whether the object shows an interact cursor / is clickable at all.
@@ -399,8 +399,10 @@ const GO_TYPE_MEETINGSTONE: i32 = 23;
/// written only by the meeting-stone server-message handler `0x4ca230`, and read back by the Lua
/// binding `IsInMeetingStoneQueue`. So a stone is highlightable **unless it is the stone you are
/// already queued at**, and nothing else about it matters. The caller resolves the equality (as it
/// does `channel_owned`); benilla has no queue yet, so it compares against 0 — which is the
/// reference's own not-queued value, not a stand-in for one. Ignored for every other type.
/// does `channel_owned`) against the live queue — [`crate::ui_dialog_verbs::MeetingStone::area`],
/// benilla's `[0xb72038]` (decision 2283; it was a hardcoded `0` while no queue existed, which is
/// the reference's own not-queued value and so was right for exactly as long as we never queued).
/// Ignored for every other type.
fn highlightable_flags(
type_id: i32,
flags: u32,
@@ -555,26 +557,19 @@ pub(crate) fn go_highlightable(
)
}
/// The area the player is currently queued at through the meeting-stone system — the reference's
/// `[0xb72038]`, which MEETINGSTONE(23)'s highlightable slot compares its `areaID` against.
///
/// **Zero until benilla carries the meeting-stone queue.** That is not a placeholder: `0xb72038` is
/// zero-initialized (`0x4c9eec`) and written only by the queue's own server-message handler
/// (`0x4ca230`), so 0 *is* the reference's not-queued value and every stone is correctly
/// highlightable. When the queue lands, this becomes its live area and the predicate is already
/// right.
const MEETING_STONE_QUEUED_AREA: u32 = 0;
/// MEETINGSTONE(23)'s highlightable override, resolved for one GameObject: the reference's
/// `template.data[2] != [0xb72038]`, expressed as its negation so the caller passes the same shape
/// of boolean as [`fishing_channel_owned`].
///
/// `queued_area` is benilla's `[0xb72038]` — [`crate::ui_dialog_verbs::MeetingStone::area`], stored
/// unconditionally by every `SMSG 0x295` and `0` when we are not queued (decision 2283).
///
/// A template that hasn't answered yet resolves `false` (⇒ highlightable), the same permissive
/// default the highlight column takes — a stone isn't dead for the first frames of its query. Note a
/// stone whose `data[2]` really is **0** is *not* highlightable when we are unqueued, which is the
/// binary's own `0 != 0` and not an edge case worth smoothing away.
pub(crate) fn meeting_stone_queued(area: Option<u32>) -> bool {
area.is_some_and(|a| a == MEETING_STONE_QUEUED_AREA)
pub(crate) fn meeting_stone_queued(area: Option<u32>, queued_area: u32) -> bool {
area.is_some_and(|a| a == queued_area)
}
/// The FISHINGNODE highlightable override's input (`0x5f6710`): is the local player currently
@@ -812,6 +807,10 @@ pub(super) fn classify_cursor(
// override reads it (the reference keeps the lit pouch on the corpse you are looting even
// once you have drifted past the range gate).
loot_latch: Res<crate::ui_loot::LootLatch>,
// `[0xb72038]` — the meeting-stone queue, MEETINGSTONE(23)'s own highlightable term
// (decision 2283). `Option` because a headless build mounts no UI dialog verbs; absent reads
// as the reference's zero-initialized global, i.e. not queued.
stone: Option<Res<crate::ui_dialog_verbs::MeetingStone>>,
) {
// A **highlightable** GameObject shows its **data-driven** cursor (wow-re cursor-system §4): a
// mailbox's Mail, a plaque's Inspect, a vein's Mine / herb's GatherHerbs / picked lock's PickLock
@@ -835,7 +834,10 @@ pub(super) fn classify_cursor(
let tmpl = hovered_object.guid.and_then(|g| go_inputs.templates.get(g));
let overrides = GoOverrides {
channel_owned: fishing_channel_owned(Some(self_store), hovered_object.guid),
meeting_stone_queued: meeting_stone_queued(tmpl.and_then(|t| t.meeting_stone_area)),
meeting_stone_queued: meeting_stone_queued(
tmpl.and_then(|t| t.meeting_stone).map(|m| m.area),
stone.as_deref().map_or(0, |s| s.area),
),
};
if !go_highlightable(store, reaction, overrides) {
return None;
@@ -1469,12 +1471,17 @@ mod tests {
);
}
// The caller's half: the resolved boolean is `data[2] == the queued area`, with an
// unanswered template reading as not-queued so a stone isn't dead while it queries. Our
// queued area is 0 — the reference's own not-queued value — so a stone whose data[2] is
// genuinely 0 is NOT highlightable, which is the binary's `0 != 0` and not a bug.
assert!(!meeting_stone_queued(None));
assert!(!meeting_stone_queued(Some(1519)));
assert!(meeting_stone_queued(Some(MEETING_STONE_QUEUED_AREA)));
// unanswered template reading as not-queued so a stone isn't dead while it queries. The
// right-hand side is the LIVE queue since 2283 — unqueued (`0`) leaves every stone
// highlightable, and queueing at one area silences exactly that area's stones.
assert!(!meeting_stone_queued(None, 0));
assert!(!meeting_stone_queued(Some(1519), 0));
assert!(meeting_stone_queued(Some(1519), 1519));
assert!(!meeting_stone_queued(Some(1519), 1517));
assert!(!meeting_stone_queued(None, 1519));
// The binary's own `0 != 0`: a `data[2] == 0` stone reads as the stone we are "queued at"
// while we are not queued at all.
assert!(meeting_stone_queued(Some(0), 0));
}
#[test]
+33 -9
View File
@@ -614,6 +614,27 @@ fn net_entity_of(
cur
}
/// The state the GameObject gates read beside the object's own store, bundled because this system
/// sits at Bevy's 16-`SystemParam` ceiling: the ask-once template cache (GENERIC's eligibility is
/// its `data[1]`, decision 0762; MEETINGSTONE's is its `data[2]`), the faction catalog behind the
/// eligibility faction term (decision 0764), and the live meeting-stone queue — benilla's
/// `[0xb72038]`, the other half of MEETINGSTONE(23)'s own highlightable predicate (decision 2283).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct GoGateInputs<'w> {
pub(crate) templates: Res<'w, crate::go_templates::GameObjectTemplates>,
pub(crate) factions: Option<Res<'w, super::ring::Factions>>,
/// `Option` because a headless/net-less build mounts no UI dialog verbs; absent reads as the
/// reference's zero-initialized `[0xb72038]`, i.e. not queued.
pub(crate) stone: Option<Res<'w, crate::ui_dialog_verbs::MeetingStone>>,
}
impl GoGateInputs<'_> {
/// `[0xb72038]` — the area we are queued at, `0` when we are not (or when there is no VM).
pub(crate) fn queued_area(&self) -> u32 {
self.stone.as_deref().map_or(0, |s| s.area)
}
}
pub(super) fn update_hovered_object(
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
window: Query<&Window, With<PrimaryWindow>>,
@@ -631,10 +652,9 @@ pub(super) fn update_hovered_object(
child_of: Query<&ChildOf>,
guids: Query<&Guid>,
stores: Query<&ObjectStore>,
// GENERIC's eligibility is its template's `data[1]` (decision 0762) — the ask-once cache.
go_templates: Res<crate::go_templates::GameObjectTemplates>,
// The faction term of eligibility (decision 0764): the GO's own template reaction toward us.
factions: Option<Res<super::ring::Factions>>,
// The three state reads the GameObject gates need beside the object's own store, as one
// param (the 16-`SystemParam` ceiling) — see [`GoGateInputs`].
go_gate: GoGateInputs,
self_q: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
parts: PickParts,
// The picker's own state, one bundled param ([`GoPickSet`] — the fn sits at Bevy's
@@ -773,7 +793,7 @@ pub(super) fn update_hovered_object(
// permissive default, so a fresh spawn isn't a dead zone for its first frames.
stores.get(net).map_or(1, |s| {
let reaction = crate::target::cursor_mode::go_reaction(
factions.as_deref(),
go_gate.factions.as_deref(),
s.0.gameobject_faction(),
self_store,
);
@@ -783,7 +803,10 @@ pub(super) fn update_hovered_object(
self_store, go_guid,
),
meeting_stone_queued: crate::target::cursor_mode::meeting_stone_queued(
go_guid.and_then(|g| go_templates.get(g)?.meeting_stone_area),
go_guid
.and_then(|g| go_gate.templates.get(g)?.meeting_stone)
.map(|m| m.area),
go_gate.queued_area(),
),
};
u32::from(crate::target::cursor_mode::go_highlightable(
@@ -831,9 +854,9 @@ pub(super) fn update_hovered_object(
// than the old behaviour (which tooltipped the portcullis itself) and documented rather than
// silently accepted; closing it wants the single-pick arbitration, which is its own slice.
if let Ok(store) = stores.get(net_entity) {
let tmpl = go_templates.get(guid.0);
let tmpl = go_gate.templates.get(guid.0);
let reaction = crate::target::cursor_mode::go_reaction(
factions.as_deref(),
go_gate.factions.as_deref(),
store.0.gameobject_faction(),
self_store,
);
@@ -849,7 +872,8 @@ pub(super) fn update_hovered_object(
Some(guid.0),
),
meeting_stone_queued: crate::target::cursor_mode::meeting_stone_queued(
tmpl.and_then(|t| t.meeting_stone_area),
tmpl.and_then(|t| t.meeting_stone).map(|m| m.area),
go_gate.queued_area(),
),
},
) {
+356 -1
View File
@@ -33,7 +33,9 @@
//! `CancelMeetingStoneRequest()` sends `0x293` unless in a party led by someone else
//! (`ERR_MEETING_STONE_NOT_LEADER`). The four display-only replies (`0x297/0x298/0x299/0x2BB`)
//! are chat lines with no state; the status-1 arm also triggers the Meeting Stones tutorial
//! (`crate::tutorial`, 1976).
//! (`crate::tutorial`, 1976). **JOINING** is the click's own leg (decision 2283): type 23's
//! use slot is its own validator, not the shared `CMSG_GAMEOBJ_USE` sender — four client-side
//! refusals ([`meeting_stone_join_refusal`]), then `CMSG 0x292 {u64 goGuid}`.
use std::time::Instant;
@@ -537,6 +539,160 @@ fn meeting_stone_enter_world(
let _ = commands.0.send(ClientCommand::MeetingStoneStatusQuery);
}
/// A right-click on a `GAMEOBJECT_TYPE_MEETINGSTONE` (23) that got past the shared gates — the
/// GO click ladder's hand-off to this module (decision 2283).
///
/// It travels as a message for the same reason the GameObject opener's cast does (2199): the
/// click system sits at Bevy's 16-`SystemParam` ceiling and cannot also hold the roster, the
/// template cache and the wire. The reference has no such split — `0x5f69d0` is one function —
/// so the *verdict* stays one function here too ([`meeting_stone_join_refusal`]); only the
/// plumbing is two systems.
#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MeetingStoneUse {
pub(crate) go_guid: u64,
}
/// What MEETINGSTONE(23)'s own use slot `0x5f69d0` does with one click — its three outcomes, as
/// the binary has them (decision 2283).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum StoneJoin {
/// `0x5f69f8` — no local player object: `false` with **no message and no packet**. Not a
/// refusal; the validator never starts.
Silent,
/// One of the four refusals: `push <id>; call 0x496720` then `xor al,al; ret`. Every one is a
/// catalog **kind 2** row, so it paints the error frame (`UI_ERROR_MESSAGE`), never a chat
/// line — and none carries a sound (type tag `0x44`, cue `"NONE"`).
Refuse(&'static str),
/// The tail `0x5f6af6`: `CMSG 0x292 {u64 goGuid}`, and nothing else at all.
Send,
}
/// The four client-side refusals inside MEETINGSTONE(23)'s own use slot (`0x5f69d0`, whose tail
/// `0x5f6af6` is the sole caller of the `CMSG 0x292` builder `0x4c9ff0`) — **VERIFIED at the
/// bytes** by wow-re's §5 round on that function (four cold workers plus the orchestrator's own
/// derivation, arbitrated; `system/object-layer/scratch/meeting-stone-use-validator.md` §4).
///
/// Two gates run before any of them. `0x5f69f8`: no local player ⇒ [`StoneJoin::Silent`].
/// `0x5f6a10 je 0x5f6a65`: **not in a group ⇒ both group refusals are skipped**, and a solo player
/// drops straight to the level test.
///
/// | # | at | refusal | predicate | key |
/// |---|---|---|---|---|
/// | 1 | `0x5f6a2f` | in a group we do not lead | the leader guid `[0xbc75f8]` against the active player's, full 64-bit | `ERR_MEETING_STONE_MUST_BE_LEADER` (`0x1b1`) |
/// | 2 | `0x5f6a4f` | the group is full | `0x4e86d0` is `GetNumPartyMembers` — the **other** members, 0..4 — and `cmp eax,4 / jb` refuses at ≥ 4 | `ERR_MEETING_STONE_GROUP_FULL` (`0x1ae`) |
/// | 3 | `0x5f6ab4` | the wrong level for this stone | `data[0] <= level <= data[1]`, **both bounds inclusive and unsigned**, over the player's own `UNIT_FIELD_LEVEL` | `ERR_MEETING_STONE_INVALID_LEVEL` (`0x1b0`) |
/// | 4 | `0x5f6ad3` | in a raid | `[0xb713e0] != 0` — the raid member **count** (`GetNumRaidMembers`) | `ERR_MEETING_STONE_NO_RAID_GROUP` (`0x1b2`) |
///
/// **Three of the four are refused a second time by the server** (vmangos
/// `HandleMeetingStoneJoinOpcode` → `MEETINGSTONE_FAIL_PARTYLEADER` / `_FULL_GROUP` /
/// `_RAID_GROUP`, which come back as `SMSG 0x2BB` and print the same three strings 1974 already
/// built). That copy is not redundant — it is what makes the refusal instant. **The level term is
/// the client's alone**: `HandleMeetingStoneJoinOpcode` reads `gInfo->meetingstone.areaID` and
/// nothing else off the template, so a client that skips it queues a level-1 character for a
/// sixty-level dungeon and the server agrees.
///
/// **An unanswered template REFUSES, and that is the reference's own arithmetic rather than a
/// fail-closed choice of ours.** `0x5f8150` reads the cached template through `[GO+0x214]`, and an
/// uncached object returns **0 for both bounds** — so every level ≥ 1 falls outside `0..=0` and
/// takes the level refusal. The permissive "skip the term while the query is in flight" default
/// the highlight column takes is *wrong* here, and the first cut of this had it. The same
/// arithmetic makes a shipped `data[0] = data[1] = 0` refuse everyone; `0/60` is the open band.
pub(crate) fn meeting_stone_join_refusal(
group: Option<&GroupState>,
self_guid: Option<u64>,
level: Option<u32>,
stone: Option<crate::go_templates::MeetingStoneTemplate>,
) -> StoneJoin {
// `0x5f69f8` — the validator needs an active player before it asks anything.
let (Some(self_guid), Some(level)) = (self_guid, level) else {
return StoneJoin::Silent;
};
// `0x5f6a10` — no group at all skips past both group terms.
let in_group = group.is_some_and(|g| g.in_group);
if in_group {
if group.map(|g| g.leader) != Some(self_guid) {
return StoneJoin::Refuse("ERR_MEETING_STONE_MUST_BE_LEADER");
}
if group.is_some_and(|g| g.members.len() >= MEETING_STONE_PARTY_CAP) {
return StoneJoin::Refuse("ERR_MEETING_STONE_GROUP_FULL");
}
}
// An uncached template reads `0/0` here, which refuses — see the doc above.
let (min_level, max_level) = stone.map_or((0, 0), |s| (s.min_level, s.max_level));
if level < min_level || level > max_level {
return StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL");
}
if in_group && group.is_some_and(|g| g.group_type == crate::ui_party::GROUPTYPE_RAID) {
return StoneJoin::Refuse("ERR_MEETING_STONE_NO_RAID_GROUP");
}
StoneJoin::Send
}
/// How many **other** party members make the stone's `GROUP_FULL` refusal fire — a party it could
/// add nobody to.
///
/// **Four, not five, and the difference is a real trap.** A vanilla party holds five *including*
/// the player, and vmangos refuses on exactly that: `Group::IsFull()` is
/// `m_memberSlots.size() >= MAX_GROUP_SIZE (5)`, counting the leader
/// (`Group/Group.h:49,232`) → `MEETINGSTONE_FAIL_FULL_GROUP`. But `SMSG_GROUP_LIST` never lists
/// the recipient (0440), so [`GroupState::members`] is the other four and the comparison is
/// against **4** with no `+ 1`. Adding one — the first cut of this did — refuses a legal
/// four-person party the server would have queued, and the string says which reading is right:
/// `ERR_MEETING_STONE_GROUP_FULL` is *"You are already in a full group"*, and a group of four is
/// not full.
const MEETING_STONE_PARTY_CAP: usize = 4;
/// The join drain: MEETINGSTONE(23)'s use slot, with the click's guid.
fn drain_meeting_stone_joins(
script: Option<NonSendMut<UiScript>>,
mut uses: MessageReader<MeetingStoneUse>,
group: Option<Res<GroupState>>,
self_guid: Res<SelfGuid>,
templates: Res<crate::go_templates::GameObjectTemplates>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
uses.clear();
return;
};
let level = self_q.single().ok().and_then(|s| s.0.unit_level());
let mut lines = Vec::new();
for &MeetingStoneUse { go_guid } in uses.read() {
let stone = templates.get(go_guid).and_then(|t| t.meeting_stone);
let verdict = meeting_stone_join_refusal(group.as_deref(), self_guid.0, level, stone);
// The interact chain's last link for this type, on the same `use` tag the click's own
// lines carry (2283): a stone that goes nowhere is one of four refusals, the no-player
// silence, or a send the server ignored — and one trace now says which.
if benilla_assets::trace::enabled_for("use") {
benilla_assets::trace::line(
"use",
&match verdict {
StoneJoin::Silent => format!("meeting stone {go_guid:#x}: no local player"),
StoneJoin::Refuse(key) => {
format!("meeting stone {go_guid:#x} refused: {key}")
}
StoneJoin::Send => {
format!("SEND CMSG_MEETINGSTONE_JOIN guid={go_guid:#x}")
}
},
);
}
match verdict {
StoneJoin::Silent => {}
StoneJoin::Refuse(key) => lines.extend(crate::ui_action::keyed_line(&script, key)),
StoneJoin::Send => {
debug!("meeting stone: join {go_guid:#x}");
let _ = commands.0.send(ClientCommand::MeetingStoneJoin { go_guid });
}
}
}
if !lines.is_empty() {
crate::ui_action::show_messages(&mut script, &mut sink, "ui_dialog_verbs", lines);
}
}
/// The leave-world sweep's leg: the text dropped, the area kept.
fn meeting_stone_leave_world(mut stone: ResMut<MeetingStone>) {
stone.leave_world();
@@ -696,6 +852,7 @@ impl Plugin for UiDialogVerbsPlugin {
.init_resource::<AreaSpiritHealer>()
.init_resource::<BattlefieldQueue>()
.init_resource::<MeetingStone>()
.add_message::<MeetingStoneUse>()
.add_systems(
Update,
(
@@ -718,6 +875,16 @@ impl Plugin for UiDialogVerbsPlugin {
feed_meeting_stone.before(UiInput),
drain_latch_verbs.after(UiInput),
drain_queue_verbs.after(UiInput),
// MEETINGSTONE(23)'s use slot (2283): the click resolved the object, this
// runs the validator and sends. Ordered after the **target chain**, not just
// after the input pass like its neighbours — `UiInput` sits *before*
// `WorldStage::Input` and `TargetUpdate` after it, so "after UiInput" alone
// says nothing about the writer and would let the join drift a frame. A
// message survives that (two-frame lifetime) and 16 ms would not be visible,
// but a click's own packet should not leave on an undefined frame.
drain_meeting_stone_joins
.after(UiInput)
.after(crate::target::TargetUpdate),
),
)
.add_systems(
@@ -745,6 +912,194 @@ fn cancel_gate_could_apply(attributes_ex: u32) -> bool {
mod tests {
use super::*;
/// `UNIT_FIELD_LEVEL`, absolute field 34.
const LEVEL_FIELD: u16 = 34;
/// The four client-side refusals of MEETINGSTONE(23)'s use slot (`0x5f69d0`, decision 2283),
/// in the reference's own order — and the pass that reaches `CMSG 0x292`.
#[test]
fn the_meeting_stone_join_refuses_in_the_references_order() {
use crate::go_templates::MeetingStoneTemplate;
const ME: u64 = 0x5e1f;
const MATE: u64 = 0xa11e;
// A stone anybody 15-60 may use — the shape most of 1.12's dungeon stones carry.
let stone = Some(MeetingStoneTemplate {
min_level: 15,
max_level: 60,
area: 1519,
});
let party = |leader: u64, others: usize, group_type: u8| GroupState {
in_group: true,
group_type,
leader,
members: (0..others)
.map(|i| benilla_protocol::messages::GroupMemberEntry {
name: format!("Mate{i}"),
guid: 0xb000 + i as u64,
status: 1,
flags: 0,
})
.collect(),
..GroupState::default()
};
// Solo, in range of the level band: nothing refuses.
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(40), stone),
StoneJoin::Send
);
// Leading a party of FOUR (three others): still fine — a full party is five, and the
// stone's job is to find the fifth. This is the assertion the `+ 1` bug failed.
assert_eq!(
meeting_stone_join_refusal(Some(&party(ME, 3, 0)), Some(ME), Some(40), stone),
StoneJoin::Send
);
// 1 — in a party someone else leads.
assert_eq!(
meeting_stone_join_refusal(Some(&party(MATE, 1, 0)), Some(ME), Some(40), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_MUST_BE_LEADER")
);
// 2 — leading a FULL party: four others, five including us, which is what
// `Group::IsFull()` refuses server-side too.
assert_eq!(
meeting_stone_join_refusal(Some(&party(ME, 4, 0)), Some(ME), Some(40), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_GROUP_FULL")
);
// 3 — the level band, both ends, inclusive.
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(14), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL")
);
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(61), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL")
);
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(15), stone),
StoneJoin::Send
);
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(60), stone),
StoneJoin::Send
);
// 4 — a raid. Its leader is refused too, which is what puts it BELOW the leader term.
assert_eq!(
meeting_stone_join_refusal(Some(&party(ME, 1, 1)), Some(ME), Some(40), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_NO_RAID_GROUP")
);
// The order is observable where two terms hold at once: a raid we do not lead answers
// MUST_BE_LEADER, not NO_RAID_GROUP.
assert_eq!(
meeting_stone_join_refusal(Some(&party(MATE, 4, 1)), Some(ME), Some(40), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_MUST_BE_LEADER")
);
// **An unanswered template refuses**, because `0x5f8150` reads `0/0` off an uncached
// object and every level >= 1 is outside `0..=0`. The permissive "skip the term while the
// query is in flight" default the highlight column takes is not this slot's — the first
// cut of this code had it, and the §5 round is what corrected it.
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(5), None),
StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL")
);
// The same arithmetic on a shipped `0/0` band; `0/60` is the open one.
let closed = Some(MeetingStoneTemplate {
min_level: 0,
max_level: 0,
area: 1519,
});
let open = Some(MeetingStoneTemplate {
min_level: 0,
max_level: 60,
area: 1519,
});
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(1), closed),
StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL")
);
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(60), open),
StoneJoin::Send
);
// `0x5f69f8` — no active player: SILENT, and not one of the four refusals. Neither a
// missing guid nor a level the store has not streamed reaches a message or a packet.
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), None, stone),
StoneJoin::Silent
);
assert_eq!(
meeting_stone_join_refusal(None, None, Some(40), stone),
StoneJoin::Silent
);
}
/// The join drain end to end: a clicked stone the player may use puts `CMSG 0x292` on the
/// wire with that object's guid, and a refused one puts **nothing** there.
#[test]
fn the_join_drain_sends_the_clicked_stone_and_refuses_silently_on_the_wire() {
use crate::go_templates::GameObjectTemplates;
// A real GameObject guid: `counter | (entry << 24) | (HIGH_GAMEOBJECT << 48)` — the
// template cache is keyed by the entry the guid carries, so a made-up number would
// silently miss and skip the level term.
const STONE_ENTRY: u32 = 0x5701;
const STONE: u64 = 0xF110 << 48 | (STONE_ENTRY as u64) << 24 | 0x22;
const ME: u64 = 0x5e1f;
const MATE: u64 = 0xa11e;
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
let mut templates = GameObjectTemplates::default();
let mut data = [0i32; 24];
(data[0], data[1], data[2]) = (15, 60, 1519);
assert_eq!(benilla_protocol::guid::entry(STONE), Some(STONE_ENTRY));
templates.insert(STONE_ENTRY, 23, "Stonard Meeting Stone".into(), &data);
app.insert_resource(templates)
.insert_resource(NetCommands(tx))
.insert_resource(SelfGuid(Some(ME)))
.init_resource::<GroupState>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.add_message::<MeetingStoneUse>()
.add_systems(Update, drain_meeting_stone_joins);
app.insert_non_send_resource(UiScript::new().expect("VM"));
app.world_mut().spawn((
SelfPlayer,
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
LEVEL_FIELD,
40,
)])),
));
let joins = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter()
.filter_map(|c| match c {
ClientCommand::MeetingStoneJoin { go_guid } => Some(go_guid),
_ => None,
})
.collect::<Vec<_>>()
};
app.world_mut()
.write_message(MeetingStoneUse { go_guid: STONE });
app.update();
assert_eq!(joins(&rx), vec![STONE], "the clicked stone's own guid");
// Now in a party led by someone else: the same click sends nothing at all.
app.world_mut().resource_mut::<GroupState>().in_group = true;
app.world_mut().resource_mut::<GroupState>().leader = MATE;
app.world_mut()
.write_message(MeetingStoneUse { go_guid: STONE });
app.update();
assert!(
joins(&rx).is_empty(),
"a refusal is a local line, never a packet"
);
}
/// **The `/reload` re-query** — the meeting-stone half of 1290's class, and the reference's
/// own behaviour rather than an invention of ours.
///
+15 -4
View File
@@ -22,10 +22,21 @@
use std::path::{Path, PathBuf};
/// The standing count. **2026-09-16, 164** — measured the day the instrument was built, against
/// 1177's 141 five weeks earlier. **165 the same day (decision 2279):** `game_plugins.rs` carries
/// the structural test for the VM's one-shot consumers, which has to name the identifier it
/// scans for. A test about the wall, not a file that learned the VM exists.
const CEILING: usize = 165;
/// 1177's 141 five weeks earlier. It moved twice on that one day, for two different files, and
/// both reasons are kept because each is a different kind of legitimate:
///
/// - **165 (decision 2279)** — `game_plugins.rs`, the structural test for the VM's one-shot
/// consumers, which has to name the identifier it scans for. A test *about* the wall, not a file
/// that learned the VM exists.
/// - **166 (decision 2283)** — `capture/probe_stone.rs`, the meeting-stone live probe, joining the
/// fifteen sibling probes that read the VM *on purpose*. A probe's whole value is that it asks
/// the **stock** binding (`IsInMeetingStoneQueue()`, `MiniMapMeetingStoneFrame:IsShown()`)
/// rather than our own mirror of it, so feeding it a model value would make it prove nothing.
///
/// The ratchet is for *production* feed code. Both numbers still went up, and both are written
/// down — which is the whole point of it, and why two sessions raising it the same day collided
/// here instead of quietly passing each other.
const CEILING: usize = 166;
/// How far under [`CEILING`] the count may sit before the test asks for the ceiling to follow it.
const SLACK: usize = 8;
+23 -1
View File
@@ -437,7 +437,29 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// full-window float image that one camera wrote and the next read back — the seam 1603 built
/// and 2215 measured — and it could not go the other way for 2206's reason: the UI camera cannot
/// move into the engine.
const CEILING: usize = 182;
/// And 182 → 183: `doodad_anim::register_fx_uv`, a PUBLISH — put one spell-effect material clone
/// on the per-instance UV-scroll lane (decision 2282). The clone is the GAME's: `entities::spell_fx`
/// makes it because one cast is one phase (the same reason 0271's animated tint clones it), and
/// nothing engine-side knows an effect instance exists. Everything after that is the engine's —
/// which registry, which delta-table row, which of 1408's two baked loop spellings, and the
/// instance-clock law that separates this lane from the shared one. Published as ONE verb rather
/// than as its three pieces (`UvLoop`'s effect variant, `register_uv`, the row) precisely because
/// this lane's recurring bug is a caller that takes some of the pieces and not the rest: 2038's
/// marked-but-unregistered parts froze every waterfall in the game for three days, and a
/// registration without a row, or a row without a registration, is that same shape. One verb
/// cannot be half-taken. It is also why the three pieces went back to `pub(crate)` in the same
/// commit, which is the rare crossing that *lowers* the surface it replaces.
/// And 183 → 184: `doodad_anim::FxUvLoops`, the argument of the verb above. A texture transform is
/// ONE authored record with four baked channels — 1408's two translation spellings plus 2019's
/// per-slot rotation and scaling — and which of them a batch fills is not a thing the caller gets
/// to reason about: a scale-only transform (`Spells\GroundingTotem_Impact.mdx`) and a
/// dead-slot-0 translation both answer `None` to the channel you would check first. So the struct
/// carries the record whole, and `any()`/`open_offset()` on it are the two questions the game
/// asks — which keeps the "does this batch animate" predicate and the "what does it open on" seed
/// in the engine, where the bake's rules live, instead of copied into the effect attach where they
/// would drift. Four positional `Option`s would have been the alternative, and swapping two of
/// them is a silent wrong-channel bug the compiler cannot see.
const CEILING: usize = 184;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
@@ -486,6 +486,43 @@ enum Command {
/// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every model the **spell-visual chain** can reach — every `SpellVisualEffectName`
/// path a kit's ten effect slots, a `SpellVisual` row's missile model or its dest-anchored
/// model names — and census the batches whose **texture transform animates**, then classify
/// each by what a consumer that runs NONE of it renders.
///
/// Decision 0271 deferred this channel on the claim that "no effect model in the current
/// corpus needs it"; this is that claim, made countable — it is what 2282 read to size the
/// missing scroll, and it is how the same question is re-asked of the lanes that still run
/// none of it (the unit and GameObject batches, which `entity_variants` still builds with
/// `play_uv = false`). It asks the **bake**, not a transcription of it: a batch is in scope
/// exactly when `tex_anim` emitted a loop on any of the three channels, the same test the
/// lanes that DO run them use (`ui_models` 2019, `spell_fx` 2282).
///
/// The classes are what a frozen batch draws, judged from the texture's own alpha through its
/// authored address mode — because a CLAMP-authored sheet's border is what a UV outside `0..1`
/// samples, and on this corpus that border is transparent:
///
/// - **INVISIBLE** — frozen, every texel the batch reaches is transparent, while the scroll
/// reaches painted ones: the batch renders **nothing at all**. `Spells\SwipeCaster.m2`
/// (druid Swipe) is the class: two 51-vertex claw-trail strips whose UVs are authored at
/// `u[+0.945..+1.944]` over a 16×16 CLAMP sheet, so frozen they sample column 15 alone —
/// alpha 0 — and the whole of their visible existence is the `−0.97` U scroll.
/// - **FROZEN** — it draws, statically: the scroll is the motion it loses.
/// - **HELD** — keyed to a constant non-identity offset, so a lane that seeds none draws it
/// mis-registered rather than still.
/// - **NEVER** / **UNKNOWN** — flagged, never counted as INVISIBLE: nothing painted at any
/// point of the loop, or no alpha lane to judge from (`Mod`/`Mod2x`, an undecodable sheet).
///
/// Each batch prints its per-axis reasoning (address mode, authored and frozen UV spans, the
/// texel indices those reach) so the call is checkable, and the report closes with the INVISIBLE
/// listing joined back through the chain: which spells reach it, and through which lifecycle
/// stage (`precast`/`cast`/`impact`/`state`/`channel`/`missile`/`area`).
Fxuvscan {
/// Internal-path prefix filter (e.g. `spells`), case-insensitive; all reachable effect
/// models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and census the batches whose texture
/// coordinates are **GENERATED, not authored** — the sphere-map environment stages
/// (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). Such a batch
@@ -944,6 +981,7 @@ fn main() -> Result<()> {
Command::Uvslotscan { prefix } => scan::uvslotscan(&mut chain, prefix.as_deref())?,
Command::Seqclockscan { prefix } => scan::seqclockscan(&mut chain, prefix.as_deref())?,
Command::Uvwrapscan { prefix } => scan::uvwrapscan(&mut chain, prefix.as_deref())?,
Command::Fxuvscan { prefix } => scan::fxuvscan(&mut chain, prefix.as_deref())?,
Command::Envmapscan { prefix } => scan::envmapscan(&mut chain, prefix.as_deref())?,
Command::Texmodescan { prefix } => scan::texmodescan(&mut chain, prefix.as_deref())?,
Command::Fxordercensus { prefix } => scan::fxordercensus(&mut chain, prefix.as_deref())?,
@@ -6,6 +6,12 @@
//! generated-texcoord environment stages (`envmapscan`), and the batches whose animated
//! texture-transform / tint loop is not the same in every sequence slot, which the bake routes to
//! a per-placement material (`uvslotscan`).
//!
//! `fxuvscan` is the intersection of the first two, asked of one corpus: the spell-effect models,
//! whose animated texture transform decides — through their CLAMP-authored UVs — whether a
//! consumer that runs none of it loses the batch's motion or its whole existence. It sized
//! decision 2282, which built the consumer; it stays the way that question is asked of the lanes
//! that still run none of it.
use std::collections::BTreeMap;
@@ -728,7 +734,6 @@ pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
};
scanned += 1;
batches += subs.len() as u64;
let mut lines: Vec<String> = Vec::new();
// This model's own contribution, folded into the corpus tally once per channel below:
// (shared, shared-and-live) batch counts, per-bucket batch counts, and the file sequence
@@ -885,3 +890,841 @@ pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
}
Ok(())
}
// ---------------------------------------------------------------------------
// fxuvscan — what the SPELL-EFFECT corpus's animated texture transforms render
// for a consumer that runs none of them.
// ---------------------------------------------------------------------------
/// How many spells print per INVISIBLE model before the rest are counted.
const FX_SPELL_ROWS: usize = 12;
/// How many unreadable model paths print before the rest are counted.
const FX_UNREAD_ROWS: usize = 20;
/// How many time samples the animated sweep takes across each slot's loop — enough to bound a
/// scroll that only crosses the sheet for part of its period (the reported box is the union, so
/// it only ever over-states what the animation reaches, never under-states it).
const FX_ANIM_SAMPLES: usize = 64;
/// One texture's mip-0 channels, as the visibility question needs them.
///
/// Alpha decides it for the modes that read alpha — `Blend`/`AlphaTest`, and M2 mode 4 `Add`
/// (`SRC_ALPHA/ONE`), which is most of the effect corpus. An `Opaque` batch paints whatever it
/// covers and a `Mod`/`Mod2x` batch's equation reads no alpha at all (decision 0528), so neither
/// is called from the sheet. The one mode that needs [`Self::rgb`] is **3, `NoAlphaAdd`**
/// (`ONE/ONE`): it adds the texel's colour with the alpha channel untouched, so an alpha-0 border
/// with non-zero RGB still puts light on the screen. `BloodSpurtSmall01`'s border column is
/// exactly that shape — alpha 0, RGB up to 107 — so the distinction is not hypothetical.
struct Sheet {
w: u32,
h: u32,
/// Row-major, one byte per texel.
alpha: Vec<u8>,
/// Row-major `max(r, g, b)` — what an `ONE/ONE` add would put on the screen.
rgb: Vec<u8>,
}
impl Sheet {
/// The inclusive texel index ranges one axis's UV span `[lo, hi]` reaches under the batch's own
/// address mode — clamped to the edge texel (`wrap == false`, the border-sampling case this
/// whole scan turns on) or folded back into `0..1`. A wrapping span of a full sheet or more
/// reaches every texel.
fn axis(lo: f32, hi: f32, n: u32, wrap: bool) -> Vec<(u32, u32)> {
let last = n.saturating_sub(1);
let texel = |t: f32| ((t * n as f32) as i64).clamp(0, i64::from(last)) as u32;
if !wrap {
return vec![(texel(lo.clamp(0.0, 1.0)), texel(hi.clamp(0.0, 1.0)))];
}
if hi - lo >= 1.0 {
return vec![(0, last)];
}
let a = lo - lo.floor();
let b = a + (hi - lo);
if b <= 1.0 {
vec![(texel(a), texel(b))]
} else {
vec![(texel(a), last), (0, texel(b - 1.0))]
}
}
/// The most this sheet can PAINT anywhere in the UV box `u × v` — the greatest alpha, or, when
/// `with_rgb` (a batch that may be `NoAlphaAdd`), the greater of alpha and `max(r, g, b)`.
/// `0` exactly when every texel the box reaches contributes nothing.
///
/// The box is the batch's UV **bounding box**, which is a superset of the texels its triangles
/// actually cover, so a `0` here is conservative in the one direction that matters: it cannot
/// call a batch invisible that in fact paints.
fn paint(
&self,
u: (f32, f32),
v: (f32, f32),
wrap_x: bool,
wrap_y: bool,
with_rgb: bool,
) -> u8 {
if self.w == 0 || self.h == 0 {
return 0;
}
let mut best = 0u8;
for &(x0, x1) in &Self::axis(u.0, u.1, self.w, wrap_x) {
for &(y0, y1) in &Self::axis(v.0, v.1, self.h, wrap_y) {
for y in y0..=y1 {
let row = (y * self.w) as usize;
for x in x0..=x1 {
let i = row + x as usize;
best = best.max(self.alpha[i]);
if with_rgb {
best = best.max(self.rgb[i]);
}
}
}
}
}
best
}
}
/// Decode one texture's mip-0 alpha channel, memoised by lowercased path. `None` for a path the
/// chain has no BLP for, or one that will not decode — reported as `UNKNOWN`, never a silent pass.
fn fx_sheet<'a>(
cache: &'a mut BTreeMap<String, Option<Sheet>>,
chain: &mut Chain,
path: &str,
) -> Option<&'a Sheet> {
cache
.entry(path.to_ascii_lowercase())
.or_insert_with(|| {
benilla_formats::read_texture_rgba(chain, path)
.ok()
.map(|(w, h, rgba)| Sheet {
w,
h,
alpha: rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect(),
rgb: rgba
.as_chunks::<4>()
.0
.iter()
.map(|p| p[0].max(p[1]).max(p[2]))
.collect(),
})
})
.as_ref()
}
/// One effect model's place in the spell-visual chain: the `SpellVisualEffectName` rows that name
/// it, and every spell that reaches it with the lifecycle stages it arrives through.
#[derive(Default)]
struct FxReach {
/// The table's own spelling of the path (`.mdx`), for display.
path: String,
effects: std::collections::BTreeSet<u32>,
/// spell id → (name, the stages that reach this model).
spells: BTreeMap<u32, (String, std::collections::BTreeSet<&'static str>)>,
}
/// Fold one `SpellVisualEffectName` id's model into the corpus, and — when `spell` is given — note
/// that that spell reaches it through that lifecycle stage. An id with no row or an empty path
/// (the table's absent-model convention) folds to nothing, which is the client's own no-op.
fn fx_record(
out: &mut BTreeMap<String, FxReach>,
visuals: &benilla_formats::SpellVisualCatalog,
effect: u32,
spell: Option<(u32, &str, &'static str)>,
) {
let Some(path) = visuals.effect_path(effect) else {
return;
};
// One shipped row spells its path with a LEADING separator
// (`\\spells\\HellFire_FirePuff_Caster_Base.mdx`, `SpellVisualEffectName` 1180). The chain has
// the model under the same name without it, so the key drops leading separators while the
// DISPLAY path keeps the table's own spelling — the quirk stays visible instead of being
// silently smoothed away.
let e = out
.entry(crate::model_key(path.trim_start_matches(['\\', '/'])))
.or_default();
if e.path.is_empty() {
e.path = path.to_string();
}
e.effects.insert(effect);
if let Some((id, name, stage)) = spell {
e.spells
.entry(id)
.or_insert_with(|| (name.to_string(), std::collections::BTreeSet::new()))
.1
.insert(stage);
}
}
/// Every model the spell-visual chain can reach, keyed by [`crate::model_key`] — walked from the
/// **tables**, not from the spells, so a kit no shipped spell names is still swept (it is still
/// content the lane can be asked to play); the spell join is the second pass over the same map.
///
/// The reachable set is every kit's ten effect slots (`VisualKit::effects` — the nine bone-attach
/// slots plus the world/state plant), every `SpellVisual` row's missile model (field 7) and its
/// dest-anchored model (field 12, the DynamicObject's own `.mdx`). The stage labels are the five
/// lifecycle kit columns plus `missile` and `area`, the latter covering both dest-anchored columns
/// (field 12's model and field 13's area kit) — one lane, one name.
fn fx_corpus(
visuals: &benilla_formats::SpellVisualCatalog,
spells: &benilla_formats::SpellCatalog,
) -> BTreeMap<String, FxReach> {
let mut out: BTreeMap<String, FxReach> = BTreeMap::new();
for kit_id in visuals.kit_ids() {
let Some(kit) = visuals.kit(kit_id) else {
continue;
};
for (_, effect) in kit.effects() {
fx_record(&mut out, visuals, effect, None);
}
}
for (_, st) in visuals.visuals() {
fx_record(&mut out, visuals, st.missile_model, None);
fx_record(&mut out, visuals, st.area_effect, None);
}
for (id, sp) in spells.iter() {
let Some(st) = visuals.stages(sp.visual).copied() else {
continue;
};
for (stage, kit_id) in [
("precast", st.precast),
("cast", st.cast),
("impact", st.impact),
("state", st.state),
("channel", st.channel),
("area", st.area_kit),
] {
let Some(kit) = visuals.kit(kit_id) else {
continue;
};
for (_, effect) in kit.effects() {
fx_record(&mut out, visuals, effect, Some((id, &sp.name, stage)));
}
}
fx_record(
&mut out,
visuals,
st.missile_model,
Some((id, &sp.name, "missile")),
);
fx_record(
&mut out,
visuals,
st.area_effect,
Some((id, &sp.name, "area")),
);
}
out
}
/// What a consumer that runs NO texture-transform animation renders for one batch whose transform
/// the bake DID key.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum FxClass {
/// The transform moves, and frozen the batch samples no painted texel while the animation does:
/// the batch renders **nothing at all**. The `SwipeCaster` class.
Invisible,
/// The transform moves, and the batch paints nothing at any point of the loop — so the missing
/// UV animation is not what is wrong with it. Flagged, never counted as INVISIBLE.
Never,
/// The transform moves and the batch draws — statically, where the scroll is the motion.
Frozen,
/// The transform is keyed but never moves within its band: a constant NON-identity offset the
/// bake carries as a `period == 0` hold. A lane that seeds no offset draws the batch at the
/// identity instead — a static mis-registration, not a missing scroll.
Held,
/// No call from the sheet: a `Mod`/`Mod2x` batch (its blend equation reads no alpha) or a
/// texture the chain will not hand over. Flagged.
Unknown,
}
impl FxClass {
const ALL: [Self; 5] = [
Self::Invisible,
Self::Never,
Self::Frozen,
Self::Held,
Self::Unknown,
];
fn label(self) -> &'static str {
match self {
Self::Invisible => "INVISIBLE",
Self::Never => "NEVER",
Self::Frozen => "FROZEN",
Self::Held => "HELD",
Self::Unknown => "UNKNOWN",
}
}
fn blurb(self) -> &'static str {
match self {
Self::Invisible => {
"frozen, the batch samples only transparent texels while the scroll reaches painted \
ones — it renders NOTHING"
}
Self::Never => {
"nothing painted at any point of the loop — the missing scroll is not this batch's \
problem (investigate)"
}
Self::Frozen => "it draws, statically: the scroll is the motion it loses",
Self::Held => {
"keyed to a constant non-identity offset — a lane that seeds none draws it \
mis-registered, not still"
}
Self::Unknown => {
"no alpha lane to judge from (Mod/Mod2x), or the texture would not decode"
}
}
}
}
/// The batch's texture-transform state at `(slot, t)` — the triple [`benilla_formats::uv_transform`]
/// composes, read the way the one lane that RUNS it reads it (`ui_models::UvPart::write_rows`): the
/// per-slot set when the bake built one, else the single shared loop.
fn fx_state(
sub: &benilla_formats::RenderSubmesh,
slot: usize,
t: f32,
) -> ([f32; 2], [f32; 4], [f32; 2]) {
let trans = match (&sub.uv_seq, &sub.uv_anim) {
(Some(s), _) => s.seq(Some(slot)).map_or([0.0, 0.0], |l| l.sample(t)),
(None, Some(a)) => a.sample(t),
(None, None) => [0.0, 0.0],
};
let rot = sub
.uv_rot_seq
.as_ref()
.and_then(|r| r.seq(Some(slot)))
.map_or([0.0, 0.0, 0.0, 1.0], |l| l.sample(t));
let scale = sub
.uv_scale_seq
.as_ref()
.and_then(|r| r.seq(Some(slot)))
.map_or([1.0, 1.0], |l| l.sample(t));
(trans, rot, scale)
}
/// How many file sequence slots this batch's transform was baked across (1 when only the shared
/// single-slot loop exists), and the longest loop period in slot `slot`.
fn fx_slots(sub: &benilla_formats::RenderSubmesh) -> usize {
let n = |v: Option<usize>| v.unwrap_or(0);
n(sub.uv_seq.as_ref().map(|s| s.slots().len()))
.max(n(sub.uv_rot_seq.as_ref().map(|s| s.slots().len())))
.max(n(sub.uv_scale_seq.as_ref().map(|s| s.slots().len())))
.max(1)
}
/// The longest period any of the batch's three transform channels loops on in slot `slot`;
/// `0.0` when every one of them is a constant hold there.
fn fx_period(sub: &benilla_formats::RenderSubmesh, slot: usize) -> f32 {
let t = match (&sub.uv_seq, &sub.uv_anim) {
(Some(s), _) => s.seq(Some(slot)).map_or(0.0, |l| l.period),
(None, Some(a)) => a.period,
(None, None) => 0.0,
};
let r = sub
.uv_rot_seq
.as_ref()
.and_then(|s| s.seq(Some(slot)))
.map_or(0.0, |l| l.period);
let s = sub
.uv_scale_seq
.as_ref()
.and_then(|s| s.seq(Some(slot)))
.map_or(0.0, |l| l.period);
t.max(r).max(s)
}
/// The batch's UV bounding box carried through [`benilla_formats::uv_transform`] — all four
/// corners, so a rotation or scale is bounded rather than assumed away.
fn fx_box(
u: (f32, f32),
v: (f32, f32),
t: [f32; 2],
q: [f32; 4],
s: [f32; 2],
) -> ((f32, f32), (f32, f32)) {
let mut out = ((f32::MAX, f32::MIN), (f32::MAX, f32::MIN));
for uv in [[u.0, v.0], [u.1, v.0], [u.0, v.1], [u.1, v.1]] {
let p = benilla_formats::uv_transform(uv, t, q, s);
out.0 .0 = out.0 .0.min(p[0]);
out.0 .1 = out.0 .1.max(p[0]);
out.1 .0 = out.1 .0.min(p[1]);
out.1 .1 = out.1 .1.max(p[1]);
}
out
}
/// One classified batch — the row the class listings and the spell join are both built from.
struct FxHit {
/// The corpus key (lowercased `.m2`), so the reach map joins straight onto it.
key: String,
batch: usize,
/// The verdict under the seed the effect lane actually renders at (the identity).
class: FxClass,
/// The verdict under the track's first key — what the same batch becomes on a lane that hands
/// `model_material` the loop but never ticks it.
key_class: FxClass,
}
/// Sweep every model the SPELL-VISUAL CHAIN can reach and census the batches whose **texture
/// transform animates** — then classify each by what a consumer that runs none of it renders. See
/// the `Fxuvscan` command doc for the why.
pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let visuals = benilla_formats::load_spell_visual_catalog(chain)?;
let spells = benilla_formats::load_spell_catalog(chain)?;
let corpus = fx_corpus(&visuals, &spells);
let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\"));
let mut sheets: BTreeMap<String, Option<Sheet>> = BTreeMap::new();
let (mut listed, mut read, mut missing, mut batches) = (0u32, 0u32, 0u32, 0u64);
let (mut anim_models, mut anim_batches) = (0u32, 0u32);
let mut per_class: BTreeMap<FxClass, u32> = BTreeMap::new();
let mut per_key_class: BTreeMap<FxClass, u32> = BTreeMap::new();
let mut hits: Vec<FxHit> = Vec::new();
// Every model the chain names that the archives do not carry, or will not parse — listed,
// never a silent hole in the sweep.
let mut unread: Vec<String> = Vec::new();
// How many batches the two frozen states disagree about (see the classification below): the
// number that says whether "what does a no-UV-animation consumer render" has one answer here
// or two.
let mut seed_disagrees = 0u32;
// Of the batches that DO draw frozen, how many draw at UVs the loop never opens at.
let mut misregistered = 0u32;
for (key, reach) in &corpus {
if pfx.as_deref().is_some_and(|p| !key.starts_with(p)) {
continue;
}
listed += 1;
let Ok(bytes) = chain.read_file(key) else {
missing += 1;
unread.push(format!("{} (not in the chain)", reach.path));
continue;
};
read += 1;
let dir = key.rsplit_once('\\').map(|(d, _)| d).unwrap_or("");
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else {
missing += 1;
unread.push(format!("{} (will not parse)", reach.path));
continue;
};
batches += subs.len() as u64;
// Does this model author M2 blend mode **3** (`NoAlphaAdd`, `ONE/ONE`) anywhere? The bake
// folds modes 3 and 4 into one `Blend` + `additive` pair, and no `RenderSubmesh` carries the
// source material back (the billboard split means batches are not even 1:1 with skin
// batches), so the question is asked of the model's MATERIAL TABLE — the same raw read
// `blendscan` does (count/ofs at `0x84`, 4-byte `{u16 flags, u16 blend}`). A model with no
// mode-3 material has alpha-gated additives and the alpha test is exact; one that has any
// gets the stricter alpha-or-RGB test on its additive batches, which can only make
// INVISIBLE harder to claim.
let no_alpha_add = {
let at = |o: usize| -> Option<u32> {
Some(u32::from_le_bytes(bytes.get(o..o + 4)?.try_into().ok()?))
};
match (at(0x84), at(0x88)) {
(Some(n), Some(ofs)) => (0..n as usize).any(|i| {
let o = ofs as usize + i * 4;
bytes
.get(o + 2..o + 4)
.is_some_and(|b| u16::from_le_bytes([b[0], b[1]]) == 3)
}),
_ => false,
}
};
let mut lines: Vec<String> = Vec::new();
for (bi, sub) in subs.iter().enumerate() {
// The UI lane's own "does this batch's transform animate" test, verbatim: any of the
// three channels baked to something.
let animated = sub.uv_anim.is_some()
|| sub.uv_seq.is_some()
|| sub.uv_rot_seq.is_some()
|| sub.uv_scale_seq.is_some();
if !animated || sub.uvs.is_empty() {
continue;
}
anim_batches += 1;
let ext = |axis: usize| {
sub.uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), t| {
(lo.min(t[axis]), hi.max(t[axis]))
})
};
let (au, av) = (ext(0), ext(1));
let slots = fx_slots(sub);
let moves = (0..slots).any(|s| fx_period(sub, s) > 0.0);
// THE TWO FROZEN STATES, because our codebase has two seeding conventions and they
// are not the same picture:
//
// - **identity** — the offset an effect part actually renders at. Its material is
// built by `model_render::batch::Materials::entity_variants`, whose `play_uv` is
// `false`, so `model_material` is handed no loop and seeds `sun_scale.zw = (0, 0)`:
// the batch draws its AUTHORED UVs, untransformed. This is the primary verdict.
// - **first key** — `uv_anim.sample(0.0)`, what the same builder seeds when a lane
// DOES hand it the loop (the doodad and off-world lanes). Reported beside it,
// because a fix that wires the loop in without ticking it lands the batch here.
//
// Rotation and scale are at the identity in both: the translation seed is the only
// transform channel a shared material carries at all (the affine pair is the UI
// lane's per-pane table row, decision 2019).
let seed = sub.uv_anim.as_ref().map_or([0.0, 0.0], |a| a.sample(0.0));
let (fu, fv) = fx_box(au, av, [0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0]);
let (ku, kv) = fx_box(au, av, seed, [0.0, 0.0, 0.0, 1.0], [1.0, 1.0]);
// ANIMATED REACH. The union over every slot and a uniform sweep of its loop — an
// over-approximation by construction, so "nothing painted here either" is a claim the
// geometry cannot contradict.
let mut ru = (f32::MAX, f32::MIN);
let mut rv = (f32::MAX, f32::MIN);
for slot in 0..slots {
let period = fx_period(sub, slot);
for i in 0..FX_ANIM_SAMPLES {
let t = period * i as f32 / (FX_ANIM_SAMPLES - 1) as f32;
let (tr, q, sc) = fx_state(sub, slot, t);
let (bu, bv) = fx_box(au, av, tr, q, sc);
ru = (ru.0.min(bu.0), ru.1.max(bu.1));
rv = (rv.0.min(bv.0), rv.1.max(bv.1));
}
}
let tex = sub.texture.as_deref();
let sheet = tex.and_then(|p| fx_sheet(&mut sheets, chain, p));
let reads_alpha = matches!(
sub.blend,
benilla_formats::ModelBlend::Blend | benilla_formats::ModelBlend::AlphaTest
);
let opaque = sub.blend == benilla_formats::ModelBlend::Opaque;
let with_rgb = sub.additive && no_alpha_add;
let (frozen_a, key_a, anim_a, dims) = match sheet {
Some(s) => (
s.paint(fu, fv, sub.wrap_x, sub.wrap_y, with_rgb),
s.paint(ku, kv, sub.wrap_x, sub.wrap_y, with_rgb),
s.paint(ru, rv, sub.wrap_x, sub.wrap_y, with_rgb),
format!("{}x{}", s.w, s.h),
),
None => (0, 0, 0, "—".to_string()),
};
// One rule, run twice — once per frozen state, so the two verdicts cannot drift apart.
let verdict = |painted: u8| {
if !moves {
FxClass::Held
} else if opaque {
FxClass::Frozen
} else if !reads_alpha || sheet.is_none() {
FxClass::Unknown
} else if painted > 0 {
FxClass::Frozen
} else if anim_a > 0 {
FxClass::Invisible
} else {
FxClass::Never
}
};
let class = verdict(frozen_a);
let key_class = verdict(key_a);
if class != key_class {
seed_disagrees += 1;
}
// A batch that draws today but at a picture the loop never opens at: the identity is
// not the seed, so what is on screen is neither the animation nor its first frame.
if class == FxClass::Frozen && (seed[0] != 0.0 || seed[1] != 0.0) {
misregistered += 1;
}
*per_class.entry(class).or_default() += 1;
*per_key_class.entry(key_class).or_default() += 1;
hits.push(FxHit {
key: key.clone(),
batch: bi,
class,
key_class,
});
let chans = |sub: &benilla_formats::RenderSubmesh| {
let n = |on: bool, s: &'static str| if on { s } else { "—" };
format!(
"{}{}{}",
n(sub.uv_anim.is_some() || sub.uv_seq.is_some(), "T"),
n(sub.uv_rot_seq.is_some(), "R"),
n(sub.uv_scale_seq.is_some(), "S"),
)
};
// The per-axis reasoning, read off the TEXELS rather than off the span: a CLAMP axis
// whose frozen span runs past an edge samples that edge's texel for every bit of it
// past the edge, so a span like `[+0.951..+1.950]` over a 16-texel sheet is not
// "mostly outside" — it is column 15, alone, for its whole length. That collapse is the
// whole mechanism, so the line names it.
let axis = |wrap: bool, a: (f32, f32), f: (f32, f32), n: u32| {
let (texels, verdict) = match n {
0 => ("—".to_string(), "no sheet"),
n => {
let r = Sheet::axis(f.0, f.1, n, wrap);
let edge_only = !wrap
&& r.len() == 1
&& r[0].0 == r[0].1
&& (r[0].0 == 0 || r[0].0 == n - 1)
&& (f.0 < 0.0 || f.1 > 1.0);
(
r.iter()
.map(|&(x, y)| format!("{x}..{y}"))
.collect::<Vec<_>>()
.join(","),
if wrap {
"wraps"
} else if f.1 <= 0.0 || f.0 >= 1.0 {
"WHOLLY OUTSIDE 0..1 — the border texel, alone"
} else if edge_only {
"CLAMPED TO ONE BORDER TEXEL for its whole length"
} else {
"reaches the sheet"
},
)
}
};
format!(
"{:<6} authored [{:+.3}..{:+.3}] frozen [{:+.3}..{:+.3}] -> texels {:<7} {}",
if wrap { "REPEAT" } else { "CLAMP" },
a.0,
a.1,
f.0,
f.1,
texels,
verdict,
)
};
let (tw, th) = sheet.map_or((0, 0), |s| (s.w, s.h));
lines.push(format!(
" batch {bi:>3} {:?}{} {} verts chans {} {} slot(s) tex {} {dims}{}",
sub.blend,
if sub.additive { " additive" } else { "" },
sub.positions.len(),
chans(sub),
slots,
tex.unwrap_or("NONE"),
if with_rgb {
" [model authors NoAlphaAdd — judged on alpha OR rgb]"
} else {
""
},
));
lines.push(format!(" u {}", axis(sub.wrap_x, au, fu, tw)));
lines.push(format!(" v {}", axis(sub.wrap_y, av, fv, th)));
lines.push(format!(
" frozen@identity alpha max {frozen_a:>3}/255 => {} \
[a lane built with `play_uv = false`: the AUTHORED UVs, untransformed]",
class.label(),
));
lines.push(format!(
" frozen@first-key alpha max {key_a:>3}/255 => {} \
seed [{:+.4},{:+.4}] u[{:+.3}..{:+.3}] v[{:+.3}..{:+.3}]",
key_class.label(),
seed[0],
seed[1],
ku.0,
ku.1,
kv.0,
kv.1,
));
lines.push(format!(
" animated alpha max {anim_a:>3}/255 \
u[{:+.3}..{:+.3}] v[{:+.3}..{:+.3}] over {} sample(s) x {slots} slot(s)",
ru.0, ru.1, rv.0, rv.1, FX_ANIM_SAMPLES,
));
}
if !lines.is_empty() {
anim_models += 1;
println!(
"{} effect {} ({} spell(s) reach it)",
reach.path,
reach
.effects
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("·"),
reach.spells.len(),
);
for l in &lines {
println!("{l}");
}
}
}
println!();
println!(
"=== summary === {listed} model(s) swept of the {} the spell-visual chain reaches, {read} \
read ({missing} unreadable), {batches} render batch(es)",
corpus.len()
);
println!(
" {anim_models} model(s) / {anim_batches} batch(es) carry a keyed TEXTURE TRANSFORM — \
the channel the spell-effect lane runs since 2282 and the unit lane still does not"
);
println!(" {:<10} {:>8} {:>9}", "", "identity", "first-key");
for c in FxClass::ALL {
println!(
" {:<10} {:>8} {:>9} — {}",
c.label(),
per_class.get(&c).copied().unwrap_or(0),
per_key_class.get(&c).copied().unwrap_or(0),
c.blurb(),
);
}
println!(
" of the {} batch(es) FROZEN@identity, {misregistered} are frozen at UVs the loop never \
opens at (a non-zero first key), so what draws is neither the animation nor its opening \
frame — a static mis-registration on top of the missing motion",
per_class.get(&FxClass::Frozen).copied().unwrap_or(0),
);
println!(
" the two columns are the two frozen states a non-running lane can be in: `identity` \
is a material built with `play_uv = false` (the AUTHORED UVs — what the spell-effect \
lane drew before 2282, and what the unit lane still draws), `first-key` what the same \
batch becomes on a lane that seeds `uv_anim.sample(0.0)` and never ticks it. \
{seed_disagrees} batch(es) are classified differently by the two — read the per-batch \
lines for which."
);
if !unread.is_empty() {
println!();
// Every one of these on the shipped 1.12 chain is a DEAD TABLE ROW, not a hole in the
// sweep: `SpellVisualEffectName` still carries the Warcraft-III-era `Particles\*.mdl`
// names the 1.12 archives never shipped. The extension split says so at a glance.
let mdl = unread
.iter()
.filter(|u| u.to_ascii_lowercase().contains(".mdl "))
.count();
println!(
"--- unreadable --- {} model(s) the chain names but could not be swept ({mdl} `.mdl` \
+ {} `.mdx`, every one absent from the archives — dead table rows, not a gap)",
unread.len(),
unread.len() - mdl,
);
for u in unread.iter().take(FX_UNREAD_ROWS) {
println!(" {u}");
}
if let Some(rest) = unread.len().checked_sub(FX_UNREAD_ROWS).filter(|n| *n > 0) {
println!(" … and {rest} more (top {FX_UNREAD_ROWS} shown)");
}
}
// Every model carrying a batch of class `c` under `pick`'s seed, with the spells that reach it.
let listing = |title: &str, note: &str, pick: fn(&FxHit) -> FxClass, c: FxClass| {
let rows: Vec<&FxHit> = hits.iter().filter(|h| pick(h) == c).collect();
if rows.is_empty() {
return;
}
println!();
println!("--- {title} --- {} batch(es){note}", rows.len());
let mut models: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
for h in &rows {
models.entry(h.key.as_str()).or_default().push(h.batch);
}
for (key, bs) in &models {
let Some(reach) = corpus.get(*key) else {
continue;
};
println!(
" {} batch(es) {}",
reach.path,
bs.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", "),
);
if reach.spells.is_empty() {
println!(" (no shipped spell reaches it — kit-table content only)");
continue;
}
for (id, (name, stages)) in reach.spells.iter().take(FX_SPELL_ROWS) {
println!(
" spell {id:<6} {name:<34} {}",
stages.iter().copied().collect::<Vec<_>>().join(","),
);
}
if let Some(rest) = reach
.spells
.len()
.checked_sub(FX_SPELL_ROWS)
.filter(|n| *n > 0)
{
println!(" … and {rest} more spell(s) (top {FX_SPELL_ROWS} shown)");
}
}
};
for c in [FxClass::Invisible, FxClass::Never, FxClass::Unknown] {
listing(
c.label(),
" (frozen@identity — what the effect lane renders today)",
|h| h.class,
c,
);
}
// The batches the first-key seed alone condemns: they draw today, and would stop drawing on a
// lane that seeds the loop's opening value without running it. Named, because that is exactly
// the shape of a half-finished fix.
let key_only: Vec<&FxHit> = hits
.iter()
.filter(|h| h.key_class == FxClass::Invisible && h.class != FxClass::Invisible)
.collect();
if !key_only.is_empty() {
println!();
println!(
"--- INVISIBLE under the FIRST-KEY seed only --- {} batch(es)\n (these draw today \
and would go dark on a lane that seeds `uv_anim.sample(0.0)` without ticking it)",
key_only.len()
);
let mut models: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
for h in &key_only {
models.entry(h.key.as_str()).or_default().push(h.batch);
}
for (key, bs) in &models {
println!(
" {} batch(es) {}",
corpus.get(*key).map_or(*key, |r| r.path.as_str()),
bs.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", "),
);
}
}
// The deliverable: the distinct spells that reach an INVISIBLE batch at all, named in full —
// the set a player can cast and see nothing for. `+` marks a spell that is only reached under
// the first-key seed, never under the one the lane renders at today.
let mut reached: BTreeMap<u32, (String, std::collections::BTreeSet<&'static str>, bool)> =
BTreeMap::new();
for h in hits
.iter()
.filter(|h| h.class == FxClass::Invisible || h.key_class == FxClass::Invisible)
{
let Some(reach) = corpus.get(&h.key) else {
continue;
};
let today = h.class == FxClass::Invisible;
for (id, (name, stages)) in &reach.spells {
let e = reached
.entry(*id)
.or_insert_with(|| (name.clone(), std::collections::BTreeSet::new(), false));
e.1.extend(stages.iter().copied());
e.2 |= today;
}
}
let today = reached.values().filter(|v| v.2).count();
println!();
println!(
"=== spells reaching an INVISIBLE batch === {today} spell(s) today, {} counting the \
first-key seed's extra batches (marked `+`)",
reached.len()
);
for (id, (name, stages, today)) in &reached {
println!(
" {} {id:<6} {name:<40} {}",
if *today { " " } else { "+" },
stages.iter().copied().collect::<Vec<_>>().join(","),
);
}
Ok(())
}
@@ -8,7 +8,8 @@
//! - [`lighting`] — what lights a model: the WMO prop lanes, M2 light blocks, terrain shadow.
//! - [`geometry`] — what geometry a model draws: billboards, geosets, flat ground quads.
//! - [`material`] — how a batch is textured and blended: blend modes, UV wrap, env stages,
//! the batches whose UV/tint loop differs between sequence slots.
//! the batches whose UV/tint loop differs between sequence slots, and what the spell-effect
//! corpus's animated texture transforms render for a consumer that runs none of them.
//! - [`particles`] — particle and ribbon emitters, and the features the corpus authors.
//! - [`skeleton`] — the bone tree and the attachment table that addresses it.
//! - [`sequence`] — which sequence an arm plays, what breaks when it is the wrong one, and
@@ -31,7 +32,9 @@ mod world;
pub use geometry::{animboundscan, bbfacescan, bbscan, geosetscan, groundscan, normalscan};
pub use lighting::{darkpropscan, m2lightscan, shadeat};
pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvslotscan, uvwrapscan};
pub use material::{
alphascan, blendscan, envmapscan, fxuvscan, texmodescan, uvslotscan, uvwrapscan,
};
pub use particles::{
cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus,
};
@@ -0,0 +1,105 @@
//! The druid's Swipe leaves no claw trail (decision 2282) — and, like the lava bubbles next door,
//! the asset says why in one number: **this model's whole visible existence is its texture
//! transform.**
//!
//! `Spells\SwipeCaster.m2` — Swipe's `cast` kit (`SpellVisual` 189 → kit 182 →
//! `SpellVisualEffectName` 215) — is two 51-vertex strips, three per batch, each rigidly skinned to
//! one bone that sweeps it through the arc. Their UVs are authored `u ∈ [0.945, 1.944]` against a
//! sheet the file addresses **CLAMP**, whose border texels are fully transparent. At the
//! translation track's first key the strips therefore sample the sheet's transparent right edge
//! along their whole length and draw *nothing at all*; the 1.5 s U-scroll is what drags the claw
//! across them.
//!
//! So a consumer that renders this model's geometry, rig, alpha loops and particles but not its
//! texture transform renders a correct-looking blood spray and no trail — which is exactly what
//! benilla's spell-effect lane did, on 0271's recorded premise that "no effect model in the
//! current corpus needs it". This file pins the three facts that make that premise false, so the
//! effect lane's UV channel can never be quietly dropped again.
//!
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
use benilla_formats::{open_chain, parse_m2_render_submeshes, uv_transform};
const SWIPE: &str = "Spells\\SwipeCaster.m2";
/// The u extent the batch's authored UVs cover once the loop's offset at `t` is folded in, by the
/// verified law (wow-re `m2-texanim-uv` §1: `uv' = R·S·((uv + t) − p) + p`, and with no rotation
/// or scaling authored here that is a pure `uv + t`).
fn u_span_at(uvs: &[[f32; 2]], offset: [f32; 2]) -> (f32, f32) {
uvs.iter()
.map(|&uv| uv_transform(uv, offset, [0.0, 0.0, 0.0, 1.0], [1.0, 1.0])[0])
.fold((f32::MAX, f32::MIN), |(lo, hi), u| (lo.min(u), hi.max(u)))
}
#[test]
fn the_swipe_claw_trail_is_nothing_but_its_uv_scroll() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain.read_file(SWIPE).expect("SwipeCaster is in the chain");
let subs = parse_m2_render_submeshes(&bytes, "Spells", &[]).expect("parse");
assert_eq!(subs.len(), 2, "the two claw-strip batches");
for (i, s) in subs.iter().enumerate() {
// 1. Every batch animates its texture transform, on the SHARED spelling (one sequence, so
// the slots cannot disagree and 1408's per-slot set is rightly absent).
let uv = s
.uv_anim
.as_ref()
.unwrap_or_else(|| panic!("batch {i} carries a UV loop"));
assert!(
s.uv_seq.is_none(),
"batch {i}: one sequence, so no per-slot set"
);
// 2. The scroll is a near-whole-sheet sweep along U, and it runs the length of the clip.
let (first, last) = (
uv.keys.first().expect("keys").1,
uv.keys.last().expect("keys").1,
);
assert!(
first[0].abs() < 0.05 && (last[0] + 0.97).abs() < 0.05,
"batch {i}: U sweeps ~0 → ~−0.97 ({first:?} → {last:?})"
);
assert!(
(uv.period - 1.5).abs() < 0.01,
"batch {i}: over the 1.5 s clip (period {})",
uv.period
);
// 3. The sheet is CLAMP-addressed on U — which is what turns the scroll from a nicety into
// the whole visual. A repeat-addressed strip would show *something* at every offset.
assert!(!s.wrap_x, "batch {i}: U is authored CLAMP");
// 4. …and so, frozen at the first key, the strip sits entirely at or past the sheet's right
// edge: clamped, every one of its texels is the border. Scrolled to the last key it
// covers the sheet instead. This pair IS the bug and its fix, in the asset's own numbers.
let frozen = u_span_at(&s.uvs, first);
assert!(
frozen.0 > 0.94,
"batch {i}: at t=0 the whole strip is at/past the right edge (u {frozen:?})"
);
let scrolled = u_span_at(&s.uvs, last);
assert!(
scrolled.0 < 0.01 && scrolled.1 > 0.95,
"batch {i}: at the last key it covers the sheet (u {scrolled:?})"
);
}
// 5. The border really is empty, so "clamped to the edge" means "invisible" and not "a stripe
// of colour". `BloodSpurtSmall01` is a 16×16 blob with a fully transparent frame.
let blp = chain
.read_file("Spells\\BloodSpurtSmall01.blp")
.expect("the claw sheet is in the chain");
let tex = benilla_formats::blp_bytes_to_mip_chain(&blp).expect("decode");
let (w, h) = (tex.width as usize, tex.height as usize);
let px = &tex.mips[0];
let alpha = |x: usize, y: usize| px[(y * w + x) * 4 + 3];
assert!(
(0..h).all(|y| alpha(w - 1, y) == 0 && alpha(0, y) == 0),
"both U border columns are fully transparent"
);
assert!(
(0..h).any(|y| (0..w).any(|x| alpha(x, y) > 128)),
"…while the middle of the sheet is the claw itself"
);
}
@@ -1,5 +1,6 @@
//! The meeting-stone queue's wire (decision 1963; wow-re `staticpopup-dialog-bindings.md` §8):
//! the server's queue state and the leave request `CancelMeetingStoneRequest` sends.
//! The meeting-stone queue's wire (decisions 1963/1974/2283; wow-re `staticpopup-dialog-bindings.md`
//! §8 and `meeting-stone-status.md` §8/§9/§10): the server's queue state, the JOIN a right-click on
//! the stone sends, and the leave request `CancelMeetingStoneRequest` sends.
use std::io::{self, Read};
@@ -21,6 +22,15 @@ pub(super) fn read_meeting_stone_set_queue(r: &mut impl Read) -> io::Result<Meet
})
}
/// Body of `CMSG 0x292` (VERIFIED, builder `0x4c9ff0`): one full little-endian `u64` — the guid of
/// the `GAMEOBJECT_TYPE_MEETINGSTONE` (23) the player right-clicked, written by the 8-byte guid
/// writer `0x418370` straight after `PutUInt32(0x292)`. Nothing else is in the packet: the area is
/// the SERVER's to resolve from the stone's `gameobject_template.data[2]`, which is why the join
/// names an object and the reply names an area.
pub fn meeting_stone_join(go_guid: u64) -> Vec<u8> {
go_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG 0x293` (VERIFIED, `0x4ca120`): empty.
pub fn meeting_stone_leave() -> Vec<u8> {
Vec::new()
@@ -51,3 +61,25 @@ pub(super) fn read_meeting_stone_member_added(r: &mut impl Read) -> io::Result<M
pub(super) fn read_meeting_stone_join_failed(r: &mut impl Read) -> io::Result<MeetingStoneNotice> {
Ok(MeetingStoneNotice::JoinFailed { code: read_u8(r)? })
}
#[cfg(test)]
mod tests {
use super::*;
/// The join body is the bare guid, little-endian, eight bytes and no more — `0x4c9ff0` writes
/// `PutUInt32(0x292)` into the header and then exactly one `0x418370` (the 8-byte guid writer).
#[test]
fn cmsg_meetingstone_join_body_golden() {
assert_eq!(
meeting_stone_join(0x1234_5678_9abc_def0),
vec![0xf0, 0xde, 0xbc, 0x9a, 0x78, 0x56, 0x34, 0x12],
"CMSG_MEETINGSTONE_JOIN body"
);
}
/// Both of the other two sends in this family are empty bodies.
#[test]
fn the_leave_body_is_empty() {
assert!(meeting_stone_leave().is_empty());
}
}
+3 -1
View File
@@ -173,7 +173,9 @@ pub use mail::{
mail_mark_as_read, mail_message_type, mail_return_to_sender, mail_take_item, mail_take_money,
send_mail, MailAttachment, MailListEntry,
};
pub use meeting_stone::{meeting_stone_leave, MeetingStoneNotice, MeetingStoneSetQueue};
pub use meeting_stone::{
meeting_stone_join, meeting_stone_leave, MeetingStoneNotice, MeetingStoneSetQueue,
};
pub use mirror_timer::{
read_pause_mirror_timer, read_start_mirror_timer, read_stop_mirror_timer, MirrorTimerKind,
MirrorTimerStart,
@@ -1161,6 +1161,16 @@ pub const CMSG_AREA_SPIRIT_HEALER_QUEUE: u16 = 0x02E3; // 739
/// healer with a positive time arms the deadline and fires `AREA_SPIRIT_HEALER_IN_RANGE`
/// (decision 1963).
pub const SMSG_AREA_SPIRIT_HEALER_TIME: u16 = 0x02E4; // 740
/// The meeting stone's JOIN — the packet a right-click on a `GAMEOBJECT_TYPE_MEETINGSTONE` (23)
/// sends: `u64 gameObjectGuid`, built and sent by `0x4c9ff0` from the tail (`0x5f6af6`) of that
/// type's own use-slot validator `0x5f69d0` = `[0x80bf40+0x1c]` (decision 2283, VERIFIED by
/// wow-re's §5 round on that function). Twelve bytes on the wire, body exactly eight, no padding.
///
/// A meeting stone **cannot** send [`CMSG_GAMEOBJ_USE`]: the shared sender `0x5f33e0` has zero
/// direct callers and is reachable only as some vtable's `+0x1c`, which type 23's is not. And
/// vmangos would drop it anyway — `GameObject::Use` has an explicit do-nothing type-23 arm
/// (`GameObject.cpp:1836`, "Should never be called for this type of object").
pub const CMSG_MEETINGSTONE_JOIN: u16 = 0x0292; // 658
/// `CancelMeetingStoneRequest()`'s packet (§8): EMPTY. Gated client-side on party leadership
/// only; clears nothing — the server's `0x295` reply does (decision 1963). The emulators' name
/// for this number does not line up with the client's block; the number is what is verified.
@@ -1,4 +1,4 @@
//! The meeting-stone queue's sends (decision 1963).
//! The meeting-stone queue's sends (decisions 1963/2283).
use anyhow::Result;
@@ -7,6 +7,17 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Join the meeting-stone queue (`CMSG 0x292`, `u64 goGuid`) — what a right-click on a
/// `GAMEOBJECT_TYPE_MEETINGSTONE` sends once that type's own use-slot validator has passed
/// its four client-side refusals (decision 2283). Never `CMSG_GAMEOBJ_USE`: the server has no
/// type-23 case for the shared opener.
pub fn meeting_stone_join(&mut self, go_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_MEETINGSTONE_JOIN,
&messages::meeting_stone_join(go_guid),
)
}
/// Leave the meeting-stone queue (`CMSG 0x293`, empty) — `CancelMeetingStoneRequest()`'s
/// packet, sent by the party leader (or a player in no party).
pub fn meeting_stone_leave(&mut self) -> Result<()> {
+466 -53
View File
@@ -265,6 +265,33 @@ pub enum UvLoop {
seqs: std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>,
host: Entity,
},
/// The **spell-effect** lane (decision 2282): this material is one effect instance's own
/// clone, and the scroll rides that instance's clip — `host`'s live `AnimationPlayer`, the
/// same source [`MatAnim::following_host`] reads, so the two material channels of one cast
/// stay in step through `Stand` → `Hold` → `Decay`.
///
/// **Per instance, not shared, because on an effect the scroll is not ambience — it IS the
/// animation.** A waterfall's loop is seamless, so one uniform for every placement on a
/// free-running clock is indistinguishable from the truth. The druid's claw trail is a
/// one-shot reveal: `Spells\SwipeCaster.m2` is two strips whose UVs are authored wholly
/// outside `0..1` against a CLAMP-addressed sheet, and the 1.5 s U-scroll is what drags the
/// claw along them. On a shared clock every bear in the raid would sweep in one phase none of
/// their casts chose; frozen at the track's first key — which is what the effect lane did
/// until 2282 — the strips sample only the sheet's transparent border and the trail does not
/// exist at all.
///
/// `seqs` (1408's per-slot set) when the batch's slots disagree, `single` otherwise; a batch
/// whose slot 0 is dead beside a live later slot carries only the first, so both are optional
/// and at least one is always present.
Instance {
seqs: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
single: Option<std::sync::Arc<benilla_formats::UvAnim>>,
host: Entity,
/// `Time::elapsed_secs` at attach — this instance's clock origin. It is the band clock
/// for an instance whose model arms no player, and the global-sequence anchor
/// (`sceneNow − attach`, decisions 0856/0858: one fresh instance per play) for every one.
attached_at: f32,
},
}
/// One registered UV-scroll material: its sampler, its table slot, and the built seed the delta
@@ -274,6 +301,26 @@ pub struct UvAnimEntry {
pub anim: UvLoop,
pub slot: u16,
pub seed: [f32; 2],
/// The effect lane's **affine half** ([`FxAffine`], decision 2282) — `None` on every other
/// lane, and on an effect batch whose transform only translates (the common case).
affine: Option<FxAffine>,
}
/// A texture transform's ROTATION and SCALING, on the effect lane: the two channels the
/// translation row cannot carry, and the second table row that does ([`affine_row`]'s encoding,
/// decision 2019).
///
/// They ride the same instance clock as the translation beside them, and they exist here for the
/// same reason that one does — `Spells\ShieldWall_Impact_Base.mdx`'s halo turns AND scales, and
/// `Spells\GroundingTotem_Impact.mdx` is a **scale-only** transform, so a lane that ran only the
/// scroll would still render both wrong. `benilla-extract fxuvscan` is where that population is
/// read; it is three models today, which is exactly why nobody would have found them by eye.
struct FxAffine {
rot: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
scale: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// The affine row's slot (`anim_slots.z`). Its encoding is a delta from the IDENTITY, not
/// from a material seed — row 0 is the identity, so there is nothing to measure from.
slot: u16,
}
impl UvAnimEntry {
@@ -295,6 +342,23 @@ impl UvAnimEntry {
.map(|l| l.sample(l.clock(band_t, gseq_now)))
})
.unwrap_or([0.0, 0.0]),
// The effect lane's clocks are the INSTANCE's, never the scene's (2282): the band
// clock is the clip this instance is playing — the spawn age while its rig has no
// player yet, which is `MatAnim`'s own degrade — and the gseq cursor is measured from
// its attach rather than from process start.
UvLoop::Instance {
seqs,
single,
attached_at,
..
} => {
let age = now - attached_at;
let band_t = playing.map_or(age, |(_, t)| t);
seqs.as_ref()
.and_then(|s| s.seq(playing.map(|(seq, _)| seq)))
.or(single.as_deref())
.map_or([0.0, 0.0], |l| l.sample(l.clock(band_t, f64::from(age))))
}
};
[
benilla_assets::quantize(uv[0], 4096.0) - self.seed[0],
@@ -303,6 +367,35 @@ impl UvAnimEntry {
0.0,
]
}
/// This entry's **affine** row and the slot it goes in, or `None` when the transform only
/// translates. Sampled on the same instance clocks [`Self::delta`] uses, so the three channels
/// of one transform are always read at one instant.
pub(crate) fn affine(
&self,
now: f32,
playing: Option<(usize, f32)>,
) -> Option<(u16, [f32; 4])> {
let a = self.affine.as_ref()?;
let UvLoop::Instance { attached_at, .. } = &self.anim else {
return None; // the affine half is the effect lane's alone
};
let age = now - attached_at;
let band_t = playing.map_or(age, |(_, t)| t);
let seq = playing.map(|(seq, _)| seq);
let gseq = f64::from(age);
let q = a
.rot
.as_ref()
.and_then(|r| r.seq(seq))
.map_or([0.0, 0.0, 0.0, 1.0], |l| l.sample(l.clock(band_t, gseq)));
let s = a
.scale
.as_ref()
.and_then(|r| r.seq(seq))
.map_or([1.0, 1.0], |l| l.sample(l.clock(band_t, gseq)));
Some((a.slot, crate::mat_anim_table::affine_row(q, s)))
}
}
impl UvAnimEntry {
@@ -310,7 +403,40 @@ impl UvAnimEntry {
fn host(&self) -> Option<Entity> {
match &self.anim {
UvLoop::Shared(_) => None,
UvLoop::PerSeq { host, .. } => Some(*host),
UvLoop::PerSeq { host, .. } | UvLoop::Instance { host, .. } => Some(*host),
}
}
/// Is this the **spell-effect** lane ([`UvLoop::Instance`])? The one entry kind
/// [`tick_anim_materials`] keeps sampling inside a deterministic capture, on exactly
/// [`MatAnim`]'s and `spell_fx::FxTintAnims`' own argument: an effect's clock is its
/// instance's frame-stepped `AnimationPlayer`, not wall time, so `fxview` can age one without
/// any golden scenario — which spawns no effects at all — moving a pixel.
fn instance_lane(&self) -> bool {
matches!(self.anim, UvLoop::Instance { .. })
}
/// The loop this entry samples right now — the instrument's read
/// ([`crate::doodad_anim::matanim_probe`]), so the probe's period and phase sweep come from
/// the same resolution [`Self::delta`] makes rather than a hand-rolled twin of it.
fn resolved(&self, playing: Option<(usize, f32)>) -> Option<&benilla_formats::UvAnim> {
match &self.anim {
UvLoop::Shared(a) => Some(a),
UvLoop::PerSeq { seqs, .. } => seqs.seq(playing.map(|(seq, _)| seq)),
UvLoop::Instance { seqs, single, .. } => seqs
.as_ref()
.and_then(|s| s.seq(playing.map(|(seq, _)| seq)))
.or(single.as_deref()),
}
}
/// The `now` a phase-sweep sample passes for this entry. The effect lane measures its clocks
/// from ATTACH, so handing it a raw phase `t` would read as a negative age; every other lane
/// is already on the scene clock. (The sweep moves the play head too — see the caller.)
fn phase_now(&self, t: f32) -> f32 {
match &self.anim {
UvLoop::Instance { attached_at, .. } => attached_at + t,
UvLoop::Shared(_) | UvLoop::PerSeq { .. } => t,
}
}
}
@@ -349,7 +475,7 @@ pub struct UvAnimMaterials(
/// write this lane ever makes (spawn-frame, where the asset is Modified anyway). A full table
/// (never seen below ~500 resident animated materials) skips registration: the batch stays
/// frozen at its built seed — a degraded look, never a wrong pixel.
pub fn register_uv(
pub(crate) fn register_uv(
reg: &mut UvAnimMaterials,
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
@@ -381,20 +507,146 @@ pub fn register_uv(
table.free(slot);
return;
};
if let UvLoop::PerSeq { host, .. } = &anim {
// The breadcrumb for the lane that has no other tell: a per-placement material is invisible
// in every count (it is one more material, one more row), so "did the bubbles take the new
// lane at all" would otherwise be a question only the eye could answer. One line per
// registration, at debug (decision 1408).
bevy::log::debug!("mat-anim: per-placement UV lane armed for host {host} (slot {slot})");
match &anim {
// The breadcrumb for the lanes that have no other tell: a per-placement or per-instance
// material is invisible in every count (it is one more material, one more row), so "did
// the bubbles / the claw trail take the new lane at all" would otherwise be a question
// only the eye could answer. One line per registration, at debug (decisions 1408, 2282).
UvLoop::PerSeq { host, .. } => {
bevy::log::debug!("mat-anim: per-placement UV lane armed for host {host} (slot {slot})")
}
UvLoop::Instance { host, .. } => {
bevy::log::debug!(
"mat-anim: per-instance UV lane armed for effect {host} (slot {slot})"
)
}
UvLoop::Shared(_) => {}
}
reg.0.insert(
id,
UvAnimEntry {
anim,
slot,
seed,
affine: None,
},
);
}
/// Put one **spell-effect material clone** on the per-instance UV lane (decision 2282): the
/// engine's whole door for `entities::spell_fx`, which owns the clone and nothing else about how
/// the scroll is delivered.
///
/// One door rather than the three pieces behind it ([`UvLoop`]'s effect variant, [`register_uv`],
/// the table row), because they are one fact — *this clone scrolls, on that instance's clip* —
/// and the class of bug this lane keeps producing is a caller that took some of the pieces and
/// not the rest (decision 2038's registry of marked-but-unregistered parts is the same shape).
///
/// `seqs` / `single` are the batch's baked loops in 1408's two spellings, at least one present;
/// `host` is the effect instance root whose `AnimationPlayer` names the playing clip, and
/// `attached_at` its `Time::elapsed_secs` origin. The clone's `sun_scale.zw` is read as the seed
/// the delta rows are measured from, exactly as on the shared lane, so a caller that seeds it at
/// the loop's own `t = 0` gets a correct FIRST frame before this lane has written a row.
pub fn register_fx_uv(
reg: &mut UvAnimMaterials,
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
id: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
loops: FxUvLoops,
host: Entity,
attached_at: f32,
) {
let FxUvLoops {
seqs,
single,
rot,
scale,
} = loops;
let turns = rot.is_some() || scale.is_some();
register_uv(
reg,
table,
materials,
id,
UvLoop::Instance {
seqs,
single,
host,
attached_at,
},
);
// The translation half decides whether there is an entry at all (a full table, a dead handle);
// the affine half attaches to it or not at all. A transform that ONLY rotates or scales still
// takes a translation row that stays at zero — one row, on a population of three models, for a
// registration path with one shape instead of two.
let Some(entry) = reg.0.get_mut(&id) else {
return;
};
if turns {
match table.alloc() {
Some(slot) => {
crate::model_render::lazy::with_material_mut(materials, id, |mat| {
mat.extension.anim_slots.z = f32::from(slot);
});
entry.affine = Some(FxAffine { rot, scale, slot });
}
None => bevy::log::warn_once!(
"mat-anim table full — an effect's UV rotation/scale stays at the identity"
),
}
}
// **Both rows, written now.** `tick_anim_materials` runs once per frame in `Update` and this
// registration happens in the same schedule, so an instance that attaches after the tick would
// otherwise draw its first frame at row 0 — the identity, which on this population is the
// authored-UV frame the whole decision is about (a Swipe strip renders NOTHING there). One
// write at spawn costs nothing and removes the class.
let (slot, row) = (entry.slot, entry.delta(attached_at, 0.0, None));
let affine = entry.affine(attached_at, None);
table.set(slot, row);
if let Some((slot, row)) = affine {
table.set(slot, row);
}
}
/// The four baked channels of one batch's texture transform, as [`register_fx_uv`] takes them —
/// 1408's two translation spellings plus 2019's per-slot rotation and scaling sets. A struct
/// because they are one authored record, and a caller passing four `Option`s positionally is a
/// caller that will eventually swap two of them.
#[derive(Default)]
pub struct FxUvLoops {
pub seqs: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
pub single: Option<std::sync::Arc<benilla_formats::UvAnim>>,
pub rot: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
pub scale: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
}
impl FxUvLoops {
/// Does this batch animate its texture transform at all? The test the effect attach makes
/// before cloning a material: any of the four channels, because a scale-only transform
/// (`Spells\GroundingTotem_Impact.mdx`) and a dead-slot-0 translation (1408) each answer
/// `None` to the obvious one.
pub fn any(&self) -> bool {
self.seqs.is_some() || self.single.is_some() || self.rot.is_some() || self.scale.is_some()
}
/// The offset the batch opens on — what a caller seeds the clone's `sun_scale.zw` with so its
/// first frame is the loop's own `t = 0` and not the authored, unscrolled UV.
pub fn open_offset(&self) -> [f32; 2] {
self.seqs
.as_ref()
.and_then(|s| s.seq(None))
.or(self.single.as_deref())
.map_or([0.0, 0.0], |l| l.sample(0.0))
}
reg.0.insert(id, UvAnimEntry { anim, slot, seed });
}
/// Re-sample the **drawn** animated materials on the shared clock — the UV scroll
/// ([`UvAnimMaterials`]) and the RGB tint ([`TintAnimMaterials`]) together, because they share the
/// draw scan below. Skipped entirely in captures (materials keep their t = 0 seed — constants still
/// show, frames stay deterministic).
/// draw scan below. Skipped in captures (materials keep their t = 0 seed — constants still show,
/// frames stay deterministic), **except the spell-effect lane**
/// ([`UvAnimEntry::instance_lane`]), whose clock is an effect instance's own frame-stepped
/// player: `fxview` exists to age an effect inside a capture, and no golden scenario spawns one,
/// so a scene with no effect in it takes the identical early return it always did.
///
/// **The draw gate, and why it is the fix for B131.** Mutating a material asset marks it Modified,
/// which on the Metal non-bindless path re-creates its uniform buffers *and its bind group* that
@@ -457,11 +709,22 @@ pub(super) fn tick_anim_materials(
}
return;
}
if crate::dev_state::deterministic_run() || matanim_off(&real) {
if matanim_off(&real) {
return;
}
// A deterministic run freezes the SHARED-clock lanes at their seed (the whole reason a golden
// frame is reproducible) but keeps the effect lane running, because that lane's clock is one
// instance's frame-stepped `AnimationPlayer` — the `fxview` carve-out `MatAnim` and
// `spell_fx::FxTintAnims` already take (decision 2282). A capture with no effect in it — every
// golden scenario — holds no such entry, so it returns here exactly as it always has.
let frozen = crate::dev_state::deterministic_run();
if frozen && !uv_reg.0.values().any(UvAnimEntry::instance_lane) {
return;
}
drawn.clear();
for (mat, vis) in &parts {
// Skipped when frozen: the only entries still sampling there are the effect lane's, and they
// are exempt from the draw gate below — so a capture pays nothing for a scan it cannot read.
for (mat, vis) in parts.iter().filter(|_| !frozen) {
if *vis != Visibility::Hidden {
let id = mat.id();
// A far-classified instance carries the far TWIN's id — never a registry key. Count
@@ -489,13 +752,27 @@ pub(super) fn tick_anim_materials(
// happens once, at spawn.
if !crate::model_render::lazy::holds(&materials, *id) {
table.free(entry.slot);
if let Some(a) = &entry.affine {
table.free(a.slot);
}
return false;
}
if drawn.contains(id) {
table.set(
entry.slot,
entry.delta(now, gseq_now, host_seq(&hosts, entry.host())),
);
// **The effect lane skips the draw gate.** That gate's argument is a material nothing
// draws costing a per-frame rebuild for ever, on a population that grows monotonically
// across a map session (B131). An effect instance is one of a handful and lives about a
// second, so there is nothing to ratchet — while making it obey the gate would mean
// threading [`AnimMatPart`] onto every fx part, card and decal, and a part that missed the
// marker would silently freeze mid-scroll. It is also what keeps the lane running inside a
// deterministic capture (`fxview`).
let fx = entry.instance_lane();
if (fx || drawn.contains(id)) && (!frozen || fx) {
let playing = host_seq(&hosts, entry.host());
table.set(entry.slot, entry.delta(now, gseq_now, playing));
// …and the rotation/scale row beside it, for the effect models that turn or scale
// their UVs rather than only sliding them (2282).
if let Some((slot, row)) = entry.affine(now, playing) {
table.set(slot, row);
}
}
true
});
@@ -504,7 +781,7 @@ pub(super) fn tick_anim_materials(
table.free(entry.slot);
return false;
}
if drawn.contains(id) {
if drawn.contains(id) && !frozen {
table.set(
entry.slot,
entry.delta(now, gseq_now, host_seq(&hosts, entry.host())),
@@ -655,9 +932,14 @@ pub fn register_tint(
/// gate's own input).
///
/// It samples the loop itself rather than watching the row over time, so it is a **one-shot that
/// works in a deterministic capture** — where [`tick_anim_materials`] is skipped entirely and the
/// rows stay zero by design. In a live run the `row` column is the tick's real output and the two
/// halves can be compared directly.
/// works in a deterministic capture** — where [`tick_anim_materials`]'s shared lanes are skipped
/// and their rows stay zero by design (the effect lane keeps running there; decision 2282). In a
/// live run the `row` column is the tick's real output and the two halves can be compared
/// directly.
///
/// A trailing block of `(unmarked)` rows names every registry entry no [`AnimMatPart`] accounts
/// for: the spell-effect lane, which is off the draw gate and therefore off the marker, plus any
/// entry whose part has gone — the readout that says a registered clone exists at all.
pub(super) fn matanim_probe(
time: Res<Time>,
uv_reg: Res<UvAnimMaterials>,
@@ -673,13 +955,74 @@ pub(super) fn matanim_probe(
// Not latched while the world is still empty: a capture holds the game clock at 0 while the
// scene builds, so "elapsed >= at" alone would fire the probe on frame one, before a single
// doodad had spawned, and report nothing.
if *fired || time.elapsed_secs() < at || parts.iter().next().is_none() {
if *fired
|| time.elapsed_secs() < at
|| (parts.iter().next().is_none() && uv_reg.0.is_empty() && tint_reg.0.is_empty())
{
return;
}
*fired = true;
let now = time.elapsed_secs();
let gseq_now = f64::from(now);
let mut rows: Vec<String> = Vec::new();
// One UV entry as a row — hoisted out of the part loop so the unvisited pass below prints
// exactly the same columns for an entry NO marked part accounts for.
let uv_row = |e: &UvAnimEntry| {
let playing = host_seq(&hosts, e.host());
// The per-placement lane has one loop per sequence, so its phase sweep is meaningless
// without a play head — it prints the head instead, and samples on the shared clock.
let (lane, period) = match &e.anim {
UvLoop::Shared(a) => (format!("shared period {:.3}s", a.period), a.period),
UvLoop::PerSeq { .. } => (format!("per-seq playing {playing:?}"), 0.0),
// The effect lane HAS a resolved loop (the clip it opened on), so unlike the
// per-placement lane its sweep is meaningful — read across the instance's own
// clip, from attach, beside the age that says where in it this frame sits.
UvLoop::Instance { attached_at, .. } => (
format!(
"per-instance age {:.3}s playing {playing:?}",
now - attached_at
),
e.resolved(playing).map_or(0.0, |l| l.period),
),
};
// Four quarter-phase samples of the loop itself: a flat set is an asset that scrolls
// nowhere, whatever the row says. The phase has to move the clock the entry actually
// reads — for the shared lane that is `now`, for a hosted one the PLAY HEAD, which is why
// the sweep substitutes the phase into `playing` rather than only into `now` (an entry
// whose host supplies a band clock ignores `now` entirely, and the sweep read flat).
let phases: Vec<String> = (0u8..4)
.map(|i| {
let t = period * f32::from(i) / 4.0;
let head = playing.map(|(seq, _)| (seq, t));
let d = e.delta(e.phase_now(t), f64::from(t), head);
format!("({:+.4},{:+.4})", d[0], d[1])
})
.collect();
let live = e.delta(now, gseq_now, playing);
// The affine half, when the transform has one: `[cos − 1, sin, sx − 1, sy − 1]`, so all
// zeros IS the identity and a flat row there says the turn/scale never reached the shader.
let affine = e.affine(now, playing).map_or(String::new(), |(slot, want)| {
let row = table.row(slot);
format!(
" · affine slot {slot} row ({:+.4},{:+.4},{:+.4},{:+.4}) live ({:+.4},{:+.4},{:+.4},{:+.4})",
row[0], row[1], row[2], row[3], want[0], want[1], want[2], want[3],
)
});
format!(
"uv slot {} seed ({:+.4},{:+.4}) row ({:+.4},{:+.4}) live ({:+.4},{:+.4}){affine} {lane} loop {}",
e.slot,
e.seed[0],
e.seed[1],
table.row(e.slot)[0],
table.row(e.slot)[1],
live[0],
live[1],
phases.join(" "),
)
};
let mut visited: std::collections::HashSet<
AssetId<benilla_assets::materials::WowModelMaterial>,
> = std::collections::HashSet::new();
for (mat, vis, vv, obj) in &parts {
// A far-classified part carries the TWIN's id; the registry is always keyed by the near
// one — the same fold `tick_anim_materials`' draw scan makes (1375).
@@ -693,36 +1036,8 @@ pub(super) fn matanim_probe(
let slots =
crate::model_render::lazy::with_material(&materials, id, |m| m.extension.anim_slots)
.unwrap_or_default();
let uv = uv_reg.0.get(&id).map(|e| {
let playing = host_seq(&hosts, e.host());
// The per-placement lane has one loop per sequence, so its phase sweep is meaningless
// without a play head — it prints the head instead, and samples on the shared clock.
let (lane, period) = match &e.anim {
UvLoop::Shared(a) => (format!("shared period {:.3}s", a.period), a.period),
UvLoop::PerSeq { .. } => (format!("per-seq playing {playing:?}"), 0.0),
};
// Four quarter-phase samples of the loop itself: a flat set is an asset that scrolls
// nowhere, whatever the row says.
let phases: Vec<String> = (0u8..4)
.map(|i| {
let t = period * f32::from(i) / 4.0;
let d = e.delta(t, f64::from(t), playing);
format!("({:+.4},{:+.4})", d[0], d[1])
})
.collect();
let live = e.delta(now, gseq_now, playing);
format!(
"uv slot {} seed ({:+.4},{:+.4}) row ({:+.4},{:+.4}) live ({:+.4},{:+.4}) {lane} loop {}",
e.slot,
e.seed[0],
e.seed[1],
table.row(e.slot)[0],
table.row(e.slot)[1],
live[0],
live[1],
phases.join(" "),
)
});
visited.insert(id);
let uv = uv_reg.0.get(&id).map(&uv_row);
let tint = tint_reg.0.get(&id).map(|e| {
format!(
"tint slot {} row ({:+.4},{:+.4},{:+.4})",
@@ -746,6 +1061,17 @@ pub(super) fn matanim_probe(
u8::from(vv.is_some_and(|v| v.get())),
));
}
// **The entries no marked part accounts for.** The spell-effect lane is exactly this
// population by design — its parts carry no [`AnimMatPart`] because they skip the draw gate
// (`tick_anim_materials`) — and so is every genuine leak: a registered material whose part is
// gone, or whose marker was never inserted. Without this pass the probe could not see the
// claw trail at all, which is the one thing decision 2282 built it to read.
for (id, e) in &uv_reg.0 {
if visited.contains(id) {
continue;
}
rows.push(format!("matanim (unmarked) mat {id} {}", uv_row(e)));
}
rows.sort();
bevy::log::info!(
"matanim probe at {now:.2}s — {} animated part(s), {} uv / {} tint registry entries",
@@ -783,6 +1109,91 @@ fn probe_at() -> Option<f32> {
mod delta_tests {
use super::*;
/// An effect-lane entry over one shared translation loop, attached at `attached_at`.
fn fx_entry(attached_at: f32) -> UvAnimEntry {
UvAnimEntry {
anim: UvLoop::Instance {
seqs: None,
single: Some(uv_loop()),
host: Entity::from_raw_u32(11).expect("a valid test entity"),
attached_at,
},
slot: 9,
seed: [0.0, 0.0],
affine: None,
}
}
/// **The effect lane reads its INSTANCE's play head, not the scene clock** (decision 2282).
/// The band clock is the clip time the host's `AnimationPlayer` reports, so two casts a minute
/// apart are at the same point of their own scroll — the property a shared-material lane
/// structurally cannot have, and the one Swipe's one-shot reveal rests on.
#[test]
fn the_effect_lane_reads_its_own_play_head() {
let anim = uv_loop();
let want = anim.sample(0.75);
for (attached_at, now) in [(0.0_f32, 0.75_f32), (600.0, 600.75), (600.0, 12.0)] {
let d = fx_entry(attached_at).delta(now, f64::from(now), Some((0, 0.75)));
assert!(
(d[0] - benilla_assets::quantize(want[0], 4096.0)).abs() < 1e-4
&& (d[1] - benilla_assets::quantize(want[1], 4096.0)).abs() < 1e-4,
"attach {attached_at}, scene {now}: the play head decides, {d:?}"
);
}
}
/// With no player to ask — the frame before an effect's rig arms, or a model that never arms
/// one — the band clock degrades to the instance's own AGE, which is `MatAnim`'s degrade next
/// door and not the free-running scene clock a shared entry would read.
#[test]
fn a_player_less_effect_falls_back_to_its_age() {
let anim = uv_loop();
let want = anim.sample(0.5);
let d = fx_entry(600.0).delta(600.5, 600.5, None);
assert!(
(d[0] - benilla_assets::quantize(want[0], 4096.0)).abs() < 1e-4,
"age 0.5 s at scene 600.5 s: {d:?}"
);
}
/// The **affine** half (2019's encoding, zero = identity): sampled on the same clocks as the
/// translation beside it, and absent — never a zeroed row — when the transform only slides.
/// `Spells\GroundingTotem_Impact.m2` is the scale-only case this stands for.
#[test]
fn the_effect_lane_samples_rotation_and_scale_on_the_same_clock() {
let mut e = fx_entry(100.0);
assert!(
e.affine(100.5, Some((0, 0.5))).is_none(),
"a translation-only transform takes no affine row"
);
e.affine = Some(FxAffine {
rot: None,
scale: Some(std::sync::Arc::new(
benilla_formats::SeqLoops::new(vec![Some(benilla_formats::UvAnim {
period: 2.0,
step: false,
wrap: false,
gseq: false,
keys: vec![(0.0, [1.0, 1.0]), (2.0, [0.25, 0.25])],
})])
.expect("one animating slot"),
)),
slot: 12,
});
let (slot, row) = e.affine(100.0, Some((0, 1.0))).expect("the affine row");
assert_eq!(slot, 12);
// Half-way: scale 0.625, encoded as `sx − 1`; rotation untouched, so `cos − 1` and `sin`
// are the identity's zeros.
assert!(
row[0].abs() < 1e-6 && row[1].abs() < 1e-6,
"no rotation authored: {row:?}"
);
assert!(
(row[2] + 0.375).abs() < 1e-3 && (row[3] + 0.375).abs() < 1e-3,
"scale 0.625 as a delta from the identity: {row:?}"
);
}
fn uv_loop() -> std::sync::Arc<benilla_formats::UvAnim> {
std::sync::Arc::new(benilla_formats::UvAnim {
period: 2.0,
@@ -804,6 +1215,7 @@ mod delta_tests {
anim: UvLoop::Shared(anim.clone()),
slot: 3,
seed,
affine: None,
};
for t in [0.0_f32, 0.35, 1.0, 1.7] {
let d = entry.delta(t, f64::from(t), None);
@@ -856,6 +1268,7 @@ mod delta_tests {
},
slot: 5,
seed: [0.0, 0.0],
affine: None,
};
// On slot 1, past its step key: the whole V flip shows.
let d = entry.delta(0.0, 0.0, Some((1, 3.0)));
+3 -2
View File
@@ -41,9 +41,10 @@ mod mat_anim;
pub(crate) use lazy::{LazyRig, SkinnedTwin};
use mat_anim::tick_anim_materials;
pub use mat_anim::{
playing_seq, register_tint, register_uv, sample_mat_anim, AnimMatPart, MatAnim,
TintAnimMaterials, TintLoop, UvAnimMaterials, UvLoop,
playing_seq, register_fx_uv, register_tint, sample_mat_anim, AnimMatPart, FxUvLoops, MatAnim,
TintAnimMaterials, TintLoop, UvAnimMaterials,
};
pub(crate) use mat_anim::{register_uv, UvLoop};
/// The client's single global `rand()` stream — the MSVC LCG at `0x7400e5`, returning `[0, 32767]`
/// (wow-re `doodad-anim-host.md` §5, decision 0768). Every doodad's variation roll draws from **one**
+11
View File
@@ -458,6 +458,17 @@ impl ParticleEmitter {
self.anchor
}
/// The **emission clock's host** ([`EmitClock::Host`]) — the model instance whose live
/// `AnimationPlayer` names the sequence this emitter samples its rate/gate tracks against;
/// `None` for a pinned or effect clock. The read an instrument or a wiring test needs, because
/// the difference is invisible in every other observable: a hosted and a pinned emitter build,
/// pool, tick and draw identically, and differ only in *which* sequence's tracks they read —
/// which is the whole difference between a firearm's muzzle blast firing and never firing
/// (decision 2281).
pub fn emit_host(&self) -> Option<Entity> {
self.host
}
/// Switch this emitter to [`OwnerLoss::Drain`] — **the effect is ending, so its already-emitted
/// particles must finish rather than pop** (wow-re `ceffect-particle-drain.md` §4a).
///
+20 -4
View File
@@ -175,7 +175,19 @@ pub fn finalize_rig_worlds(
// still needs this pass for two things — clearing `pose_dirty` (or the model pass re-composes
// it every frame for ever) and, when it authors billboard/arm bones, the camera-dependent
// anchor re-seat its cards and emitters ride. The palette write alone is skipped.
mut rigs: Query<(Entity, &mut RigPose, Option<&RigSkin>, Has<AnimParked>)>,
// `Has<RigRider>`: a rider owns its slot's rows from the OTHER end (decision 1609 — the
// placement is composed in the HOST's rig frame, never from this absolute one), and decision
// 2281 gave that lane a posed arm, so an animated ranged prop arrives here with both a pose
// and a skin and must still not have its rows written twice from two different frames.
// Everything else this pass does for it — clearing `pose_dirty`, re-seating the anchors its
// emitters ride — is exactly what it needs.
mut rigs: Query<(
Entity,
&mut RigPose,
Option<&RigSkin>,
Has<AnimParked>,
Has<crate::rig_rider::RigRider>,
)>,
// B0001: the `Changed` filter reads `GlobalTransform` ticks, which conflicts with the
// mutable frame query — a `ParamSet` sequences them (the refresh set is collected first).
mut worlds_params: ParamSet<(
@@ -202,7 +214,7 @@ pub fn finalize_rig_worlds(
let refresh: Vec<Entity> = {
let roots_changed = worlds_params.p0();
rigs.iter()
.filter(|(_, rig, _, parked)| {
.filter(|(_, rig, _, parked, _)| {
!parked
&& (rig.pose_dirty
|| roots_changed.contains(rig.joints_root)
@@ -308,10 +320,13 @@ pub fn finalize_rig_worlds(
}
};
for &holder in &refresh {
let Ok((_, rig, skin, _)) = rigs.get_mut(holder) else {
let Ok((_, rig, skin, _, rider)) = rigs.get_mut(holder) else {
continue;
};
let rig = rig.into_inner();
// A rider's rows are the rider lane's (see the query's note): hand `finalize` no skin so
// it does the anchors and nothing else.
let skin = skin.filter(|_| !rider);
finalize(rig, skin, &mut globals, &mut cascade, false);
rig.pose_dirty = false;
}
@@ -319,12 +334,13 @@ pub fn finalize_rig_worlds(
// re-finalize against the fresh seat. One level deep by construction (a seat anchor's
// subtree holds no further seat anchors).
if !cascade.is_empty() {
for (holder, rig, skin, _) in &mut rigs {
for (holder, rig, skin, _, rider) in &mut rigs {
if !cascade.contains(&holder) {
continue;
}
let rig = rig.into_inner();
let mut ignore = Vec::new();
let skin = skin.filter(|_| !rider);
finalize(rig, skin, &mut globals, &mut ignore, true);
rig.pose_dirty = false;
}
+10 -2
View File
@@ -608,11 +608,19 @@ impl RigPalettes {
/// why this returns the pair and [`Self::world_palette`] (a picker read, not a precision one)
/// does not.
pub fn rider_placement(&self, slot: u16) -> Option<(Vec3, Vec3)> {
self.row_placement(slot, 0)
}
/// The same pair for an arbitrary bone of a slot. A bind-pose rider repeats one frame across
/// every row, so row 0 answers for the model; a **posed** one (decision 2281 — the flexing
/// ranged prop) does not, and asking which bone is the only way to see that its rows differ at
/// all. `None` for an unallocated slot or a bone past its length.
pub fn row_placement(&self, slot: u16, bone: u32) -> Option<(Vec3, Vec3)> {
let s = slot as usize;
if *self.slot_len.get(s)? == 0 {
if bone >= *self.slot_len.get(s)? {
return None;
}
let r = 3 * *self.table.get(s)? as usize;
let r = 3 * (*self.table.get(s)? + bone) as usize;
let o = self.origins.get(s)?;
Some((
Vec3::new(o[0], o[1], o[2]),
+120 -9
View File
@@ -49,10 +49,11 @@
//! `welds_billboard` displays whose 0841/0854 joint rig is camera-replaced per frame and so is not
//! a bind-pose rigid frame.
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
use bevy::prelude::*;
use crate::rig_anim::RigPose;
use crate::rig_palette::{rebase_origin, RigPalettes};
use crate::rig_palette::{rebase_origin, RigPalettes, RigSkin};
/// An attached model whose palette slot is placed from a bone of `host`'s rig, in `host`'s own rig
/// frame (decision 1609). Lives on the attached model's root, beside the [`crate::rig_palette::RigSkin`]
@@ -79,13 +80,28 @@ pub struct RigRider {
/// alone — they hold the last frame written, which is what a torn-down joint gets in the entity
/// lane too. [`RigPalettes::write_rider`] is idempotent, so a standing unit's riders cost one
/// compare each and never reach the upload.
///
/// **A rider that carries a pose of its OWN is posed, not repeated** (decision 2281). 1609's
/// "every row is the placement" collapse is a consequence of bind pose, and the ranged weapon prop
/// does not rest at bind pose: the reference re-arms `[CGUnit+0xd24]`'s own M2 instance to
/// BowPull(160) at `$BWP` and to BowRelease(161) / Stand(0) at `$BWR`, so its bones move. Such a
/// rider's row `b` is `F × own.model[b] × ibp[b]` — the same placement `F`, composed with the
/// prop's own model-space pose, every factor still rig-sized, so the precision property this whole
/// lane exists for is untouched. The population is one prop per unit holding a DRAWN animated
/// ranged weapon (46 of the 571 shipped weapon models author 160/161), which is why the posed arm
/// re-writes unconditionally where the rigid arm compares first.
pub(crate) fn write_rig_riders(
riders: Query<&RigRider>,
riders: Query<(Entity, &RigRider)>,
poses: Query<&RigPose>,
skins: Query<&RigSkin>,
frames: Query<&GlobalTransform>,
ibps: Res<Assets<SkinnedMeshInverseBindposes>>,
// The posed arm's row scratch, reused across frames and riders — a posed prop is re-derived
// every frame and a fresh `Vec` per rider per frame is pure churn.
mut scratch: Local<Vec<GlobalTransform>>,
mut palettes: ResMut<RigPalettes>,
) {
for rider in &riders {
for (entity, rider) in &riders {
let Ok(pose) = poses.get(rider.host) else {
continue;
};
@@ -106,11 +122,24 @@ pub(crate) fn write_rig_riders(
let origin = rebase_origin(root_g.translation());
let mut basis = root_g.affine();
basis.translation -= bevy::math::Vec3A::from(origin);
palettes.write_rider(
rider.slot,
basis * *bone * bevy::math::Affine3A::from_translation(rider.local),
origin,
);
let frame = basis * *bone * bevy::math::Affine3A::from_translation(rider.local);
// The prop's OWN pose, when it has one: compose each of its model-space bone frames onto
// the placement and let the ordinary world write lay the rows down. `write_rig_worlds`
// takes it from there — same `frame × ibp` row math, same origin word, same dirty push.
let posed = poses
.get(entity)
.ok()
.filter(|own| !own.model.is_empty())
.zip(skins.get(entity).ok())
.and_then(|(own, skin)| Some((own, skin, ibps.get(&skin.ibp)?)));
match posed {
Some((own, skin, ibp)) => {
scratch.clear();
scratch.extend(own.model.iter().map(|m| GlobalTransform::from(frame * *m)));
palettes.write_rig_worlds(skin, &scratch, ibp, origin);
}
None => palettes.write_rider(rider.slot, frame, origin),
}
}
}
@@ -118,7 +147,7 @@ pub(crate) fn write_rig_riders(
mod tests {
use super::*;
use crate::rig_palette::RigSkin;
use bevy::math::{Affine3A, DVec3};
use bevy::math::{Affine3A, DVec3, Mat4};
/// **The rig's frame is `joints_root`'s, not the host entity's** — the one distinction that
/// separates a helm sitting on a head from one sitting a saddle's height below it.
@@ -133,6 +162,8 @@ mod tests {
fn a_rider_composes_through_the_rigs_own_frame_not_the_units() {
let mut app = App::new();
app.init_resource::<RigPalettes>();
// The posed arm's asset lookup (decision 2281); `AssetPlugin` provides it in the app.
app.init_resource::<Assets<SkinnedMeshInverseBindposes>>();
// The seat: a yard and a half up and turned 90° — a mount's saddle, exaggerated so a
// composition through the wrong frame cannot land near the right answer by luck.
const SEAT: Vec3 = Vec3::new(1.0, 1.5, -2.0);
@@ -200,6 +231,86 @@ mod tests {
);
}
/// **A rider that poses writes DIFFERENT rows** (decision 2281) — the property that makes the
/// gun's bolt recoil and the bow's limbs bend without giving either the absolute-frame ULP
/// staircase 1609 removed.
///
/// The subject is the flexing prop's shape: two bones, the second displaced by its own pose,
/// hanging off a host bone. Bind-pose riders repeat one frame into every row, so the test that
/// this lane has really changed is that row 1 is **not** row 0 — and that row 1 lands where the
/// composition `F × model[1]` says, in the host's frame with the host's origin word.
#[test]
fn a_posed_rider_composes_each_bone_instead_of_repeating_the_placement() {
let mut app = App::new();
app.init_resource::<RigPalettes>();
app.init_resource::<Assets<SkinnedMeshInverseBindposes>>();
// The host: a unit standing off the origin, its own rig frame at its feet.
const AT: Vec3 = Vec3::new(-9451.0, 42.0, 61.0);
let unit = app
.world_mut()
.spawn(GlobalTransform::from(Transform::from_translation(AT)))
.id();
let mut host_pose =
crate::testing::test_rig_pose(unit, &[Vec3::ZERO, Vec3::new(0.0, 1.4, 0.0)]);
host_pose.compose();
app.world_mut().entity_mut(unit).insert(host_pose);
// Identity inverse bindposes, so a row reduces to `F × model[b]` and the arithmetic in the
// assertion is the mechanism rather than a second implementation of it.
let ibp = app
.world_mut()
.resource_mut::<Assets<SkinnedMeshInverseBindposes>>()
.add(SkinnedMeshInverseBindposes::from(vec![Mat4::IDENTITY; 2]));
let skin = {
let mut palettes = app.world_mut().resource_mut::<RigPalettes>();
RigSkin::allocate_bones(&mut palettes, 2, ibp).unwrap()
};
let slot = skin.slot;
// The prop's OWN pose: bone 1 a third of a yard down its length — stand-in for the muzzle
// bone a firearm's BowRelease swings.
const MUZZLE: Vec3 = Vec3::new(0.33, 0.0, 0.0);
let prop = app.world_mut().spawn_empty().id();
let mut prop_pose = crate::testing::test_rig_pose(prop, &[Vec3::ZERO, MUZZLE]);
prop_pose.compose();
app.world_mut().entity_mut(prop).insert((
skin,
prop_pose,
RigRider {
host: unit,
bone: 1,
local: Vec3::ZERO,
slot,
},
));
app.add_systems(Update, write_rig_riders);
app.update();
let (origin, row0) = app
.world()
.resource::<RigPalettes>()
.row_placement(slot, 0)
.expect("written");
let (_, row1) = app
.world()
.resource::<RigPalettes>()
.row_placement(slot, 1)
.expect("written");
assert_eq!(origin, AT, "still measured from the HOST's frame (1609)");
assert!(
row1.distance(row0) > 0.3,
"row 1 must carry the prop's own pose, not a copy of row 0 ({row0:?} vs {row1:?})"
);
// Row 0 is the placement itself: the host bone 1.4 yd up, rig-relative.
assert!(
row0.abs_diff_eq(Vec3::new(0.0, 1.4, 0.0), 1.0e-4),
"row 0 is the attach frame: {row0:?}"
);
assert!(
row1.abs_diff_eq(Vec3::new(0.0, 1.4, 0.0) + MUZZLE, 1.0e-4),
"row 1 is that frame composed with the prop's bone: {row1:?}"
);
}
/// **The pin** (decision 1609), in the shape decision 0974 set for the body: score both
/// routes' *rendered motion* against an f64 oracle of the identical chain, at Goldshire, with
/// real glam f32 arithmetic — not a model of it.