Merge upstream main (samwhosung/benilla edec5b22) into lighting

Ports the lighting settings into upstream's single settings registry and the lighting plugins
into game_plugins.rs. 1108 world+formats tests green; world scenes (inn, Northshire dusk,
Stormwind) render clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-17 21:17:41 +01:00
co-authored by Claude Fable 5.1
497 changed files with 25335 additions and 6061 deletions
Generated
+23
View File
@@ -624,6 +624,7 @@ dependencies = [
"num-bigint",
"rand 0.8.6",
"sha1",
"strum",
]
[[package]]
@@ -6138,6 +6139,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "svg_fmt"
version = "0.4.5"
+10
View File
@@ -12,6 +12,16 @@ authors = ["sam <samuv.eth@gmail.com>"]
# since day one, enacted at the 0530 debt sweep). Per-crate feature additions stay in the crate.
# `image` deliberately stays per-crate: its default-features/feature sets differ per consumer,
# and a workspace default-features choice can't be subtracted from downstream.
# **Lint policy** (decision 2266 §C4). `too_many_arguments` is a readability lint for plain
# functions; on a Bevy system the parameter list IS the scheduler's dependency declaration, and
# a system that reads eight resources has eight parameters by construction. The lint had fired
# 364 times in this workspace and been allowed 364 times, 281 of them on systems — a lint whose
# every verdict is overridden is not a gate, it is a tax on adding a system. Allowed once here;
# the per-site attributes are gone. `type_complexity` stays on: it fires on shapes a type alias
# or a `SystemParam` bundle genuinely fixes.
[workspace.lints.clippy]
too_many_arguments = "allow"
[workspace.dependencies]
anyhow = "1.0.102"
# bevy's `default` is `["2d", "3d", "ui"]`, and every one of those three pulls `audio`
+2 -2
View File
@@ -51,8 +51,8 @@ benilla is done when a 1.12.1 player can do everything here that they could in t
client, it looks and feels the same, and it runs from a download on Windows, Linux and macOS.
No dates; the order is what is likely, not a promise.
- Battlegrounds and meeting stones, then the long tail of small features that separates a
working client from a finished one.
- Battlegrounds, then the long tail of small features that separates a working client from a
finished one.
- Addons, options and performance, ongoing.
- The no-brainer fixes from VanillaFixes, SuperWoW and the like.
- Playable downloads for Windows, Linux and macOS. Linux first.
+3
View File
@@ -5,5 +5,8 @@ edition.workspace = true
license.workspace = true
description = "ADT terrain-tile reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021"
[lints]
workspace = true
[dependencies]
benilla-bytes = { path = "../benilla-bytes" }
+3
View File
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true
description = "The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher shim so a commit dirties ~30 lines instead of this crate (decision 0993)"
[lints]
workspace = true
[features]
# **The dev/player seam** (decisions 0026 + 1173). Default-ON, so every ordinary `cargo run`,
# capture, probe and gate builds exactly what it built before. `--no-default-features` compiles
-1
View File
@@ -145,7 +145,6 @@ fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> {
/// indoor override), the PvP tuple, write the host globals, then fire the elected zone event
/// and — independently, like the client's second site — `MINIMAP_ZONE_CHANGED` when the
/// subzone-else-zone line changed.
#[allow(clippy::too_many_arguments)]
fn feed_zone_events(
script: Option<NonSendMut<UiScript>>,
world: benilla_world::world_point::WorldPoint,
+1 -1
View File
@@ -112,7 +112,7 @@ impl Plugin for AreaTriggerPlugin {
Update,
check_area_triggers
.in_set(WorldStage::Stream)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
.in_set(crate::char_select::InWorldGated),
);
}
}
-2
View File
@@ -742,7 +742,6 @@ type AuraParts<'w, 's> = Query<
/// Note the swap keys on the *instance* alpha only: a batch whose own animated colour alpha dips is
/// a per-batch quantity the reference combines separately, and forcing those onto a blend pass would
/// change every existing unit's look.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(crate) fn apply_aura_alpha(
time: Res<Time>,
mut commands: Commands,
@@ -842,7 +841,6 @@ fn author_cards(
/// property that its attached models render off (the same reason
/// [`benilla_world::model_fade::apply_despawn_fade`] and the self feather both walk descendants, not
/// children).
#[allow(clippy::too_many_arguments)] // the author's full channel set, threaded down the walk
fn author_descendants(
entity: Entity,
alpha: f32,
+92 -54
View File
@@ -244,6 +244,7 @@ fn app_with_chains() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<crate::creature_anim::SpellKitFx>();
app.add_message::<crate::net::FieldChanged>();
app.add_message::<crate::creature_anim::SpellKitSound>();
app.add_message::<AuraProc>();
app.add_message::<crate::creature_anim::BaseAnimRecompute>();
@@ -354,7 +355,9 @@ fn a_proc_only_state_kit_arms_stealth_translucency() {
let stealthed = ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)]);
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(stealthed))
.spawn(crate::net::ObjectStore(
stealthed.into_created(benilla_protocol::messages::ObjectType::Unit),
))
.id();
app.update();
@@ -368,11 +371,12 @@ fn a_proc_only_state_kit_arms_stealth_translucency() {
assert!(n.head_tint().is_none(), "kit 312 carries no tint");
// The aura drops: the node goes and the target ramps back to opaque.
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
95, 0,
)])));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
ObjectFields::from_pairs(&[(95, 0)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.update();
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
assert!(n.alpha.is_empty(), "reaped on the aura-remove edge");
@@ -386,10 +390,10 @@ fn a_kit_with_both_procs_installs_both_nodes() {
let mut app = app_with_chains();
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[
(47, GHOST),
(95, 0x0E),
])))
.spawn(crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
))
.id();
app.update();
@@ -404,7 +408,12 @@ fn a_kit_with_both_procs_installs_both_nodes() {
fn two_auras_stack_and_unstack_through_the_slots() {
let mut app = app_with_chains();
let both = ObjectFields::from_pairs(&[(47, STEALTH), (48, GHOST), (95, 0xEE)]);
let unit = app.world_mut().spawn(crate::net::ObjectStore(both)).id();
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(
both.into_created(benilla_protocol::messages::ObjectType::Unit),
))
.id();
app.update();
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
assert!(
@@ -413,12 +422,12 @@ fn two_auras_stack_and_unstack_through_the_slots() {
);
// Stealth alone drops (slot 0 cleared, slot 1 still occupied).
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[
(48, GHOST),
(95, 0xE0),
])));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
ObjectFields::from_pairs(&[(48, GHOST), (95, 0xE0)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.update();
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
assert_eq!(n.alpha.as_slice(), [(GHOST, 0.5)]);
@@ -458,10 +467,10 @@ fn an_unmodelled_proc_only_kit_arms_nothing() {
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[
(47, SAP),
(95, 0x0E),
])))
.spawn(crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, SAP), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
))
.id();
app.update();
assert!(
@@ -606,7 +615,10 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])),
crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
),
skin,
))
.id();
@@ -623,11 +635,12 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
// The aura leaves its slot: the reference drops the tint node (`0x5ff320`) with no ease, and the
// channel goes back to identity — which is the shader's no-op word, not a colour.
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
95, 0x00,
)])));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
ObjectFields::from_pairs(&[(95, 0x00)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.update();
let tints = app
.world()
@@ -663,7 +676,10 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() {
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])),
crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
),
body,
))
.id();
@@ -683,11 +699,12 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() {
);
// The aura drops: both ends of the chain go back to identity, not just the unit's own slot.
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
95, 0x00,
)])));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
ObjectFields::from_pairs(&[(95, 0x00)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.update();
let tints = app
.world()
@@ -721,7 +738,10 @@ fn an_unchained_rig_inherits_nothing() {
};
let loose_slot = loose.slot;
app.world_mut().spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])),
crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
),
body,
));
app.world_mut().spawn(loose);
@@ -753,7 +773,10 @@ fn the_rig_free_hook_clears_a_dead_units_tint() {
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])),
crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
),
skin,
))
.id();
@@ -821,7 +844,10 @@ fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() {
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
),
rig_playing(&[(4, 1.35), (54, 1.0)]),
))
.id();
@@ -841,11 +867,12 @@ fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() {
app.update(); // idempotent
// The aura leaves the slots.
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
95, 0,
)])));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
ObjectFields::from_pairs(&[(95, 0)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.update();
assert!(app
.world()
@@ -875,7 +902,10 @@ fn a_clip_armed_under_the_freeze_comes_back_at_its_own_speed() {
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
),
rig_playing(&[(4, 1.35), (54, 1.0)]),
))
.id();
@@ -902,11 +932,12 @@ fn a_clip_armed_under_the_freeze_comes_back_at_its_own_speed() {
app.update();
assert!(clip(&app, unit, 156).0, "held on arrival");
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
95, 0,
)])));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
ObjectFields::from_pairs(&[(95, 0)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.update();
assert!(!clip(&app, unit, 156).0);
assert_eq!(
@@ -928,7 +959,10 @@ fn the_freeze_reaches_the_mount_body() {
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
),
rig_playing(&[(91, 1.0), (54, 1.0)]),
crate::entities::mount::MountChild(mount),
))
@@ -938,11 +972,12 @@ fn the_freeze_reaches_the_mount_body() {
assert!(clip(&app, unit, 91).0, "the rider");
assert!(clip(&app, mount, 5).0, "and the mount under them");
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
95, 0,
)])));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
ObjectFields::from_pairs(&[(95, 0)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.update();
assert!(!clip(&app, mount, 5).0);
assert_eq!(clip(&app, mount, 5).1, 2.0);
@@ -956,7 +991,10 @@ fn an_aura_without_the_proc_never_touches_a_clock() {
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)])),
crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
),
rig_playing(&[(4, 1.35), (54, 1.0)]),
))
.id();
+5 -5
View File
@@ -49,7 +49,7 @@ use bevy::prelude::*;
use benilla_ui::script::keybind::{AddonBindingBody, KeybindCommand, KeybindRequest};
use benilla_ui::script::UiScript;
use crate::char_select::ClientState;
use crate::char_select::InWorldGated;
use crate::ui_script::{PlayerUiHover, PointerOverUiPanel, UiKeyboardCapture};
pub(crate) mod chord;
@@ -221,9 +221,10 @@ impl Plugin for BindingsPlugin {
.chain()
.in_set(crate::ui_script::UiInput)
.in_set(BindingSet)
.before(benilla_world::schedule::WorldStage::Input)
.run_if(in_state(ClientState::InWorld)),
drain_binding_requests,
.in_set(InWorldGated),
// After the tick: the requests are Lua's own (`SaveBindings`, `RunBinding`),
// queued by handlers the tick dispatched, and a save must not wait a frame.
drain_binding_requests.after(crate::ui_script::UiInput),
),
);
}
@@ -435,7 +436,6 @@ fn sync_dispatch(script: Option<NonSendMut<UiScript>>, mut dispatch: ResMut<Bind
/// The dispatch pass — see the module doc. Runs right after the UI key feed (same frame's
/// capture gate), before `WorldStage::Input` (a bound key must act this frame, once).
#[allow(clippy::too_many_arguments)]
fn latch_and_dispatch(
script: Option<NonSendMut<UiScript>>,
mut keyboard: MessageReader<KeyboardInput>,
+1 -1
View File
@@ -2254,7 +2254,7 @@ mod tests {
/// The install's own `Bindings.xml`, parsed — `None` (and a skipped test) without a client.
fn install_bindings() -> Option<Vec<benilla_ui::bindings_xml::AddonBinding>> {
let data = benilla_formats::wow_data()?;
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open the 1.12 patch chain");
let xml = String::from_utf8_lossy(
&chain
+1 -1
View File
@@ -233,7 +233,7 @@ fn sync_shadows(
/// Re-project each shadow whose inputs moved; clear it when the box degenerates, the fade
/// reaches zero, or no receiving surface is in the box (the reference's no-ground gate).
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
#[allow(clippy::type_complexity)]
fn update_shadows(
video: Res<crate::video::VideoConfig>,
time: Res<Time>,
+28 -11
View File
@@ -7,10 +7,14 @@
//!
//! Benilla's transcription draws the two segments through Bevy's gizmo lines — the same
//! immediate-mode screen-space-width primitive class as the client's GX lines. Named
//! deviations (decision record): the string color/width are INFERRED (the callback emits a
//! packed vertex color the round didn't decode — a dark cord is used); the bow prop is not yet
//! animated (BowPull/BowRelease bend the limbs and carry the tips with them in the ref — our
//! anchors ride the static prop frame, so the limbs stay straight while the middle draws).
//! deviation (decision record): the string color/width are INFERRED (the callback emits a
//! packed vertex color the round didn't decode — a dark cord is used).
//!
//! **The tips are POSED** (decision 2281). The bow prop animates — `$BWP` arms BowPull(160) on the
//! prop's own model and `$BWR` returns it to Stand(0) — so the `$WTT`/`$WTB` markers ride limb
//! bones that bend through the draw. Composing them through the prop's rigid root frame instead
//! (which is all this file could do while the prop rested at bind pose) leaves the chord pinned to
//! the un-bent limb tips while the mesh around it moves: the string would visibly leave the bow.
use bevy::prelude::*;
@@ -28,15 +32,21 @@ const HAND_ARROW: u16 = 0x23;
pub(crate) struct Bowstring {
/// The unit wearing the bow — the HandArrow middle vertex and nock latch live on it.
pub(crate) owner: Entity,
/// The `$WTT` top / `$WTB` bottom anchors, model-local Bevy space (the prop root's frame).
pub(crate) top: Vec3,
pub(crate) bottom: Vec3,
/// The `$WTT` top / `$WTB` bottom anchors as `(bone, model-local Bevy offset)` — the bone is
/// load-bearing on an animated prop (see the module note): the limb tips move with the draw.
pub(crate) top: (u16, Vec3),
pub(crate) bottom: (u16, Vec3),
}
/// Draw every visible bow's string (per frame, post-propagation so the prop/joint frames are
/// this frame's). Two segments: tip → middle → tip.
fn draw_bowstrings(
bows: Query<(&Bowstring, &GlobalTransform, &InheritedVisibility)>,
bows: Query<(
&Bowstring,
&GlobalTransform,
&InheritedVisibility,
Option<&benilla_world::rig_anim::RigPose>,
)>,
owners: Query<(
&BoneAttach,
&benilla_world::rig_anim::RigPose,
@@ -47,12 +57,19 @@ fn draw_bowstrings(
) {
// A dark waxed-cord tone; the ref's packed vertex color is not decoded (INFERRED, §G2).
const STRING_COLOR: Color = Color::srgb(0.12, 0.10, 0.08);
for (bs, prop, vis) in &bows {
for (bs, prop, vis, flex) in &bows {
if !vis.get() {
continue;
}
let top = prop.transform_point(bs.top);
let bottom = prop.transform_point(bs.bottom);
// The prop's own pose when it flexes (`joints_root` IS this entity, so its
// `GlobalTransform` is the frame `posed_point` wants); its rigid frame otherwise, which is
// the same answer for a bow standing at bind pose.
let tip = |(bone, offset): (u16, Vec3)| {
flex.and_then(|p| p.posed_point(prop, bone, offset))
.unwrap_or_else(|| prop.transform_point(offset))
};
let top = tip(bs.top);
let bottom = tip(bs.bottom);
// The middle control point: the owner's HandArrow attach world position while the nock
// latch holds (the drawn string follows the hand), else the relaxed straight chord.
let middle = owners
-1
View File
@@ -284,7 +284,6 @@ fn load_shakes(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
struct Shakes(CameraShakeCatalog);
/// Enqueue a shake for each qualifying footfall and death thud on the frame's event stream.
#[allow(clippy::too_many_arguments)]
fn fire_shakes(
mut events: MessageReader<AnimSoundEvent>,
time: Res<Time>,
@@ -33,7 +33,6 @@ const SUBJECT_YAW: f32 = 2.36;
/// feeds then push it into the VM during the settle window exactly as live wire data would. Item
/// icons resolve through the offline `ItemDisplayCatalog` (display ids chosen from entries that
/// catalog is known to carry); names land directly in the caches (no server to ask).
#[allow(clippy::too_many_arguments)]
pub(super) fn seed_ui_fixture(
mut ctx: ResMut<CaptureCtx>,
mut commands: Commands,
+9 -4
View File
@@ -12,7 +12,7 @@ use super::{FxViewRequest, FxViewState, FXVIEW_POS};
use crate::creature_anim::FxStage;
use crate::entities::display::{empty_shell, ModelHandle};
use crate::entities::spell_fx::{
attach_effect_visuals, EffectHost, FxTintAnims, SpellFx, FALLBACK_SPAN,
attach_effect_visuals, EffectHost, FxMaterials, FxTintAnims, SpellFx, FALLBACK_SPAN,
};
/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) — a high-word `0xF130` creature guid
@@ -29,7 +29,6 @@ const FXVIEW_GO_GUID: u64 = (0xF110u64 << 48) | 0xFC0FEE;
/// through the same [`attach_effect_visuals`] body the game uses, and (for missiles) fly the
/// root along its facing so trails extend. Inert outside fxview captures (the request resource
/// only exists then).
#[allow(clippy::too_many_arguments)]
pub(crate) fn drive_fx_view(
req: Option<Res<FxViewRequest>>,
state: Option<ResMut<FxViewState>>,
@@ -39,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,
@@ -216,8 +217,12 @@ pub(crate) fn drive_fx_view(
} else {
FxStage::OneShot
}),
&mut wow_materials,
&mut tint_reg,
&mut FxMaterials {
store: &mut wow_materials,
tint: &mut tint_reg,
uv: &mut uv_reg,
table: &mut anim_table,
},
&ibps,
&mut palettes,
None, // the fixture previews a kit effect on its model's own `Stand`
+2 -2
View File
@@ -126,7 +126,7 @@ fn burst_path(out: &str, index: u32, count: u32) -> String {
/// filter instead of the scene. That cost a whole B38 session: eight bursts and three retracted
/// mechanisms measured on a corpse, because the images still looked like a plausible frame. A loud
/// no-op is worth far more than a burst that has to be recognised as garbage after the fact.
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
#[allow(clippy::type_complexity)] // one Bevy system's full input set
fn fire_live_shot(
mut shot: ResMut<LiveShot>,
time: ProbeClock,
@@ -134,7 +134,7 @@ fn fire_live_shot(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
subjects: Query<(&Guid, &Transform), (With<NetEntity>, Without<SelfPlayer>)>,
camera: Query<(&Camera, &Transform), With<WorldCamera>>,
mut names: ResMut<NameCache>,
names: Res<NameCache>,
net_commands: Res<NetCommands>,
mut refused: Local<bool>,
mut next_require_warn: Local<f32>,
+7 -3
View File
@@ -92,6 +92,7 @@ mod phase_probe;
mod pick_probe;
mod probe_auction;
mod probe_bank;
mod probe_bg;
mod probe_bg_queue;
mod probe_binder;
mod probe_book;
@@ -101,6 +102,7 @@ mod probe_charter;
mod probe_chest;
mod probe_clam;
mod probe_crossing;
pub(crate) mod probe_env;
mod probe_gm_ticket;
mod probe_goquest;
mod probe_guard_poi;
@@ -110,6 +112,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;
@@ -122,6 +125,7 @@ pub(crate) use phase_probe::PhaseProbePlugin;
pub(crate) use pick_probe::PickProbePlugin;
pub(crate) use probe_auction::ProbeAuctionPlugin;
pub(crate) use probe_bank::ProbeBankPlugin;
pub(crate) use probe_bg::ProbeBgPlugin;
pub(crate) use probe_bg_queue::ProbeBgQueuePlugin;
pub(crate) use probe_binder::ProbeBinderPlugin;
pub(crate) use probe_book::ProbeBookPlugin;
@@ -140,6 +144,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::{
@@ -912,7 +917,6 @@ impl Plugin for CapturePlugin {
/// Each frame, force the deterministic capture conditions: pinned time-of-day, no perf HUD, and the
/// fixed camera pose. Runs in `WorldStage::Present` (after `control` is gated off and after terrain
/// streaming reads the camera), so the harness is the sole, stable author of the view.
#[allow(clippy::too_many_arguments)]
fn pin_scene(
ctx: Res<CaptureCtx>,
mut debug: ResMut<DebugState>,
@@ -992,7 +996,8 @@ fn hold_clock(mut clock: ResMut<Time<Virtual>>) {
/// The three scene-population queries the `FPS_PROBE` line prints — bundled because they are one
/// concern (how much world is resident, and how much of it survived the cull) and because
/// `drive_capture` sits against Bevy's 16-parameter ceiling, which `cvars::KnobParams` hit first.
/// `drive_capture` sits against Bevy's 16-parameter ceiling, which the CVar host's old knob
/// bundle hit first (retired by 2303).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct ProbeCensus<'w, 's> {
particles: Query<'w, 's, &'static benilla_world::particles::ParticleEmitter>,
@@ -1001,7 +1006,6 @@ pub(crate) struct ProbeCensus<'w, 's> {
}
/// Drive the capture lifecycle: wait for streaming, settle, screenshot, exit.
#[allow(clippy::too_many_arguments)]
fn drive_capture(
mut ctx: ResMut<CaptureCtx>,
mut watch: ResMut<FrameWatch>,
@@ -447,7 +447,7 @@ fn error_name(error: u32) -> &'static str {
}
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
#[allow(clippy::too_many_lines)]
fn auction_probe(
time: ProbeClock,
mut probe: ResMut<AuctionProbe>,
@@ -202,7 +202,6 @@ fn find_bank_slot(store: &ObjectStore, guid: u64) -> Option<u8> {
(0..24u8).find(|&i| store.0.player_bank_slot(i) == Some(guid))
}
#[allow(clippy::too_many_arguments)]
fn bank_probe(
time: ProbeClock,
mut probe: ResMut<BankProbe>,
File diff suppressed because it is too large Load Diff
@@ -95,7 +95,6 @@ enum Phase {
}
// One Bevy system's full input set (the guard-poi probe's shape).
#[allow(clippy::too_many_arguments)]
fn bg_queue_probe(
time: ProbeClock,
mut probe: ResMut<BgQueueProbe>,
@@ -267,7 +267,6 @@ fn vm_bind_location(script: &UiScript) -> String {
.unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
fn binder_probe(
time: ProbeClock,
mut probe: ResMut<BinderProbe>,
@@ -234,7 +234,6 @@ fn report(label: &str, s: &[Sample]) {
);
}
#[allow(clippy::too_many_arguments)]
fn book_probe(
time: ProbeClock,
mut probe: ResMut<BookProbe>,
+1 -1
View File
@@ -141,7 +141,7 @@ impl Plugin for ProbeCamPlugin {
drive_probe_cam
.in_set(WorldStage::Input)
.before(crate::player::PlayerControlSet)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
.in_set(crate::char_select::InWorldGated),
);
}
}
@@ -453,13 +453,12 @@ fn probe_guild_name(prefix: &str) -> String {
format!("{prefix} {secs:08}")
}
#[allow(clippy::too_many_arguments)]
fn charter_probe(
time: ProbeClock,
mut probe: ResMut<CharterProbe>,
gossip: Res<GossipState>,
registrar: Res<GuildRegistrarState>,
mut items: ResMut<Items>,
items: Res<Items>,
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
player: Res<Player>,
@@ -211,7 +211,6 @@ fn report(label: &str, expect: u16, seen: &[Option<u16>]) -> u32 {
}
}
#[allow(clippy::too_many_arguments)]
fn chest_probe(
time: ProbeClock,
mut probe: ResMut<ChestProbe>,
+3 -4
View File
@@ -184,7 +184,7 @@ fn window_open(script: &UiScript, loot: &LootState) -> bool {
fn find_in_backpack(
store: &ObjectStore,
entry: u32,
items: &mut Items,
items: &Items,
net: &NetCommands,
) -> Option<(u64, u32)> {
let (i, guid) = (0..16u8)
@@ -204,13 +204,12 @@ fn find_in_backpack(
.map(|_| (guid, u32::from(i) + 1))
}
#[allow(clippy::too_many_arguments)]
fn clam_probe(
time: ProbeClock,
mut probe: ResMut<ClamProbe>,
script: Option<NonSendMut<UiScript>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
mut items: ResMut<Items>,
items: Res<Items>,
mut cfg: ResMut<LootConfig>,
loot: Res<LootState>,
latch: Res<LootLatch>,
@@ -241,7 +240,7 @@ fn clam_probe(
probe.phase = Phase::Stocking { sent_at: now };
}
Phase::Stocking { sent_at } => {
if let Some((guid, slot)) = find_in_backpack(store, entry, &mut items, &net) {
if let Some((guid, slot)) = find_in_backpack(store, entry, &items, &net) {
info!(
"PROBE_CLAM: item {entry} is guid {guid:#x} in backpack slot {slot} — \
sampling {CONTROL_FRAMES} control frames with it UNCLICKED"
+567
View File
@@ -0,0 +1,567 @@
//! The probe fleet's **environment registry** — every `WOW_PROBE*` variable the app reads, in one
//! table the code is checked against in both directions (decision 2265 §A5).
//!
//! The set used to live in three places that could not see each other: the read sites (one per
//! variable, spread over the fleet), a hand-kept arming list in `dev.rs` for the un-occludable
//! window ([`super::ProbeFocusPlugin`], decision 0906), and nothing at all that said what the
//! fleet accepts. The arming list is the one that bit: 0906's rule is that *every* scripted
//! probe defends itself against the macOS occlusion throttle, and the list had drifted to ten of
//! the twenty-five variables that schedule on the wall clock — a mail or auction probe launched
//! exactly as the method prescribes ran covered, at ~1 fps, measuring garbage.
//!
//! So the list is now a **column** here ([`ProbeVar::wall_clock`]), the arming reads the column,
//! and two structural tests below keep the table honest: every `"WOW_PROBE…"` string literal in
//! the crate is a row, every row is a literal somewhere other than this file, and every value
//! `WOW_PROBE` itself dispatches on is in [`PROBE_NAMES`]. `WOW_PROBE=list` prints it.
//!
//! What this is **not**: a dispatcher. The variables keep their own read sites and their own
//! shapes — several carry free-form values (Lua chunks, chat lines with spaces and `=`, `A>B`
//! drag specs) and several combine in one run — so folding them into one variable would need an
//! escaping mini-language and would change the launch line every session uses. The defect was
//! the drift, and a registry is the whole cure for drift.
/// One probe-fleet environment variable.
pub(crate) struct ProbeVar {
/// The variable, exactly as the read site spells it.
pub name: &'static str,
/// One line: the value shape and what setting it does, from the comment at the read site.
pub purpose: &'static str,
/// Whether the variable **schedules on elapsed real time** — its script fires at `<secs>`
/// marks, integrates a rate per frame, or steps a phase machine on `Time<Real>` — so the run
/// is only right while frames keep arriving at full rate.
///
/// Why it matters (decision 0906): macOS drops a fully covered window to ~1 fps drawables,
/// and on such a window a wall-clock probe does not measure slowly, it **runs the wrong
/// script** — one leg fired `W@16` and `Space@19` in the same frame and jumped from a
/// standstill; another integrated its camera at 125 yd/s for 500 and never crossed the
/// radius it was testing (0794). Every `true` row therefore arms
/// [`super::ProbeFocusPlugin`] from `dev.rs`, which keeps the probe window un-occludable
/// (and, for a run that draws no pixels, parks it small in a corner — decision 1148).
///
/// A `false` row is a flag or a modifier: a `_AT`/`_STEP`/`_KEEP` rides its parent's
/// arming, a pricing lever changes *what* a run draws rather than *when*, and a state
/// machine that advances on wire replies (the char-create probe) has no clock to be robbed of.
pub wall_clock: bool,
}
/// Every `WOW_PROBE*` variable the app reads. Grouped by the probe that owns each; the order is
/// the order `WOW_PROBE=list` prints.
pub(crate) const PROBE_VARS: &[ProbeVar] = &[
// ── The variable itself, and the run shell every scripted probe rides ────────────────────
ProbeVar {
name: "WOW_PROBE",
purpose: "<name> — a value-dispatched live probe (see the names below); `list` prints this table",
// Five of the six values step a phase machine on `time.elapsed_secs` (melee's 3 s swing
// cadence, crossing/taxi/guardpoi's phases, castcancel's press-at-t); `partner` answers
// invites the frame they land, but rides the same variable.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_EXIT_AT",
purpose: "<secs> — exit the app after N wall seconds; bounds any scripted live probe's lifetime",
// Fires on `ProbeClock` at `<secs>`, and it is the one probe variable a trace-only run
// sets on its own (`WOW_MOVE_TRACE`/`WOW_STREAM_TRACE` legs, method.md) — 0794's
// throttled camera leg was exactly such a run.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_PARK",
purpose: "corner|edge|off — where the un-occludable probe window sits, and whether it is pinned on top",
// A dial ON the occlusion defence itself, not a schedule.
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_RESIZE",
purpose: "\"<secs>:<W>x<H>\" — resize the primary window mid-run (the headless fullscreen-toggle stand-in)",
// Fires on `ProbeClock` at `<secs>`.
wall_clock: true,
},
// ── The actuation channels: chat, keys, Lua, pointer ─────────────────────────────────────
ProbeVar {
name: "WOW_PROBE_CHAT",
purpose: "\"<line>[;<line>…]\" — send each line as chat once in-world; the park-the-probe-anywhere instrument (`.go xyz …`)",
// Line `n` is due at `at + every * n` on `ProbeClock`.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_CHAT_AT",
purpose: "<secs> — when the first chat line goes out (default 8)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_CHAT_EVERY",
purpose: "<secs> — space the chat lines apart instead of one burst (do X, wait, then do Y)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_KEY",
purpose: "\"<key>@<secs>[:<hold>][;…]\" — synthesize key presses once in-world (a jump, a mount flourish, a held W)",
// Each tap fires at its `@<secs>` and releases `<hold>` later, on `ProbeClock`.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_LUA",
purpose: "\"<chunk>\" — run a Lua chunk in the live UI VM once per world entry (the press-the-button-headlessly instrument)",
// First fire at `_AT` seconds, re-armed `_AGAIN` seconds into every later world entry.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_LUA_AT",
purpose: "<secs> — when the chunk runs after the first world entry (default 10)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_LUA_AGAIN",
purpose: "<secs> — how long after every LATER world entry (a relog) the chunk runs again (default 4)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_HOVER",
purpose: "\"<frame>[;<frame>…]\" — sweep the real pointer across the named frames' centres, pressing nothing",
// Starts at `_AT`, one move per `_STEP` seconds, alternating on `_DUTY` — all wall seconds.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_HOVER_AT",
purpose: "<secs> — when the sweep starts (default 14)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_HOVER_STEP",
purpose: "<secs> — seconds per pointer move (default 0.25; ~0.016 sweeps at frame rate, the director's gesture)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_HOVER_JITTER",
purpose: "<px> — walk the pointer inside a px box each step so it moves while the hovered frame stays put",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_HOVER_DUTY",
purpose: "<on>:<off> — sweep for `on` seconds, park over nothing for `off`, repeat (a leg that alternates can be read)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_DRAG",
purpose: "\"<From>><To>[;…]\" — drag one named frame onto another through the real pointer path, one gesture step per frame",
// Starts at `_AT`, advances one step per `_STEP` seconds, on `ProbeClock`.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_DRAG_AT",
purpose: "<secs> — when the first drag starts (default 14)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_DRAG_STEP",
purpose: "<secs> — seconds per gesture step (default 0.1); a press and release in one frame is a click, not a drag",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_DRAG_LUA",
purpose: "\"<chunk>\" — a Lua chunk evaluated after each drag whose string result is logged as the report",
wall_clock: false,
},
// ── The scripted controller inputs (decisions 0621/0653) ────────────────────────────────
ProbeVar {
name: "WOW_PROBE_LOOK",
purpose: "\"<deg_per_sec>@<start_s>:<duration_s>[;…]\" — the scripted mouse-turn: turn the avatar's aim at a rate for a while",
// `90°/s for 6 s` has to produce 540° whatever the frame rate did; integrates rate × dt
// between `at` and `until` on `ProbeClock`.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_PITCH",
purpose: "\"<deg>@<start_s>[:<deg_per_sec>][;…]\" — the scripted dive: aim a swimming avatar's nose up or down (+up)",
// A dive scripted to reach 30° by second 25 has to reach it at second 25, and it is the
// one script that can be a run's only actuator (a drifting swimmer needs no keys).
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_CAM",
purpose: "\"<yaw_deg>,<pitch_deg>[,<dist_yd>]@<start_s>[:<pan_deg_per_s>][;…]\" — park the third-person camera at absolute poses",
// Each pose takes over at its `@<start>` and pans at its rate from then, on `ProbeClock`.
wall_clock: true,
},
// ── The FPS probe's dials (`WOW_LIVE_FPS`, the capture harness) ─────────────────────────
ProbeVar {
name: "WOW_PROBE_UNCAP",
purpose: "immediate|vsync — the FPS probe's present mode instead of the measured-best AutoNoVsync",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_VSYNC",
purpose: "1 — keep vsync ON in the FPS probe: it then measures the present ceiling the display grants this window",
wall_clock: false,
},
// ── The pricing levers and traces: change what a run draws or logs, never when ───────────
ProbeVar {
name: "WOW_PROBE_UI_ONE_TEX",
purpose: "1 — pricing lever: split UI runs on state flags alone, ignoring texture identity (the draw-count ceiling an atlas would reach)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_SHARED_SKIN",
purpose: "1 — pricing lever: share character-skin materials across bodies (see `char_skin::build_char_skin_materials`)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_NAME_TRACE",
purpose: "1 — per-frame NAME_TRACE lines for the self player's plate seat (a smoothness question is measured, never eyeballed)",
wall_clock: false,
},
// ── The `=1` live probes: each parks the body somewhere real and steps a phase machine ───
// Every one below reads `time.elapsed_secs_f64()` into a `since`/`now` phase machine —
// waits, settles and timeouts in wall seconds — so each arms the occlusion defence.
ProbeVar {
name: "WOW_PROBE_BG_SAMPLES",
purpose: "n — how many 12 s census samples WOW_PROBE_BG takes inside the battleground (default 12; ~30 reaches vmangos's 5-minute premature finish, i.e. the end of a match)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_BG",
purpose: "wsg|ab|av — queue for that battleground, take the port through the stock verb and census the instance from inside",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_BGQUEUE",
purpose: "1 — level past the bracket floor, greet Stormwind's Warsong Gulch battlemaster and queue through his list",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_MAIL",
purpose: "1 — GM-mail the probe's own character, open the Goldshire mailbox and drive inbox/take/send/delete through the VM",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_AUCTION",
purpose: "1 — GM-hop to a Stormwind auctioneer and drive browse/throttle/sell/owner-list/cancel through the VM",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_BANK",
purpose: "1 — GM-hop to a pure banker and drive the six-opcode bank wire (activate/deposit/withdraw/buy-slot/refusal)",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_BINDER",
purpose: "1 — GM-hop to an innkeeper, select the bind row and answer the confirm through the VM's own ConfirmBinder()",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_SERVICE",
purpose: "1 — right-click four real NPCs, one per UNIT_NPC_FLAGS shape, and report the service-ladder arm and the window that opened",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_VENDOR_SWAP",
purpose: "1 — open one Goldshire vendor over the other's window and read the `npc` token, title and portrait at MERCHANT_SHOW",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_MODEL_CAMERA",
purpose: "1 — build a plain <Model> pane on a camera-bearing file and read the renderer's camera and root back",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_GMTICKET",
purpose: "1 — drive the five-opcode GM ticket wire through the VM (status, clean slate, file, edit, abandon)",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_CHARTER",
purpose: "1 — buy a guild charter at the Stormwind registrar, open it with a real bag right-click and rename it",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_BOOK",
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",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_GOQUEST",
purpose: "1 or <x>,<y>,<z>[,<map>] — park at a GameObject questgiver and report the dialog status below and above MinLevel",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_CLAM",
purpose: "1 or <entry> — stock an openable item, right-click it through UseContainerItem and report whether a loot window opens",
wall_clock: true,
},
// ── Modifiers of the value-dispatched probes ────────────────────────────────────────────
ProbeVar {
name: "WOW_PROBE_DOCK",
purpose: "x,y,z[,map] — the dock `WOW_PROBE=crossing` boards from (map defaults to 0 and must be sent)",
wall_clock: false,
},
// ── The character-select probe: advances on wire replies, not on a clock ───────────────
ProbeVar {
name: "WOW_PROBE_CHARCREATE",
purpose: "\"<name>[,race,class,gender[,skin,face,hair,haircolor,facial]]\" — create (and delete) a character at select over the wire",
// A roster/result state machine: `AwaitingRoster` → create → result byte → delete →
// result byte, each step on the reply that arrives. No clock read anywhere in it.
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_CHARCREATE_KEEP",
purpose: "1 — keep the character the probe created instead of deleting it",
wall_clock: false,
},
];
/// The named values of `WOW_PROBE` itself, `(value, purpose)` — the one place they are listed.
/// `dev.rs` dispatches on the literals (one plugin per value) and `lib.rs` answers `list`
/// before any window opens; the test below keeps both in step with this table.
pub(crate) const PROBE_NAMES: &[(&str, &str)] = &[
("list", "print this table and exit, before any window opens"),
(
"melee",
"auto-fight the nearest enemy so the dbg-trace sink can record the combat-text timeline",
),
(
"partner",
"the second client that says yes: auto-accept every group invite and duel challenge (0434/0637)",
),
(
"crossing",
"board a cross-continent boat and report the map seam surviving (0455)",
),
(
"taxi",
"open the flight-master menu on the wire and ride Stormwind → Sentinel Hill to a verdict (0484)",
),
(
"guardpoi",
"ask a Stormwind guard for the weapons trainer and check the SMSG_GOSSIP_POI marker field by field",
),
(
"castcancel",
"hearth and press W mid-cast — the local self-cancel's end-to-end timing instrument",
),
];
/// The variables whose presence arms the un-occludable probe window — the `wall_clock` column.
pub(crate) fn wall_clock_vars() -> impl Iterator<Item = &'static str> {
PROBE_VARS.iter().filter(|v| v.wall_clock).map(|v| v.name)
}
/// `WOW_PROBE=list` — print the registry, one variable per line, then the named values.
pub(crate) fn print() {
println!("The probe fleet's environment (capture::probe_env, decision 2265 §A5).");
println!(
"wall-clock: the variable schedules on elapsed real time and arms the un-occludable probe"
);
println!("window (ProbeFocusPlugin, decision 0906) — covered, such a run drops to ~1 fps and");
println!("executes the wrong script.");
println!();
println!("{:<26} {:<10} PURPOSE", "VARIABLE", "WALL-CLOCK");
for v in PROBE_VARS {
let wc = if v.wall_clock { "yes" } else { "-" };
println!("{:<26} {:<10} {}", v.name, wc, v.purpose);
}
println!();
println!("WOW_PROBE=<name>:");
for (name, purpose) in PROBE_NAMES {
println!(" {name:<12} {purpose}");
}
}
#[cfg(test)]
mod tests {
use super::*;
/// This file's own path under `src/` — excluded from both scans, since its table and its
/// tests are the one place the literals are *supposed* to appear without being read.
const SELF: &str = "capture/probe_env.rs";
/// **The table cannot drift from the code, in either direction** (decision 2265 §A5).
///
/// Every `"WOW_PROBE…"` string literal in the crate must be a row here — a variable the
/// fleet reads that the registry does not know is exactly the hand-maintained gap this
/// module replaced — and every row must be a literal somewhere other than this file, or the
/// registry is describing a variable nothing reads. String literals only: doc comments
/// write the backticked form and are skipped by line.
#[test]
fn every_probe_variable_is_registered_and_every_registered_variable_is_read() {
let registered: Vec<&str> = PROBE_VARS.iter().map(|v| v.name).collect();
for (i, name) in registered.iter().enumerate() {
assert!(
!registered[..i].contains(name),
"`{name}` is registered twice"
);
assert!(
name.starts_with("WOW_PROBE"),
"`{name}` is not a probe variable — the registry is `WOW_PROBE*` only"
);
}
let mut read: Vec<(String, String)> = Vec::new(); // (literal, file)
for (rel, text) in crate_sources() {
if rel == SELF {
continue;
}
for lit in probe_literals(&text) {
read.push((lit, rel.clone()));
}
}
let unregistered: Vec<String> = read
.iter()
.filter(|(lit, _)| !registered.contains(&lit.as_str()))
.map(|(lit, file)| format!("{file}: \"{lit}\""))
.collect();
assert!(
unregistered.is_empty(),
"these `WOW_PROBE…` literals are read but not in `PROBE_VARS` — add a row (name, \
purpose from the read site's comment, and whether it schedules on the wall clock):\n {}",
unregistered.join("\n ")
);
let unread: Vec<&str> = registered
.iter()
.copied()
.filter(|name| !read.iter().any(|(lit, _)| lit == name))
.collect();
assert!(
unread.is_empty(),
"these `PROBE_VARS` rows are not a string literal anywhere else in the crate — \
nothing reads them; drop the row or restore the read site:\n {}",
unread.join("\n ")
);
}
/// **`WOW_PROBE`'s named values are listed once.** Every `Ok("<name>")` the code compares
/// `WOW_PROBE`'s value against (the `dev.rs` plugin dispatch, the `lib.rs` `list` exit) must
/// be a `PROBE_NAMES` row, and every row must be dispatched on — so `WOW_PROBE=list` prints
/// the values that exist, not the ones someone remembered.
#[test]
fn every_dispatched_probe_name_is_listed_and_every_listed_name_is_dispatched() {
let listed: Vec<&str> = PROBE_NAMES.iter().map(|(n, _)| *n).collect();
for (i, name) in listed.iter().enumerate() {
assert!(!listed[..i].contains(name), "`{name}` is listed twice");
}
let mut dispatched: Vec<(String, String)> = Vec::new(); // (value, file)
for (rel, text) in crate_sources() {
if rel == SELF {
continue;
}
for value in dispatched_probe_values(&text) {
dispatched.push((value, rel.clone()));
}
}
assert!(
!dispatched.is_empty(),
"no `WOW_PROBE` value dispatch found anywhere — the scanner has lost its needle"
);
let unlisted: Vec<String> = dispatched
.iter()
.filter(|(v, _)| !listed.contains(&v.as_str()))
.map(|(v, file)| format!("{file}: WOW_PROBE={v}"))
.collect();
assert!(
unlisted.is_empty(),
"these `WOW_PROBE` values are dispatched on but not in `PROBE_NAMES`:\n {}",
unlisted.join("\n ")
);
let undispatched: Vec<&str> = listed
.iter()
.copied()
.filter(|name| !dispatched.iter().any(|(v, _)| v == name))
.collect();
assert!(
undispatched.is_empty(),
"these `PROBE_NAMES` rows are not dispatched on anywhere (`var(\"WOW_PROBE\")… == \
Ok(\"<name>\")`):\n {}",
undispatched.join("\n ")
);
}
/// Every `.rs` under this crate's `src/`, as `(path under src/, text)`.
fn crate_sources() -> Vec<(String, String)> {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut out = Vec::new();
let mut stack = vec![src.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("crate source dir is readable") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
let rel = path
.strip_prefix(&src)
.expect("under src")
.to_string_lossy()
.replace('\\', "/");
let text = std::fs::read_to_string(&path).expect("source is readable");
out.push((rel, text));
}
}
}
out.sort();
out
}
/// The source with every comment line (`//`, `///`, `//!`) dropped — the scans below want
/// code, and a doc comment quoting a variable is not a read of it.
fn code_lines(text: &str) -> String {
text.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
/// Every `"WOW_PROBE[A-Z0-9_]*"` string literal in the code — the whole literal, quote to
/// quote, so a variable named inside a longer message is not a match.
fn probe_literals(text: &str) -> Vec<String> {
let code = code_lines(text);
let mut out = Vec::new();
let mut rest = code.as_str();
while let Some(i) = rest.find("\"WOW_PROBE") {
let after = &rest[i + 1..];
let end = after
.find(|c: char| !(c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'))
.unwrap_or(after.len());
if after[end..].starts_with('"') {
out.push(after[..end].to_string());
}
rest = &rest[i + 1..];
}
out
}
/// Every value the code compares `WOW_PROBE` against: `var("WOW_PROBE")` followed, within the
/// same expression, by `Ok("<value>")`.
fn dispatched_probe_values(text: &str) -> Vec<String> {
let code = code_lines(text);
let needle = "var(\"WOW_PROBE\")";
let mut out = Vec::new();
let mut rest = code.as_str();
while let Some(i) = rest.find(needle) {
let after = &rest[i + needle.len()..];
// The comparison sits right after the read: `.as_deref() == Ok("…")`. Bound the
// look-ahead so a bare `is_ok()` gate is not paired with some later `Ok("…")`.
let window = &after[..after.len().min(48)];
if let Some(j) = window.find("Ok(\"") {
let value = &after[j + 4..];
if let Some(k) = value.find('"') {
out.push(value[..k].to_string());
}
}
rest = after;
}
out
}
}
@@ -211,7 +211,6 @@ fn report(label: &str, expect: u32, seen: Option<u32>) -> u32 {
}
}
#[allow(clippy::too_many_arguments)]
fn goquest_probe(
time: ProbeClock,
mut probe: ResMut<GoQuestProbe>,
@@ -73,7 +73,6 @@ enum Phase {
}
// One Bevy system's full input set (the taxi-probe shape).
#[allow(clippy::too_many_arguments)]
fn guard_poi_probe(
time: ProbeClock,
mut probe: ResMut<GuardPoiProbe>,
+1 -1
View File
@@ -110,7 +110,7 @@ impl Plugin for ProbeLookPlugin {
drive_probe_look
.in_set(WorldStage::Input)
.before(crate::player::PlayerControlSet)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
.in_set(crate::char_select::InWorldGated),
);
}
}
+1 -2
View File
@@ -222,7 +222,6 @@ fn last_event(script: &UiScript) -> String {
.unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
fn mail_probe(
time: ProbeClock,
mut probe: ResMut<MailProbe>,
@@ -378,7 +377,7 @@ fn mail_probe(
};
let landed = entry_of(&mail, letter_id).and_then(|e| {
(e.item_text_id != 0)
.then(|| mail.bodies.get(&e.item_text_id).cloned())
.then(|| mail.bodies.get(e.item_text_id).cloned())
.flatten()
.map(|body| (body, e.checked & CHECKED_READ != 0))
});
@@ -203,7 +203,6 @@ fn shoot(commands: &mut Commands, name: &str) {
.observe(save_to_disk(path));
}
#[allow(clippy::too_many_arguments)]
fn model_camera_probe(
mut commands: Commands,
time: ProbeClock,
@@ -126,7 +126,7 @@ impl Plugin for ProbePitchPlugin {
drive_probe_pitch
.in_set(WorldStage::Input)
.before(crate::player::PlayerControlSet)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
.in_set(crate::char_select::InWorldGated),
);
}
}
@@ -295,7 +295,6 @@ struct Rig {
}
/// The whole rig: find-or-create the body at select, enter as it, then apply the state batch.
#[allow(clippy::too_many_arguments)]
fn drive_rig(
mut rig: ResMut<Rig>,
mut roster: ResMut<Roster>,
@@ -340,7 +340,6 @@ fn open_window(
None
}
#[allow(clippy::too_many_arguments)]
fn service_probe(
time: ProbeClock,
mut probe: ResMut<ServiceProbe>,
@@ -519,6 +518,9 @@ fn service_probe(
let why: &str = match other {
ServiceAction::AskBinder => "CONFIRM_BINDER, no packet",
ServiceAction::AskSpiritHealer => "CONFIRM_XP_LOSS, no packet",
ServiceAction::AcquireSpiritGuide => {
"adopts the area spirit healer; the packet leaves from the cache"
}
ServiceAction::Silent(w) => w,
ServiceAction::Send(_) | ServiceAction::SellFromCursor(_) => {
unreachable!()
@@ -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);
});
}
}
}
+1 -1
View File
@@ -145,7 +145,7 @@ fn load_expectations(mut probe: ResMut<TaxiProbe>, world_assets: Option<Res<Worl
}
// One Bevy system's full input set (the crossing-probe shape) + its self query tuple.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
#[allow(clippy::type_complexity)]
fn taxi_probe(
time: ProbeClock,
mut probe: ResMut<TaxiProbe>,
@@ -163,13 +163,12 @@ fn clear_log(script: &UiScript) {
}
}
#[allow(clippy::too_many_arguments)]
fn vendor_swap_probe(
time: ProbeClock,
mut probe: ResMut<VendorSwapProbe>,
merchant: Res<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
mut names: ResMut<NameCache>,
names: Res<NameCache>,
panes: Res<BoothPanes>,
images: Res<PortraitImages>,
self_player: Query<(), With<SelfPlayer>>,
@@ -220,7 +219,7 @@ fn vendor_swap_probe(
let (brog, dobbins) = (find(BROG_ENTRY), find(DOBBINS_ENTRY));
// Both names must be cached before the legs read them back out of the VM — the
// ask-once resolve is what the unit feed itself would send on the open frame.
let mut named = |g: Option<u64>| g.is_some_and(|g| names.resolve(g, &net).is_some());
let named = |g: Option<u64>| g.is_some_and(|g| names.resolve(g, &net).is_some());
let (brog_named, dobbins_named) = (named(brog), named(dobbins));
if brog_named && dobbins_named {
probe.brog = brog;
@@ -82,7 +82,6 @@ type DressQuery = (
);
/// One line per streamed player within [`DEFAULT_RADIUS`], contradictions first.
#[allow(clippy::too_many_arguments)]
fn fire_dress_census(
mut probe: ResMut<DressCensus>,
time: ProbeClock,
@@ -114,7 +114,6 @@ type CensusQuery = (
/// One census line per [`ground_derived`] body within [`GroundCensus::radius`] of ours, worst drop
/// first, under a summary line naming the count that matters.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
fn fire_ground_census(
mut probe: ResMut<GroundCensus>,
time: ProbeClock,
@@ -122,7 +121,7 @@ fn fire_ground_census(
// reader back to the server's own tables to answer it — and the cache is already warm: it is
// asked at first *sight* for every unit that streams in. A `None` is a name still in flight,
// never a missing one.
mut names: ResMut<NameCache>,
names: Res<NameCache>,
net_commands: Res<NetCommands>,
world: benilla_world::collision::WorldCollision,
point: benilla_world::world_point::WorldPoint,
@@ -242,7 +242,6 @@ fn palette_probes(rows: &[Mat4], binds: &[Vec3], radii: &[f32]) -> Vec<Vec3> {
}
/// One `JIT` line per frame for the subject: the three terms' Δ and Δ², in mm and in pixels.
#[allow(clippy::too_many_arguments)] // one param per term the reading has to separate
fn sample_jitter(
mut meter: ResMut<JitterMeter>,
time: ProbeClock,
@@ -204,7 +204,6 @@ struct SamplePin<'w, 's> {
/// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the
/// harness probe's `Phase::ProbeWarmup`/`Probing` arms.
#[allow(clippy::too_many_arguments)]
fn drive_live_fps(
mut probe: ResMut<LiveFps>,
time: Res<Time<bevy::time::Real>>,
@@ -72,7 +72,6 @@ impl Plugin for RevealAuditPlugin {
}
}
#[allow(clippy::too_many_arguments)]
fn drive_reveal_audit(
mut audit: ResMut<RevealAudit>,
progress: Res<WorldLoadProgress>,
@@ -120,7 +120,6 @@ type VisualQuery = (
/// One line per streamed entity within [`UnitVisuals::radius`] of the body — cubes first, then
/// everything else — under a summary line naming the count that matters.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
fn fire_unit_visuals(
mut probe: ResMut<UnitVisuals>,
time: ProbeClock,
@@ -379,7 +379,6 @@ fn class_file(class: u8) -> &'static str {
/// Its own resource rather than a [`CreateSelection`] field on purpose: ticking it there would trip
/// that resource's change detection every frame and defeat `refresh_dynamic`'s `is_changed` gate,
/// re-running the whole dial/panel/icon refresh 60× a second.
#[allow(clippy::too_many_arguments)]
fn create_input(
buttons: Query<(Entity, &CreateAction)>,
clicks: Res<crate::glue::GlueClicks>,
@@ -23,7 +23,7 @@ use crate::glue::widgets::{FallbackFace, GlueDisabled, Hilight, HoverLabel, Lock
/// Refill everything that follows the selection — icon rects, dial labels, info texts, faction
/// tints, class-slot mapping — on selection change or a fresh spawn.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
#[allow(clippy::type_complexity)]
pub(super) fn refresh_dynamic(
sel: Res<CreateSelection>,
catalog: Option<Res<CharCreate>>,
@@ -224,7 +224,7 @@ pub(super) fn refresh_dynamic(
/// latch while a create is in flight. The screen-agnostic passes — up/down art swaps, the glue
/// buttons' art + caption color, outline mirroring — are [`crate::glue`]'s, registered beside
/// this in the plugin chain.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub(super) fn refresh_hover(
sel: Res<CreateSelection>,
catalog: Option<Res<CharCreate>>,
@@ -37,7 +37,6 @@ const SCREEN_Z: i32 = 1100;
// ── Spawn ────────────────────────────────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) fn enter_create(
mut commands: Commands,
assets: Res<AssetServer>,
+34 -29
View File
@@ -516,13 +516,13 @@ fn status_label<'a>(strings: &'a GlueStrings, token: &'a str) -> &'a str {
}
/// Flip the *Load out of date AddOns* box: the `checkAddonVersion` CVar **inverted** (1292 §2,
/// byte-verified — ticked = `"0"`). Written through [`UiScript::set_cvar_engine`] so it rides the
/// change queue like a Lua `SetCVar` and the host's sync persists it (the minimap-zoom pattern) —
/// nothing else to do: the statuses repaint from [`drive_addons_panel`]'s per-frame mirror, no
/// rescan, because the gate re-reads the flag per query (1292 §2.2).
fn toggle_force_load(script: &mut UiScript) {
let checking = script.cvar("checkAddonVersion").is_none_or(|v| v != "0");
script.set_cvar_engine("checkAddonVersion", if checking { "0" } else { "1" });
/// byte-verified — ticked = `"0"`). A host write into the registry (2303), which is what
/// persists it and what the VM's mirror learns — nothing else to do: the statuses repaint from
/// [`drive_addons_panel`]'s per-frame mirror, no rescan, because the gate re-reads the flag per
/// query (1292 §2.2).
fn toggle_force_load(cvars: &mut crate::cvars::Cvars) {
let checking = cvars.addon_version_check();
cvars.set("checkAddonVersion", if checking { "0" } else { "1" });
}
/// The panel's clickable parts.
@@ -572,14 +572,14 @@ pub(super) struct ScrollBand;
///
/// Ordered before the select screen's own click handling so a click that lands on the panel is
/// never also read as a click on the screen behind it.
#[allow(clippy::too_many_arguments)]
pub(super) fn drive_addons_panel(
mut commands: Commands,
mut panel: ResMut<AddonsPanel>,
art: Res<GlueArt>,
assets: Res<AssetServer>,
strings: Option<Res<GlueStrings>>,
mut script: Option<NonSendMut<UiScript>>,
script: Option<NonSendMut<UiScript>>,
mut cvars: ResMut<crate::cvars::Cvars>,
keys: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
@@ -642,9 +642,7 @@ pub(super) fn drive_addons_panel(
AddonsAction::ForceLoad => {
// No VM (a bare test world / a capture): nothing to write to and nothing the
// walk would read differently — the box is inert, honestly.
if let Some(script) = script.as_deref_mut() {
toggle_force_load(script);
}
toggle_force_load(&mut cvars);
}
}
}
@@ -1599,7 +1597,6 @@ fn spawn_panel(
/// the row's TOPLEFT at (−14, 0), spawned as the row strip's child so the anchor is structural.
/// `## URL` rides as an extra line (1197: information, not a launch button). A MIXED checkbox
/// hover shows `ENABLED_FOR_SOME` alone — the reference's GlueTooltip split, on the same box.
#[allow(clippy::too_many_arguments)]
fn spawn_tooltip(
parent: &mut ChildSpawnerCommands,
art: &GlueArt,
@@ -2009,28 +2006,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// The force-load box IS the `checkAddonVersion` CVar inverted, and a toggle goes through
/// the engine write ([`UiScript::set_cvar_engine`]) so it rides the change queue — that is
/// the whole persistence story (the host's sync drains the queue and dirties the config);
/// the panel itself only repaints from its per-frame mirror, rescanning nothing (1292 §2.2).
/// The force-load box IS the `checkAddonVersion` CVar inverted, and a toggle is a host
/// write into the registry (2303) — that is the whole persistence story (the write dirties
/// the config and reaches the VM's mirror through the outbox); the panel itself only
/// repaints from its per-frame mirror, rescanning nothing (1292 §2.2).
#[test]
fn the_force_load_box_flips_the_cvar_through_the_engine_queue() {
let mut script = UiScript::new().unwrap();
script.register_cvars([("checkAddonVersion", "1")]);
fn the_force_load_box_flips_the_cvar_in_the_registry() {
let mut cvars = crate::cvars::Cvars::default();
assert!(
cvars.addon_version_check(),
"the registrar default: check ON"
);
toggle_force_load(&mut script);
toggle_force_load(&mut cvars);
assert_eq!(
script.take_cvar_changes(),
vec![("checkAddonVersion".to_string(), "0".to_string())],
cvars.get("checkAddonVersion"),
Some("0"),
"tick: check ON (\"1\") flips to \"0\" — box ticked = version gate open"
);
assert_eq!(script.cvar("checkAddonVersion").as_deref(), Some("0"));
toggle_force_load(&mut script);
assert!(!cvars.addon_version_check());
assert_eq!(
script.take_cvar_changes(),
vec![("checkAddonVersion".to_string(), "1".to_string())],
"untick: back to the registrar default, and the queue carries it again"
cvars.take_events().len(),
1,
"a host write is an accepted move — the config dirties and the mirror learns it"
);
toggle_force_load(&mut cvars);
assert_eq!(
cvars.get("checkAddonVersion"),
Some("1"),
"untick: back to the registrar default"
);
}
+1 -1
View File
@@ -88,7 +88,7 @@ struct DialogUi;
/// Okay (enabled only while the typed text matches) sends the delete; Cancel/Esc close; Enter
/// confirms when armed. Runs before the list refresh so a successful delete's roster update
/// repaints the same frame it lands.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
#[allow(clippy::type_complexity)]
pub(super) fn drive_delete_dialog(
mut commands: Commands,
mut dialog: ResMut<DeleteDialog>,
+1 -1
View File
@@ -24,7 +24,7 @@ const DOUBLE_CLICK_SECS: f32 = 0.4;
/// back/escape (return to the login screen — decision 0539, retiring 0465 §6's exit-the-client
/// collapse), arrow-key cycling. Inert while the delete dialog is up (it owns the keyboard and
/// sits over the buttons).
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
#[allow(clippy::type_complexity)]
pub(super) fn select_input(
buttons: Query<(Entity, &SelectAction)>,
clicks: Res<crate::glue::GlueClicks>,
+101 -134
View File
@@ -65,6 +65,20 @@ pub(crate) enum ClientState {
InWorld,
}
/// **The one "only while in the world" gate** (decision 2265 §C3). A system that has no business
/// at a glue screen — an input reader, a world-packet feed, a per-character file watcher — joins
/// this set instead of carrying its own `run_if(in_state(ClientState::InWorld))`: twenty-odd
/// copies of that condition said one thing in twenty-odd places, and the condition a set carries
/// ANDs with a member's own, so a member keeps whatever else it needs (the controller's
/// capture-mode gate, say) and the conversion is exact.
///
/// Configured once, in [`CharSelectPlugin`] — the state's home — for `Update`, the only schedule
/// a member lives in. A member in another schedule means configuring the set *there* as well
/// (a set's conditions are per schedule; an unconfigured set gates nothing), never a fresh
/// `run_if` at the site.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct InWorldGated;
/// The character-select subsystem: the state machine + the select screen.
pub(crate) struct CharSelectPlugin {
/// The screen this session opens on.
@@ -94,6 +108,8 @@ fn publish_world_live(
impl Plugin for CharSelectPlugin {
fn build(&self, app: &mut App) {
app.insert_state(self.start)
// The in-world gate, configured beside the state it reads (see [`InWorldGated`]).
.configure_sets(Update, InWorldGated.run_if(in_state(ClientState::InWorld)))
.init_resource::<Roster>()
// **The engine's world-existence bit** (1160's wire (b)): the session owner is this
// module, so this module tells the world whether there is one. Ordered ahead of every
@@ -105,8 +121,14 @@ impl Plugin for CharSelectPlugin {
)
.init_resource::<dialog::DeleteDialog>()
.init_resource::<addons::AddonsPanel>()
.add_systems(OnEnter(ClientState::CharSelect), screen::enter_select)
.add_systems(OnExit(ClientState::CharSelect), screen::exit_select)
.add_systems(
OnEnter(ClientState::CharSelect),
(screen::enter_select, stamp_select_entry),
)
.add_systems(
OnExit(ClientState::CharSelect),
(screen::exit_select, clear_select_entry),
)
.add_systems(Update, (debug_glue_roundtrip, debug_logout_smoke))
.add_systems(
Update,
@@ -151,6 +173,11 @@ impl Plugin for CharSelectPlugin {
)
.chain()
.before(crate::glue::GlueVisuals)
// After the UI tick: one member holds the VM (the addons panel reads
// the manifest through it), and every VM holder in `Update` declares
// its side of the tick (decision 2304). A glue screen has no push the
// tick must see, so the whole chain takes the drain side.
.after(crate::ui_script::UiInput)
.run_if(in_state(ClientState::CharSelect)),
)
.chain()
@@ -385,9 +412,9 @@ fn last_character_row(value: &str) -> Option<usize> {
/// Out of range falls back to the **first** row, never to the nearest one: a character deleted
/// since you last played, or a realm with fewer characters, puts you at the top of the list rather
/// than beside where the old row used to be.
fn remembered_row(persist: &crate::cvars::CvarPersist, len: usize) -> usize {
persist
.stored(CVAR_LAST_CHARACTER)
fn remembered_row(cvars: &crate::cvars::Cvars, len: usize) -> usize {
cvars
.get(CVAR_LAST_CHARACTER)
.and_then(last_character_row)
.filter(|&row| row < len)
.unwrap_or(0)
@@ -401,42 +428,28 @@ fn remembered_row(persist: &crate::cvars::CvarPersist, len: usize) -> usize {
/// and clicks and arrow keys reach `0x472740` without ever going near it. [`Roster::pending_pick`]
/// is exactly that moment for us, so [`Roster::pending_index`] is what this reads.
///
/// The write goes through [`UiScript::set_cvar_engine`] so it rides the change queue like a Lua
/// `SetCVar` and the host's sync persists it — the minimap-zoom pattern (1131), already used from
/// this screen by the AddOns panel's force-load box (1293). **One divergence, stated:** the
/// The write is a host write into the registry ([`crate::cvars::Cvars::set`]), which is what
/// persists it and what the VM's mirror learns (2303) — the shape the AddOns panel's force-load
/// box uses from this same screen (1293). **One divergence, stated:** the
/// reference flushes `Config.wtf` synchronously in the same call (`0x46b6f6`), while ours reaches
/// disk on the exit edge with every other CVar (1528) — so a crash between entering the world and
/// quitting loses the memory, where the reference would not. That is the CVar store's shape, not
/// this key's, and changing it is an autosave design (1528's own "what this does NOT fix").
fn persist_last_character(
roster: Res<Roster>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
// Memory about the VM's CVar table, so it dies with the VM (decision 1290). A bare `Local`
// here would be betting that `cvars::sync_cvars`'s per-VM seed carries this key into the next
// table — true today, and exactly the "correct against one VM, silently wrong against the
// next" shape 1290 built its structural gate to refuse. `get_for` because this system also
// runs while there is no VM at all.
mut mirrored: Local<crate::ui_script::VmMemo<Option<usize>>>,
mut cvars: ResMut<crate::cvars::Cvars>,
// A plain `Local` is honest here since 2303: the registry outlives every VM, so the row
// written once stays written — the per-VM memo this used to need was betting on the seed.
mut mirrored: Local<Option<usize>>,
) {
let Some(row) = roster.pending_index() else {
return; // nobody is entering the world — nothing to remember
};
let mirrored = mirrored.get_for(script.as_deref());
if *mirrored == Some(row) {
return;
}
let Some(script) = script.as_deref_mut() else {
return;
};
script.set_cvar_engine(CVAR_LAST_CHARACTER, &last_character_value(row));
// Latch only once the table actually took it. An engine write to a name the host has not
// registered yet is a deliberate silent no-op (`script::cvars::set_from_engine`), and the
// per-VM seed that registers it runs in `cvars::sync_cvars` — a sibling `Update` system with
// no ordering against this one. Latching on the frame the write was dropped would swallow
// exactly one entry, and it would be the session's first.
if script.cvar(CVAR_LAST_CHARACTER).is_some() {
*mirrored = Some(row);
}
cvars.set(CVAR_LAST_CHARACTER, &last_character_value(row));
*mirrored = Some(row);
}
/// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending.
@@ -456,11 +469,11 @@ fn apply_roster_policy(
mut msgs: MessageReader<CharListMessage>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
// The remembered row (decision 1622) — read off the persist state rather than the VM's table
// because it is a value the *file* owns and the session only mirrors, and because reading it
// The remembered row (decision 1622) — read off the registry rather than the VM's table
// because the registry is the store that outlives every VM (2303), and because reading it
// here keeps this system send-able. It is consulted exactly once per process: `selected` is
// `None` only before the first roster lands.
persist: Res<crate::cvars::CvarPersist>,
cvars: Res<crate::cvars::Cvars>,
// Is the character pick already spoken for? Present only when a rig is driving this run
// (decision 1174's always-present run fact) — absent in every ordinary run, which is the
// player answer and the one this screen was written for.
@@ -521,7 +534,7 @@ fn apply_roster_policy(
// reference reads the CVar in the char-list rebuild itself (`0x4724d0` → `0x472740`),
// not once at startup, so the screen always opens on whoever you last entered the
// world as. Out of range falls back to the first row; see `remembered_row`.
let row = remembered_row(&persist, roster.chars.len());
let row = remembered_row(&cvars, roster.chars.len());
// Greppable, and it names the character rather than only the index: "the first row
// happened to be right" and "the memory worked" are the same picture on screen, and
// this line is the only thing that tells them apart in a log.
@@ -783,7 +796,6 @@ fn debug_glue_roundtrip(
/// hand-written `AppExit` from `Update` skipped that question for this smoke's whole life, and the
/// bug it would have caught — every saved variable, every addon file and the camera pose lost on
/// every window close — shipped underneath it.
#[allow(clippy::too_many_arguments)] // a smoke test that drives the whole round trip
fn debug_logout_smoke(
state: Res<State<ClientState>>,
player: Res<crate::player::Player>,
@@ -822,7 +834,7 @@ fn debug_logout_smoke(
// world-live falling edge, not the state edge), so the probe was reading the count before
// the thing it is checking had happened, and reporting the world it had just left.
//
// It cost more than a wrong line: 1291 wrote the reading up as a live contradiction of
// It cost more than a wrong line: 2277 wrote the reading up as a live contradiction of
// 0777's release-world claim, on the strength of it reproducing identically on an older
// commit — which it did, because an instrument that measures too early does that reliably.
// A number that is always wrong teaches everyone to skip the line (0777's own lesson about
@@ -844,7 +856,7 @@ fn debug_logout_smoke(
},
4 if *state.get() == ClientState::InWorld && player.active && now - *mark > 3.0 => {
// **The suppressor reading is the point of the second entry, not a decoration on it**
// (B306, decision 1542). This leg has crossed the boundary on every run since 1291 and
// (B306, decision 1542). This leg has crossed the boundary on every run since 2277 and
// could only ever report that it *happened* — tiles, UI rebuilds, error counts — none
// of which a character who re-entered unable to move would disturb. `scripts/smoke.sh`
// fails on anything but `none`, and separately reports whether the run's logout was
@@ -954,6 +966,31 @@ fn debug_select_walk(
}
}
/// When the select screen came up: `Time::elapsed_secs()` at `OnEnter(CharSelect)`, gone again
/// at `OnExit`. The one clock the screen's "a few seconds after the screen is up" instruments
/// ([`debug_select_dialog`], [`debug_select_addons`], [`debug_select_shot`]) measure from, in
/// place of a `Local` each stamped on its own first run (decision 2265 §C3). Those three run
/// under `in_state(CharSelect)` in `Update`, so the resource is always there when they read it.
#[derive(Resource, Clone, Copy)]
struct CharSelectEnteredAt(f32);
impl CharSelectEnteredAt {
/// Seconds the screen has been up.
fn elapsed(&self, time: &Time) -> f32 {
time.elapsed_secs() - self.0
}
}
/// `OnEnter(CharSelect)`: stamp the screen's entry.
fn stamp_select_entry(mut commands: Commands, time: Res<Time>) {
commands.insert_resource(CharSelectEnteredAt(time.elapsed_secs()));
}
/// `OnExit(CharSelect)`: the screen is down, so is its clock.
fn clear_select_entry(mut commands: Commands) {
commands.remove_resource::<CharSelectEnteredAt>();
}
/// The shot instrument's delete-dialog dial (`WOW_CHARSELECT_DIALOG=<typed>`): open the
/// typed-confirm dialog for the selected character a few seconds after the screen is up, with
/// `<typed>` pre-typed (may be empty) — so the dialog's geometry (the ChatInputBorder edit box,
@@ -963,7 +1000,7 @@ fn debug_select_dialog(
roster: Res<Roster>,
mut dialog: ResMut<dialog::DeleteDialog>,
time: Res<Time>,
mut entered_at: Local<Option<f32>>,
entered_at: Res<CharSelectEnteredAt>,
mut done: Local<bool>,
) {
if *done {
@@ -973,8 +1010,7 @@ fn debug_select_dialog(
*done = true;
return;
};
let start = *entered_at.get_or_insert(time.elapsed_secs());
if time.elapsed_secs() - start < 4.0 {
if entered_at.elapsed(&time) < 4.0 {
return;
}
let Some(c) = roster.selected_char() else {
@@ -995,7 +1031,7 @@ fn debug_select_addons(
roster: Res<Roster>,
mut panel: ResMut<addons::AddonsPanel>,
time: Res<Time>,
mut entered_at: Local<Option<f32>>,
entered_at: Res<CharSelectEnteredAt>,
mut done: Local<bool>,
) {
if *done {
@@ -1005,8 +1041,7 @@ fn debug_select_addons(
*done = true;
return;
}
let start = *entered_at.get_or_insert(time.elapsed_secs());
if time.elapsed_secs() - start < 4.0 {
if entered_at.elapsed(&time) < 4.0 {
return;
}
if roster.chars.is_empty() {
@@ -1041,7 +1076,7 @@ const SELECT_SHOT_AT: f32 = 8.0;
fn debug_select_shot(
mut commands: Commands,
time: Res<Time>,
mut entered_at: Local<Option<f32>>,
entered_at: Res<CharSelectEnteredAt>,
mut done: Local<bool>,
) {
if *done {
@@ -1055,8 +1090,7 @@ fn debug_select_shot(
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(SELECT_SHOT_AT);
let start = *entered_at.get_or_insert(time.elapsed_secs());
if time.elapsed_secs() - start < at {
if entered_at.elapsed(&time) < at {
return;
}
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
@@ -1405,8 +1439,8 @@ mod tests {
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(match stored {
Some(v) => crate::cvars::CvarPersist::with_stored(CVAR_LAST_CHARACTER, v),
None => crate::cvars::CvarPersist::default(),
Some(v) => crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, v),
None => crate::cvars::Cvars::default(),
})
.add_message::<CharListMessage>()
.add_message::<AppExit>()
@@ -1528,10 +1562,7 @@ mod tests {
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(crate::cvars::CvarPersist::with_stored(
CVAR_LAST_CHARACTER,
"0",
))
.insert_resource(crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, "0"))
.add_message::<CharListMessage>()
.add_message::<AppExit>()
.add_systems(Update, apply_roster_policy);
@@ -1581,14 +1612,11 @@ mod tests {
#[test]
fn only_entering_the_world_writes_the_cvar() {
let (tx, _rx) = crossbeam_channel::unbounded();
let script = benilla_ui::script::UiScript::new().unwrap();
script.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.init_resource::<crate::cvars::Cvars>()
.insert_resource(CharPick(tx))
.insert_non_send_resource(script)
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
@@ -1602,96 +1630,35 @@ mod tests {
}
app.update();
assert!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes()
.is_empty(),
!app.world().resource::<crate::cvars::Cvars>().has_events(),
"a selection alone must NOT be remembered — the reference writes nothing here",
);
// …and now Enter World, on the row that was selected.
app.world_mut().resource_mut::<Roster>().pending_pick = Some(2);
app.update();
let moved: Vec<(String, String)> = app
.world_mut()
.resource_mut::<crate::cvars::Cvars>()
.take_events()
.into_iter()
.map(|e| (e.name, e.new))
.collect();
assert_eq!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes(),
moved,
vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())],
"guid 2 sits at row 1, and the row is what rides the queue",
"guid 2 sits at row 1, and the row is what the registry took",
);
}
/// **The 1290 property, at this call site.** A login replaces the VM, and the memo of "the
/// table already says 1" must die with it — otherwise the mirror stays quiet against a table
/// that has never been told, and the memory survives only for as long as some *other* module
/// happens to carry the key across (`cvars::sync_cvars`'s saved-base seed does, today).
#[test]
fn a_replaced_vm_is_told_the_remembered_row_again() {
let (tx, _rx) = crossbeam_channel::unbounded();
let fresh = || {
let s = benilla_ui::script::UiScript::new().unwrap();
s.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
s
};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_non_send_resource(fresh())
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars = vec![character(1, "Kerwind"), character(2, "Xero")];
roster.pending_pick = Some(2);
}
// Steady frames stay quiet, and the next VM is seeded from the registry — which is what
// replaced the per-VM memo this system used to carry (1290's property, now held by the
// store outliving the VM, 2303).
app.update();
app.update(); // steady frames stay quiet — the memo does its job within one VM
// The login edge: `ui_script::lifecycle` drops the VM and installs a boot VM in its place.
app.world_mut()
.insert_non_send_resource::<benilla_ui::script::UiScript>(fresh());
app.update();
assert_eq!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes(),
vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())],
"the new VM's table must be told the row too — a memo that outlived the old one \
would leave this table on its default and lose the memory at quit",
);
}
/// The write must survive the frame in which the host has not registered its table yet: an
/// engine write to an unregistered name is a deliberate silent no-op, so latching on it would
/// swallow the session's FIRST entry — the one launch-to-launch memory exists for.
#[test]
fn an_entry_made_before_the_cvar_table_exists_is_not_lost() {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap())
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars = vec![character(1, "Kerwind"), character(2, "Xero")];
roster.pending_pick = Some(2);
}
app.update(); // the table has no such name yet — the write is dropped
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
app.update(); // ...and the next frame must still catch it up
assert_eq!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes(),
vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())],
);
let cvars = app.world().resource::<crate::cvars::Cvars>();
assert!(!cvars.has_events());
assert!(cvars
.vm_seed()
.iter()
.any(|r| r.name == CVAR_LAST_CHARACTER && r.value == "1"));
}
/// The reverse arrival order (result first, roster after) stays armed and is answered by the
@@ -18,7 +18,7 @@ use super::{class_name, Roster};
/// Refill the row texts + visibility, the selected name, and the realm banner whenever the roster
/// changes (a fresh enum, a selection move) — or when the screen was just (re)spawned (returning
/// from the create screen finds an unchanged roster; the fresh, empty tree must still fill).
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub(super) fn refresh_list(
roster: Res<Roster>,
areas: Option<Res<AreaTableRes>>,
@@ -98,7 +98,6 @@ pub(super) fn enter_select(mut preview: ResMut<GluePreview>) {
/// Spawn the screen tree once its prerequisites exist — and upgrade an artless early spawn the
/// moment the client art lands (despawn + respawn; the tree is cheap and static). With no client
/// data at all the artless tree still spawns after a short grace (the graceful-absence posture).
#[allow(clippy::too_many_arguments)]
pub(super) fn materialize_screen(
mut commands: Commands,
existing: Query<(Entity, &CharSelectUi)>,
@@ -148,7 +147,6 @@ pub(super) fn materialize_screen(
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_screen(
commands: &mut Commands,
assets: &AssetServer,
+11 -2
View File
@@ -376,7 +376,7 @@ fn load_bubble_art(
/// pieces, tail, wrapped chatType-colored text — bottom-seated on the projected anchor.
/// Runs after [`VPlateSet`] (the spawn gate reads this frame's plate verdict), inside the
/// [`UiQuadAppend`] window.
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
#[allow(clippy::type_complexity)] // one Bevy system's full input set
fn drive_bubbles(
mut queue: ResMut<BubbleQueue>,
mut bubbles: ResMut<Bubbles>,
@@ -609,7 +609,6 @@ struct Pending {
/// Append one bubble's draw list: the Backdrop pieces (bg fill inset by the border-unit +
/// the 8-piece edge), the tail square, and the wrapped, centered, chatType-colored text.
#[allow(clippy::too_many_arguments)] // the draw inputs + the jitter trace's decomposition
fn draw_bubble(
atlas: &mut UiFontAtlas,
quads: &mut UiQuads,
@@ -813,8 +812,18 @@ pub(crate) struct BubbleSet;
pub(crate) struct ChatBubblePlugin;
/// The two bubble switches' change callback (1139, 2303): flags, like every other pair.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut bubbles: ResMut<BubbleConfig>) {
match ev.key().as_str() {
"chatbubbles" => bubbles.all = ev.flag(),
"chatbubblesparty" => bubbles.party = ev.flag(),
_ => {}
}
}
impl Plugin for ChatBubblePlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<BubbleQueue>()
.init_resource::<BubbleConfig>()
.init_resource::<Bubbles>()
+11 -8
View File
@@ -1,14 +1,16 @@
//! `ChrClasses.dbc`, loaded once and read by everything that asks the client a question about a
//! class.
//!
//! Two consumers today and they are unrelated to each other — [`crate::ui_pet_book`] wants field
//! 4's pet name token, `UnitHasRelicSlot` wants field 16's relic flag — which is exactly why the
//! table does not live inside either of them. It used to live in the pet book, back when the pet
//! Three consumers today and they are unrelated to each other — [`crate::ui_pet_book`] wants field
//! 4's pet name token, `UnitHasRelicSlot` wants field 16's relic flag, and
//! [`crate::spell_mods`] wants field 15's class spell-family — which is exactly why the
//! table does not live inside any of them. It used to live in the pet book, back when the pet
//! token was the only column anyone read.
//!
//! The resource is **absent**, not empty, when the load fails. Both readers then fall to the
//! reference's own degraded answer (`"PET"` for the token, no relic slot for any class), so a
//! missing table costs a warlock the word "Demon" and a paladin their relic branches rather than
//! The resource is **absent**, not empty, when the load fails. Every reader then falls to the
//! reference's own degraded answer (`"PET"` for the token, no relic slot for any class, family 0
//! — which the modifier gate's first conjunct refuses), so a missing table costs a warlock the
//! word "Demon", a paladin their relic branches and everyone their talent modifiers rather than
//! taking the client down.
use bevy::prelude::*;
@@ -39,8 +41,9 @@ fn load_chr_classes(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
Ok(table) => commands.insert_resource(ChrClassTable(table)),
Err(e) => warn!(
"chr_classes: ChrClasses.dbc failed to load — every pet book tab reads the client's \
own \"PET\" fallback, so a warlock's says Pet rather than Demon, and no class reads \
as having a relic slot: {e:#}"
own \"PET\" fallback, so a warlock's says Pet rather than Demon, no class reads \
as having a relic slot, and no talent spell modifier can apply (the gate has no \
class family to match): {e:#}"
),
}
}
+11 -1
View File
@@ -233,7 +233,6 @@ fn free_slot(texts: &[WorldText], anchor: Entity) -> Option<u8> {
/// living into glyph quads appended to [`UiQuads`] after the script extract. Ordering (the
/// [`UiQuadAppend`] set, after [`UiInput`]) guarantees the mesh rebuild never lands between the
/// script's replace and our append — see `ui_pass`.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(crate) fn float_combat_text(
mut spawns: MessageReader<CombatTextSpawn>,
mut texts: ResMut<WorldTexts>,
@@ -586,10 +585,21 @@ fn melee_impact_text(
/// inside the append window the mesh rebuild waits on).
pub(crate) struct CombatTextPlugin;
/// The damage-text rows' change callback (decision 2303): three flags.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut gates: ResMut<DamageTextGates>) {
match ev.key().as_str() {
"combatdamage" => gates.combat_damage = ev.flag(),
"petmeleedamage" => gates.pet_melee = ev.flag(),
"petspelldamage" => gates.pet_spell = ev.flag(),
_ => {}
}
}
impl Plugin for CombatTextPlugin {
fn build(&self, app: &mut App) {
// The Update append window (see [`UiQuadAppend`]): after the camera controller, and
// projecting through the camera's FRESH Transform (not the stale propagated global).
app.add_observer(on_cvar);
app.init_resource::<WorldTexts>()
.init_resource::<DamageTextFont>()
.init_resource::<DamageTextGates>()
+412
View File
@@ -0,0 +1,412 @@
//! The in-game **console command registry** (decision 2303) — the reference's `ConsoleCommand`
//! table, host side: `ConsoleCommandRegister 0x63f9e0` over a `TSExplicitList<CONSOLECOMMAND>`,
//! with parse / register / lookup / execute (wow-re `console/console.md`, the upper band). A
//! subsystem registers its commands from its own plugin ([`ConsoleCommandApp::console_command`]),
//! the way each of the reference's registers into the one table, and `/console <line>` reaches
//! [`execute`] through the chat drain.
//!
//! **A CVar is a command too.** The reference's `CVar::Register` installs a per-CVar command
//! under the CVar's own name (`0x63dde0`): a bare name prints `CVar "%s" is "%s"`, a name with a
//! value sets it. So does this — over [`Cvars`] — after the registered commands, which is the
//! reference's own lookup order. The four commands `ConsoleVar.cpp`'s `Initialize` registers
//! (`set`, `cvar_reset`, `cvar_default`, `cvarlist`) are the built-ins [`ConsolePlugin`] adds,
//! plus `help`, which the reference's registry runtime owns.
//!
//! There is no console screen here — the drop-down that `` ` `` toggles in the reference — so a
//! command's output is a system line in the chat frame. That is the seam `/console` already
//! used; the registry is what the arm match behind it became.
use std::collections::BTreeMap;
use bevy::prelude::*;
use crate::cvars::{Cvars, SetOutcome};
/// A command's body: the whole world, the argument tail after the command name (trimmed), and
/// the lines to print in return.
pub(crate) type ConsoleHandler = fn(&mut World, &str) -> Vec<String>;
#[derive(Clone, Copy)]
struct ConsoleCommand {
/// The registered spelling, as `help` prints it.
name: &'static str,
help: &'static str,
run: ConsoleHandler,
}
/// The registry — lowercased name → command, in name order.
#[derive(Resource, Default)]
pub(crate) struct ConsoleCommands {
by_key: BTreeMap<String, ConsoleCommand>,
}
/// `app.console_command(name, help, handler)` — a subsystem registering its own command from
/// its plugin, the reference's `ConsoleCommandRegister`.
pub(crate) trait ConsoleCommandApp {
fn console_command(
&mut self,
name: &'static str,
help: &'static str,
run: ConsoleHandler,
) -> &mut Self;
}
impl ConsoleCommandApp for App {
fn console_command(
&mut self,
name: &'static str,
help: &'static str,
run: ConsoleHandler,
) -> &mut Self {
let mut table = self.world_mut().get_resource_or_init::<ConsoleCommands>();
let key = name.to_ascii_lowercase();
// Two registrations of one name is a wiring bug, never a runtime condition — and the
// reference's registry would silently shadow one with the other.
assert!(
!table.by_key.contains_key(&key),
"console command {name:?} registered twice"
);
table.by_key.insert(key, ConsoleCommand { name, help, run });
self
}
}
/// The built-ins: the reference's four `ConsoleVar.cpp` commands and `help`.
pub(crate) struct ConsolePlugin;
impl Plugin for ConsolePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ConsoleCommands>()
.console_command("set", "Set the value of a CVar.", set)
.console_command("cvar_reset", "Reset a CVar to its default.", cvar_default)
.console_command("cvar_default", "Reset a CVar to its default.", cvar_default)
.console_command(
"cvarlist",
"List the CVars, optionally matching a string.",
cvarlist,
)
.console_command("help", "List the console commands, or describe one.", help);
}
}
/// Run one console line — the reference's `ConsoleCommandExecute`: the first token is a command
/// name (case-insensitive), the rest is its argument tail. A registered command runs; else a
/// registered CVar answers as its per-CVar command does; else the line is reported as unknown.
/// Returns the lines to print.
///
/// A CVar write here is applied **at once** — its observers run before this returns — which is
/// the reference's shape (the callback runs inside `CVar::Set`), and what makes a typed
/// `/console farclip 500` visible on the next frame rather than the one after.
pub(crate) fn execute(world: &mut World, line: &str) -> Vec<String> {
let line = line.trim();
let (name, args) = line
.split_once(char::is_whitespace)
.map_or((line, ""), |(n, a)| (n, a.trim()));
if name.is_empty() {
return vec!["console: no command given (`help` lists the commands)".to_string()];
}
let command = world
.get_resource::<ConsoleCommands>()
.and_then(|t| t.by_key.get(&name.to_ascii_lowercase()).copied());
if let Some(command) = command {
return (command.run)(world, args);
}
if world
.get_resource::<Cvars>()
.is_some_and(|c| c.row(name).is_some())
{
return if args.is_empty() {
vec![print_cvar(world, name)]
} else {
set_cvar(world, name, args)
};
}
vec![format!(
"console: '{name}' is not a command or a CVar (`help` lists the commands)"
)]
}
/// `CVar "%s" is "%s"` — the per-CVar command's empty-argument arm (`0x63dde0`), with the
/// staged value beside it when one is waiting for the latch boundary.
fn print_cvar(world: &World, name: &str) -> String {
let cvars = world.resource::<Cvars>();
let Some(row) = cvars.row(name) else {
return format!("console: no CVar named '{name}'");
};
match &row.pending {
Some(staged) => format!(
"CVar \"{}\" is \"{}\" (\"{staged}\" staged until the next RestartGx)",
row.name, row.value
),
None => format!("CVar \"{}\" is \"{}\"", row.name, row.value),
}
}
/// A write through the registry, its observers run, and the outcome said back.
fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec<String> {
let (outcome, events) = {
let mut cvars = world.resource_mut::<Cvars>();
let outcome = cvars.set(name, value);
(outcome, cvars.take_events())
};
for event in events {
world.trigger(event);
}
match outcome {
SetOutcome::Unknown => vec![format!("console: no CVar named '{name}'")],
SetOutcome::Refused => vec![format!(
"console: '{value}' is not a number, and {name} takes one"
)],
SetOutcome::Unchanged | SetOutcome::Changed => Vec::new(),
SetOutcome::Staged => vec![format!(
"{name} staged as \"{value}\" — applies at the next RestartGx"
)],
}
}
/// `set <name> <value>` — the reference's `0x63d500`. **One stated divergence:** the reference
/// registers an unknown name as a new category-5 record, invisible to Lua; ours refuses it,
/// because a row nothing reads is exactly what the registry's honest-tree rule exists to keep
/// out, and `config.toml` preserves an unknown key on its own.
fn set(world: &mut World, args: &str) -> Vec<String> {
let (name, value) = args
.split_once(char::is_whitespace)
.map_or((args, ""), |(n, v)| (n, v.trim()));
if name.is_empty() || value.is_empty() {
return vec!["usage: set <cvar> <value>".to_string()];
}
set_cvar(world, name, value)
}
/// `cvar_reset <name>` / `cvar_default <name>` — the reference's `0x63d590`/`0x63d640`. One
/// body for both: our rows carry no separate reset value, so the reset target is the default.
fn cvar_default(world: &mut World, args: &str) -> Vec<String> {
let name = args.split_whitespace().next().unwrap_or("");
if name.is_empty() {
return vec!["usage: cvar_default <cvar>".to_string()];
}
let default = world
.resource::<Cvars>()
.default_of(name)
.map(str::to_string);
let Some(default) = default else {
return vec![format!("console: no CVar named '{name}'")];
};
set_cvar(world, name, &default)
}
/// `cvarlist [match]` — the reference's `0x63d6f0`, in a format of our own: one line per row,
/// the value, the default when the value moved, the staged value when one is waiting, and
/// whether the session owns the row or an addon declared it.
fn cvarlist(world: &mut World, args: &str) -> Vec<String> {
let wanted = args.split_whitespace().next().map(str::to_ascii_lowercase);
let cvars = world.resource::<Cvars>();
let mut rows: Vec<_> = cvars
.rows()
.filter(|r| {
wanted
.as_deref()
.is_none_or(|w| r.name.to_ascii_lowercase().contains(w))
})
.collect();
rows.sort_by_key(|r| r.name.to_ascii_lowercase());
let mut out: Vec<String> = rows
.iter()
.map(|r| {
let mut line = format!("{} = \"{}\"", r.name, r.value);
if r.value != r.default {
line.push_str(&format!(" (default \"{}\")", r.default));
}
if let Some(staged) = &r.pending {
line.push_str(&format!(" [staged \"{staged}\"]"));
}
if cvars.is_session_owned(&r.name) {
line.push_str(" [session]");
}
if r.addon {
line.push_str(" [addon]");
}
line
})
.collect();
out.push(format!("{} CVar(s)", rows.len()));
out
}
/// `help [command]` — the registry's own listing.
fn help(world: &mut World, args: &str) -> Vec<String> {
let table = world.resource::<ConsoleCommands>();
let wanted = args.split_whitespace().next();
match wanted {
Some(name) => match table.by_key.get(&name.to_ascii_lowercase()) {
Some(c) => vec![format!("{} — {}", c.name, c.help)],
None => vec![format!("console: no command named '{name}'")],
},
None => {
let mut out: Vec<String> = table
.by_key
.values()
.map(|c| format!("{} — {}", c.name, c.help))
.collect();
out.push(
"…and every CVar by name: `<cvar>` prints it, `<cvar> <value>` sets it".into(),
);
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn console_app() -> App {
let mut app = App::new();
app.add_plugins(bevy::MinimalPlugins)
.init_resource::<Cvars>()
.add_plugins(ConsolePlugin);
app
}
fn run(app: &mut App, line: &str) -> Vec<String> {
execute(app.world_mut(), line)
}
/// **The lookup order is the reference's**: a registered command first, then the CVar's own
/// per-name command (bare name prints, a value sets), then unknown — case-insensitively at
/// every step, like the engine's own table lookup.
#[test]
fn a_line_is_a_command_then_a_cvar_then_unknown() {
let mut app = console_app();
assert_eq!(
run(&mut app, "MusicVolume"),
vec!["CVar \"MusicVolume\" is \"0.4\"".to_string()]
);
assert!(
run(&mut app, "musicvolume 0.7").is_empty(),
"a set is quiet"
);
assert_eq!(
app.world().resource::<Cvars>().get("MusicVolume"),
Some("0.7")
);
assert_eq!(
run(&mut app, "SET MusicVolume 0.2"),
Vec::<String>::new(),
"`set` is a registered command, matched case-insensitively"
);
assert_eq!(
app.world().resource::<Cvars>().get("MusicVolume"),
Some("0.2")
);
assert_eq!(
run(&mut app, "cvar_default musicVolume"),
Vec::<String>::new()
);
assert_eq!(
app.world().resource::<Cvars>().get("MusicVolume"),
Some("0.4")
);
assert_eq!(
run(&mut app, "nosuchthing 1"),
vec![
"console: 'nosuchthing' is not a command or a CVar (`help` lists the commands)"
.to_string()
]
);
assert_eq!(
run(&mut app, "set nosuchthing 1"),
vec!["console: no CVar named 'nosuchthing'".to_string()],
"the stated divergence: no record conjured at the console"
);
assert_eq!(
run(&mut app, "MusicVolume wat"),
vec!["console: 'wat' is not a number, and MusicVolume takes one".to_string()]
);
assert_eq!(
run(&mut app, " "),
vec!["console: no command given (`help` lists the commands)".to_string()]
);
}
/// A write to a latched row says it is staged, prints as staged, and the list shows it.
#[test]
fn a_latched_cvar_reports_its_stage() {
let mut app = console_app();
assert_eq!(
run(&mut app, "gxVSync 0"),
vec!["gxVSync staged as \"0\" — applies at the next RestartGx".to_string()]
);
assert_eq!(
run(&mut app, "gxvsync"),
vec!["CVar \"gxVSync\" is \"1\" (\"0\" staged until the next RestartGx)".to_string()]
);
let list = run(&mut app, "cvarlist gxv");
assert_eq!(
list,
vec![
"gxVSync = \"1\" [staged \"0\"]".to_string(),
"1 CVar(s)".to_string()
]
);
}
/// `cvarlist` marks a moved value with its default, a session-owned row, and an addon's.
#[test]
fn cvarlist_says_where_each_row_stands() {
let mut app = console_app();
{
let mut cvars = app.world_mut().resource_mut::<Cvars>();
cvars.set("farclip", "500");
cvars.own_for_session("uiScale", Some("1.2"));
cvars.learn_addon_row("myAddonKnob", "7");
}
let list = run(&mut app, "cvarlist");
assert!(
list.contains(&"farclip = \"500\" (default \"350\")".to_string()),
"{list:?}"
);
assert!(
list.contains(&"uiScale = \"1.2\" (default \"0.9\") [session]".to_string()),
"{list:?}"
);
assert!(
list.contains(&"myAddonKnob = \"7\" [addon]".to_string()),
"{list:?}"
);
assert!(list.last().unwrap().ends_with(" CVar(s)"));
let some = run(&mut app, "cvarlist FARCLIP");
assert_eq!(some.len(), 2, "matched case-insensitively: {some:?}");
}
/// `help` lists what is registered, and a subsystem's registration is one call.
#[test]
fn help_lists_the_registry_and_a_registration_is_one_call() {
fn shout(_: &mut World, args: &str) -> Vec<String> {
vec![format!("{}!", args.to_ascii_uppercase())]
}
let mut app = console_app();
app.console_command("shout", "Shout the argument back.", shout);
assert_eq!(
run(&mut app, "Shout hello there"),
vec!["HELLO THERE!".to_string()]
);
let listing = run(&mut app, "help");
assert!(listing.contains(&"shout — Shout the argument back.".to_string()));
assert!(listing.contains(&"set — Set the value of a CVar.".to_string()));
assert_eq!(
run(&mut app, "help cvarlist"),
vec!["cvarlist — List the CVars, optionally matching a string.".to_string()]
);
}
#[test]
#[should_panic(expected = "registered twice")]
fn a_name_registered_twice_is_a_wiring_bug() {
fn nothing(_: &mut World, _: &str) -> Vec<String> {
Vec::new()
}
let mut app = console_app();
app.console_command("set", "again", nothing);
}
}
-1
View File
@@ -175,7 +175,6 @@ impl Cooldowns {
/// used to do made the GO insert of any cooldown-carrying spell (Frost Nova) overwrite its
/// own running GCD node — the whole bar's pie flashed and died ~100 ms after the press,
/// while cooldown-less spells (Arcane Explosion) kept theirs (decision 0947).
#[allow(clippy::too_many_arguments)] // the SPELLHISTORY node's own field list
fn add(
&mut self,
spell_id: u32,
+236
View File
@@ -0,0 +1,236 @@
//! **A crash leaves an artefact** (decision 2266 §B2).
//!
//! Until this module, a panic printed to stderr and the process was gone — and with it everything
//! a reporter could have attached. The only log sink was the terminal; a packaged Windows build
//! has no console; of bug B390's four reporters one attached a backtrace, because he had set
//! `RUST_BACKTRACE=1` on his own. `shutdown.rs` says the other half out loud: *"A crash, a
//! `SIGKILL`, a wedged teardown. Nothing survives those."*
//!
//! [`install`] chains a panic hook in front of Rust's default one. The default still prints to
//! stderr exactly as before (so every log a session already greps keeps reading the same); then
//! the report goes to **`benilla-config/Diagnostics/crash-<unix-seconds>.txt`**, resolved through
//! [`crate::local_state`] like every other file benilla writes (the one-folder rule, 0954/1175 —
//! never a platform log dir, never beside the install). The file carries: the build id (the sha
//! is the version, `build_id`), the time and the uptime, the thread, the panic's location and
//! payload, a backtrace captured **regardless of `RUST_BACKTRACE`**, and the last
//! [`benilla_world::log_ring::CAPACITY`] log lines — the minute before, which is what turns a
//! symptom into a story. The path is printed to stderr as the last line, so "attach the file it
//! names" is the whole instruction to a reporter.
//!
//! **What it does not cover, said out loud.** A non-unwinding panic — an allocation failure's
//! abort, a `panic` inside a `Drop` during unwinding, a foreign frame — never reaches any hook;
//! B390's own was one (`thread caused non-unwinding panic. aborting.`). The hook covers the
//! unwinding class (B88, B130, B260, B383's shape: four of the six panics the ledger holds).
//! Flushing saved variables and the CVar diff from inside a panic is a second, riskier item —
//! the world is half-torn by then — and is deliberately not bundled here (2265 §B2).
//!
//! A hermetic capture run (`local_state::home()` answers `None`) writes no file and loses
//! nothing: the harness already has stderr.
use std::fmt::Write as _;
use std::panic::PanicHookInfo;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::BuildId;
/// Chain the crash-report hook in front of the current panic hook. Called once, from
/// [`crate::run`], before the `App` exists — a panic while plugins build is still a crash.
pub(crate) fn install(build: BuildId) {
let started = Instant::now();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
previous(info);
report(info, build, started.elapsed());
}));
}
/// `WOW_CRASH_INJECT=<at_secs>` — one deliberate main-thread panic, mid-run: the standing test
/// affordance for this module, in the shape of `perf::stall`'s injectors and armed beside them.
/// The end-to-end falsifier is a run with it set: the process must die with a
/// `crash-<unix>.txt` whose log tail ends in the `crash-inject` line below.
pub(crate) fn arm_injector(app: &mut bevy::prelude::App) {
use bevy::prelude::*;
if let Some(at) = std::env::var("WOW_CRASH_INJECT")
.ok()
.and_then(|v| v.parse::<f32>().ok())
{
app.insert_resource(CrashInject { at });
app.add_systems(Update, crash_inject);
}
}
#[derive(bevy::prelude::Resource)]
struct CrashInject {
at: f32,
}
fn crash_inject(
inject: bevy::prelude::Res<CrashInject>,
time: bevy::prelude::Res<bevy::prelude::Time<bevy::time::Real>>,
) {
if time.elapsed_secs() >= inject.at {
bevy::log::warn!(
"crash-inject: panicking the main thread at {:.1} s",
inject.at
);
panic!("crash-inject: deliberate panic (WOW_CRASH_INJECT)");
}
}
/// Re-entrancy latch: a panic *inside* the hook (a poisoned lock, a failed write) must not
/// recurse into it. Never cleared on purpose — after one report the process is on its way out,
/// and a second panic's report would only overwrite the useful one with a worse one.
static REPORTING: AtomicBool = AtomicBool::new(false);
fn report(info: &PanicHookInfo<'_>, build: BuildId, uptime: Duration) {
if REPORTING.swap(true, Ordering::SeqCst) {
return;
}
let Some(dir) = crate::local_state::diagnostics_dir() else {
return;
};
let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_owned()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_owned()
};
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "<unknown>".to_owned());
let thread = std::thread::current();
let text = render(&Report {
build,
unix: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
uptime,
thread: thread.name().unwrap_or("<unnamed>"),
location: &location,
payload: &payload,
backtrace: &std::backtrace::Backtrace::force_capture().to_string(),
log: &benilla_world::log_ring::recent(),
});
if let Some(path) = write(&dir, &text) {
eprintln!("crash report written to {}", path.display());
}
}
/// Everything the report says, gathered before rendering so the renderer is a pure function the
/// test can drive without panicking.
struct Report<'a> {
build: BuildId,
unix: u64,
uptime: Duration,
thread: &'a str,
location: &'a str,
payload: &'a str,
backtrace: &'a str,
log: &'a [String],
}
fn render(r: &Report<'_>) -> String {
let mut out = String::new();
let _ = writeln!(out, "benilla crash report");
let _ = writeln!(
out,
"build: {} (sha {})",
r.build.summary(),
r.build.sha
);
let _ = writeln!(
out,
"time: {} unix, {:.1} s after launch",
r.unix,
r.uptime.as_secs_f64()
);
let _ = writeln!(
out,
"platform: {} {}",
std::env::consts::OS,
std::env::consts::ARCH
);
let _ = writeln!(out, "thread: {}", r.thread);
let _ = writeln!(out, "at: {}", r.location);
let _ = writeln!(out, "panic: {}", r.payload);
let _ = writeln!(out, "\nbacktrace:\n{}", r.backtrace);
let _ = writeln!(out, "last {} log lines (oldest first):", r.log.len());
for line in r.log {
let _ = writeln!(out, "{line}");
}
out
}
/// Write the report under `dir` as `crash-<unix>.txt`, creating the folder on demand (an empty
/// `Diagnostics/` on every run would be this instrument advertising itself, `perf::stall`'s
/// reason). Plain `fs::write`, not `local_state::write_atomic`: the process is dying, and a
/// half-written report beats none.
fn write(dir: &Path, text: &str) -> Option<PathBuf> {
let unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = dir.join(format!("crash-{unix}.txt"));
std::fs::create_dir_all(dir).ok()?;
std::fs::write(&path, text).ok()?;
Some(path)
}
#[cfg(test)]
mod tests {
use super::*;
const BUILD: BuildId = BuildId {
sha: "0123456789abcdef0123456789abcdef01234567",
short: "0123456",
date: "2026-09-16",
profile: "debug",
};
/// The report names the build, the place, the payload and the log — the four things a
/// reporter cannot reconstruct after the fact — and lands in the folder it was given.
#[test]
fn a_report_carries_the_build_the_place_the_payload_and_the_log() {
let log = vec!["+ 1.000s INFO benilla_app::net: entered world".to_owned()];
let text = render(&Report {
build: BUILD,
unix: 1_800_000_000,
uptime: Duration::from_millis(12_345),
thread: "main",
location: "crates/benilla-app/src/net/apply.rs:100:5",
payload: "index out of bounds: the len is 3 but the index is 7",
backtrace: " 0: benilla_app::net::apply::apply_net_updates",
log: &log,
});
for needle in [
"sha 0123456789abcdef0123456789abcdef01234567",
"0123456 · 2026-09-16 · debug",
"12.3 s after launch",
"thread: main",
"at: crates/benilla-app/src/net/apply.rs:100:5",
"panic: index out of bounds: the len is 3 but the index is 7",
"apply_net_updates",
"last 1 log lines",
"entered world",
] {
assert!(text.contains(needle), "missing {needle:?} in:\n{text}");
}
let dir = std::env::temp_dir().join(format!("benilla-crash-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = write(&dir, &text).expect("report written");
assert!(path.starts_with(&dir), "{}", path.display());
assert!(path
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("crash-"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
let _ = std::fs::remove_dir_all(&dir);
}
}
+23 -2
View File
@@ -390,7 +390,6 @@ pub(crate) fn stop_attack_local(
/// `0x6131d9`). `0x5ecb70`'s own validator legs — the target's alive-or-feign + `CanAttack
/// 0x606980` walk, and the `[0xb4b3e4]` world gate — stay with the callers that already compute
/// them (`target::scan`'s `new_attackable`, the drain's `attack_actor_refusal`).
#[allow(clippy::too_many_arguments)] // the tail writes four sinks; the alternative is a bundle
pub(crate) fn start_attack_local(
entity: Entity,
target: u64,
@@ -444,7 +443,6 @@ pub(crate) fn start_attack_local(
/// `[0xb4b3e4]` — the reference's second condition on the stop arm, a world/session global also
/// tested at `0x5ecbc0` — is unmodelled; nothing in benilla can be false there while a press is
/// being drained.
#[allow(clippy::too_many_arguments)] // the seams' write set, minus the bundle a caller can't hold
pub(crate) fn toggle_attack_local(
entity: Entity,
target: u64,
@@ -1265,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,
@@ -1392,6 +1410,7 @@ mod nock_latch_tests {
entity: unit,
ident: *b"$BWP",
data: 0,
anim_id: 0,
pos: None,
});
app.update();
@@ -1403,6 +1422,7 @@ mod nock_latch_tests {
entity: unit,
ident: *b"$BWR",
data: 0,
anim_id: 0,
pos: None,
});
app.update();
@@ -1417,6 +1437,7 @@ mod nock_latch_tests {
entity: bare,
ident: *b"$BWP",
data: 0,
anim_id: 0,
pos: None,
});
app.update();
@@ -68,7 +68,6 @@ pub(super) fn load_blood_tables(mut commands: Commands, assets: Option<Res<World
/// `info` and every fired spurt at `debug`, so "I never see blood" localizes to a link in one
/// fight instead of a code audit (no drop lines at all ⇒ the break is upstream, in the
/// [`SwingImpact`] feed itself).
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(super) fn blood_spurts(
mut swings: MessageReader<SwingImpact>,
transforms: Query<&Transform>,
@@ -141,7 +141,6 @@ pub(super) struct LastPuff(EntityHashMap<f32>);
///
/// A unit seen for the first time has no [`BreathEnv`] and is classified at once, so a unit that
/// streams in inside a cold zone breathes on its next idle loop rather than up to 10 s later.
#[allow(clippy::too_many_arguments)] // one system's full input set
pub(super) fn classify_breath(
mut commands: Commands,
time: Res<Time>,
@@ -184,7 +183,6 @@ pub(super) fn classify_breath(
/// off the mount — so the **puff goes on the event's own entity** (its mouth), while the unit
/// state the ladder reads (drunk, the environment) comes from the composite's **root**, exactly
/// as the footfall visuals split them.
#[allow(clippy::too_many_arguments)] // one system's full input set
pub(super) fn fire_breath(
mut events: MessageReader<AnimSoundEvent>,
time: Res<Time>,
@@ -263,7 +263,7 @@ fn transplant_up(
/// enter/loop/exit the Special states (jump, sit/sleep/kneel) as one-shot-bracketed loops, play the
/// per-packet melee swings as preemptible one-shots, and cross-fade the gaits (the engaged Ready
/// idle among them).
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub(super) fn drive_animations(
mut commands: Commands,
mut units: Query<(
@@ -363,7 +363,7 @@ pub(super) fn drive_animations(
),
// The variation roll's LCG state (decision 0114 — the client's single CRT `_rand` stream,
// shared by every play; [`select::msvc_rand`]).
mut rng: Local<u32>,
mut rng: ResMut<benilla_assets::AnimRng>,
// The SELF unit's last-written anim state line of the `WOW_MOVE_TRACE` debug trace (the
// diff-only filter; see the trace block after the mode machine).
mut anim_trace_last: Local<std::collections::HashMap<Entity, String>>,
@@ -685,9 +685,7 @@ pub(super) fn drive_animations(
let c = if first {
c
} else {
anims
.pick_variation(c.anim_id, select::msvc_rand(&mut rng))
.unwrap_or(c)
anims.pick_variation(c.anim_id, rng.draw()).unwrap_or(c)
};
let active =
tr.play(&mut player, c.node, Duration::from_secs_f32(c.blend_time));
@@ -63,7 +63,7 @@ pub(super) fn run(
drv: &mut AnimDriver,
tr: &mut AnimationTransitions,
player: &mut AnimationPlayer,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
) {
let Frame {
entity,
@@ -579,7 +579,7 @@ pub(super) fn run(
// gait it WRAPPED to its head at completion — the frames + cross-fade
// against the restarted reach-to-quiver were the director's "jumps
// back to the start the moment it gets fully pulled", in every build
// that replayed a pull (trace-caught, decision 0412). The clamp IS the
// that replayed a pull (trace-caught, decision 2276). The clamp IS the
// drawn pose; nothing follows it (0994).
// Loot 50 is likewise authored clamp — one 0.5 s kneel-down that must
// FREEZE in the rummage pose; as Forever it would wrap back to standing
@@ -144,7 +144,7 @@ pub(super) fn cut_loop(
anims: &ModelAnimations,
id: u16,
catalog: Option<&AnimDataCatalog>,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
) {
let Some(head) = find_resolved(anims, id, catalog) else {
@@ -178,12 +178,12 @@ fn arm(
pub(super) fn roll_oneshot<'a>(
anims: &'a ModelAnimations,
head: &'a AnimClip,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
) -> (&'a AnimClip, RepeatAnimation) {
let c = anims
.pick_variation(head.anim_id, select::msvc_rand(rng))
.pick_variation(head.anim_id, rng.draw())
.unwrap_or(head);
let repeat = match select::replay_count(c.replay, select::msvc_rand(rng)) {
let repeat = match rng.replay_count(c.replay) {
r if r > 1 => RepeatAnimation::Count(r),
_ => RepeatAnimation::Never,
};
@@ -201,11 +201,11 @@ pub(super) fn pick_loop_variation<'a>(
anims: &'a ModelAnimations,
head: &'a AnimClip,
relaxed: bool,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
) -> &'a AnimClip {
if relaxed {
anims
.pick_variation(head.anim_id, select::msvc_rand(rng))
.pick_variation(head.anim_id, rng.draw())
.unwrap_or(head)
} else {
head
@@ -222,10 +222,10 @@ pub(super) fn roll_loop<'a>(
anims: &'a ModelAnimations,
head: &'a AnimClip,
relaxed: bool,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
) -> (&'a AnimClip, u32) {
let c = pick_loop_variation(anims, head, relaxed, rng);
let r = select::replay_count(c.replay, select::msvc_rand(rng));
let r = rng.replay_count(c.replay);
(c, r)
}
@@ -237,7 +237,6 @@ pub(super) fn roll_loop<'a>(
/// decision 0123) **and its budget** ([`roll_loop`] — decision 0516), publishing the armed
/// `(node, R)` into `window` for the watchdog's advance; a one-shot arm clears it (its budget is
/// the `Count` repeat — no window outlives the arm).
#[allow(clippy::too_many_arguments)] // the resolve+roll+play primitive's full input set
pub(super) fn play(
lock: &mut BaseAnimLock,
tr: &mut AnimationTransitions,
@@ -248,7 +247,7 @@ pub(super) fn play(
relaxed: bool,
rate: f32,
catalog: Option<&AnimDataCatalog>,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
) {
// The lock's guard is `PlayAnimation`'s **front door**, above the arm helper that draws the
@@ -382,7 +381,6 @@ pub(super) fn holds_own_clip(
/// and settles through [`Mode::Entering`]; **Fall has no enter** — the client plays the Fall(40)
/// loop directly the tick FALLINGFAR latches (`0x602c40`) — so it goes straight to
/// [`Mode::Looping`] with a looping play.
#[allow(clippy::too_many_arguments)]
pub(super) fn enter_special(
lock: &mut BaseAnimLock,
sp: Special,
@@ -391,7 +389,7 @@ pub(super) fn enter_special(
player: &mut AnimationPlayer,
anims: &ModelAnimations,
catalog: Option<&AnimDataCatalog>,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
) -> Mode {
if sp == Special::Fall {
@@ -434,7 +432,6 @@ pub(super) fn enter_special(
/// started moving drops straight to the gait, letting the cross-fade carry the half-pose into the
/// walk; an airborne state landing plays its [`jump_land_pick`]; otherwise `sp` plays its graceful
/// exit one-shot, which [`super::drive_animations`] then waits out. Returns the mode to adopt.
#[allow(clippy::too_many_arguments)]
pub(super) fn leave_special(
lock: &mut BaseAnimLock,
sp: Special,
@@ -446,7 +443,7 @@ pub(super) fn leave_special(
player: &mut AnimationPlayer,
anims: &ModelAnimations,
catalog: Option<&AnimDataCatalog>,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
frozen: &mut Option<bevy::animation::graph::AnimationNodeIndex>,
) -> Mode {
@@ -147,6 +147,8 @@ fn advance(app: &mut App, ms: u64) {
fn app() -> App {
let mut app = App::new();
// The client's ONE `rand()` stream (2301) — the play's variation and replay rolls.
app.init_resource::<benilla_assets::AnimRng>();
// Asset + animation plugins so tests with REAL clip assets (the watchdog test) get Bevy's
// `advance_animations` ticking completions; units without a graph handle are skipped by it,
// so the asset-less tenants are unaffected.
@@ -115,14 +115,13 @@ pub(super) fn wound_evict(
/// sheath reconcile and the event scan. `id` is the wound anim to lay (8–10), already resolved
/// by the caller — melee by severity/engagement ([`select::wound_anim`]), a spell impact by its
/// kit's own column ([`WoundEdge`]).
#[allow(clippy::too_many_arguments)] // the trigger's full live-state + rng input set
pub(super) fn wound_trigger(
entity: Entity,
drv: &mut AnimDriver,
player: &mut AnimationPlayer,
anims: &ModelAnimations,
catalog: Option<&AnimDataCatalog>,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
id: u16,
mv: &MovementState,
mounted: bool,
@@ -137,7 +136,7 @@ pub(super) fn wound_trigger(
// (`end = clock`, expired on arrival) — skip. No resolvable clip at all is the
// `0x711a20` asset-presence abort.
let clip = find_resolved(anims, id, catalog)
.and_then(|h| anims.pick_variation(h.anim_id, select::msvc_rand(rng)))
.and_then(|h| anims.pick_variation(h.anim_id, rng.draw()))
.filter(|c| c.duration > 0.0);
let node = clip.and_then(|c| {
if full {
@@ -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),
});
}
@@ -144,7 +144,6 @@ fn is_whiff(victim_state: u32) -> bool {
/// Cache swings, consume impact tags, dispatch `$CPP` defenses, flush on supersede/attack-stop,
/// silently drop records whose attacker despawned (the client's dtor clears without flushing).
/// Ordered after [`super::events::fire_anim_events`] so a tag fires the same frame it's crossed.
#[allow(clippy::too_many_arguments)]
pub(super) fn route_swing_impacts(
mut swings: MessageReader<SwingMessage>,
mut events: MessageReader<AnimSoundEvent>,
@@ -319,6 +318,7 @@ mod tests {
entity,
ident,
data: 0,
anim_id: 0,
pos: None,
});
app.update();
+1 -1
View File
@@ -90,7 +90,7 @@ const FALLBACK_RADIUS: f32 = 6.0;
/// frame. Exempt: the body we drive (the camera rides its attachment-17 pivot, and it must keep
/// animating faded-out in first person) and its mount child. `WOW_NO_ANIM_LOD=1` disables
/// parking — the live-probe A/B lever; `WOW_NO_ROOM_LOD=1` disables only the room leg.
#[allow(clippy::type_complexity, clippy::too_many_arguments)] // one Bevy system's full input set
#[allow(clippy::type_complexity)] // one Bevy system's full input set
pub(super) fn gate_rig_animation(
time: Res<Time>,
cam: Query<&Frustum, With<WorldCamera>>,
@@ -1005,32 +1005,6 @@ pub(crate) fn blend_lambda(remaining_frac: f32) -> f32 {
(3.0 - 2.0 * t) * t * t
}
/// The client's `_rand` — the MSVCRT LCG (`state × 214013 + 2531011`, output `(state >> 16) &
/// 0x7fff`; byte-verified wow-re `rf36-rand-stub.md` at `0x7400e5`) — the roll feeding op4's
/// per-play **variation pick** (`ModelAnimations::pick_variation`) and its **replay-count roll**
/// ([`replay_count`] — the second `_rand` site). Owned exactly rather than delegating to a host
/// RNG, per the determinism guidance in the same note; one stream shared by every play, like the
/// client's single CRT stream.
pub(crate) fn msvc_rand(state: &mut u32) -> u16 {
*state = state.wrapping_mul(214013).wrapping_add(2531011);
((*state >> 16) & 0x7fff) as u16
}
/// The per-arm **replay-count roll** (wow-re `loop-replay-fidget.md`, op4's second `_rand` site
/// `0x712692..0x7126cd`): `R = max(1, min + ⌊roll·(max−min)/32768⌋)` from the sequence's
/// `(minReplay, maxReplay)`. The client multiplies `R` into the play window (`0x7126d8`) — a
/// clamp-flag one-shot runs its timeline `R` times before freezing; loop-flag sequences ignore it.
/// Benilla expresses the same window as a repeat count on the one-shot play. `(0, 0)` → 1.
pub(super) fn replay_count(replay: (u32, u32), roll: u16) -> u32 {
let (min, max) = replay;
let extra = if max > min {
(u64::from(roll) * u64::from(max - min) / 32768) as u32
} else {
0
};
(min + extra).max(1)
}
/// The engaged standing idle (decision 0073 — the `0x5fd360` arm's weapon-class Ready pick,
/// `0x5fcdc0`). Note the buckets differ from the swing table: fist **and** dagger ready as 1H.
pub(super) fn ready_anim(main: Option<(u8, u8)>) -> u16 {
@@ -834,36 +834,6 @@ fn wound_weight_decays_smoothstep_to_zero() {
assert!((wound_weight(1.0, 9.0) - 27.0).abs() < 1e-4);
}
#[test]
fn msvc_rand_is_the_crt_lcg() {
// srand(1)'s canonical first outputs: 41, 18467, 6334 — the exact MSVCRT stream the client's
// variation roll consumes (wow-re rf36-rand-stub.md).
let mut state = 1u32;
assert_eq!(msvc_rand(&mut state), 41);
assert_eq!(msvc_rand(&mut state), 18467);
assert_eq!(msvc_rand(&mut state), 6334);
// Output stays in the client's 0..0x7fff roll domain.
let mut state = 0xdead_beefu32;
for _ in 0..100 {
assert!(msvc_rand(&mut state) <= 0x7fff);
}
}
#[test]
fn replay_count_rolls_the_window_multiplier() {
// (0,0) — the overwhelming majority — always 1.
assert_eq!(replay_count((0, 0), 0), 1);
assert_eq!(replay_count((0, 0), 0x7fff), 1);
// A fixed authored count passes through (clamped ≥ 1).
assert_eq!(replay_count((3, 3), 0x7fff), 3);
// A range: R = min + ⌊roll·(max−min)/32768⌋ — max is approached, never exceeded.
assert_eq!(replay_count((2, 4), 0), 2);
assert_eq!(replay_count((2, 4), 16384), 3);
assert_eq!(replay_count((2, 4), 0x7fff), 3); // ⌊32767·2/32768⌋ = 1
// Malformed (max < min) degrades to min.
assert_eq!(replay_count((5, 2), 0x7fff), 5);
}
#[test]
fn defense_anim_matches_the_byte_lut() {
// Decision 0279: the `0x60ec98` LUT read off WoW.exe — dagger parries 1H (unlike its swing),
@@ -341,7 +341,6 @@ fn arm_leg(
/// gets at most one masked overlay, and [`VisualSheath`] holds that arm's pre-swap placement until
/// its clip reaches the authored `$SHL`/`$SHR` moment. An arm whose leg has no playable clip
/// snaps; no arm playing anything at all → no ceremony, and the whole transition snaps.
#[allow(clippy::too_many_arguments)]
pub(super) fn start_sheath_ceremony(
commands: &mut Commands,
entity: Entity,
@@ -380,7 +379,6 @@ pub(super) fn start_sheath_ceremony(
/// [`sheath_phase2`] so it can reach for the new weapon. The ceremony ends when every arm has run
/// out of legs, at which point [`VisualSheath`] is dropped and the resolver falls back to the
/// committed state.
#[allow(clippy::too_many_arguments)]
pub(super) fn advance_sheath_ceremony(
commands: &mut Commands,
entity: Entity,
@@ -19,7 +19,7 @@ use benilla_protocol::EntityKind;
use crate::aura_visual::AuraProc;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore};
use crate::net::{FieldChanged, NetCommands, NetEntity, ObjectStore};
use benilla_assets::{LockRecover, WorldAssets};
use super::{
@@ -334,7 +334,7 @@ pub(super) fn load_spell_visuals(mut commands: Commands, assets: Option<Res<Worl
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct WeaponVisualSrc<'w, 's> {
displays: Option<Res<'w, ItemDisplays>>,
items: Option<ResMut<'w, Items>>,
items: Option<Res<'w, Items>>,
net: Option<Res<'w, NetCommands>>,
units: Query<'w, 's, (Option<&'static NetEntity>, &'static ObjectStore)>,
}
@@ -355,7 +355,7 @@ impl WeaponVisualSrc<'_, '_> {
EntityKind::Player => s
.player_visible_item_entry(17)
.filter(|e| *e != 0)
.and_then(|entry| self.items.as_deref_mut()?.held(entry, self.net.as_deref()?))
.and_then(|entry| self.items.as_deref()?.held(entry, self.net.as_deref()?))
.map(|t| t.display_info_id),
_ => None,
}
@@ -765,7 +765,6 @@ fn play_impact(
/// have no packet, decision 0099): field set → the **channel** kit's anim as the hold + its
/// sound once at start; field cleared → hold drops. The per-entity edge cache is the dedup the
/// client gets from its per-tick armed-id guard — a held channel never restarts its clip.
#[allow(clippy::too_many_arguments)]
pub(super) fn route_cast_visuals(
mut commands: Commands,
mut events: MessageReader<CastEvent>,
@@ -1323,15 +1322,24 @@ pub(super) fn route_cast_visuals(
/// exactly that bug: Stealth's kit 312 carries no models, no anim and no attach at all (its whole
/// visual is one proc-14 CharProc), so an effects-only test dropped it and the character showed
/// nothing.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
/// The reference's one aura watch: `UNIT_FIELD_AURA` + `UNIT_FIELD_AURAFLAGS` as a single
/// registration — `0x604d00`, registered `604226 mov edx,0xa4` / `604221 push 0xd8` for class 3
/// (wow-re `object-layer.md`): field offset `0xa4` = dword 41 past the 6 OBJECT fields =
/// `FIELD_UNIT_AURA`, length `0xd8` bytes = 54 dwords = the 48 slot ids and the 6 packed flag words.
const AURA_WATCH_BASE: u16 = benilla_protocol::field::FIELD_UNIT_AURA;
const AURA_WATCH_SPAN: u16 = 54;
pub(crate) fn arm_aura_state_fx(
// The slot diff below is a pure function of the store's aura fields, so it re-runs only when
// the store was written — `arm_level_up_fx`'s idiom (decision 1357's sibling gate): at the
// LBRS pin this was ~800 full aura-slot walks/frame re-deriving an unchanged answer. The
// The slot diff below is a pure function of the store's aura fields, so it re-runs for a unit
// only when one of THOSE moved — the field edges inside the reference's own aura watch span
// (decision 2297; before that, any store write: at the LBRS pin ~800 full aura-slot walks per
// frame re-deriving an unchanged answer, most of them health ticks) — and on the unit's first
// sight, because a standing aura is a STATE the streamed-in unit wears, not an edge. The
// unfiltered twin runs exactly once per DBC-resource arrival: a unit that streamed in before
// `SpellVisuals`/`Spells` landed carries standing auras no store write will re-announce.
// `SpellVisuals`/`Spells` landed carries standing auras no edge will re-announce.
units: Query<(Entity, &ObjectStore)>,
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
arrived: Query<Entity, Added<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut fx: MessageWriter<SpellKitFx>,
@@ -1348,7 +1356,17 @@ pub(crate) fn arm_aura_state_fx(
let scan = if full_sweep {
units.iter().collect::<Vec<_>>()
} else {
changed.iter().collect::<Vec<_>>()
let mut due: EntityHashSet = arrived.iter().collect();
due.extend(
edges
.read()
.filter(|e| {
e.unit_array_slot(AURA_WATCH_BASE, AURA_WATCH_SPAN)
.is_some()
})
.map(|e| e.entity),
);
due.into_iter().filter_map(|e| units.get(e).ok()).collect()
};
for (entity, store) in scan {
let prev = armed.entry(entity).or_default();
@@ -1454,14 +1472,17 @@ pub(crate) fn arm_aura_state_fx(
/// [`LOOT_FX_KEY`] — the client hangs loot art on the same `Effect_C` node type its spell
/// visuals use, so sharing the one attach body is the faithful shape.
pub(super) fn arm_loot_fx(
// Dead+lootable is a pure function of store fields — the same `Changed<ObjectStore>` gate as
// `arm_level_up_fx` below and `arm_aura_state_fx` above, with the same one-shot full sweep
// when the DBC resource lands after units already streamed in. `NetEntity` splits the two
// predicates: a **unit** answers `UNIT_DYNFLAG_LOOTABLE`, a **corpse object** answers its own
// Dead+lootable is a pure function of four store fields, so a unit is re-read on a field
// edge on any of them and on first sight (a body that streams in lootable wears the sparkle —
// the reference arms it at build, `0x5d6e30`, and re-arms off the field's own mirror handler),
// with the same one-shot full sweep as `arm_aura_state_fx` when the DBC resource lands after
// units already streamed in (decision 2297). `NetEntity` splits the two predicates: a **unit**
// answers `UNIT_DYNFLAG_LOOTABLE`, a **corpse object** answers its own
// `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0 (decision 1723) — different fields at different indices,
// and a corpse descriptor has no UNIT block to ask at all.
// and a corpse descriptor has no UNIT block to ask at all; the edge's `kind` keeps them apart.
units: Query<(Entity, &ObjectStore, &crate::net::NetEntity)>,
changed: Query<(Entity, &ObjectStore, &crate::net::NetEntity), Changed<ObjectStore>>,
arrived: Query<Entity, Added<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
mut fx: MessageWriter<SpellKitFx>,
mut armed: Local<EntityHashSet>,
@@ -1474,7 +1495,24 @@ pub(super) fn arm_loot_fx(
let scan = if full_sweep {
units.iter().collect::<Vec<_>>()
} else {
changed.iter().collect::<Vec<_>>()
use benilla_protocol::field::{
FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_HEALTH,
FIELD_UNIT_MAXHEALTH,
};
let mut due: EntityHashSet = arrived.iter().collect();
due.extend(
edges
.read()
.filter(|e| {
e.unit_field(FIELD_UNIT_DYNAMIC_FLAGS)
|| e.unit_field(FIELD_UNIT_HEALTH)
|| e.unit_field(FIELD_UNIT_MAXHEALTH)
|| (e.kind == benilla_protocol::messages::ObjectType::Corpse
&& e.index == FIELD_CORPSE_DYNAMIC_FLAGS)
})
.map(|e| e.entity),
);
due.into_iter().filter_map(|e| units.get(e).ok()).collect()
};
for (entity, store, net) in scan {
// The **corpse object**'s own sparkle (wow-re `corpse-decal-and-loot-sparkle.md` §Q2, §5
@@ -1539,51 +1577,46 @@ pub(super) fn arm_loot_fx(
/// `UNIT_FIELD_LEVEL` **change** on any streamed unit (the client's descriptor change-watcher
/// `CMirrorHandler 0x6045b0` → `SpawnHardcodedEffect(5)`) spawns [`LEVEL_UP_EFFECT`]
/// (`Spells\LevelUp\LevelUp.mdl`) at the base attach — the ding visual is DECOUPLED from
/// `SMSG_LEVELUP_INFO`, so anyone leveling nearby flashes too. First sight of a unit arms
/// silently (it streamed in *carrying* a level, it didn't just gain one). The instance
/// self-terminates on its own 1.867 s clip (spell id 0, the kit-push stage-0 shape — no reap
/// key needed, unlike the persistent loot sparkle above), and its **sound is the model's own**
/// `$SND(888)` event → `Sound\Spells\LevelUp.wav`, fired by
/// `SMSG_LEVELUP_INFO`, so anyone leveling nearby flashes too. A unit that streams in
/// *carrying* a level didn't just gain one — and the field-edge stream is create-suppressed, so
/// that needs no memory here (decision 2297; this was the first `Local<EntityHashMap>` shadow
/// to go). The instance self-terminates on its own 1.867 s clip (spell id 0, the kit-push
/// stage-0 shape — no reap key needed, unlike the persistent loot sparkle above), and its
/// **sound is the model's own** `$SND(888)` event → `Sound\Spells\LevelUp.wav`, fired by
/// `crate::entities::spell_fx`'s event-track scanner.
pub(super) fn arm_level_up_fx(
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
mut levels: Local<EntityHashMap<u32>>,
mut fx: MessageWriter<SpellKitFx>,
) {
for (entity, store) in &changed {
let Some(level) = store.0.unit_level() else {
for e in edges.read() {
if !e.unit_field(benilla_protocol::field::FIELD_UNIT_LEVEL) {
continue;
};
match levels.insert(entity, level) {
Some(prev) if prev != level => {
let Some((effect, path)) = visuals
.as_ref()
.and_then(|v| v.0.hardcoded_effect(LEVEL_UP_EFFECT))
else {
continue; // no client data / no such row (the DBC-resource degrade shape)
};
debug!("anim: level {prev} → {level}, the ding flashes ({entity})");
fx.write(SpellKitFx::Begin {
entity,
spell_id: 0,
persistent: false,
class: FxClass::Hold,
// The ding self-terminates on its own 1.867 s clip — the stage-0 shape.
stage: FxStage::OneShot,
effects: vec![FxSlot {
tag: HARDCODED_FX_ATTACH,
effect,
path: path.to_string(),
}],
});
}
_ => {} // first sight arms; an unchanged write is a no-op
}
let Some((effect, path)) = visuals
.as_ref()
.and_then(|v| v.0.hardcoded_effect(LEVEL_UP_EFFECT))
else {
continue; // no client data / no such row (the DBC-resource degrade shape)
};
debug!(
"anim: level {} → {}, the ding flashes ({})",
e.old, e.new, e.entity
);
fx.write(SpellKitFx::Begin {
entity: e.entity,
spell_id: 0,
persistent: false,
class: FxClass::Hold,
// The ding self-terminates on its own 1.867 s clip — the stage-0 shape.
stage: FxStage::OneShot,
effects: vec![FxSlot {
tag: HARDCODED_FX_ATTACH,
effect,
path: path.to_string(),
}],
});
}
// Streamed units despawn on range-out — drop their level memory with them.
levels.retain(|e, _| units.contains(*e));
}
/// The **mount poof** — the cloud the director reports seeing at mount-up and we never drew.
@@ -1617,45 +1650,41 @@ pub(super) fn arm_level_up_fx(
/// today (the mounted gate refuses a second mount spell) and bounded at "one extra puff", so it
/// is named rather than modelled.
pub(super) fn arm_mount_poof_fx(
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
mut displays: Local<EntityHashMap<u32>>,
mut fx: MessageWriter<SpellKitFx>,
) {
for (entity, store) in &changed {
let mount_display = store.0.unit_mount_display_id();
match displays.insert(entity, mount_display) {
// The build leg's gate is on the NEW value, so a dismount (`mount_display == 0`)
// spawns nothing — and first sight arms silently, exactly like the ding: a unit that
// streams in already mounted did not just mount.
Some(prev) if prev != mount_display && mount_display != 0 => {
let Some((effect, path)) = visuals
.as_ref()
.and_then(|v| v.0.hardcoded_effect(MOUNT_POOF_EFFECT))
else {
continue; // no client data / no such row (the DBC-resource degrade shape)
};
debug!("anim: mount display {prev} → {mount_display}, the poof puffs ({entity})");
fx.write(SpellKitFx::Begin {
entity,
spell_id: 0,
persistent: false,
class: FxClass::Hold,
// `0x5fbf50` — destroy at the first completion; the shipped model runs 2.8 s.
stage: FxStage::OneShot,
effects: vec![FxSlot {
tag: HARDCODED_FX_ATTACH,
effect,
path: path.to_string(),
}],
});
}
_ => {}
for e in edges.read() {
// The build leg's gate is on the NEW value, so a dismount (`new == 0`) spawns nothing —
// and a unit that streams in already mounted did not just mount, which the
// create-suppressed edge stream says for us, exactly as for the ding (2297).
if !e.unit_field(benilla_protocol::field::FIELD_UNIT_MOUNTDISPLAYID) || e.new == 0 {
continue;
}
let Some((effect, path)) = visuals
.as_ref()
.and_then(|v| v.0.hardcoded_effect(MOUNT_POOF_EFFECT))
else {
continue; // no client data / no such row (the DBC-resource degrade shape)
};
debug!(
"anim: mount display {} → {}, the poof puffs ({})",
e.old, e.new, e.entity
);
fx.write(SpellKitFx::Begin {
entity: e.entity,
spell_id: 0,
persistent: false,
class: FxClass::Hold,
// `0x5fbf50` — destroy at the first completion; the shipped model runs 2.8 s.
stage: FxStage::OneShot,
effects: vec![FxSlot {
tag: HARDCODED_FX_ATTACH,
effect,
path: path.to_string(),
}],
});
}
// Streamed units despawn on range-out — drop their mount memory with them.
displays.retain(|e, _| units.contains(*e));
}
/// The one-slot **pending-morph latch** — the reference's `[unit+0xd54]`, a `SpellRec*` (wow-re
@@ -1689,52 +1718,40 @@ fn is_morph_spell(spells: &crate::ui_action::Spells, spell_id: u32) -> bool {
})
}
/// Arm [`MorphLatch`] from the aura-slot diff — the reference's TWO arm sites folded into the
/// one place benilla sees both edges: the aura-add watcher and the aura-remove handler's tail
/// Arm [`MorphLatch`] from the aura-slot edges — the reference's TWO arm sites folded into the
/// one place benilla sees both: the aura-add watcher and the aura-remove handler's tail
/// (`0x6123ad`) both call `0x5ff0c0(spellId)`, and a later arm overwrites an earlier one (one
/// slot). First sight of a unit seeds the diff baseline silently — the reference's watchers
/// fire on VALUES deltas, never on create, so a druid streaming in mid-form must not latch.
/// Keyed to its own full-slot diff rather than [`arm_aura_state_fx`]'s `armed` map, which
/// tracks only state-kit spells — the form spells carry none.
/// slot). Each `UNIT_FIELD_AURA` slot edge is one dword of `0x604d00`'s watch (decision 2297):
/// its old spell is a REMOVE arm, its new spell an ADD arm — remove first, add second, so a
/// form→form swap in one slot latches the form being ENTERED (both resolve to the same cloud
/// family regardless). A unit's first sight emits no edge, so a druid streaming in mid-form does
/// not latch — the reference's watchers fire on VALUES deltas, never on create.
pub(super) fn arm_morph_latch(
units: Query<(Entity, &ObjectStore)>,
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
spells: Option<Res<crate::ui_action::Spells>>,
mut latch: ResMut<MorphLatch>,
mut seen: Local<EntityHashMap<Vec<u32>>>,
) {
for (entity, store) in &changed {
let mut cur: Vec<u32> = store.0.unit_auras().map(|a| a.spell_id).collect();
cur.sort_unstable();
cur.dedup();
match seen.entry(entity) {
bevy::platform::collections::hash_map::Entry::Vacant(e) => {
e.insert(cur); // first sight arms silently
if let Some(spells) = spells.as_deref() {
for e in edges.read() {
if e.unit_array_slot(
benilla_protocol::field::FIELD_UNIT_AURA,
u16::from(benilla_protocol::messages::UNIT_AURA_SLOTS),
)
.is_none()
{
continue;
}
bevy::platform::collections::hash_map::Entry::Occupied(mut e) => {
let prev = e.get_mut();
if *prev == cur {
continue; // some other field moved
for spell_id in [e.old, e.new] {
if spell_id != 0 && is_morph_spell(spells, spell_id) {
debug!("anim: morph latch armed ({}, spell {spell_id})", e.entity);
latch.0.insert(e.entity, spell_id);
}
if let Some(spells) = spells.as_deref() {
// Remove edges first, add edges second: a form→form swap latches the form
// being ENTERED (both resolve to the same cloud family regardless).
let removed = prev.iter().filter(|s| cur.binary_search(s).is_err());
let added = cur.iter().filter(|s| prev.binary_search(s).is_err());
for &spell_id in removed.chain(added) {
if is_morph_spell(spells, spell_id) {
debug!("anim: morph latch armed ({entity}, spell {spell_id})");
latch.0.insert(entity, spell_id);
}
}
}
*prev = cur;
}
}
}
// Streamed units despawn on range-out — the latch and the baseline die with them.
// Streamed units despawn on range-out — the latch dies with them.
latch.0.retain(|e, _| units.contains(*e));
seen.retain(|e, _| units.contains(*e));
}
/// The rebuild's **impact-kit replay** — the tail of the reference's `0x60abe0` (wow-re
@@ -1748,7 +1765,6 @@ pub(super) fn arm_morph_latch(
/// cold-cache shift-in — whose SPELL_GO impact instance was still PENDING at the drain and thus
/// survived — briefly runs that instance and the replay's twin together (one denser cloud, once
/// per session per model; the warm-cache GO instance dies in the drain like the reference's).
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(super) fn replay_morph_kit(
mut swaps: MessageReader<crate::entities::DisplaySwapped>,
visuals: Option<Res<SpellVisuals>>,
@@ -47,6 +47,7 @@ fn app() -> App {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -210,6 +211,7 @@ fn precast_kit_sound_rings_once_at_start() {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -489,6 +491,7 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<SpellKitFx>();
app.add_message::<crate::net::FieldChanged>();
// The watcher's other fan-outs: the kit's CharProc edges (`crate::aura_visual`) and its
// sound leg (0852). Food's kit 409 carries neither, so nothing is asserted here — the
// messages just have to exist for the writers.
@@ -553,7 +556,12 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() {
let eating = ObjectFields::from_pairs(&[(47, FOOD), (95, 0x0E)]);
let fasted = ObjectFields::from_pairs(&[(95, 0)]);
let unit = app.world_mut().spawn(crate::net::ObjectStore(eating)).id();
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(
eating.into_created(benilla_protocol::messages::ObjectType::Unit),
))
.id();
app.update();
{
let log = &app.world().resource::<FxLog>().0;
@@ -591,9 +599,11 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() {
);
// The aura leaves the slots: one AuraState reap.
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(fasted));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
fasted.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.update();
{
let log = &app.world().resource::<FxLog>().0;
@@ -639,6 +649,7 @@ fn a_harmful_go_wounds_each_hit_once_and_a_missile_arrival_always() {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -747,6 +758,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -861,6 +873,7 @@ fn a_targetless_dest_go_spawns_a_ground_missile_whose_arrival_sounds_at_the_poin
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -1055,6 +1068,7 @@ fn the_mount_poof_puffs_on_the_build_leg_only() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<SpellKitFx>();
app.add_message::<crate::net::FieldChanged>();
app.insert_resource(SpellVisuals(
SpellVisualCatalog::from_tables(HashMap::new(), HashMap::new()).with_hardcoded(
"HARDCODED Mount Poof",
@@ -1064,10 +1078,14 @@ fn the_mount_poof_puffs_on_the_build_leg_only() {
));
app.add_systems(Update, super::arm_mount_poof_fx);
let store =
|v: u32| crate::net::ObjectStore(ObjectFields::from_pairs(&[(FIELD_MOUNTDISPLAYID, v)]));
// Streams in ALREADY mounted: first sight arms the memory, silently.
let unit = app.world_mut().spawn(store(2404)).id();
let fields = |v: u32| ObjectFields::from_pairs(&[(FIELD_MOUNTDISPLAYID, v)]);
// Streams in ALREADY mounted: the create block is no edge (decision 2297), so silence.
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(
fields(2404).into_created(benilla_protocol::messages::ObjectType::Unit),
))
.id();
app.update();
let puffs = |app: &mut App| -> Vec<super::FxSlot> {
let mut out = Vec::new();
@@ -1090,12 +1108,12 @@ fn the_mount_poof_puffs_on_the_build_leg_only() {
);
// Dismount — the NEW value is 0, so the build leg (and the whole allocation) is skipped.
app.world_mut().entity_mut(unit).insert(store(0));
crate::net::apply_fields_for_test(app.world_mut(), unit, fields(0));
app.update();
assert!(puffs(&mut app).is_empty(), "no poof on the way down");
// Mount: 0 → N.
app.world_mut().entity_mut(unit).insert(store(2404));
crate::net::apply_fields_for_test(app.world_mut(), unit, fields(2404));
app.update();
assert_eq!(
puffs(&mut app),
@@ -1108,12 +1126,12 @@ fn the_mount_poof_puffs_on_the_build_leg_only() {
);
// A steady mounted frame is not an edge — the watcher fires on the field CHANGING.
app.world_mut().entity_mut(unit).insert(store(2404));
crate::net::apply_fields_for_test(app.world_mut(), unit, fields(2404));
app.update();
assert!(puffs(&mut app).is_empty(), "no re-puff while just riding");
// A swap (N → N′) is a change, and the reference rebuilds and puffs again.
app.world_mut().entity_mut(unit).insert(store(2405));
crate::net::apply_fields_for_test(app.world_mut(), unit, fields(2405));
app.update();
assert_eq!(
puffs(&mut app),
@@ -1234,7 +1252,8 @@ fn a_kit_with_a_chain_char_proc_asks_for_a_beam_from_both_dispatcher_sites() {
let channeller = app
.world_mut()
.spawn(crate::net::ObjectStore(
benilla_protocol::ObjectFields::from_pairs(&[(144, BEAM_SPELL)]),
benilla_protocol::ObjectFields::from_pairs(&[(144, BEAM_SPELL)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
))
.id();
app.update();
@@ -1351,6 +1370,7 @@ fn real_shooter(weapon: &RealRanged) -> Option<(App, Entity)> {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -1523,11 +1543,12 @@ fn a_shooter_with_no_ranged_weapon_resolves_no_clip_at_all() {
return;
};
// Strip the equipment field — the wire's "nothing in slot 17".
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(
benilla_protocol::ObjectFields::from_pairs(&[(VISIBLE_RANGED_ENTRY_FIELD, 0)]),
));
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
benilla_protocol::ObjectFields::from_pairs(&[(VISIBLE_RANGED_ENTRY_FIELD, 0)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
);
app.world_mut()
.write_message(cast_event(unit, AUTO_SHOT, CastEventKind::Start));
app.world_mut()
@@ -1580,6 +1601,7 @@ fn a_state_kits_anim_is_a_recompute_and_never_a_second_play() {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -1678,6 +1700,7 @@ fn an_anim_only_state_kit_still_arms_on_the_aura_add_edge() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<crate::aura_visual::AuraProc>()
.add_message::<SpellKitSound>()
.add_message::<super::BaseAnimRecompute>();
@@ -1711,10 +1734,10 @@ fn an_anim_only_state_kit_still_arms_on_the_aura_add_edge() {
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[
(47, SPELL),
(95, 0x0E),
])))
.spawn(crate::net::ObjectStore(
ObjectFields::from_pairs(&[(47, SPELL), (95, 0x0E)])
.into_created(benilla_protocol::messages::ObjectType::Unit),
))
.id();
app.update();
+14 -4
View File
@@ -336,11 +336,23 @@ impl Plugin for CursorPlugin {
app.init_resource::<DisplayedCursor>();
#[cfg(target_os = "macos")]
app.add_systems(Startup, macos::setup.after(AssetSet::Open))
.add_systems(Update, (drive_displayed_cursor, macos::drive).chain());
.add_systems(
Update,
(drive_displayed_cursor, macos::drive)
.chain()
// After the tick: a FrameXML `SetCursor` made this frame is read here.
.after(crate::ui_script::UiInput),
);
#[cfg(not(target_os = "macos"))]
app.init_resource::<other::PayloadCursorImages>()
.add_systems(Startup, other::setup.after(AssetSet::Open))
.add_systems(Update, (drive_displayed_cursor, other::drive).chain());
.add_systems(
Update,
(drive_displayed_cursor, other::drive)
.chain()
// After the tick: a FrameXML `SetCursor` made this frame is read here.
.after(crate::ui_script::UiInput),
);
}
}
@@ -388,7 +400,6 @@ mod other {
/// Each frame: while a cursor payload with a resolved icon is held, show ITS 32×32 hardware
/// cursor (decoded/downsampled on first use, then cached by path); otherwise swap to the
/// classified mode (base-stem fallback, then Point, then OS) — unchanged from before 0216 §5.
#[allow(clippy::too_many_arguments)]
pub(super) fn drive(
mut commands: Commands,
cursor: Res<super::DisplayedCursor>,
@@ -543,7 +554,6 @@ mod macos {
/// §5, decoded/downsampled to 32×32 on first use and cached by path) if one is held and
/// resolved, else the classified mode (base-stem fallback, then Point); on entering/leaving
/// mouselook, hide/show it via the app-global hide counter.
#[allow(clippy::too_many_arguments)]
pub(super) fn drive(
cursors: Option<NonSend<NativeCursors>>,
mut payload_cursors: NonSendMut<PayloadCursors>,
File diff suppressed because it is too large Load Diff
+15 -19
View File
@@ -16,7 +16,7 @@ use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// Where our corpse is — the `MSG_CORPSE_QUERY` answer (decision 0308 §5). Raw WoW coords.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -177,7 +177,6 @@ const SPIRIT_HEALER_RANGE_SQ: f32 = 5.5556 * 5.5556;
/// Per-frame: derive the death state from the self descriptor, push the countdown/offer snapshot,
/// and fire the reference's death events on the edges (before `UiInput`, so a frame's `OnEvent`
/// sees current values — the [`crate::ui_unit`] feed convention).
#[allow(clippy::too_many_arguments)]
fn feed_death(
script: Option<NonSendMut<UiScript>>,
self_q: Query<(&ObjectStore, &Transform), With<SelfPlayer>>,
@@ -188,14 +187,14 @@ fn feed_death(
// comparison must be the same clock.
time: Res<Time<Real>>,
mut feed: ResMut<DeathFeedState>,
mut names: ResMut<crate::names::NameCache>,
names: Res<crate::names::NameCache>,
net: Res<NetCommands>,
index: Res<GuidIndex>,
transforms: Query<&Transform>,
map: Option<Res<benilla_world::world_map::CurrentMap>>,
status: Res<crate::net::NetStatus>,
spells: Option<Res<Spells>>,
mut items: ResMut<crate::items::Items>,
items: Res<crate::items::Items>,
) {
// **Only a LIVE session's descriptor is a snapshot** (decision 1732). A reconnect-able
// disconnect keeps the self avatar as the local puppet (0065) — descriptor and all — so
@@ -260,7 +259,7 @@ fn feed_death(
// `HasSoulstone()` — see [`resolve_self_res`] for the three gates and their order. Not a
// per-frame inventory walk in general: the dead gate is first, so while alive this is one
// health read, and while dead-unreleased the walk only runs on a zero field.
self_res_label: resolve_self_res(&store.0, &mut items, spells.as_deref(), &net)
self_res_label: resolve_self_res(&store.0, &items, spells.as_deref(), &net)
.map(|r| r.label().to_owned()),
});
@@ -471,7 +470,7 @@ impl SelfRes {
/// the lookup fires answers within a frame or two and the next resolve sees it.
fn resolve_self_res(
store: &benilla_protocol::ObjectFields,
items: &mut crate::items::Items,
items: &crate::items::Items,
spells: Option<&Spells>,
commands: &NetCommands,
) -> Option<SelfRes> {
@@ -629,7 +628,7 @@ fn drain_death(
match store.as_ref().and_then(|store| {
resolve_self_res(
store,
&mut ladder.items,
&ladder.items,
ladder.spells.as_deref(),
&ladder.commands,
)
@@ -719,7 +718,7 @@ impl Plugin for DeathPlugin {
.add_systems(
Update,
(
feed_death.before(UiInput),
feed_death.in_set(UiFeed),
drain_death.after(UiInput),
drive_death_look,
// Before the feed, so the frame a session ends is already a frame the feed
@@ -838,7 +837,7 @@ mod self_res_tests {
#[test]
fn the_dead_gate_precedes_the_field() {
let (net, _rx) = commands();
let mut items = Items::default();
let items = Items::default();
let spells = catalog([(REINCARNATION, named("Reincarnation", [94, 0, 0]))]);
let alive = ObjectFields::from_pairs(&[
@@ -847,7 +846,7 @@ mod self_res_tests {
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&alive, &mut items, Some(&spells), &net),
resolve_self_res(&alive, &items, Some(&spells), &net),
None,
"alive with a self-res owed: nil"
);
@@ -860,7 +859,7 @@ mod self_res_tests {
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&ghost, &mut items, Some(&spells), &net),
resolve_self_res(&ghost, &items, Some(&spells), &net),
None,
"a released ghost still holds the field, and still answers nil"
);
@@ -871,7 +870,7 @@ mod self_res_tests {
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&dead, &mut items, Some(&spells), &net),
resolve_self_res(&dead, &items, Some(&spells), &net),
Some(SelfRes::Spell {
spell: REINCARNATION,
label: "Reincarnation".into(),
@@ -884,12 +883,12 @@ mod self_res_tests {
#[test]
fn an_unresolvable_spell_id_reads_unknown_not_nil() {
let (net, _rx) = commands();
let mut items = Items::default();
let items = Items::default();
let dead =
ObjectFields::from_pairs(&[(F_MAXHEALTH, 4000), (F_HEALTH, 0), (F_SELF_RES, 999_999)]);
for spells in [None, Some(catalog([]))] {
assert_eq!(
resolve_self_res(&dead, &mut items, spells.as_ref(), &net),
resolve_self_res(&dead, &items, spells.as_ref(), &net),
Some(SelfRes::Spell {
spell: 999_999,
label: "UNKNOWN".into(),
@@ -949,7 +948,7 @@ mod self_res_tests {
}
let dead = ObjectFields::from_pairs(&pairs);
match resolve_self_res(&dead, &mut items, Some(&spells), &net) {
match resolve_self_res(&dead, &items, Some(&spells), &net) {
Some(SelfRes::Item { entry, label, .. }) => {
assert_eq!(
entry, 12,
@@ -962,9 +961,6 @@ mod self_res_tests {
// Take it away and the answer is nil again — not "UNKNOWN", which is the spell leg's.
let bare = ObjectFields::from_pairs(&[(F_MAXHEALTH, 4000), (F_HEALTH, 0)]);
assert_eq!(
resolve_self_res(&bare, &mut items, Some(&spells), &net),
None
);
assert_eq!(resolve_self_res(&bare, &items, Some(&spells), &net), None);
}
}
@@ -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
@@ -192,7 +196,6 @@ pub(super) struct InspectStores<'w, 's> {
/// The inspector overlay, drawn only while armed: a weak top-centre "armed" pill (so it's obvious the
/// mode is on and how to leave it) and, whenever the cursor is over an identified object, a compact
/// identity card pinned to the cursor. No chrome, no panel — its own lightweight surface.
#[allow(clippy::too_many_arguments)]
pub(super) fn inspect_ui(
mut contexts: EguiContexts,
inspect: Res<InspectMode>,
@@ -206,7 +209,7 @@ pub(super) fn inspect_ui(
drivers: Query<&crate::creature_anim::AnimDriver>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut names: ResMut<crate::names::NameCache>,
names: Res<crate::names::NameCache>,
net_commands: Res<crate::net::NetCommands>,
// Bundled into one param (Bevy's system-function arity ceiling): the copy-click button, and
// the flag it must yield to — a left press this frame the UI already consumed as a
@@ -262,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
@@ -417,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) {
@@ -156,13 +156,12 @@ fn age(now: f64, at: f64) -> String {
/// The journal overlay: while inspect is armed, the recent casts as a top-left column, newest
/// first — click a row to copy its one-line identity block. Shares the inspector card's style
/// (decision 0025's one overlay look).
#[allow(clippy::too_many_arguments)]
pub(super) fn journal_ui(
mut contexts: EguiContexts,
inspect: Res<InspectMode>,
journal: Res<CastJournal>,
self_guid: Res<SelfGuid>,
mut names: ResMut<crate::names::NameCache>,
names: Res<crate::names::NameCache>,
net_commands: Res<NetCommands>,
time: Res<Time>,
// The copied row's edge stamp (`CastRecord::at` is unique per row) + when the copy happened,
@@ -28,7 +28,8 @@ struct Knob {
}
// MONKEY (lighting debug panel): keep names, typed reads and presentation together. The numeric
// bounds mirror `cvars::apply_to_knobs` (including its wider ambient/fill/attenuation ranges);
// bounds mirror `video::on_cvar`'s MONKEY (lighting) arms — the registry's change-callback
// observer since 2303 — including their wider ambient/fill/attenuation ranges;
// shared shadow constants follow the setter directly. Casting the booleans through u32 keeps
// the descriptor read-only without inventing a parallel configuration type.
macro_rules! knob {
+2 -2
View File
@@ -37,6 +37,7 @@ use bevy_egui::{
PrimaryEguiContext,
};
use benilla_assets::LockRecover;
use benilla_world::lighting::{ClockSource, GameClock, WowLighting};
use benilla_world::model_render::{ModelKind, ModelPart};
use benilla_world::modkeys::{dev_chord, DEV_CHORD};
@@ -381,7 +382,6 @@ fn toggle_panel(keys: Res<ButtonInput<KeyCode>>, mut debug: ResMut<DebugState>)
/// Draw the panel as a translucent **overlay** on the right — the world renders full-screen
/// underneath (no viewport inset). `ui_script::PointerOverUi` keeps the cursor's panel
/// interactions from leaking into gameplay mouse-look.
#[allow(clippy::too_many_arguments)]
fn debug_panel_ui(
mut contexts: EguiContexts,
stamp: Res<benilla_world::build_id::BuildId>,
@@ -682,7 +682,7 @@ fn debug_panel_ui(
// The LAST sample, not the ring average the meter shows: the panel
// is the instrument, and "what did the most recent pong measure" is
// the question a stuck or spiking meter needs answered.
let last_rtt = ping.0.lock().expect("ping clock").last_rtt_ms;
let last_rtt = ping.0.lock_recover().last_rtt_ms;
ui.label(if net_status.connected {
match last_rtt {
Some(ms) => format!("connected · {ms} ms ping"),
+1 -1
View File
@@ -45,7 +45,7 @@ pub(super) struct WorldReadout<'w, 's> {
skybox_weight: Res<'w, benilla_world::skybox::SkyboxWeight>,
streamer: Res<'w, benilla_world::terrain_stream::TerrainStreamer>,
self_guid: Res<'w, crate::net::SelfGuid>,
names: ResMut<'w, crate::names::NameCache>,
names: Res<'w, crate::names::NameCache>,
net_commands: Res<'w, crate::net::NetCommands>,
camera: Query<'w, 's, &'static Transform, With<benilla_world::view::WorldCamera>>,
land: MessageWriter<'w, LandHere>,
+41 -25
View File
@@ -34,6 +34,15 @@ pub(crate) fn print_scenario_names() {
crate::capture::print_scenario_names();
}
/// `WOW_PROBE=list` — the probe fleet's environment registry (`capture::probe_env`): every
/// `WOW_PROBE*` variable, whether it arms the un-occludable window, and `WOW_PROBE`'s own named
/// values — printed before any window or asset setup. Answers nothing in a player build, which
/// has no probes.
pub(crate) fn print_probe_vars() {
#[cfg(feature = "dev")]
crate::capture::probe_env::print();
}
/// `WOW_HOVER_LOG_REPORT=<csv>` — re-read a recorded run and print its report, no window, no game.
/// New analysis lands on runs already captured (see `hover_log`).
pub(crate) fn report_recorded_hover_log(_path: &str) {
@@ -105,34 +114,26 @@ impl Plugin for DevProbesPlugin {
// Any scripted probe keeps its window un-occludable: a fully covered macOS window drops to
// ~1 fps drawables, and every probe schedule is wall-clock — a throttled run doesn't measure
// slowly, it runs the wrong script (see `capture::ProbeFocusPlugin`, decision 0906).
// Which probe variables count is the registry's `wall_clock` column
// (`capture::probe_env::PROBE_VARS`), not a list kept here: this list was hand-kept
// and had drifted to ten of the twenty-five wall-clock variables when 2265 §A5 read
// it against the code, so a mail or auction probe ran covered. The four below are the
// non-probe instruments that schedule on the wall clock too.
// (`WOW_LIVE_FPS` is in the list because an occluded SETTLE phase streams the world at ~1 fps
// and under-warms the scene before sampling even starts — the assertion has to be live from
// the first tick, not at the uncap.)
if [
"WOW_PROBE",
"WOW_PROBE_CHAT",
"WOW_PROBE_HOVER",
"WOW_PROBE_KEY",
"WOW_PROBE_LUA",
// The swim-pitch aim is a wall-clock timeline like every other probe schedule, and
// it is the one script that can be a run's *only* actuator (a drifting swimmer
// needs no keys) — so it arms the occlusion defence on its own.
"WOW_PROBE_PITCH",
"WOW_PROBE_CHEST",
"WOW_PROBE_CLAM",
"WOW_PROBE_VENDOR_SWAP",
"WOW_PROBE_MODEL_CAMERA",
"WOW_RIG",
"WOW_LIVE_FPS",
// A screenshot burst and a pick burst are wall-clock schedules too, and on an
// occluded window they capture the same stale drawable over and over: a whole
// day of lamppost A/Bs on the Air read identical to the decimal because every
// frame in every burst was one frame (the halo record).
"WOW_LIVE_SHOT",
"WOW_PICK",
]
.iter()
.any(|k| std::env::var(k).is_ok())
if crate::capture::probe_env::wall_clock_vars()
.chain([
"WOW_RIG",
"WOW_LIVE_FPS",
// A screenshot burst and a pick burst are wall-clock schedules too, and on an
// occluded window they capture the same stale drawable over and over: a whole
// day of lamppost A/Bs on the Air read identical to the decimal because every
// frame in every burst was one frame (the halo record).
"WOW_LIVE_SHOT",
"WOW_PICK",
])
.any(|k| std::env::var(k).is_ok())
{
app.add_plugins(crate::capture::ProbeFocusPlugin);
}
@@ -318,6 +319,14 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_BGQUEUE").is_ok() {
app.add_plugins(crate::capture::ProbeBgQueuePlugin);
}
// The inside-a-battleground live probe: `WOW_PROBE_BG=wsg|ab|av` walks the whole
// player road — level, greet, queue, take the port through the stock
// `AcceptBattlefieldPort` — and then censuses the battleground from inside. The
// interface arc (1963/1972/1974/1980) built everything up to the port button and
// nothing past it; this is the first instrument that looks (see `capture::ProbeBgPlugin`).
if std::env::var("WOW_PROBE_BG").is_ok() {
app.add_plugins(crate::capture::ProbeBgPlugin);
}
// The mail-arc live probe: `WOW_PROBE_MAIL=1` GM-mails the probe's own character, opens the
// Goldshire mailbox on the real wire, and drives the inbox/take/send/delete surface through
// the live Lua VM — decisions 0544/0548's end-to-end instrument (see `capture::ProbeMailPlugin`).
@@ -386,6 +395,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;
+25 -3
View File
@@ -104,7 +104,7 @@ pub(crate) use collision_height::CollisionHeight;
/// spawned under the same attach-point joints as held items, lifetime per the kit stage.
mod missile;
use missile::{attach_missile_models, move_missiles, spawn_missiles};
pub(crate) use missile::{MissileMiss, MissileSound};
pub(crate) use missile::{MissileMiss, MissileSound, PendingMissiles};
/// WMO-display GameObject doodad props (the ship's sails / the zeppelin's rotor): the WMO's MODD
/// M2s spawned as children of the streamed gameobject, so they ride a moving transport.
@@ -602,7 +602,6 @@ pub(crate) struct DisplayBuildSet;
/// doc for why a clear is always safe mid-session). These caches are get-or-insert at every use
/// site, so a cleared entry rebuilds on the next spawn that wants it; without this, every display
/// id, material key, and composited skin ever seen stayed resident for the life of the process.
#[allow(clippy::too_many_arguments)]
fn evict_display_caches(
mut changes: MessageReader<benilla_world::world_map::MapChange>,
mut composites: ResMut<SkinComposites>,
@@ -1236,7 +1235,6 @@ fn setup_entities(
/// For every display id active among the net entities: ensure its [`DisplayModel`] exists (resolve the
/// catalog + request the model handle), and once the handle has loaded, build its spawn parts (the
/// per-submesh material, with creature skin slots filled from the display's variations).
#[allow(clippy::too_many_arguments)]
fn update_display_models(
// `ObjectStore` rides along for the corpses: which cache holds a corpse's model is a
// descriptor question (`CORPSE_FLAG_BONES`), not a display-id one — decision 1706.
@@ -1250,6 +1248,10 @@ fn update_display_models(
mut forms: ResMut<benilla_world::model_forms::ModelForms>,
asset_server: Res<AssetServer>,
mut mats: benilla_world::model_render::M2BatchMaterials,
// The UV lane a batch's texture transform is delivered on (decision 2295): an entity batch is
// seeded AND registered in one call, so a display built here can never be marked-but-frozen.
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
// The bone-pile display cache + the ChrRaces fileStrings its paths are built from (1706).
mut bones: ResMut<corpse::BonesModels>,
// The glue-preview want (decisions 0423 + 0465): the glue screens' look's body displayId, so
@@ -1265,6 +1267,15 @@ fn update_display_models(
return; // no lighting yet → no materials to build
}
let (m2s, wmos) = (&model_assets.0, &model_assets.1);
// `instance: None` — every display cache here is keyed by display id and shared by every unit
// wearing it, so these materials belong to the batch. The one population that needs its own
// (a GameObject whose file-sequence slots bake different loops) takes its clone at spawn,
// where the host entity exists (`attach::dress`).
let mut uv = benilla_world::model_render::EntityUvLane {
reg: &mut uv_reg,
table: &mut anim_table,
instance: None,
};
// The (kind, display) pairs live in the world this frame — cheap to collect.
let mut actives: Vec<(EntityKind, u32)> = entities
@@ -1357,6 +1368,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: creatures — no hull collider, no bake variant
);
}
@@ -1387,6 +1399,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
true, // gameobject: hull collider + the interior BAKE material variant
);
}
@@ -1412,6 +1425,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: a prop body — unit lighting, no hull collider
);
}
@@ -1438,6 +1452,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: held items — unit lighting, no collider
);
}
@@ -1458,6 +1473,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: effects — unit lighting, no collider
);
}
@@ -1478,6 +1494,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: effects — unit lighting, no collider
);
}
@@ -1678,6 +1695,11 @@ mod display_mirror_tests {
}),
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
@@ -281,7 +281,7 @@ fn shared_skin_probe() -> bool {
/// available (those parts then keep their built, untextured material). A composited atlas is uploaded once per look ([`super::super::SkinComposites`] cache); a baked or hair
/// BLP loads through the async `mpq://` pipeline (which dedups by path). `parts` supplies the hair
/// batches' blend (hair is alpha-cut, so it can't be forced opaque like the body).
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
#[allow(clippy::type_complexity)]
pub(super) fn build_char_skin_materials(
look: &CharLook,
// The worn armor display ids (bodyslot 2–9) + the cloak's, and the ItemDisplayInfo catalog to
+225 -15
View File
@@ -103,6 +103,161 @@ pub(super) fn part_materials<'a>(
}
}
/// The material store and the two animated-material registries, for the one entity population
/// that needs a material of its **own** rather than the batch's (decision 2295).
///
/// Taken as one bundle, and taken by the dressing path itself rather than by a system that fixes
/// parts up afterwards, because the cloned handles have to be in hand *before* the part's
/// [`InteriorLit`](benilla_world::interior) and [`FadeMaterials`] are built from them — a later
/// pass would have to reach into both and rewrite what the classifier already wrote.
pub(super) struct OwnMats<'a> {
pub(super) store: &'a mut Assets<WowModelMaterial>,
pub(super) uv: &'a mut benilla_world::doodad_anim::UvAnimMaterials,
pub(super) tint: &'a mut benilla_world::doodad_anim::TintAnimMaterials,
pub(super) table: &'a mut benilla_world::mat_anim_table::MatAnimTable,
}
/// One part's **own** variant set — [`PartMaterials`]' owning twin, held by the spawn for as long
/// as it takes to dress the part.
struct OwnedMaterials {
steady: Handle<WowModelMaterial>,
interior: Option<Handle<WowModelMaterial>>,
fade_blend: Option<Handle<WowModelMaterial>>,
bake: Option<Handle<WowModelMaterial>>,
bake_blend: Option<Handle<WowModelMaterial>>,
zfill: Option<Handle<WowModelMaterial>>,
}
impl OwnedMaterials {
fn borrow(&self) -> PartMaterials<'_> {
PartMaterials {
steady: &self.steady,
interior: self.interior.as_ref(),
fade_blend: self.fade_blend.as_ref(),
bake: self.bake.as_ref(),
bake_blend: self.bake_blend.as_ref(),
zfill: self.zfill.as_ref(),
}
}
}
/// Give this part a material set of its **own**, registered against its own instance's anim host —
/// or `None`, which is every part but a measured thirteen batches (decision 2295).
///
/// **Who needs one.** A batch whose file-sequence slots bake *different* UV or tint loops cannot be
/// served by a material every instance of the model shares: the registries are keyed by material,
/// so one row would have to answer for placements that are playing different slots. That is
/// 1408's finding, and the entity side of it is six GameObject models — `BloodOfHeroes`'s five
/// bubble sheets (slot 0 holds them still, slot 1 sweeps them, at freq 16384/16383, so the pools
/// bubble independently), `Valentines_Blanket`, `G_GnomeTerminal`'s screen, and on the tint
/// channel `G_FreezingTrap`'s glow card, `OrgrimmarPentagram` and `ScholomanceCrystalBall01`.
/// **Four of the five tint batches bake nothing in slot 0**, so their shared material can only
/// ever seed white however faithfully it is ticked — the hunter's trap glows flat instead of
/// pulsing blue-white as it arms.
///
/// **Why it is gated on `GameObject`.** Not as a safety valve: the census is the reason. No unit,
/// no player body, no corpse and no held item in the shipped corpus authors a per-sequence set on
/// either channel (`benilla-extract entityuvscan`), so widening this past GameObjects would add a
/// clone-and-register branch to the hottest spawn path in the client for an empty population. If
/// a future model joins it, the gate is the line to move and this paragraph is why.
fn own_per_sequence_materials(
part: &EntityPart,
dress: &PartDress<'_>,
own: &mut OwnMats<'_>,
) -> Option<OwnedMaterials> {
let loops = part.uv_loops();
let (uv_seq, tint_seq) = (loops.seqs.is_some(), part.rgb_seq.is_some());
if dress.kind != ModelKind::GameObject || !(uv_seq || tint_seq) {
return None;
}
let src = part_materials(part, dress.char_mats);
// The shared variants may still be PARKED (`model_render::lazy` reserves a handle and holds
// the value until something view-visible binds it), so each is realized before it is read —
// the same order `spell_fx`'s clone takes, and the reason 2038's registrations all returned
// `None` when they skipped it.
let mut clone = |h: &Handle<WowModelMaterial>| -> Option<Handle<WowModelMaterial>> {
benilla_world::model_render::lazy::realize(own.store, h.id());
let mut mat = own.store.get(h.id()).cloned()?;
// A clone leaves the shared table (decision 1381): its rows are its own, and a carried
// slot would add the shared delta on top of them.
mat.extension.anim_slots = Vec4::ZERO;
Some(own.store.add(mat))
};
let owned = OwnedMaterials {
steady: clone(src.steady)?,
interior: src.interior.and_then(&mut clone),
fade_blend: src.fade_blend.and_then(&mut clone),
bake: src.bake.and_then(&mut clone),
bake_blend: src.bake_blend.and_then(&mut clone),
// The depth-prime twin is NOT cloned and NOT registered, exactly as on the placed-doodad
// lane: it draws only while the part feathers, and its alpha test reading unscrolled UVs
// for those frames is the behaviour the streamer has always had.
zfill: src.zfill.cloned(),
};
// Every variant the part can swap to over its life — indoors, feathering, both — takes a row,
// or a GameObject that walks under a roof would stop mid-scroll.
for id in [
Some(&owned.steady),
owned.interior.as_ref(),
owned.fade_blend.as_ref(),
owned.bake.as_ref(),
owned.bake_blend.as_ref(),
]
.into_iter()
.flatten()
.map(Handle::id)
{
if uv_seq {
benilla_world::doodad_anim::register_entity_uv(
own.uv,
own.table,
own.store,
id,
&loops,
Some(dress.unit),
);
}
if let Some(seqs) = &part.rgb_seq {
benilla_world::doodad_anim::register_tint(
own.tint,
own.table,
own.store,
id,
benilla_world::doodad_anim::TintLoop::PerSeq {
seqs: seqs.clone(),
host: dress.unit,
},
);
}
}
Some(owned)
}
/// Put this part in `tick_anim_materials`' draw scan — **iff something registered a row for it**.
///
/// The marker and the registration have to ask ONE question, because 2038's whole bug class is the
/// two disagreeing: a marked part with no registry entry trips the lane's own tripwire, and a
/// registered material that no marked part accounts for freezes silently, which is how every
/// waterfall in the game stood still for three days. So this is not a predicate over the part's
/// channels — it is a statement about what actually happened a few lines up.
///
/// Two registrations can have happened. The **shared** UV lane's, made inside
/// `entity_variants` when the batch's transform moves at all (`UvLoops::animates`, which is not
/// `uv_anim.is_some()`: a rotate-only batch answers `None` there). And this instance's **own**
/// clone, when it has one — which is the only way the TINT channel is ever registered on an
/// entity, and why `owned` has to be consulted rather than the part's `rgb_seq` re-tested: the
/// clone can decline (a full table, a dead handle), and a marker for a registration that did not
/// happen is the first half of the bug above.
fn mark_mat_anim(
child: &mut bevy::ecs::system::EntityCommands<'_>,
part: &EntityPart,
owned: &Option<OwnedMaterials>,
) {
if part.uv_loops().animates() || owned.is_some() {
child.insert(benilla_world::doodad_anim::AnimMatPart);
}
}
/// Stamp a pickable part (or card) with the pick population its kind belongs to: the mouseover
/// pickers filter by these markers instead of kind-comparing every `WorldObject` row per frame.
/// One site for both the mesh-part and the billboard-card spawn, so the two can't drift.
@@ -183,10 +338,11 @@ pub(super) fn spawn_group(
part: &EntityPart,
group: &super::merge::BodyGroup,
dress: &PartDress,
own: &mut OwnMats<'_>,
) -> bool {
let members =
(group.members.len() > 1).then(|| super::merge::DressedGroup(group.members.clone().into()));
spawn_part(commands, part, group.first() as usize, members, dress)
spawn_part(commands, part, group.first() as usize, members, dress, own)
}
/// Spawn one mesh part (or billboard card) of a body under `dress.unit`; `group` is the merged
@@ -197,11 +353,18 @@ pub(super) fn spawn_part(
index: usize,
group: Option<super::merge::DressedGroup>,
dress: &PartDress,
own: &mut OwnMats<'_>,
) -> bool {
if let Some(info) = &part.billboard {
return spawn_billboard_part(commands, part, index, info, dress);
return spawn_billboard_part(commands, part, index, info, dress, own);
}
let mats = part_materials(part, dress.char_mats);
// A material of this instance's OWN, for the measured thirteen batches whose slots disagree
// (2295) — resolved first, because everything below dresses from whatever this returns.
let owned = own_per_sequence_materials(part, dress, own);
let mats = match &owned {
Some(o) => o.borrow(),
None => part_materials(part, dress.char_mats),
};
let set = mats.fade_set();
// A freshly-streamed CGObject appear-fades in (decision 0032): spawn already on the blend twin
// with a ≈0 `MeshTag`, so it doesn't flash opaque for a frame before `apply_render_fade` ramps
@@ -314,6 +477,7 @@ pub(super) fn spawn_part(
dress.unit,
));
}
mark_mat_anim(&mut child, part, &owned);
effective.dress(&mut child, &set)
}
@@ -328,6 +492,7 @@ fn spawn_billboard_part(
index: usize,
info: &benilla_assets::BillboardInfo,
dress: &PartDress,
own: &mut OwnMats<'_>,
) -> bool {
let anchor = commands
.spawn((
@@ -363,12 +528,15 @@ fn spawn_billboard_part(
// (decision 0836). Splitting the batch into a world-root entity is benilla's parenting detail;
// it is not a reason for the glow on a weapon's gems to blaze at full strength over a body that
// has not appeared yet.
let set = FadeSet {
steady: &part.material,
blend: part.fade_blend.as_ref(),
bake_blend: part.material_interior_bake_blend.as_ref(),
zfill: part.zfill.as_ref(),
// A card may need a material of its own for exactly the same reason a mesh part does — and
// on this population it is the card that needs it: `World\Goober\G_FreezingTrap`'s animated
// batch IS the additive glow card, whose blue-white pulse is keyed in file slot 2 alone.
let owned = own_per_sequence_materials(part, dress, own);
let mats = match &owned {
Some(o) => o.borrow(),
None => part_materials(part, dress.char_mats),
};
let set = mats.fade_set();
let effective = PartFade::resolve(dress.fade, &set);
let (init_mat, tag_alpha) = effective.seed(&set, dress.now);
let mut card = commands.spawn((
@@ -401,9 +569,9 @@ fn spawn_billboard_part(
card.insert(aabb);
}
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
mats.steady,
mats.interior,
mats.bake,
dress.bake_center,
dress.unit,
) {
@@ -417,6 +585,11 @@ fn spawn_billboard_part(
dress.unit,
));
}
// …and its batch's material animation, on the same rule as a mesh part (2295). A card is a
// batch of the unit's own model; the split into a world-root entity is benilla's parenting
// detail, never a reason for one batch of a model to animate and its sibling not to — and on
// this population it is the card that carries it.
mark_mat_anim(&mut card, part, &owned);
let armed = effective.dress(&mut card, &set);
let card = card.id();
// The card follows the JOINT when the unit is rigged, so it does not cascade with the anchor's
@@ -431,6 +604,28 @@ fn spawn_billboard_part(
#[cfg(test)]
mod tests {
use super::*;
/// A standalone [`OwnMats`] for the spawn tests — none of them dresses a GameObject, so the
/// lane is never taken; it exists so the spawn's signature can stay non-optional, which is
/// what stops a production caller passing `None` and silently losing the clone.
#[derive(Default)]
struct TestOwn {
store: Assets<WowModelMaterial>,
uv: benilla_world::doodad_anim::UvAnimMaterials,
tint: benilla_world::doodad_anim::TintAnimMaterials,
table: benilla_world::mat_anim_table::MatAnimTable,
}
impl TestOwn {
fn lane(&mut self) -> OwnMats<'_> {
OwnMats {
store: &mut self.store,
uv: &mut self.uv,
tint: &mut self.tint,
table: &mut self.table,
}
}
}
use benilla_world::model_fade::{FadeMaterials, PendingAppearFade};
/// One synthetic batch at `char_slot`/`two_sided`, carrying its own (distinguishable) built
@@ -456,6 +651,11 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
@@ -546,11 +746,12 @@ mod tests {
now: 0.0,
fade: JoinedFade::Steady,
};
let mut own = TestOwn::default();
let mut queue = bevy::ecs::world::CommandQueue::default();
let armed = {
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &fadeable, 0, None, &dress)
spawn_part(&mut commands, &fadeable, 0, None, &dress, &mut own.lane())
};
queue.apply(app.world_mut());
assert!(!armed, "steady: no ramp armed");
@@ -610,11 +811,12 @@ mod tests {
now: 0.0,
fade: JoinedFade::Pending { since: SINCE },
};
let mut own = TestOwn::default();
let mut queue = bevy::ecs::world::CommandQueue::default();
let armed = {
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &card, 0, None, &dress)
spawn_part(&mut commands, &card, 0, None, &dress, &mut own.lane())
};
queue.apply(app.world_mut());
assert!(
@@ -686,12 +888,20 @@ mod tests {
now: 0.0,
fade: JoinedFade::Steady,
};
let mut own = TestOwn::default();
let mut queue = bevy::ecs::world::CommandQueue::default();
{
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &part(None, false), 0, None, &dress);
spawn_part(&mut commands, &billboard, 1, None, &dress);
spawn_part(
&mut commands,
&part(None, false),
0,
None,
&dress,
&mut own.lane(),
);
spawn_part(&mut commands, &billboard, 1, None, &dress, &mut own.lane());
}
queue.apply(app.world_mut());
let world = app.world_mut();
@@ -364,6 +364,11 @@ mod tests {
welded_billboard: false,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
+24 -2
View File
@@ -294,7 +294,7 @@ struct RigBuild {
/// / player body) as submesh children, or a colored cube fallback. The entity's pose is owned by the
/// net bridge — or, for our own avatar, the player controller — we only add the geometry (and bake
/// per-display scale onto the root). Our own avatar is the same streamed entity and renders here too.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub(super) fn attach_entity_visuals(
mut commands: Commands,
pending: Query<
@@ -348,6 +348,14 @@ pub(super) fn attach_entity_visuals(
// The merged-group forms (`merge`): built on a body's first silhouette, shared after.
ResMut<Assets<Mesh>>,
ResMut<merge::MergedFormsCache>,
// …and the animated-material lane a spawned part may need a material of its OWN on
// (decision 2295) — a GameObject whose file-sequence slots bake different UV or tint
// loops. Taken by the dressing path itself, because the clone has to exist before the
// part's interior and fade records are built from it; nested here for this tuple's own
// stated reason, the 16-param limit.
ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
ResMut<benilla_world::mat_anim_table::MatAnimTable>,
),
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
@@ -365,6 +373,9 @@ pub(super) fn attach_entity_visuals(
mut mats,
mut meshes,
mut merged,
mut uv_reg,
mut tint_reg,
mut anim_table,
) = skin_build;
// Arm each entity's appear-fade at the moment its visual attaches (≈ its first-visible moment).
let now = time.elapsed_secs();
@@ -870,7 +881,18 @@ pub(super) fn attach_entity_visuals(
for group in &groups {
let forms = merged.forms(parts, group, &mut meshes);
let part = merge::group_part(parts, group, forms);
unit_will_fade |= spawn_group(&mut commands, &part, group, &dress);
unit_will_fade |= spawn_group(
&mut commands,
&part,
group,
&dress,
&mut dress::OwnMats {
store: mats.materials(),
uv: &mut uv_reg,
tint: &mut tint_reg,
table: &mut anim_table,
},
);
}
// Mirror the appear-fade clock onto the unit root (see `unit_will_fade` above): a held item
// / helm / shoulder attaching later reads this to join the same ramp
@@ -159,7 +159,6 @@ pub(in crate::entities) struct PreviewCtx<'a, 'w> {
/// entity-visuals chain (after `update_display_models` has built the body model from the
/// want-list), so a fresh look's models are ready within a frame or two; until then it leaves the
/// bake untouched and retries. A bare yaw change never reaches here (the booth handles it).
#[allow(clippy::too_many_arguments)]
pub(in crate::entities) fn build_glue_preview(
preview: Res<GluePreview>,
mut bake: ResMut<GluePreviewBake>,
@@ -351,7 +350,6 @@ pub(in crate::entities) fn build_glue_preview(
/// plus the tried-on substitutions. The body display is the player's own, so it is already built
/// (they are standing in the world); an ITEM model still has to load, which is what the retry latch
/// is for.
#[allow(clippy::too_many_arguments)]
pub(in crate::entities) fn build_dressup_preview(
preview: Res<DressUpPreview>,
mut bake: ResMut<DressUpBake>,
@@ -68,7 +68,7 @@ type PartWrites<'a> = (
/// creature's look never changes this way, and a character-model NPC wears its display's columns —
/// a *display* change is a different model and stays a teardown
/// ([`super::super::live_display::refresh_live_display`]).
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub(in crate::entities) fn redress_player_looks(
mut commands: Commands,
mut players: Query<
@@ -115,6 +115,15 @@ pub(in crate::entities) fn redress_player_looks(
ResMut<Assets<Mesh>>,
ResMut<MergedFormsCache>,
),
// The animated-material lane a re-dressed part may need a material of its own on (2295).
// Plumbing here rather than a live case: a re-dress is a PLAYER's gear change, and no
// character batch in the corpus carries a per-sequence loop — but the dressing law is one law,
// and a path that could not express it would be a place for the two to drift.
mut own_lane: (
ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
ResMut<benilla_world::mat_anim_table::MatAnimTable>,
),
time: Res<Time>,
) {
let (
@@ -288,7 +297,18 @@ pub(in crate::entities) fn redress_player_looks(
}
let forms = merged.forms(parts, group, &mut meshes);
let part = merge::group_part(parts, group, forms);
spawn_group(&mut commands, &part, group, &dress);
spawn_group(
&mut commands,
&part,
group,
&dress,
&mut super::dress::OwnMats {
store: mats.materials(),
uv: &mut own_lane.0,
tint: &mut own_lane.1,
table: &mut own_lane.2,
},
);
shown += 1;
}
// One line per re-dress — a handful per session, and the only readout of a mechanism whose
@@ -398,6 +418,11 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
@@ -425,7 +450,12 @@ mod tests {
.init_resource::<MergedFormsCache>()
// The engine's material cache — normally `model_render::plugin`'s, which this bare
// harness does not install.
.init_resource::<benilla_world::model_render::ModelMaterials>();
.init_resource::<benilla_world::model_render::ModelMaterials>()
// The animated-material lane the dressing path now takes (2295) — never exercised by
// a re-dress, which is a player's gear change, but the system asks for it.
.init_resource::<benilla_world::doodad_anim::UvAnimMaterials>()
.init_resource::<benilla_world::doodad_anim::TintAnimMaterials>()
.init_resource::<benilla_world::mat_anim_table::MatAnimTable>();
let mut dm = empty_display();
dm.parts = Some(geosets.iter().map(|g| part(*g)).collect());
@@ -240,7 +240,6 @@ fn select_targets(
/// reapable: the reference only ever publishes a flagged node to the owner slots that the channel
/// teardown sweeps (`0x6ecdaa` gates the AddRef on the flag), so a one-shot beam always runs its own
/// clock to the end.
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_chain_beams(
mut commands: Commands,
time: Res<Time>,
@@ -463,7 +462,6 @@ fn push_strand(
/// Per frame: age the beam, re-resolve every hop's live endpoints, re-jitter and advect its
/// polyline, and write the ribbons into the shared effect-quad stream — the reference's
/// `LightningObject::Update` (`0x6ec460`) and `CLightning::Render` (`0x7afcb0`) in one pass.
#[allow(clippy::too_many_arguments)]
pub(crate) fn simulate_chain_beams(
time: Res<Time>,
mut commands: Commands,
+1 -1
View File
@@ -94,7 +94,7 @@ fn conform_rotation(mode: u8, n_local: Vec3) -> Quat {
/// spawn seats instantly like the reference; and a parked rig (off-frustum, the 0448 anim-LOD
/// gate) skips its ray and drops its vector, re-seeding warm on wake — the 0448 absolute-snap
/// philosophy applied to the stance, bounding the cost at one ray per flagged unit ON SCREEN.
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
#[allow(clippy::type_complexity)] // one Bevy system's full input set
pub(super) fn conform_units(
time: Res<Time>,
spatial: SpatialQuery,
+9 -4
View File
@@ -42,7 +42,7 @@ use crate::creature_anim::{SpellKitSound, SpellVisuals};
use crate::net::{NetEntity, ObjectStore};
use benilla_protocol::EntityKind;
use super::spell_fx::{attach_effect_visuals, ensure_model, SpellFx};
use super::spell_fx::{attach_effect_visuals, ensure_model, FxMaterials, SpellFx};
/// The client's hardcoded shard-model table (`0x870e24`, 7 entries — wow-re
/// `dynobject-visual-machine.md` Q-A1). `CharParamZero`'s decoded small int indexes it.
@@ -267,7 +267,6 @@ pub(super) fn tick_shard_emitters(
/// Attach model parts to pending instances whose M2 finished building (the missile pattern —
/// free world models, ground-anchored so authored flat quads decal to the terrain), start the
/// one-shot clocks, and run both reapers (one-shot expiry; the loop-repeat override).
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(super) fn attach_ground_fx_models(
mut commands: Commands,
time: Res<Time>,
@@ -276,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>,
) {
@@ -299,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,
+56 -2
View File
@@ -127,6 +127,30 @@ 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 per-file-sequence-slot form of [`Self::rgb_anim`], carried only where the slots
/// disagree (decision 1408) — [`Self::uv_seq`]'s twin, and the reason a GameObject may need a
/// material of its own. Five batches on three GameObject models author one, and **four of
/// them bake nothing in slot 0**, so `rgb_anim` is `None` for them and a shared material can
/// only ever seed white: the hunter's Freezing Trap glow card is the class (decision 2295).
pub(super) rgb_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 3]>>>,
/// 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 runs it on the shared, deduped material
/// (decision 2295). `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
@@ -134,6 +158,23 @@ pub(super) struct EntityPart {
pub(super) ground_quad: Option<benilla_formats::GroundQuad>,
}
impl EntityPart {
/// This part's four baked texture-transform channels, in the shape the engine's lanes take
/// them — **the one place a spawner reads them**, so "does this batch animate its transform"
/// has a single answer wherever it is asked (the effect attach's clone test, the entity
/// spawn's [`benilla_world::doodad_anim::AnimMatPart`] marker). `UvLoops::animates` is that
/// question: a scale-only transform and a dead-slot-0 translation each answer `None` to the
/// channel you would check first.
pub(super) fn uv_loops(&self) -> benilla_world::doodad_anim::UvLoops {
benilla_world::doodad_anim::UvLoops {
seqs: self.uv_seq.clone(),
single: self.uv_anim.clone(),
rot: self.uv_rot_seq.clone(),
scale: self.uv_scale_seq.clone(),
}
}
}
/// Everything needed to render one display id, cached so a model is loaded + built once and shared by
/// every entity of that display. `parts` is `None` while the asset loads, then `Some` — empty if the
/// model failed / has no geometry (→ cube/none fallback), else the spawn parts.
@@ -375,7 +416,6 @@ pub(super) fn empty_display() -> DisplayModel {
/// Build a display model's spawn parts once its asset has loaded — each submesh's `WowModelMaterial`,
/// with a creature skin slot filled from the display's variation (`<dir>\<name>.blp`). Returns early
/// (leaving `parts` `None`) while the asset is still loading.
#[allow(clippy::too_many_arguments)]
pub(super) fn build_parts(
dm: &mut DisplayModel,
m2s: &Assets<M2Model>,
@@ -386,6 +426,10 @@ pub(super) fn build_parts(
forms: &mut benilla_world::model_forms::ModelForms,
asset_server: &AssetServer,
mats: &mut benilla_world::model_render::M2BatchMaterials,
// Where this display's animated texture transforms are delivered (decision 2295) — taken
// rather than reached for, because building an entity batch's material and putting it on the
// lane are one act.
uv: &mut benilla_world::model_render::EntityUvLane<'_>,
// Whether this display is a GAMEOBJECT: gates the hull-collider build (chests/veins/doors;
// creatures use unit collision).
gameobject: bool,
@@ -511,7 +555,7 @@ pub(super) fn build_parts(
// the law that decides which of them collapse onto each other (decisions 0842
// / 0865 / 0831 / 0355). `None` cannot happen here: the caller gated on the
// light buffer before the first build.
let v = mats.entity_variants(sub, texture, order);
let v = mats.entity_variants(sub, texture, order, uv);
let v = v.expect("light buffer checked at entry");
EntityPart {
// Index-parallel with the submeshes by the forms contract; a miss is a
@@ -538,6 +582,11 @@ pub(super) fn build_parts(
welded_billboard: sub.geometry.welded_billboard,
alpha_anim: sub.alpha_anim.clone(),
rgb_anim: sub.rgb_anim.clone(),
rgb_seq: sub.rgb_seq.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,
}
})
@@ -588,6 +637,11 @@ 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,
rgb_seq: 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()
@@ -172,7 +172,7 @@ pub(crate) struct DressKey {
/// descriptor changed, its key changed, or a global input moved — the [`Items`] epochs (an
/// object ingest covers the quiver bag walk, a landed template answers every pending ask) and
/// the two client-data load edges. The idle crowd costs one tick check and one small compare.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub(in crate::entities) fn resolve_equipment(
mut commands: Commands,
units: Query<(
@@ -192,7 +192,7 @@ pub(in crate::entities) fn resolve_equipment(
Has<crate::net::SelfPlayer>,
)>,
held: Option<ResMut<ItemDisplays>>,
mut templates: ResMut<Items>,
templates: Res<Items>,
net: Res<NetCommands>,
asset_server: Res<AssetServer>,
// The creature display cache — a character-model NPC's helm/shoulder ids + race/sex live on its
@@ -638,7 +638,7 @@ pub(in crate::entities) fn resolve_equipment(
EntityKind::Player if char_component => {
let race = s.unit_race().unwrap_or(1);
let sex = s.unit_gender().unwrap_or(0).min(1);
let mut resolve = |slot: u8| {
let resolve = |slot: u8| {
s.player_visible_item_entry(slot)
.filter(|e| *e != 0)
.and_then(|entry| templates.held(entry, &net))
@@ -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,22 @@ 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())
});
}
// …and the batch's texture transform (decision 2295), on the same one predicate every
// other entity spawn asks. One held-item batch in the whole 1.12 corpus animates one —
// `Item\ObjectComponents\Shield\shield_epic_a_01`, the Drillborer Disk, whose
// Ragnaros-skinned lava is a 6.7 s global-sequence scroll — which is exactly why the
// marker is a property of the batch here and not of a list somebody maintains.
if part.uv_loops().animates() {
child.insert(benilla_world::doodad_anim::AnimMatPart);
}
effective.dress(&mut child, &set);
}
@@ -656,6 +724,9 @@ fn spawn_slot(
if let Some(anim) = &part.alpha_anim {
card.insert(benilla_world::doodad_anim::MatAnim::resting(anim.clone()));
}
if part.uv_loops().animates() {
card.insert(benilla_world::doodad_anim::AnimMatPart);
}
effective.dress(&mut card, &set);
}
// The item's own particle emitters — the held torch's flame (0130 phase 4: the same
@@ -698,6 +769,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 +809,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 +829,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
@@ -782,6 +873,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,
@@ -823,6 +924,11 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
@@ -1138,6 +1244,173 @@ mod tests {
assert_eq!(a.joints, 0, "a rigid item builds no joint rig");
}
/// **The ranged prop gets a clock, and only the ranged prop** (decision 2281).
///
/// Four things have to be true together or the gun's muzzle blast never fires, and each one
/// alone is a silent no-op: the prop root carries a [`crate::ranged_flex::RangedProp`] naming
/// its wearer (that is how a `$BWR` key finds it), a [`benilla_world::rig_anim::RigPose`] and a
/// player (so 161 can be armed and posed at all), its emitter is **hosted on that root** rather
/// than pinned to the loader idle (the blast's gate track is keyed only in 161 — a pinned
/// emitter reads Stand's flat zero for ever), and the emitter rides its BONE's anchor rather
/// than the model root (bone 2 is what aims it down the barrel).
///
/// The control is the same display in the MAINHAND slot: nothing is ever passed to the
/// reference's `[+0xd24]` arms but the ranged weapon, so a melee item keeps the resting lane —
/// no player, no pose, a pinned clock.
#[test]
fn a_flexing_ranged_prop_gets_a_pose_a_clock_and_hosted_emitters() {
fn attach(slot_idx: usize, attach: u16) -> (App, Option<Entity>) {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
app.init_resource::<Assets<bevy::image::Image>>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
let mut dm = empty_display();
let mut p = part(false);
p.skinned_mesh = Some(skinned_handle());
dm.parts = Some(vec![p]);
dm.skeleton = benilla_assets::ModelSkeleton {
joints: vec![
benilla_assets::ModelJoint {
parent: -1,
local_translation: Vec3::ZERO,
billboard: None,
parent_arm: None,
},
// The muzzle bone the emitters hang off.
benilla_assets::ModelJoint {
parent: 0,
local_translation: Vec3::X,
billboard: None,
parent_arm: None,
},
],
spine_bone: None,
head_bone: None,
};
dm.inverse_bindposes = Some(Handle::default());
// A firearm's animation shape: Stand + BowRelease, and no BowPull.
dm.animations = Some(benilla_assets::ModelAnimations {
graph: Handle::default(),
clips: Vec::new(),
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
// `owns(161)` — the gate, the reference's own `0x711960` question.
animation_lookup: {
let mut v = vec![0xffffu16; 162];
v[0] = 0;
v[crate::ranged_flex::BOW_RELEASE as usize] = 1;
v
},
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
});
let mut em = benilla_assets::ModelEmitter {
def: benilla_world::testing::plain_particle_def(),
texture: Some(Handle::default()),
bone_pivot: [0.0; 3],
billboard: None,
recursion: None,
geometry: None,
owner_reach: 0.0,
water_bound: (Vec3::ZERO, 0.0),
idle_seq: 0,
};
em.def.bone = 1;
dm.emitters = vec![em];
displays.models.insert((7, ItemModelKind::Weapon), dm);
app.insert_resource(displays);
let bones = BoneAttach {
points: HashMap::from([(attach, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
let mut items = HeldItems::default();
items.slots[slot_idx] = Some(HeldSlot {
display: 7,
kind: ItemModelKind::Weapon,
attach,
visual: NO_GLOW,
});
let wearer = app
.world_mut()
.spawn((items, bones, Transform::default()))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
app.world_mut().entity_mut(wearer).insert(pose);
app.add_systems(Update, attach_held_items);
app.update();
(app, Some(wearer))
}
// The ranged slot (2), drawn in the right hand.
let (mut app, wearer) = attach(2, attach_id::HAND_RIGHT);
let wearer = wearer.unwrap();
let (prop, owner) = app
.world_mut()
.query::<(Entity, &crate::ranged_flex::RangedProp)>()
.iter(app.world())
.map(|(e, p)| (e, p.owner))
.next()
.expect("the ranged prop is marked");
assert_eq!(owner, wearer, "…and names its wearer, like `[+0xd24]` does");
assert!(
app.world().entity(prop).contains::<AnimationPlayer>()
&& app
.world()
.entity(prop)
.contains::<benilla_world::rig_anim::RigPose>(),
"a flexing prop carries a player and a pose of its own"
);
let hosts: Vec<Option<Entity>> = app
.world_mut()
.query::<&benilla_world::particles::ParticleEmitter>()
.iter(app.world())
.map(|e| e.emit_host())
.collect();
assert_eq!(
hosts,
vec![Some(prop)],
"its emitter reads the sequence the PROP is playing"
);
// The emitter's owner frame is the muzzle bone's anchor, not the model root.
let anchors: Vec<(Entity, u16)> = app
.world_mut()
.query::<&benilla_world::rig_anim::RigAnchor>()
.iter(app.world())
.map(|a| (a.rig, a.bone))
.collect();
assert!(
anchors.contains(&(prop, 1)),
"the emitter minted the muzzle bone's anchor: {anchors:?}"
);
// The control: the SAME display in the mainhand keeps the resting lane.
let (mut app, _) = attach(0, attach_id::HAND_RIGHT);
assert_eq!(
app.world_mut()
.query::<&crate::ranged_flex::RangedProp>()
.iter(app.world())
.count(),
0,
"a melee item is never armed — the reference only ever re-anims the ranged prop"
);
let hosts: Vec<Option<Entity>> = app
.world_mut()
.query::<&benilla_world::particles::ParticleEmitter>()
.iter(app.world())
.map(|e| e.emit_host())
.collect();
assert_eq!(
hosts,
vec![None],
"…and its emitter stays on the pinned clock"
);
}
/// **What a booth can see of an equipped item** (decision 0822, `#bugs` B118's paper-doll half).
/// An item model's camera-facing batch spawns as a world-ROOT card and its emitters as free
/// owner-followed entities — neither is a unit descendant, so the portrait / paper-doll booths,
+9 -4
View File
@@ -55,7 +55,7 @@ use benilla_assets::m2_url;
use benilla_assets::materials::WowModelMaterial;
use super::equipment::{ItemDisplays, ItemModelKind};
use super::spell_fx::{attach_effect_visuals, EffectHost, FxTintAnims};
use super::spell_fx::{attach_effect_visuals, EffectHost, FxMaterials, FxTintAnims};
use super::{DisplayModel, ModelHandle};
/// The glow chain's data + the effect-model cache: the joined `ItemVisuals`/`ItemVisualEffects`
@@ -177,7 +177,6 @@ pub(crate) struct ItemGlowAttached;
/// waits, so a two-model visual can't spawn half of itself and then re-enter here. A model that
/// never loads simply never glows (the item is still perfectly drawn); the retry is a handful of
/// roots per frame.
#[allow(clippy::too_many_arguments)]
pub(super) fn attach_item_glows(
mut commands: Commands,
pending: Query<(Entity, &ItemGlow), Without<ItemGlowAttached>>,
@@ -186,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
@@ -307,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

Some files were not shown because too many files have changed in this diff Show More