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:
Generated
+23
@@ -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
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
+1556
-1490
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user