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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user