diff --git a/crates/benilla-app/src/char_select/input.rs b/crates/benilla-app/src/char_select/input.rs index 3ade846a..0fc59a3a 100644 --- a/crates/benilla-app/src/char_select/input.rs +++ b/crates/benilla-app/src/char_select/input.rs @@ -30,6 +30,7 @@ pub(super) fn select_input( clicks: Res, keys: Res>, mut roster: ResMut, + mut realms: ResMut, pick: Res, mut dialog: ResMut, mut panel: ResMut, @@ -98,6 +99,18 @@ pub(super) fn select_input( panel.open_for(realm, chars); } SelectAction::Back => back_to_login = true, + // The reference's `CHANGE_REALM`: leave the parked session for the realm list. The + // IO thread keeps the logon, so this is a world dial away rather than a re-login — + // and the pending pick has to go, or the fresh roster on the OTHER realm would be + // auto-answered with a guid from this one. + SelectAction::ChangeRealm => { + sounds.write(GlueSound("gsLoginChangeRealmOK")); + roster.pending_pick = None; + // The remembered realm IS this one, so the list that comes back must not be + // auto-answered with it — that would be a button that appears to do nothing. + crate::realm_select::force_screen(&mut realms); + let _ = pick.0.send(crate::net::CharRequest::ChangeRealm); + } _ => {} } } diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index b00080bc..e983387a 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -37,8 +37,7 @@ use crate::net::{ LoggedOutMessage, }; -/// The app's lifecycle: which screen owns the session (decision 0193). Grows glue variants -/// (`RealmList`, …) as the glue arc fills in. +/// The app's lifecycle: which screen owns the session (decision 0193). #[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub(crate) enum ClientState { /// Parked pre-logon at the login screen (decision 0539): the IO thread waits for credentials; @@ -46,6 +45,11 @@ pub(crate) enum ClientState { /// resubmit, or the director's typed submit). #[default] Login, + /// Parked at the **realm list**: the logon succeeded, the IO thread is holding the world dial + /// until the app names a realm, and [`crate::realm_select`]'s policy decides what answers it + /// (`WOW_REALM`, the remembered `realmName`, or the director's click). 0193 planned this + /// variant and it took until the realm subsystem existed to mean anything. + RealmList, /// Parked at character select: the select screen is up, the IO thread waits for a pick, and /// the in-world input surfaces (player controller, FrameXML keyboard) are gated off. CharSelect, diff --git a/crates/benilla-app/src/char_select/refresh.rs b/crates/benilla-app/src/char_select/refresh.rs index d1e1e1f9..119d9d02 100644 --- a/crates/benilla-app/src/char_select/refresh.rs +++ b/crates/benilla-app/src/char_select/refresh.rs @@ -95,20 +95,27 @@ pub(super) fn refresh_list( if let Ok(mut t) = banner.single_mut() { let new = match &roster.realm { Some(realm) => { - let suffix = match realm.realm_type { - 1 => strings.text("PVP_PARENTHESES", "(PVP)"), - 6 => strings.text("RP_PARENTHESES", "(RP)"), - 8 => strings.text("RPPVP_PARENTHESES", "(RPPVP)"), - _ => "", + // `GetServerName`'s own `isPVP, isRP` pair, from the one table that owns it. + // The banner's normal-realm arm is EMPTY, unlike the realm list's `Normal` — the + // reference leaves `serverType = ""` when neither flag is set. + let suffix = match crate::realm_select::pvp_rp(realm.realm_type) { + (true, true) => strings.text("RPPVP_PARENTHESES", "(RPPVP)"), + (false, true) => strings.text("RP_PARENTHESES", "(RP)"), + (true, false) => strings.text("PVP_PARENTHESES", "(PVP)"), + (false, false) => "", }; let down = (status.last_reason.is_some() && roster.pending_pick.is_none()) .then(|| strings.text("SERVER_DOWN", "Server down")); realm_banner(&realm.name, suffix, down) } - None => match &status.last_reason { - Some(_) => strings.text("SERVER_DOWN", "Server down").to_string(), - None => "Connecting…".to_string(), - }, + // **`CharSelectRealmName:Hide()`** (`CharacterSelect.lua` l.66): with no server name + // the reference hides the FontString outright — it has no string for this case, and + // ours used to invent one (`"Connecting…"`). Empty text is our Hide. + // + // It is also all but unreachable now. The realm is chosen *before* the world dial, so + // by the time this screen exists the session has one; what is left here is the + // no-network harness, which is exactly the case the reference draws nothing for. + None => String::new(), }; if t.0 != new { t.0 = new; @@ -173,7 +180,6 @@ pub(super) fn refresh_banner_and_buttons( for (action, mut disabled) in &mut disables { let want = match action { SelectAction::EnterWorld | SelectAction::Delete => !have_chars, - SelectAction::ChangeRealm => true, _ => false, }; if disabled.0 != want { diff --git a/crates/benilla-app/src/char_select/screen.rs b/crates/benilla-app/src/char_select/screen.rs index c5a99c10..d8a9a68c 100644 --- a/crates/benilla-app/src/char_select/screen.rs +++ b/crates/benilla-app/src/char_select/screen.rs @@ -8,8 +8,8 @@ //! name (`GlueFontNormalHuge` at BOTTOM (0,100)), Enter World (200×60 at BOTTOM (0,30)) with the //! rotate pair tucked under it, Back (BOTTOMRIGHT (−30,25)) and Delete Character to its left, and //! the right-column character frame: 260×642 at TOPRIGHT (−5,−15), `Glue-Tooltip` backdrop tinted -//! `DEFAULT_TOOLTIP_COLOR` at 0.85 alpha, holding the realm banner, the disabled Change Realm -//! button (realm choice is out of scope — decision 0465 §6), ten 256×70 row buttons from TOPLEFT +//! `DEFAULT_TOOLTIP_COLOR` at 0.85 alpha, holding the realm banner, the Change Realm +//! button (back to [`crate::realm_select`]'s list), ten 256×70 row buttons from TOPLEFT //! (24,−65) at the authored 57 px pitch (13 px overlap, hit-inset 15), and Create New Character at //! the frame's BOTTOM (0,15). The delete dialog is [`super::dialog`]'s. @@ -41,7 +41,7 @@ pub(super) enum SelectAction { Back, Delete, CreateChar, - /// Rendered disabled — realm choice is out of scope (decision 0465 §6). + /// Back to the realm list — drops the parked session, keeps the logon. ChangeRealm, /// Open the AddOns list (decision 1197) — the reference's `CharacterSelectAddonsButton`. Addons, @@ -388,8 +388,9 @@ fn character_frame( font, s, ); - // Change Realm (below the banner) — rendered disabled: realm choice is out of scope - // (decision 0465 §6; a dead-but-enabled button would lie). + // Change Realm (below the banner) — live. It was drawn permanently disabled for as + // long as there was no realm list behind it to go back to; there is now, and it + // costs a world dial rather than a re-login (`crate::realm_select`). frame .spawn((Node { position_type: PositionType::Absolute, diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 48558784..3155d5d0 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -705,18 +705,21 @@ fn flourish_to_anim( } } -/// Play a **wound-flinch** on a unit: the given `AnimationData.dbc` id (8–10, the CombatWound -/// family) laid into the wound SECONDARY-blend slot — a decaying overlay that never interrupts -/// what plays underneath (decision 0111). The spell pipeline's counterpart to the melee flinch: -/// the client's kit player itself branches here (`0x60edf0` @ `0x60f3ad`: anim in `[8,10]` → -/// the wound trigger `0x60ea70`, anything else → `PlayAnimation`), so an impact kit's wound anim -/// must never ride the [`EmoteAnim`] one-shot route — that replaces the base track, the exact -/// routing decision 0111 falsified. Written by [`spell_visual::route_cast_visuals`]; consumed by -/// [`driver::drive_animations`] into the same per-frame wound slot as a melee hit. +/// A **spell-side wound flinch** on `entity` — the client's `0x60ea70(unit, severity = 0)` +/// reached from three spell paths (decision 2058): the kit player's own branch (`0x60edf0` @ +/// `0x60f3ad`: a kit anim in `[8,10]` goes here instead of `PlayAnimation`), the instant-hit +/// impact loop (`0x6e8bf0` @ `0x6e8c89` — after the impact kit, iff the spell targets enemies, +/// [`benilla_formats::SpellDisplay::is_harmful`]), and the missile impact hand-off (`0x61dc50` @ +/// `0x61dc74` — before the impact kit, every living target). All three pass **severity 0**, so +/// the id is never the kit's own column: the trigger picks CombatWound(9) / StandWound(8) by the +/// victim's engagement exactly like a non-crit melee hit ([`select::wound_anim`]) and lays it +/// into the SECONDARY-blend slot — a decaying overlay that never interrupts what plays +/// underneath (decision 0111). Never the [`EmoteAnim`] one-shot route: that replaces the base +/// track, the exact routing 0111 falsified. Written by [`spell_visual::route_cast_visuals`]; +/// consumed by [`driver::drive_animations`] into the same per-frame wound slot as a melee hit. #[derive(Message, Clone, Copy)] pub(crate) struct WoundAnim { pub(crate) entity: Entity, - pub(crate) anim_id: u16, } /// The target lists off one `SMSG_SPELL_GO` (decision 0099 phase 4) — the payload [`CastEvent`]'s diff --git a/crates/benilla-app/src/creature_anim/blood.rs b/crates/benilla-app/src/creature_anim/blood.rs index a051f4b1..2d6a6cba 100644 --- a/crates/benilla-app/src/creature_anim/blood.rs +++ b/crates/benilla-app/src/creature_anim/blood.rs @@ -13,6 +13,7 @@ use benilla_formats::BloodCatalog; use crate::net::NetEntity; use benilla_assets::{LockRecover, WorldAssets}; +use super::spell_visual::FxSlot; use super::spell_visual::SpellVisuals; use super::{SpellKitFx, SwingImpact, SwingMessage}; @@ -132,10 +133,10 @@ pub(super) fn blood_spurts( _ => true, }; let large = swing.hit_info & 0x2000 != 0; // HITINFO crushing — the Large row, not crit - let Some(path) = blood + let Some((effect, path)) = blood .0 .effect_id(blood_id, VIOLENCE_LEVEL, front, large) - .and_then(|id| visuals.0.effect_path(id)) + .and_then(|id| visuals.0.effect_path(id).map(|path| (id, path))) else { info!("blood: dropped — no effect for blood {blood_id} (front {front}, large {large})"); continue; @@ -149,10 +150,15 @@ pub(super) fn blood_spurts( // The blood spurt is `CEffect::AddEffect` off the melee path, not a kit stage — but it // is the same self-terminating shape (one pass, then gone). stage: super::FxStage::OneShot, - effects: vec![( - if front { ATTACH_FRONT } else { ATTACH_BACK }, - path.to_string(), - )], + // One spurt per (record, tag) on a body: five attackers hitting the same flank + // REPLACE each other's spurt rather than stacking five copies of it — the + // reference's `0x6208e0` walk, which `resolve_spell_fx` runs for every slot + // (decision 2057). It is why a busy fight does not brighten without bound there. + effects: vec![FxSlot { + tag: if front { ATTACH_FRONT } else { ATTACH_BACK }, + effect, + path: path.to_string(), + }], }); } } diff --git a/crates/benilla-app/src/creature_anim/breath.rs b/crates/benilla-app/src/creature_anim/breath.rs index 0be8107c..602196e5 100644 --- a/crates/benilla-app/src/creature_anim/breath.rs +++ b/crates/benilla-app/src/creature_anim/breath.rs @@ -86,7 +86,7 @@ use crate::entities::Creatures; use crate::net::{NetEntity, ObjectStore, SelfPlayer}; use super::events::AnimSoundEvent; -use super::spell_visual::{FxClass, FxStage, SpellKitFx, SpellVisuals}; +use super::spell_visual::{FxClass, FxSlot, FxStage, SpellKitFx, SpellVisuals}; /// The event that asks "what does this unit breathe?" — `0x5fffad cmp eax,0x48544224`. const BTH: [u8; 4] = *b"$BTH"; @@ -202,7 +202,7 @@ pub(super) fn fire_breath( let (Some(visuals), Some(creatures)) = (visuals, creatures) else { return; }; - let Some(path) = visuals.0.hardcoded_effect(COLD_BREATH_EFFECT) else { + let Some((effect, path)) = visuals.0.hardcoded_effect(COLD_BREATH_EFFECT) else { return; // no such row — no breath (the DBC-resource degrade shape) }; let path = path.to_string(); @@ -261,7 +261,11 @@ pub(super) fn fire_breath( class: FxClass::Hold, // `0x5fbf50` — destroy at the first completion; the shipped clip runs 1.5 s. stage: FxStage::OneShot, - effects: vec![(BREATH_ATTACH, path.clone())], + effects: vec![FxSlot { + tag: BREATH_ATTACH, + effect, + path: path.clone(), + }], }); } // Streamed units despawn on range-out — drop their puff memory with them. diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index eded1eec..71c71d7a 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -364,11 +364,11 @@ pub(super) fn drive_animations( // keys on what is *currently playing* when each call runs. let mut pending: bevy::ecs::entity::EntityHashMap> = default(); // …and by victim: a landed hit with the flinch bit (`HitInfo & 0x2` — the sole trigger gate, - // decision 0111) plays the victim's wound-flinch **decay overlay** below, as does a spell - // impact whose kit carries a CombatWound anim ([`WoundAnim`], decision 0099 phase 4 — the - // kit player's own 8–10 branch). Last hit wins, matching the client, where a re-trigger - // re-seeds the same secondary slot. Independent of the attacker-side 0x10000 suppressor - // (that bit gates the *swing* animation only). + // decision 0111) plays the victim's wound-flinch **decay overlay** below, as does a spell-side + // flinch ([`WoundAnim`] — the kit player's 8–10 branch, a harmful instant impact, a missile + // impact; severity 0 every time, decision 2058). Last hit wins, matching the client, where a + // re-trigger re-seeds the same secondary slot. Independent of the attacker-side 0x10000 + // suppressor (that bit gates the *swing* animation only). let mut pending_wound: bevy::ecs::entity::EntityHashMap = default(); for s in swings.read() { // HitInfo bit 0x10000 suppresses the swing anim (decision 0073's verified suppressor). @@ -394,7 +394,7 @@ pub(super) fn drive_animations( } } for w in spell_wounds.read() { - pending_wound.insert(w.entity, WoundEdge::Spell(w.anim_id)); + pending_wound.insert(w.entity, WoundEdge::Spell); } // This frame's victim DEFENSE reactions (`$CPP`, decision 0279), keyed by victim — last wins // (a re-trigger re-arms the same primary). Resolved to an anim id inside the loop: the parry @@ -1369,11 +1369,12 @@ pub(super) fn drive_animations( wound_evict(&mut drv, &mut player, masked_played, base_played); if let Some(&edge) = pending_wound.get(&entity) { - // A melee edge picks its wound id by severity + the victim's engagement (decision - // 0111); a spell edge arrives with the kit's own id (the client passes it through). + // Both edges pick the wound id by severity + the victim's engagement (decision + // 0111's `0x60ea70`); a spell edge is the client's `severity = 0` call (decision + // 2058), so it is CombatWound/StandWound by engagement and never the kit's own id. let id = match edge { WoundEdge::Melee(hit_info) => select::wound_anim(hit_info, engaged), - WoundEdge::Spell(anim_id) => anim_id, + WoundEdge::Spell => select::wound_anim(0, engaged), }; wound_trigger( &mut drv, diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index b0071022..860a01f7 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -529,11 +529,11 @@ fn cast_hold_stows_even_when_the_model_lacks_the_spell_anims() { assert_eq!(sheath(&app), Some(1), "re-drawn once the cast resolves"); } -/// A spell impact whose kit carries a CombatWound anim rides the wound **secondary slot**, never -/// the one-shot route — the client's own 8–10 branch inside the kit player (`0x60edf0` @ -/// `0x60f3ad`, decision 0099 phase 4): the [`WoundAnim`] edge arms the decaying overlay and the -/// base track keeps playing untouched underneath (routing it as a one-shot would replace the -/// base — the exact mistake decision 0111 falsified for melee). +/// A spell-side flinch rides the wound **secondary slot**, never the one-shot route — the +/// client's `0x60ea70(severity = 0)` from the kit player's 8–10 branch, the harmful instant +/// impact, or the missile arrival (decision 2058): the [`WoundAnim`] edge arms the decaying +/// overlay and the base track keeps playing untouched underneath (routing it as a one-shot would +/// replace the base — the exact mistake decision 0111 falsified for melee). #[test] fn spell_impact_wound_rides_the_secondary_slot() { let mut app = app(); @@ -541,7 +541,7 @@ fn spell_impact_wound_rides_the_secondary_slot() { graph: Handle::default(), clips: vec![ clip(0, 1, true), // Stand - clip(9, 2, false), // CombatWound — Fireball's impact-kit anim + clip(8, 2, false), // StandWound — the unengaged victim's severity-0 pick ], hand_close: [None, None], playable_animation_lookup: Vec::new(), @@ -567,14 +567,11 @@ fn spell_impact_wound_rides_the_secondary_slot() { assert!(drv(&app, unit).wound.is_none()); let gait_before = drv(&app, unit).gait; - app.world_mut().write_message(WoundAnim { - entity: unit, - anim_id: 9, - }); + app.world_mut().write_message(WoundAnim { entity: unit }); app.update(); assert!( drv(&app, unit).wound.is_some(), - "the impact kit's wound anim armed the secondary slot" + "the spell flinch armed the secondary slot" ); assert_eq!( drv(&app, unit).gait, @@ -583,6 +580,64 @@ fn spell_impact_wound_rides_the_secondary_slot() { ); } +/// The spell flinch is the client's **severity-0** wound call (decision 2058): it never carries +/// the kit's own id, so the clip is CombatWound(9) on an engaged victim and StandWound(8) on an +/// unengaged one — `0x60ea70`'s `(severity, engaged)` pick, the same as a non-crit melee hit. +#[test] +fn spell_flinch_picks_the_wound_by_engagement() { + fn model() -> ModelAnimations { + ModelAnimations { + graph: Handle::default(), + clips: vec![ + clip(0, 1, true), // Stand + clip(8, 2, false), // StandWound + clip(9, 3, false), // CombatWound + clip(10, 4, false), // CombatCritical — a spell flinch must never land here + ], + hand_close: [None, None], + playable_animation_lookup: Vec::new(), + animation_lookup: Vec::new(), + global_bones: Vec::new(), + first_seq: None, + pose: Default::default(), + } + } + let mut app = app(); + let engaged = app + .world_mut() + .spawn(( + model(), + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + Engaged, + )) + .id(); + let idle = app + .world_mut() + .spawn(( + model(), + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + )) + .id(); + app.update(); // settle both bases + app.world_mut().write_message(WoundAnim { entity: engaged }); + app.world_mut().write_message(WoundAnim { entity: idle }); + app.update(); + let node = |e: Entity| { + app.world() + .entity(e) + .get::() + .unwrap() + .wound + .map(|w| w.node.index()) + }; + assert_eq!(node(engaged), Some(3), "engaged: CombatWound(9)"); + assert_eq!(node(idle), Some(2), "unengaged: StandWound(8)"); +} + /// The whiff slow-down touches SWING anims only (decision 0279's scoping): a spell kit's /// full-body special (Special1H 57) rides the same `Mode::Swing` slot, and a concurrent /// auto-attack miss must not drag it to half speed — the director's "the Eviscerate spin diff --git a/crates/benilla-app/src/creature_anim/driver/wound.rs b/crates/benilla-app/src/creature_anim/driver/wound.rs index fbf5d793..bf5519aa 100644 --- a/crates/benilla-app/src/creature_anim/driver/wound.rs +++ b/crates/benilla-app/src/creature_anim/driver/wound.rs @@ -9,14 +9,15 @@ use bevy::prelude::*; use super::super::{find_resolved, AnimDriver, MovementState, Wound}; use super::select::{self, STAND}; -/// One frame's wound edge for a victim — the two client trigger paths into the same secondary -/// slot (`0x60ea70`): a landed melee hit (`SMSG_ATTACKERSTATEUPDATE`, resolved to a wound id by -/// severity + engagement at trigger time) or a spell-impact kit whose anim is the CombatWound -/// family (the kit player's own 8–10 branch — decision 0099 phase 4, carrying the kit's id). +/// One frame's wound edge for a victim — the client's trigger paths into the same secondary +/// slot (`0x60ea70`): a landed melee hit (`SMSG_ATTACKERSTATEUPDATE`, its `HitInfo` — severity +/// is the crit bit) or a spell-side flinch ([`super::super::WoundAnim`]: the kit player's 8–10 +/// branch, the harmful instant impact, the missile impact — every one `severity = 0`, decision +/// 2058). Both resolve to an id by severity + engagement at trigger time. #[derive(Clone, Copy)] pub(super) enum WoundEdge { Melee(u32), - Spell(u16), + Spell, } /// Wound-flinch decay upkeep (decision 0111): the client's kernel advances every armed @@ -48,6 +49,9 @@ pub(super) fn wound_upkeep(drv: &mut AnimDriver, player: &mut AnimationPlayer) { if finished { player.stop(wd.node); drv.wound = None; + if benilla_assets::trace::enabled() { + benilla_assets::trace::line("fct", "wound expire (λ reached 0, slot released)"); + } } } } @@ -78,6 +82,15 @@ pub(super) fn wound_evict( if evicted { player.stop(wd.node); drv.wound = None; + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fct", + &format!( + "wound evict masked={} (a blended re-arm took the bone's secondary)", + wd.masked + ), + ); + } } } } @@ -157,7 +170,29 @@ pub(super) fn wound_trigger( span: c.duration, masked, }); + // The `WOW_MOVE_TRACE` line a flinch report is read against: which id, which + // bone, over what span, and what it lays over (`others` = the subtree's other + // weight, so the peak share is always 75% — decision 0111). + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fct", + &format!( + "wound trigger id={id} masked={masked} span={:.3} base={base} others={others}", + c.duration + ), + ); + } + } else if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fct", + &format!("wound trigger id={id} SKIPPED (the base track owns the node)"), + ); } + } else if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fct", + &format!("wound trigger id={id} SKIPPED (no playable clip, or a zero span)"), + ); } } } diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index 9f4739eb..7052fa39 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -64,14 +64,18 @@ const MEETING_STONE_JOIN_EFFECT: &str = "HARDCODED Meeting Stone Join"; /// `0x61fae0(kind = 0xc, &guid, completion = 0x5fbf50)`: one-shot, non-persistent, the /// end-of-clip terminator, attached at `0x13` like every `HARDCODED` effect. pub(crate) fn meeting_stone_join_fx(visuals: &SpellVisuals, entity: Entity) -> Option { - let path = visuals.0.hardcoded_effect(MEETING_STONE_JOIN_EFFECT)?; + let (effect, path) = visuals.0.hardcoded_effect(MEETING_STONE_JOIN_EFFECT)?; Some(SpellKitFx::Begin { entity, spell_id: 0, persistent: false, class: FxClass::Hold, stage: FxStage::OneShot, - effects: vec![(HARDCODED_FX_ATTACH, path.to_string())], + effects: vec![FxSlot { + tag: HARDCODED_FX_ATTACH, + effect, + path: path.to_string(), + }], }) } @@ -174,6 +178,27 @@ pub(crate) enum FxStage { Relive, } +/// One attach-point **effect model** a play hangs on a unit: the `SpellVisualEffectName` +/// **record id**, the M2 attachment tag it hangs from, and the model path. +/// +/// The id is not decoration. It is half the reference's **same-slot replace** key: every +/// `CEffect::AddEffect` (`0x61fdd0`) first calls `0x6208e0(owner, rec, tag)`, which walks the +/// owner's `+0xb4` list and **destroys** every node carrying the same record id at the same tag +/// (wow-re `kit30-effect-slot.md` §4, VERIFIED `[0x6208e0, 0x62092c]`; decision 2057). Keying on +/// the path instead would be a different rule — two `SpellVisualEffectName` rows may name one +/// `.mdx`, and the reference lets those coexist. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FxSlot { + /// The M2 attachment id to hang from ([`benilla_formats::KIT_SLOT_TAGS`]), or + /// [`benilla_formats::WORLD_EFFECT_TAG`] for the field-12 world plant — the reference's + /// `tag == -1`, which `0x620913` excludes from the replace walk, so those genuinely stack. + pub(crate) tag: u16, + /// The `SpellVisualEffectName` record id — the replace key's other half. + pub(crate) effect: u32, + /// The effect model's path (the model-cache key). + pub(crate) path: String, +} + /// A spell-visual kit's attach-point **effect model** edge (kit fields 3–11 → /// `SpellVisualEffectName` `.mdx` — decision 0099 phase 3; slot loop byte-pinned in wow-re /// `spell-visual-apply.md` §1.3). Written here, consumed by `crate::entities::spell_fx` (the @@ -181,8 +206,8 @@ pub(crate) enum FxStage { /// reap-then-begin never races. #[derive(Message, Clone)] pub(crate) enum SpellKitFx { - /// Attach this kit's effect models to the unit — one instance per populated slot, - /// `(M2 attachment id, effect model path)`. `persistent` = the precast/channel/aura-state + /// Attach this kit's effect models to the unit — one instance per populated slot + /// ([`FxSlot`]). `persistent` = the precast/channel/aura-state /// lifetime (lives until a matching-[`FxClass`] [`Self::Reap`] — the client's stage-4/2 /// no-self-termination); else the effect self-terminates after one pass of its model's /// first sequence (the stage-0/1 completion callback `0x5fbf50`). A persistent Begin @@ -196,7 +221,7 @@ pub(crate) enum SpellKitFx { /// ([`FxStage`]) — a separate axis from `class`/`persistent`, both of which are about /// lifetime ownership rather than what the model plays. stage: FxStage, - effects: Vec<(u16, String)>, + effects: Vec, }, /// The owner's lifetime ended (GO / fail / channel-clear / a replacing cast for /// [`FxClass::Hold`]; the aura leaving the slots for [`FxClass::AuraState`]) — despawn the @@ -471,9 +496,15 @@ pub(crate) fn held_strike_sound( /// A kit's populated emitter slots resolved to `(attach tag, model path)` pairs — the /// [`SpellKitFx::Begin`] payload. Slots whose `SpellVisualEffectName` row/path is missing are /// dropped (the client's NULL-record skip in the slot loop). -fn resolve_kit_effects(visuals: &SpellVisualCatalog, kit: &VisualKit) -> Vec<(u16, String)> { +fn resolve_kit_effects(visuals: &SpellVisualCatalog, kit: &VisualKit) -> Vec { kit.effects() - .filter_map(|(tag, id)| visuals.effect_path(id).map(|p| (tag, p.to_string()))) + .filter_map(|(tag, effect)| { + visuals.effect_path(effect).map(|path| FxSlot { + tag, + effect, + path: path.to_string(), + }) + }) .collect() } @@ -549,7 +580,8 @@ fn play_kit( ) { if let Some(anim_id) = kit.anim_id { if (8..=10).contains(&anim_id) { - out.wounds.write(WoundAnim { entity, anim_id }); + // `0x60f3b8: push 0; call 0x60ea70` — severity 0, the kit's own id is NOT what plays. + out.wounds.write(WoundAnim { entity }); } else { out.oneshots.write(EmoteAnim { entity, @@ -596,6 +628,18 @@ fn play_kit( } } +/// One spell landing on one unit — [`play_impact`]'s per-impact inputs. +#[derive(Clone, Copy)] +struct ImpactPlay { + spell_id: u32, + /// The CASTER's ranged fallback visual, already resolved (the caster may be gone by a + /// missile arrival), through which a basic shot's impact kit resolves. + weapon_visual: Option, + /// Lay the victim's severity-0 wound flinch beside the kit (decision 2058). + flinch: bool, + seq: u64, +} + /// The **unit-impact hand-off** (the client's `0x61dc50`, decision 0099 phase 4): the spell /// landed on `entity` — play the impact kit (stage 1), then the state kit (stage 2). The state /// kit plays its anim + sound only: its **effect models live for the aura's life**, owned by @@ -608,15 +652,31 @@ fn play_kit( /// `0x61dc20` (37 B, content unpinned) is not modeled. `weapon_visual` = the CASTER's ranged /// fallback visual (already resolved — the caster may be gone by a missile arrival), through /// which a basic shot's impact kit resolves. +/// +/// [`ImpactPlay::flinch`] is the **spell impact body twitch** (decision 2058): the client lays +/// the victim's wound flinch at `severity = 0` beside the impact kit — the instant-hit loop +/// `0x6e8bf0` does it after the kit iff the spell targets enemies (`0x6ea280 == 2`, +/// [`benilla_formats::SpellDisplay::is_harmful`]), the missile hand-off `0x61dc50` does it +/// before the kit for every living target. The caller decides which; the driver picks +/// CombatWound/StandWound by engagement and blends it as a decaying overlay over whatever plays. +/// Order between the flinch and the kit's own wound branch is moot here: both are severity-0 +/// edges on the same entity in the same frame. fn play_impact( entity: Entity, - spell_id: u32, - weapon_visual: Option, - seq: u64, + impact: ImpactPlay, spells: &crate::ui_action::Spells, visuals: &SpellVisualCatalog, out: &mut KitOut, ) { + let ImpactPlay { + spell_id, + weapon_visual, + flinch, + seq, + } = impact; + if flinch { + out.wounds.write(WoundAnim { entity }); + } for (stage, play) in [ ( (|s: &VisualStages| s.impact) as fn(&VisualStages) -> u32, @@ -712,9 +772,13 @@ pub(super) fn route_cast_visuals( for p in pushes.read() { // The kit-push opcode (decision 0280, stage-0 semantics): fresh transient effects/sound - // on EVERY send — the client allocates new CEffects per call, no dedup — while the body - // anim rides the driver's arm-level same-id dedup (`0x5fdba0`), so a looping eat/drink - // clip free-runs across the ~5 s resends instead of restarting. + // on EVERY send, with no *kit-level* active-scan — while the body anim rides the driver's + // arm-level same-id dedup (`0x5fdba0`), so a looping eat/drink clip free-runs across the + // ~5 s resends instead of restarting. + // + // "No dedup" was too strong, and decision 2057 corrected it: there is no kit-level scan, + // but each SLOT still runs the same-(record, tag) replace (`0x6208e0`) on its way in, so + // the resend **swaps the old batch out** rather than piling a second jug on the first. if let Some(kit) = visuals.0.kit(p.kit_id) { play_kit( p.entity, @@ -906,11 +970,16 @@ pub(super) fn route_cast_visuals( // A missile arrived on this unit (decision 0099 phase 4; speed-0 impacts play // inline from the GO loop below and never round-trip through here). The caster's // ranged fallback rode the missile — resolve through it, not the target. + // `0x61dc50` @ `0x61dc74`: the arrival flinches every living target — no + // hostility test on this path (decision 2058). play_impact( ev.entity, - ev.spell_id, - weapon_visual, - ev.seq, + ImpactPlay { + spell_id: ev.spell_id, + weapon_visual, + flinch: true, + seq: ev.seq, + }, spells, &visuals.0, &mut out, @@ -996,12 +1065,18 @@ pub(super) fn route_cast_visuals( } } if display.speed <= 0.0 { + // `0x6e8bf0` @ `0x6e8c7b`–`0x6e8c89`: each hit target flinches after its impact kit + // iff the spell targets enemies (decision 2058). + let flinch = display.is_harmful(); for &target in &go.hits { play_impact( target, - go.spell_id, - wv, - go.seq, + ImpactPlay { + spell_id: go.spell_id, + weapon_visual: wv, + flinch, + seq: go.seq, + }, spells, &visuals.0, &mut out, @@ -1318,7 +1393,7 @@ pub(super) fn arm_loot_fx( mut tutorials: Option>, ) { let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed()); - let Some(path) = visuals.as_ref().and_then(|v| v.0.loot_art_path()) else { + let Some((effect, path)) = visuals.as_ref().and_then(|v| v.0.loot_art_effect()) else { return; // no client data / no such row — no loot art (the DBC-resource degrade shape) }; let scan = if full_sweep { @@ -1367,7 +1442,11 @@ pub(super) fn arm_loot_fx( } else { FxStage::Relive }, - effects: vec![(HARDCODED_FX_ATTACH, path.to_string())], + effects: vec![FxSlot { + tag: HARDCODED_FX_ATTACH, + effect, + path: path.to_string(), + }], }); } else if !lootable && armed.remove(&entity) { fx.write(SpellKitFx::Reap { @@ -1404,7 +1483,7 @@ pub(super) fn arm_level_up_fx( }; match levels.insert(entity, level) { Some(prev) if prev != level => { - let Some(path) = visuals + let Some((effect, path)) = visuals .as_ref() .and_then(|v| v.0.hardcoded_effect(LEVEL_UP_EFFECT)) else { @@ -1418,7 +1497,11 @@ pub(super) fn arm_level_up_fx( class: FxClass::Hold, // The ding self-terminates on its own 1.867 s clip — the stage-0 shape. stage: FxStage::OneShot, - effects: vec![(HARDCODED_FX_ATTACH, path.to_string())], + effects: vec![FxSlot { + tag: HARDCODED_FX_ATTACH, + effect, + path: path.to_string(), + }], }); } _ => {} // first sight arms; an unchanged write is a no-op @@ -1472,7 +1555,7 @@ pub(super) fn arm_mount_poof_fx( // 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(path) = visuals + let Some((effect, path)) = visuals .as_ref() .and_then(|v| v.0.hardcoded_effect(MOUNT_POOF_EFFECT)) else { @@ -1486,7 +1569,11 @@ pub(super) fn arm_mount_poof_fx( class: FxClass::Hold, // `0x5fbf50` — destroy at the first completion; the shipped model runs 2.8 s. stage: FxStage::OneShot, - effects: vec![(HARDCODED_FX_ATTACH, path.to_string())], + effects: vec![FxSlot { + tag: HARDCODED_FX_ATTACH, + effect, + path: path.to_string(), + }], }); } _ => {} diff --git a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs index 1c7cab41..62080aa1 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs @@ -567,7 +567,11 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() { assert_eq!(*class, super::FxClass::AuraState); assert_eq!( effects.as_slice(), - [(0x16, "Spells\\Item_Bread.mdx".to_string())], + [super::FxSlot { + tag: 0x16, + effect: BREAD_FX, + path: "Spells\\Item_Bread.mdx".to_string(), + }], "bread at the spell hand" ); } @@ -921,6 +925,7 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { const FIELD_MOUNTDISPLAYID: u16 = 133; /// `SpellVisualEffectName` row 1185's shipped path — the druid-morph cloud. const POOF: &str = "Spells\\DruidMorph_Impact_Base.mdx"; + const POOF_FX: u32 = 1185; // the shipped `SpellVisualEffectName` row (decision 0927) /// The M2 attach the hardcoded-effect spawn stamps (`DAT_0080c968[6]`). const BASE_ATTACH: u16 = 0x13; @@ -928,8 +933,11 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { app.add_plugins(MinimalPlugins); app.add_message::(); app.insert_resource(SpellVisuals( - SpellVisualCatalog::from_tables(HashMap::new(), HashMap::new()) - .with_hardcoded("HARDCODED Mount Poof", POOF), + SpellVisualCatalog::from_tables(HashMap::new(), HashMap::new()).with_hardcoded( + "HARDCODED Mount Poof", + POOF_FX, + POOF, + ), )); app.add_systems(Update, super::arm_mount_poof_fx); @@ -938,7 +946,7 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { // Streams in ALREADY mounted: first sight arms the memory, silently. let unit = app.world_mut().spawn(store(2404)).id(); app.update(); - let puffs = |app: &mut App| -> Vec<(u16, String)> { + let puffs = |app: &mut App| -> Vec { let mut out = Vec::new(); let world = app.world_mut(); let mut msgs = world.resource_mut::>(); @@ -968,7 +976,11 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { app.update(); assert_eq!( puffs(&mut app), - vec![(BASE_ATTACH, POOF.to_string())], + vec![super::FxSlot { + tag: BASE_ATTACH, + effect: POOF_FX, + path: POOF.to_string(), + }], "the build leg puffs the druid-morph cloud at the base attach" ); @@ -980,7 +992,14 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { // A swap (N → N′) is a change, and the reference rebuilds and puffs again. app.world_mut().entity_mut(unit).insert(store(2405)); app.update(); - assert_eq!(puffs(&mut app), vec![(BASE_ATTACH, POOF.to_string())]); + assert_eq!( + puffs(&mut app), + vec![super::FxSlot { + tag: BASE_ATTACH, + effect: POOF_FX, + path: POOF.to_string(), + }] + ); } /// **The link that makes the beam exist at all** (decision 0955 slice 2): a kit carrying a chain diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs index 0df9d963..89ec312e 100644 --- a/crates/benilla-app/src/entities/spell_fx/mod.rs +++ b/crates/benilla-app/src/entities/spell_fx/mod.rs @@ -16,10 +16,20 @@ //! (precast/channel) lives until its spell-id-keyed [`SpellKitFx::Reap`] (the client's //! `0x614150`); a **self-terminating** one (cast release, kit push) despawns after one pass of //! its model's sequence 0 — the stage-0/1 completion callback's clock, which runs whether or not -//! the sequence moves a bone (the eat/drink tankard is a 6.667 s sequence with zero bone keys; -//! against the ~5 s kit-resend cadence its instances overlap into a continuously held jug). The -//! attach cascade for a model lacking the requested point is the client's: tag → `0xf` → `0x13` -//! → the unit's base (wow-re §5, `0x61ceb0`). +//! the sequence moves a bone (the eat/drink tankard is a 6.667 s sequence with zero bone keys, +//! so it outlives the ~5 s kit-resend cadence and the jug is held continuously). +//! +//! Every spawn first runs the **same-slot replace** ([`replace_same_slot`], decision 2057): the +//! reference's `0x6208e0`, which destroys any live instance of the same `SpellVisualEffectName` +//! record at the same attach tag. It is what keeps a busy fight's effect count flat in the number +//! of attackers, and it is also what swaps the tankard out on each resend. +//! +//! **The attach cascade below is mis-attributed and is a known divergence** (named in 2057, not +//! yet fixed): `tag → 0xf → 0x13 → the unit's base` is `CMissile`'s (`0x61ceb0`, +//! `Missile_C.cpp`), not `AddEffect`'s. wow-re corrected this on 2026-09-02 +//! (`spell-visual-apply.md`, from `melee-blood-spurt-suppression.md` §1/§7): `0x61fdd0` has no +//! attachment test at all, so on a model lacking the tag the reference drops the effect +//! permanently and invisibly where we relocate it. //! //! Effect models run their **bone rigs** ([`arm_effect_rig`] — the birth clip + global sequences //! pose the joints that meshes skin to and emitters/ribbons/cards ride), advance them through the @@ -53,6 +63,7 @@ use bevy::animation::AnimatedBy; use bevy::ecs::entity::EntityHashMap; use bevy::prelude::*; +use crate::creature_anim::spell_visual::FxSlot; use crate::creature_anim::{scan_events, AnimSoundEvent, FxClass, FxStage, SpellKitFx}; use benilla_assets::m2_url; use benilla_assets::materials::WowModelMaterial; @@ -566,6 +577,9 @@ struct FxInstance { /// The M2 attachment id to hang from ([`benilla_formats::KIT_SLOT_TAGS`]), or /// [`benilla_formats::WORLD_EFFECT_TAG`] for the field-12 world-plant slot (0848/0850). tag: u16, + /// The `SpellVisualEffectName` record id — with [`Self::tag`], the reference's same-slot + /// replace key (`0x6208e0`; [`replace_same_slot`], decision 2057). + effect: u32, /// The model-cache key. path: String, /// The spawned instance root (a child of the attach joint), `None` while the model loads. @@ -699,7 +713,11 @@ pub(super) fn resolve_spell_fx( if *persistent { reap_matching(&mut instances, *spell_id, *class, &fx, now, &mut commands); } - for (tag, path) in effects { + for FxSlot { tag, effect, path } in effects { + // **The same-slot replace**, run per slot exactly where the reference + // runs it: `CEffect::AddEffect 0x61fdd0` opens with + // `0x6208e0(owner, rec, tag)` (decision 2057). + replace_same_slot(&mut instances, *effect, *tag, &mut commands); fx.models .entry(path.clone()) .or_insert_with(|| DisplayModel { @@ -712,6 +730,7 @@ pub(super) fn resolve_spell_fx( class: *class, stage: *stage, tag: *tag, + effect: *effect, path: path.clone(), root: None, expires: None, @@ -735,6 +754,42 @@ pub(super) fn resolve_spell_fx( } } +/// **The same-slot replace walk** (`0x6208e0`, VERIFIED `[0x6208e0, 0x62092c]` — wow-re +/// `kit30-effect-slot.md` §4; decision 2057): every `CEffect::AddEffect` (`0x61fdd0`) opens by +/// walking the owner's `+0xb4` list and **destroying** (`0x6203e0`) each node carrying the same +/// `SpellVisualEffectName` record at the same attach tag, so a re-play *replaces* a still-live +/// same-model-same-slot emitter instead of stacking on it. +/// +/// This is the whole reason a busy fight does not brighten without bound in the reference: five +/// mobs' blood spurts on one flank of one body are one instance there, not five, and the count is +/// **flat in the number of attackers** rather than linear. It is a *destroy*, not the reap's +/// decay — the node is gone the same frame, and the newcomer is born in its place. +/// +/// Two exclusions, both the reference's own: +/// - **`tag == -1`** ([`benilla_formats::WORLD_EFFECT_TAG`], the field-12 world plant) is skipped +/// at `0x620913`, so successive world plants genuinely coexist. +/// - an instance already **decaying** is not reachable: the reference has moved it off `+0xb4` +/// onto the pending-destroy list, exactly as [`reap_matching`] documents. +fn replace_same_slot( + instances: &mut Vec, + effect: u32, + tag: u16, + commands: &mut Commands, +) { + if tag == benilla_formats::WORLD_EFFECT_TAG { + return; + } + instances.retain(|i| { + if i.decaying || i.effect != effect || i.tag != tag { + return true; + } + if let Some(root) = i.root { + commands.entity(root).despawn(); + } + false + }); +} + /// The spell-id-keyed reap walk (`0x614150`): every live persistent instance of `(spell_id, class)` /// **plays its `Decay` out** and is torn down when that span ends — the reference's three gates in /// order (`0x614187`–`0x6141a1`: a model handle, a ready model, and a `Decay` the model actually @@ -1054,6 +1109,7 @@ mod tests { class: FxClass::AuraState, stage: FxStage::State, tag: 0x13, + effect: 1499, // Ice Barrier's state model path: "Spells\\IceShield_State.mdx".into(), root: Some(root), expires: None, @@ -1151,4 +1207,121 @@ mod tests { app.update(); assert_eq!(instances_of(&app, unit), vec![(true, Some(roots[0]))]); } + + // ---- the same-slot replace (`0x6208e0`, decision 2057) ---- + + /// The blood spurt's record and the two flank tags — the case the rule exists for. + const SPURT: u32 = 63; + const FRONT: u16 = 0xf; + const BACK: u16 = 0x10; + + /// An app running `resolve_spell_fx` alone, plus one bare unit. + fn resolving() -> (App, Entity) { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_asset::() + .init_asset::() + .init_resource::() + .add_message::() + .add_systems(Update, resolve_spell_fx); + let unit = app.world_mut().spawn_empty().id(); + (app, unit) + } + + /// One self-terminating `Begin` carrying one slot. + fn begin(entity: Entity, effect: u32, tag: u16, path: &str) -> SpellKitFx { + SpellKitFx::Begin { + entity, + spell_id: 0, + persistent: false, + class: FxClass::Hold, + stage: FxStage::OneShot, + effects: vec![FxSlot { + tag, + effect, + path: path.into(), + }], + } + } + + fn send(app: &mut App, msg: SpellKitFx) { + app.world_mut() + .resource_mut::>() + .write(msg); + app.update(); + } + + /// Every live instance's `(effect, tag)`, in list order. + fn slots_of(app: &App, unit: Entity) -> Vec<(u32, u16)> { + app.world() + .entity(unit) + .get::() + .map(|a| a.instances.iter().map(|i| (i.effect, i.tag)).collect()) + .unwrap_or_default() + } + + /// **The rule** (`0x6208e0`): a second play of the same record at the same tag DESTROYS the + /// first — five attackers' spurts on one flank are one instance, not five, which is why the + /// reference's effect count is flat in the number of attackers where ours was linear. + #[test] + fn a_replay_of_the_same_record_at_the_same_tag_replaces_rather_than_stacks() { + let (mut app, unit) = resolving(); + send(&mut app, begin(unit, SPURT, FRONT, "Particles\\Spurt.mdx")); + // The first instance has been attached: give it the root the spawn pass would. + let root = app.world_mut().spawn_empty().id(); + app.world_mut() + .entity_mut(unit) + .get_mut::() + .unwrap() + .instances[0] + .root = Some(root); + + send(&mut app, begin(unit, SPURT, FRONT, "Particles\\Spurt.mdx")); + assert_eq!( + slots_of(&app, unit), + vec![(SPURT, FRONT)], + "one instance, not two" + ); + assert!( + app.world().get_entity(root).is_err(), + "the replaced node is DESTROYED the same frame, not left to decay", + ); + } + + /// The tag is half the key: the same record at a different attach point coexists — Execute's + /// cast kit hangs one record on BOTH hands (`0x15` and `0x16`), and both must survive. + #[test] + fn the_same_record_at_a_different_tag_coexists() { + let (mut app, unit) = resolving(); + send(&mut app, begin(unit, SPURT, FRONT, "Particles\\Spurt.mdx")); + send(&mut app, begin(unit, SPURT, BACK, "Particles\\Spurt.mdx")); + assert_eq!(slots_of(&app, unit), vec![(SPURT, FRONT), (SPURT, BACK)]); + } + + /// The record is the other half: two different records at one tag coexist, and keying the + /// walk on the model PATH instead would wrongly collapse them (two rows may name one `.mdx`). + #[test] + fn a_different_record_at_the_same_tag_coexists() { + let (mut app, unit) = resolving(); + send(&mut app, begin(unit, SPURT, FRONT, "Particles\\Spurt.mdx")); + send( + &mut app, + begin(unit, SPURT + 1, FRONT, "Particles\\Spurt.mdx"), + ); + assert_eq!( + slots_of(&app, unit), + vec![(SPURT, FRONT), (SPURT + 1, FRONT)] + ); + } + + /// The reference excludes `tag == -1` from the walk (`0x620913`), so successive world plants + /// genuinely stack — a second Thunder Clap ring does not eat the first. + #[test] + fn the_world_plant_slot_is_excluded_from_the_replace() { + let (mut app, unit) = resolving(); + let tag = benilla_formats::WORLD_EFFECT_TAG; + send(&mut app, begin(unit, SPURT, tag, "Spells\\Ring.mdx")); + send(&mut app, begin(unit, SPURT, tag, "Spells\\Ring.mdx")); + assert_eq!(slots_of(&app, unit), vec![(SPURT, tag), (SPURT, tag)]); + } } diff --git a/crates/benilla-app/src/glue/art.rs b/crates/benilla-app/src/glue/art.rs index b6feceef..82a10e0b 100644 --- a/crates/benilla-app/src/glue/art.rs +++ b/crates/benilla-app/src/glue/art.rs @@ -150,6 +150,13 @@ pub(crate) struct GlueArt { pub(crate) dropdown_arrow_up: Option>, pub(crate) dropdown_arrow_down: Option>, pub(crate) quest_hilight: Option>, + /// The same `UI-QuestLogTitleHighlight` art as a **plain texture**. The realm list tints its + /// selection band per row (`RealmListHighlightTexture:SetVertexColor` — green when you have + /// characters on that realm, red when it is invalid, gold otherwise), and a shared + /// `AddUiMaterial` handle cannot carry a per-row colour. **Stated divergence:** drawn + /// alpha-blended rather than the reference's `alphaMode="ADD"`, which is the price of the + /// tint until the ADD material grows a colour uniform. + pub(crate) title_highlight: Option>, pub(crate) check_disabled: Option>, pub(crate) tooltip_border: Option, pub(crate) char_scrollbar: Option<(Handle, Vec2)>, @@ -399,6 +406,8 @@ impl GlueArt { images, add_mats, ); + self.title_highlight = + assets.sprite_texture("Interface\\QuestFrame\\UI-QuestLogTitleHighlight", images); self.check_disabled = assets.sprite_texture("Interface\\Buttons\\UI-CheckBox-Check-Disabled", images); self.tooltip_border = super::backdrop::backdrop_edges( @@ -413,13 +422,14 @@ impl GlueArt { ); debug!( "glue art: addonlist set — helpframe {} header {} close {} droparrow {}/{} \ - questhl {} greycheck {} tipborder {} scrolltrack {}", + questhl {} titlehl {} greycheck {} tipborder {} scrolltrack {}", self.help_frame.is_some(), self.dialog_header.is_some(), self.close_btn.is_some(), self.dropdown_arrow_up.is_some(), self.dropdown_arrow_down.is_some(), self.quest_hilight.is_some(), + self.title_highlight.is_some(), self.check_disabled.is_some(), self.tooltip_border.is_some(), self.char_scrollbar.is_some(), diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 75ea7b27..f222ae1b 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -100,6 +100,7 @@ mod preflight; mod probe_shield; mod quest_markers; mod raid_marks; +mod realm_select; mod realmlist; mod run_mode; mod screen_fade; @@ -585,6 +586,9 @@ pub fn run(build: BuildId) -> AppExit { // The login screen (decision 0539): the faithful AccountLogin glue + the credential policy // that answers the IO thread's pre-logon park. .add_plugins(login::LoginPlugin) + // The realm list: the faithful RealmList glue + the policy that answers the IO thread's + // realm park. The client used to take `realms.first()` and offer no way to say otherwise. + .add_plugins(realm_select::RealmSelectPlugin) // The character-creation screen + its live preview booth (decision 0423). .add_plugins(char_create::CharCreatePlugin) // Audio: the delegated mixer + WoW's owned selection layer (decision 0070). diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 87e6c62d..e0676232 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -112,6 +112,7 @@ impl Plugin for NetPlugin { app.insert_resource(NetEvents(handles.events)) .insert_resource(NetCommands(handles.commands)) .insert_resource(CharPick(handles.pick)) + .insert_resource(RealmChoice(handles.realm)) .insert_resource(LoginSubmit(handles.login)) .insert_resource(LoginAbandon(handles.login_abandon)) .insert_resource(PingShared(handles.ping)) @@ -142,6 +143,7 @@ impl Plugin for NetPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -385,6 +387,32 @@ pub(crate) enum CharRequest { /// Select's Back (decision 0539): drop the parked session and return the IO thread to the /// pre-logon park — the app is heading to the login screen. Abandon, + /// Select's **Change Realm**: drop the parked session but keep the logon, so the IO thread + /// reopens the realm list rather than the login screen. Picking a different realm re-dials a + /// world server; it does not re-authenticate. + ChangeRealm, +} + +/// The **realm channel**: the app's answer to each [`RealmListMessage`] while the IO thread is +/// parked between the logon and the world dial. Sent by [`crate::realm_select`]'s policy (a +/// remembered realm, `WOW_REALM`, or the player's click on the realm list); the parked read thread +/// blocks on the other end. +#[derive(Resource)] +pub(crate) struct RealmChoice(pub(crate) Sender); + +/// One request to the IO thread parked at the realm list. +pub(crate) enum RealmRequest { + /// Enter this realm — dial its world server. Carries the realm's **name**, not its index: the + /// list is re-requested every few seconds while the screen is up, and a server that adds or + /// drops a realm between the draw and the click would otherwise silently move the row out from + /// under the player's finger. + Enter(String), + /// Re-request the realm list on the still-open realmd connection (the reference's + /// `RequestRealmList`, fired by `RealmList_OnUpdate` every 5 s while the window is open). + Refresh, + /// The realm list's Cancel: drop the logon and return the IO thread to the pre-logon park — + /// the app is heading back to the login screen. + Abandon, } /// The credentials channel (decision 0539): the app's answer to the IO thread's **pre-logon park**. @@ -2337,6 +2365,15 @@ pub(crate) enum ClientCommand { }, } +/// The realms this account may enter (`CMD_REALM_LIST`, bridged from the Net drain): the IO thread +/// is parked between the logon and the world dial, waiting for the app to name one. Consumed by +/// [`crate::realm_select`]'s policy — auto-answer (the remembered realm / `WOW_REALM`) or show the +/// realm list and wait for the director. +#[derive(Message)] +pub(crate) struct RealmListMessage { + pub(crate) realms: Vec, +} + /// The account's character roster (`SMSG_CHAR_ENUM`, bridged from the Net drain): the IO thread is /// parked at character select waiting for the app's pick. Consumed by [`crate::char_select`]'s /// policy — auto-answer (pending pick / `WOW_CHAR`) or show the roster and wait for the director. diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 6ae2f1c8..e640462e 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -123,7 +123,13 @@ pub(crate) fn apply_net_updates( session_msgs: ( MessageWriter, MessageWriter, - MessageWriter, + // **The two park publications, paired** — the tuple is at Bevy's 16-param ceiling, and + // these are the pair that belongs together: the realm list and the character roster are + // the same thing one park apart, each published so the app's policy can answer it. + ( + MessageWriter, + MessageWriter, + ), MessageWriter, MessageWriter, MessageWriter, @@ -524,7 +530,7 @@ pub(crate) fn apply_net_updates( let ( mut teleports, mut worldports, - mut char_lists, + (mut char_lists, mut realm_lists), mut char_actions, mut entered_world, mut logged_out, @@ -580,6 +586,9 @@ pub(crate) fn apply_net_updates( terminal, dial, } => session::login_failed(refusal, reason, terminal, dial, &mut login_failures), + SessionEvent::RealmList { realms } => { + realm_lists.write(crate::net::RealmListMessage { realms }); + } SessionEvent::CharacterList { characters, realm } => { session::character_list(characters, realm, &mut status, &mut char_lists) } @@ -723,6 +732,7 @@ pub(crate) fn apply_net_updates( pitch, time, heartbeat, + teleport, fall_time, jump, transport, @@ -743,6 +753,7 @@ pub(crate) fn apply_net_updates( jump, transport, heartbeat, + teleport, }, now_ms, &mut commands, diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index ee08ca5f..be3e7fb5 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -34,7 +34,7 @@ use benilla_protocol::{ }; use crossbeam_channel::{Receiver, Sender}; -use super::{CharRequest, ChatKind, ClientCommand}; +use super::{CharRequest, ChatKind, ClientCommand, RealmRequest}; /// **The inbound census** — every packet the read thread pulls off the world socket, whatever its /// opcode, and the wall-clock of the most recent one (unix ms). Two numbers, one job: telling a @@ -76,6 +76,7 @@ pub(crate) fn inbound_census() -> (u64, Option) { /// submit time — the thread discards the attempt at its next stage boundary if the shared counter /// has moved (a Cancel bumps it). A counter, not a flag: a flag cleared by the *next* submit would /// un-cancel the attempt still in flight. +#[derive(Clone)] pub(crate) struct LoginRequest { pub(crate) user: String, pub(crate) pass: String, @@ -88,6 +89,36 @@ pub(crate) struct LoginRequest { pub(crate) generation: u64, } +/// **The three park-answer receivers**, in the order a cycle blocks on them: credentials, realm, +/// character. One struct rather than three parameters because they are one concern — the app's side +/// of every place this thread stops and waits — and because a cycle that grew a fourth park should +/// not have to re-thread every signature to say so. +pub(super) struct Parks { + pub(super) login_rx: Receiver, + pub(super) realm_rx: Receiver, + pub(super) pick_rx: Receiver, +} + +/// A logon carried across a **Change Realm** — the credentials that bought it and the realmd +/// connection it still holds. +/// +/// Picking a different realm is not a new login: the SRP6 session key authenticates against any +/// world server on the account's realm list, and the realm list itself is already in hand. Holding +/// both here is what lets the next cycle skip the credentials park and reopen the realm list, so +/// Change Realm costs a world dial rather than a full re-authentication — which is also what the +/// reference does (its realmd connection outlives the screen; `CGlueMgr::ConnectToRealm`). +pub(super) struct HeldLogon { + pub(super) req: LoginRequest, + pub(super) logon: benilla_protocol::Logon, +} + +/// How long a realm-list refresh waits for realmd before giving up on the connection. +/// +/// The refresh runs **inside the realm park**, so this is a bound on how long the park can be deaf +/// to the player's click. Generous enough that an ordinary round trip never trips it, short enough +/// that a server which accepts the request and says nothing costs one beat rather than the screen. +const REALM_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); + /// The keepalive cadence — the real client's 30 000 ms ping timer (VERIFIED wow-re net W1, /// `0x537ff0`: the connection drain arms the next ping 30 s out). vmangos *kicks* a player socket /// whose pings repeat faster than 27 s apart more than twice (`WorldSocket::_HandlePing`), so this @@ -236,12 +267,13 @@ enum Cycle { } /// Everything [`spawn_net`] hands the app: the inbound event stream, the outbound command sender, -/// the two park-answer channels (credentials + character pick), the shared abandon generation the -/// Cancel button bumps, and the ping clock. +/// the three park-answer channels (credentials, realm, character pick), the shared abandon +/// generation the Cancel button bumps, and the ping clock. pub(super) struct NetHandles { pub(super) events: Receiver, pub(super) commands: Sender, pub(super) pick: Sender, + pub(super) realm: Sender, pub(super) login: Sender, pub(super) login_abandon: Arc, pub(super) ping: Arc>, @@ -254,6 +286,7 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { let (events_tx, events_rx) = crossbeam_channel::unbounded(); let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let (pick_tx, pick_rx) = crossbeam_channel::unbounded::(); + let (realm_tx, realm_rx) = crossbeam_channel::unbounded::(); let (login_tx, login_rx) = crossbeam_channel::unbounded::(); let login_abandon = Arc::new(AtomicU64::new(0)); let ping_clock = Arc::new(Mutex::new(PingClock::default())); @@ -281,6 +314,18 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { benilla_world::thread_qos::promote_current_thread( benilla_world::thread_qos::QosClass::UserInitiated, ); + // **The logon that survives a Change Realm.** Picking a different realm re-dials a + // world server; it does not re-authenticate, because the SRP6 session key and the + // realm list are still good. So the cycle hands its logon back here rather than + // dropping it, and the next `run` skips the credentials park and reopens the realm + // list directly. Every *failure* path leaves this `None` — a cycle that broke goes + // back to the login screen exactly as it did before. + let mut held: Option = None; + let parks = Parks { + login_rx, + realm_rx, + pick_rx, + }; loop { // **A cycle starts with no measurements.** Every way the last one ended — a // stream failure, a logout, a re-park — lands here, so one clear covers them @@ -293,10 +338,10 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { &cfg, &events_tx, &writer_tx, - &pick_rx, - &login_rx, + &parks, &abandon, &read_clock, + &mut held, ) { Ok(Cycle::Exit) => return, Ok(Cycle::Repark) => {} @@ -339,35 +384,55 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { events: events_rx, commands: cmd_tx, pick: pick_tx, + realm: realm_tx, login: login_tx, login_abandon, ping: ping_clock, } } -/// One connection cycle: **park pre-logon** for credentials (decision 0539) → logon → world -/// handshake → the character roster → **park at character select** until the app picks → enter the +/// One connection cycle: **park pre-logon** for credentials (decision 0539) → logon → **park at +/// the realm list** until the app names a realm → world handshake → the character roster → **park +/// at character select** until the app picks → enter the /// world, hand the writer to the write thread, then stream decoded [`SessionEvent`]s until the /// socket dies (`Err`), the character logs out ([`Cycle::LoggedOut`]), or the app drops a channel /// end ([`Cycle::Exit`]). Every pre-roster failure emits [`SessionEvent::LoginFailed`] and /// re-parks ([`Cycle::Repark`]) — never a retry loop; resubmission is the app's policy. +/// +/// **Three parks, one rule.** Each is the thread blocking on a channel for an answer only the app +/// can give, and at each one the thread stays a policy-free sequencer: which credentials, which +/// realm and which character are all the app's to decide. fn run( cfg: &NetConfig, events_tx: &Sender, writer_tx: &Sender, - pick_rx: &Receiver, - login_rx: &Receiver, + parks: &Parks, abandon: &AtomicU64, ping_clock: &Mutex, + held: &mut Option, ) -> Result { + let Parks { + login_rx, + realm_rx, + pick_rx, + } = parks; // ── The pre-logon park (decision 0539): block for credentials. ────────────────────────────── - let Ok(req) = login_rx.recv() else { - return Ok(Cycle::Exit); + // + // Skipped entirely when the last cycle handed its logon back (a Change Realm): those + // credentials are already spent, and the session key they bought is still valid. + let resumed = held.take(); + let req = match &resumed { + Some(h) => h.req.clone(), + None => match login_rx.recv() { + Ok(req) => req, + Err(_) => return Ok(Cycle::Exit), + }, }; // The attempt is live until the abandon generation moves past its submit-time value (Cancel // bumps it; the next submit carries the bumped value). Checked at every stage boundary — a // blocking dial can't be interrupted, but its result is discarded silently. - let canceled = || abandon.load(Ordering::SeqCst) != req.generation; + let generation = req.generation; + let canceled = move || abandon.load(Ordering::SeqCst) != generation; let stage = |s: LoginStage| { let _ = events_tx.send(SessionEvent::LoginStage { stage: s }); }; @@ -403,32 +468,93 @@ fn run( Ok(Cycle::Repark) }; - // Logon (the dial + SRP6 exchange — one blocking sequence against realmd). - stage(LoginStage::Connecting); - let logon = match benilla_protocol::logon(&req.host, &req.user, &req.pass) { - Ok(l) => l, - Err(e) => { - if canceled() { - return Ok(Cycle::Repark); + // Logon (the dial + SRP6 exchange — one blocking sequence against realmd). A resumed cycle + // already holds one: a Change Realm re-dials a world server, never realmd. + let mut logon = match resumed { + Some(h) => h.logon, + None => { + stage(LoginStage::Connecting); + match benilla_protocol::logon(&req.host, &req.user, &req.pass) { + Ok(l) => l, + Err(e) => { + if canceled() { + return Ok(Cycle::Repark); + } + // A server refusal carries its auth result byte (the app maps it to the client's + // own AUTH_* string); a transport failure carries None. A failure to get a socket at + // all carries the dial verdict, which is the one the screen can turn into advice. + if let Some(dial) = e.downcast_ref::() { + return fail_dial(dial.clone(), format!("{e:#}")); + } + let refusal = e + .downcast_ref::() + .map(|r| benilla_protocol::LoginRefusal::Logon(r.code)); + return fail(refusal, format!("{e:#}")); + } } - // A server refusal carries its auth result byte (the app maps it to the client's - // own AUTH_* string); a transport failure carries None. A failure to get a socket at - // all carries the dial verdict, which is the one the screen can turn into advice. - if let Some(dial) = e.downcast_ref::() { - return fail_dial(dial.clone(), format!("{e:#}")); - } - let refusal = e - .downcast_ref::() - .map(|r| benilla_protocol::LoginRefusal::Logon(r.code)); - return fail(refusal, format!("{e:#}")); } }; if canceled() { return Ok(Cycle::Repark); } - // The realm we connect to (`realms.first()`, decision 0193) — carried on each roster emit so - // the select screen can banner its name + type (decision 0465). - let realm = logon.realms.first().cloned(); + + // ── The realm park: **which realm to dial is the app's answer, not this thread's.** ────── + // + // This used to be `logon.realms.first()` — the client walked into whichever realm the server + // happened to list first and there was no way to say otherwise. The realm the player picks is + // policy, and policy lives app-side, so the thread does here exactly what it does at the other + // two parks: publish the facts, block, obey. + // + // A **realm-less** server never reaches the park: with nothing to choose between, blocking + // would be a hang, so the old fallback address stands and the cycle carries straight on. + let realm = if logon.realms.is_empty() { + None + } else { + // A pick queued during a dead cycle must not answer THIS list. + while realm_rx.try_recv().is_ok() {} + loop { + if events_tx + .send(SessionEvent::RealmList { + realms: logon.realms.clone(), + }) + .is_err() + { + return Ok(Cycle::Exit); + } + let Ok(req) = realm_rx.recv() else { + return Ok(Cycle::Exit); + }; + if canceled() { + return Ok(Cycle::Repark); + } + match req { + // The realm list's Cancel — back to the login screen. + RealmRequest::Abandon => return Ok(Cycle::Repark), + // The reference re-requests the list every 5 s while its window is up. A refresh + // that fails is not an error the player needs: the list we hold stays on screen + // and `Logon` stops asking (see `Logon::refresh_realms`). + RealmRequest::Refresh => { + logon.refresh_realms(REALM_REFRESH_TIMEOUT); + } + // By name, so a list that changed under the click cannot enter the wrong realm. + // A name that is no longer there falls through to re-publishing the list, which + // is the honest answer: the realm they clicked is gone. + RealmRequest::Enter(name) => { + if let Some(realm) = logon.realms.iter().find(|r| r.name == name) { + // Named, because "connected to 127.0.0.1:8085" cannot tell a chosen realm + // from the realm-less fallback that dials the same address. + bevy::log::info!( + "net: realm {:?} ({} of {})", + realm.name, + realm.address, + logon.realms.len() + ); + break Some(realm.clone()); + } + } + } + } + }; let world_addr = realm .as_ref() .map(|r| r.address.clone()) @@ -545,13 +671,22 @@ fn run( // meanwhile, the login below fails → the caller cycles → a fresh roster → the app auto-resends // its pick — self-healing, no keep-alive needed (decision 0193). let guid = loop { - let Ok(req) = pick_rx.recv() else { + // Named `pick` rather than `req`: the cycle's own `req` (the credentials) is still live + // here and Change Realm hands it forward. + let Ok(pick) = pick_rx.recv() else { return Ok(Cycle::Exit); }; - let (action, code) = match req { + let (action, code) = match pick { CharRequest::Enter(guid) => break guid, // Select's Back (decision 0539): drop the parked session, return to the login park. CharRequest::Abandon => return Ok(Cycle::Repark), + // Select's **Change Realm**: drop the parked world session but keep the logon, so the + // next cycle reopens the realm list instead of the login screen. The realm list is + // re-published from there; nothing here needs to say so. + CharRequest::ChangeRealm => { + *held = Some(HeldLogon { req, logon }); + return Ok(Cycle::Repark); + } CharRequest::Create(create) => (CharAction::Create, session.create_character(&create)?), CharRequest::Delete(target) => (CharAction::Delete, session.delete_character(target)?), }; diff --git a/crates/benilla-app/src/net/motion/relay.rs b/crates/benilla-app/src/net/motion/relay.rs index 0485aa0e..20303cb1 100644 --- a/crates/benilla-app/src/net/motion/relay.rs +++ b/crates/benilla-app/src/net/motion/relay.rs @@ -47,6 +47,29 @@ pub(crate) struct RelayMove { /// `MSG_MOVE_HEARTBEAT` — excluded from the pre-fire reconcile lerp (the reference's `0x619090` /// skips tag `0x26`); it applies as an outright snap. pub(crate) heartbeat: bool, + /// `MSG_MOVE_TELEPORT` — somebody else blinked (decision 2061). Excluded from the pre-fire + /// reconcile for the opposite reason to a heartbeat's: not "the dead-reckon has already + /// converged" but "there is nothing to converge to". The pose is a **discontinuity**; lerping + /// a mover toward it would drag them across the gap the teleport exists to skip, sweeping the + /// capsule through every wall in between. + pub(crate) teleport: bool, +} + +impl RelayMove { + /// **Does this queued move arm the pre-fire reconcile?** The two blends + /// ([`super::remote::facing_lerp`] and the position lerp) exist to make the pose a queued move + /// carries land *smoothly* at its fire-time, so both are skipped for the two kinds of move that + /// smoothing cannot help — for opposite reasons: + /// + /// - a **heartbeat** needs no blend: the scheduled dead-reckon has structurally converged on it + /// by the fire-time, and the reference skips it explicitly (tag `0x26` at `0x619030 + /// @0x61904b` / `0x619090 @0x6190bb`; decisions 0601/0603); + /// - a **teleport** has nothing to blend *to*: its position is a discontinuity, and lerping the + /// mover into it would walk them — swept capsule and all — across the gap the teleport exists + /// to skip (decision 2061). + pub(crate) fn reconciles(&self) -> bool { + !self.heartbeat && !self.teleport + } } /// One **scheduled** relayed move — the reference's queued move-event node (`0x617570`: fire-time at diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index 6ea7df0e..1c84cb5b 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -127,7 +127,16 @@ pub(in crate::net) fn trace_relay( return; } let lead = chain.lead_ms(now_ms); - let kind = if mv.heartbeat { "hb" } else { "tr" }; + // `tp` is its own tag, not folded into `tr`: a mover that appears in the wrong place is the + // one report where "was there a teleport?" is the whole question, and the trace has to answer + // it without a second run (decision 2061). + let kind = if mv.heartbeat { + "hb" + } else if mv.teleport { + "tp" + } else { + "tr" + }; benilla_assets::trace::line( "rly", &format!( @@ -666,14 +675,18 @@ pub(in crate::net) fn extrapolate_remote_units( // blend the SIMULATED pose so it lands on the event position at the fire-time. // A heartbeat is excluded from both arms (`0x619030 @0x61904b` / `0x619090 @0x6190bb` // skip tag 0x26) — it snaps at fire, and by then the scheduled dead-reckon has - // structurally converged. + // structurally converged. **A teleport is excluded too** (decision 2061), for the + // opposite reason: not "already converged" but "nothing to converge to". Both blends are + // built to make a *continuous* pose land smoothly; a blink is a discontinuity, and + // blending toward it walks the mover — swept capsule and all — across the very gap the + // teleport skips. if let Some(ev) = rm.pending.front() { let remaining_s = ((ev.fire_ms - now_ms) / 1000.0) as f32; // A heartbeat is excluded from BOTH pre-fire blends — the reference's facing arm // `0x619030` skips tag 0x26 exactly as the position arm `0x619090` does (wow-re // `remote-air-facing.md`, decision 0603) — so it applies as an outright snap at // fire; the smoothed facings are the transition/SET_FACING family's. - if remaining_s > 0.0 && !ev.mv.heartbeat { + if remaining_s > 0.0 && ev.mv.reconciles() { orientation = facing_lerp(orientation, ev.mv.orientation, dt, remaining_s); // Predict from the pre-frame state to the fire-time (this frame's dt + what's left). let (predicted, ..) = rm.advance(s, dt + remaining_s); diff --git a/crates/benilla-app/src/net/motion/tests.rs b/crates/benilla-app/src/net/motion/tests.rs index 7cb24ace..79b212ed 100644 --- a/crates/benilla-app/src/net/motion/tests.rs +++ b/crates/benilla-app/src/net/motion/tests.rs @@ -100,6 +100,7 @@ fn a_relayed_swimmer_renders_pitched_and_the_gates_render_level() { jump: None, transport: None, heartbeat: false, + teleport: false, }; // Through the real arrival path, not a hand-set field: `apply_move` is what the relay and // the queue drain both go through, so this is the seam that would drop the pitch. @@ -891,6 +892,7 @@ fn replay_frames(script: &[(u32, f64, u32)]) -> (Vec, u32) { jump: None, transport: None, heartbeat: false, + teleport: false, }; let (live, empty) = (rm.flags, rm.pending.is_empty()); let fire_ms = rm.relay.schedule(wire_ms, now, live, empty); @@ -1191,3 +1193,127 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() { "a mover carrying a direction bit is integrated and resolved against the world" ); } + +// ── The observer leg of the movement-mode family (decision 2061) ────────────────────────────── + +/// Build the `RelayMove` the six observer opcodes decode to — an ordinary relay carrying whatever +/// flags word the server wrote, since apply/unapply rides that word and not the opcode. +fn observed(flags: u32, position: [f32; 3], teleport: bool) -> super::relay::RelayMove { + super::relay::RelayMove { + wire_ms: 1000, + position, + orientation: 0.0, + flags, + pitch: 0.0, + fall_time: 0, + jump: None, + transport: None, + heartbeat: false, + teleport, + } +} + +/// Run one relayed move through the real arrival path (`apply_move` — the seam the relay and the +/// queue drain both go through), starting from `before`. +fn apply_observed(before: RemoteMotion, mv: &super::relay::RelayMove) -> RemoteMotion { + use bevy::ecs::system::RunSystemOnce; + let mv = mv.clone(); + let mut world = bevy::prelude::World::new(); + world.init_resource::>(); + let e = world.spawn_empty().id(); + world + .run_system_once( + move |mut commands: bevy::prelude::Commands, + mut landings: bevy::prelude::MessageWriter| { + let mut rm = before.clone(); + super::remote::apply_move(e, &mv, &mut rm, 0.0, &mut commands, &mut landings); + rm + }, + ) + .expect("the one-shot apply runs") +} + +/// **A watched player's root actually stops them** (decision 2061) — the reported "he keeps sliding +/// after the root lands". +/// +/// The mechanism is not ours: the rooted player's OWN client wipes its direction bits when it +/// applies `SetRoot 0x7c7340` (`& 0xffe07f00`), acks with that wiped word, and vmangos stores the +/// ack verbatim (`HandleMoverRelocation`: `pMover->m_movementInfo = movementInfo`, forcing +/// `MOVEFLAG_ROOT` back on) before broadcasting it to observers as `MSG_MOVE_ROOT`. So the correct +/// receiver is the plain one — fold the whole word — and the ONLY thing that was wrong was that we +/// never parsed the packet. This pins the consequence: after it, nothing the integration gate tests +/// survives, which is what stops the dead-reckon. +#[test] +fn an_observed_root_lands_the_wiped_word_and_stops_the_dead_reckon() { + let walking = motion(move_flags::FORWARD, 0.0); + assert_ne!( + walking.flags & move_flags::INTEGRATED, + 0, + "precondition: this mover is being stepped" + ); + + // What vmangos actually broadcasts: ROOT set, the direction bits gone. + let rooted = apply_observed(walking, &observed(move_flags::ROOT, [0.0; 3], false)); + + assert_eq!( + rooted.flags & move_flags::INTEGRATED, + 0, + "nothing the integration gate tests survives the root — this is what stops the slide" + ); + assert_ne!( + rooted.flags & move_flags::ROOT, + 0, + "and the bit itself lands" + ); +} + +/// **Levitate reaches an observer** (decision 2061, with 1706's three-at-once): `SPELL_AURA_HOVER`, +/// `_FEATHER_FALL` and `_WATER_WALK` are granted together, each broadcast on its own observer +/// opcode, and each carries the *whole* `m_movementInfo` flags word — so the last one to arrive +/// holds all three bits. The extrapolator's ground resolve reads exactly this word (unioned with +/// the `SMSG_SPLINE_MOVE_*` component) for its hover offset and water plane, so landing the word IS +/// landing the effect. +#[test] +fn an_observed_levitate_lands_all_three_granted_bits() { + let trio = move_flags::HOVER | move_flags::SAFE_FALL | move_flags::WATER_WALKING; + let floating = apply_observed(motion(0, 0.0), &observed(trio, [0.0; 3], false)); + assert_eq!( + floating.flags, trio, + "hover + feather fall + water walk, from one broadcast word" + ); + + // …and the un-levitate is the same word with the bits gone — no opcode says "unapply". + let landed = apply_observed(floating, &observed(0, [0.0; 3], false)); + assert_eq!(landed.flags, 0, "the revoke is the absence of the bits"); +} + +/// **A blink is a discontinuity, and the pre-fire reconcile must not blend into it** (decision +/// 2061). Both blends exist to land a *continuous* pose smoothly; armed on a teleport they would +/// drag the mover across the 20 yards the Blink exists to skip — sweeping the shared capsule +/// through every wall in between — which is the same "he slid there" symptom, wearing a new hat. +/// +/// The pose itself still applies in full: a teleport is excluded from the *blend*, never from the +/// apply. +#[test] +fn a_teleport_is_excluded_from_the_pre_fire_reconcile_but_not_from_the_apply() { + let dest = [20.0, 5.0, 3.0]; + let tp = observed(move_flags::FORWARD, dest, true); + let hb = super::relay::RelayMove { + heartbeat: true, + ..observed(move_flags::FORWARD, dest, false) + }; + let walk = observed(move_flags::FORWARD, dest, false); + + assert!(!tp.reconciles(), "a blink is not blended toward"); + assert!(!hb.reconciles(), "nor is a heartbeat (decision 0601)"); + assert!( + walk.reconciles(), + "…but an ordinary transition still is — the smoothing this gate protects" + ); + + let there = apply_observed(motion(move_flags::FORWARD, 0.0), &tp); + assert_eq!( + there.wow_pos, dest, + "the teleport pose lands outright: excluded from the blend, never from the apply" + ); +} diff --git a/crates/benilla-app/src/realm_select/input.rs b/crates/benilla-app/src/realm_select/input.rs new file mode 100644 index 00000000..b9cbc44f --- /dev/null +++ b/crates/benilla-app/src/realm_select/input.rs @@ -0,0 +1,167 @@ +//! The realm list's input — the reference's `RealmSelectButton_OnClick`/`OnDoubleClick`, +//! `RealmList_OnOk`/`OnCancel`, `RealmListTab_OnClick`, `SortRealms`, and `RealmList_OnKeyDown`. + +use bevy::input::mouse::MouseWheel; +use bevy::prelude::*; + +use crate::char_select::ClientState; +use crate::net::{RealmChoice, RealmRequest}; +use crate::sound::GlueSound; + +use super::screen::{RealmAction, MAX_ROWS}; +use super::{answer, is_down, Realms}; + +/// The double-click window — the same conventional interval the select screen uses. +const DOUBLE_CLICK_SECS: f32 = 0.4; + +/// Clicks: a row selects (a second one enters), the column headers sort, Okay enters, Cancel and +/// the close X leave. +#[allow(clippy::too_many_arguments)] +pub(super) fn clicks( + buttons: Query<(Entity, &RealmAction)>, + hits: Res, + mut realms: ResMut, + choice: Res, + mut next: ResMut>, + mut sounds: MessageWriter, + time: Res