From 0d5ff18fa2c2898eccf684cb3f06e0407112bd18 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 8 Sep 2026 16:06:45 +0700 Subject: [PATCH] the player picks the realm, and a harmful spell flinches its victim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the player picks the realm: benilla connected to whichever realm the auth server listed first — not a policy but the absence of one — with the reference's Change Realm button drawn permanently disabled and the character screen's banner reading "Connecting…", a string 1.12 does not have, invented because the realm only ever arrived as a passenger on the character list (`CharacterSelect.lua` l.66 ends that ladder with `CharSelectRealmName:Hide()` — no name, no banner); `read_realm_list` had read the flags byte, the category byte and the realm id into `_` locals and stringified the population on arrival, and all four are load-bearing; the IO thread gains a third park — logon → which realm? → world handshake → which character — publishing `SessionEvent::RealmList` and blocking on `RealmRequest::{Enter, Refresh, Abandon}` exactly as at the other two, `Enter` carrying the realm's name and not its index since the list is re-requested every 5 s (`RealmList.lua`'s `REALM_LIST_REFRESH_TIME`) off a realmd socket `Logon` now keeps; Change Realm hands the logon forward (`HeldLogon`) so a different realm costs a world dial, not a re-authentication, and a deliberate ask arms `Realms::forced` for exactly one list, because the realm remembered from last time is precisely the realm you are standing on and asking to leave — without it the button re-entered the same realm and appeared to do nothing; the auto-answer ladder is a pure `auto_answer` — `WOW_REALM`, then the remembered `realmName` CVar (which `set_realm_name` had poked into its slot in place, never reaching `cvar_changes`, so the persisted CVar whose whole job is "the last realm connected to" was absent from disk forever; it writes through `set_from_engine` now), then an unattended run takes the first realm that is up, every arm requiring the realm to be up — and the screen is `RealmList.xml` rebuilt on the same 640×512 HelpFrame plate the AddOns list uses, eighteen rows at the authored 20 px pitch, four sortable columns; the Population word is a BAND, not the float: `realm_load_stats` (0x46e510) takes the mean and `sqrt(Σ(pop − mean)² / (n − 1)) · 0.6`, `realm_load_classify` (0x46ec60) reads the sentinels first (0x20 → Recommended, 0x40 → New, 0x80 → Full) then strict `pop < mean − stddev` → Low, `pop > mean + stddev` → High, else Medium — so the same realm reads High on a quiet list and Medium on a busy one, and a lone realm is banded against the `n ≤ 1` short circuit's literal 1.0, so a local vmangos realm at 0.06 reads Low, not Medium; then wow-re's cross-check of `RealmList.cpp [0x46e3bf, 0x46fa05)` confirmed 0x01 = invalid / 0x02 = offline and that the real client does auto-connect by `realmName` without raising the list (`0x5ab800` resolves the stored name against the fresh list, its success leg calling no `SetStatus`), and corrected four things the first cut shipped: the statistics are GLOBAL — `0x46e510` walks the flat all-categories array unconditionally (`0x46e5ee fadd [esi+0x12c]`), the per-category bucketing in the same loop a side effect and never a bound, so scoping them to the tab made the same realm read a different word per tab; Recommended/New/Full never travel as flag bits at all — the parser recognises magic populations by bit pattern and rewrites them, 600.0 → 0.0 + 0x20, 200.0 → 0.001 + 0x40, 400.0 → 8.0 + 0x80, and it has to happen in `read_realm_list` before anything averages, since one realm advertised Recommended arriving as 600.0 drags the global mean past every other row; pvp/rp is a `Cfg_Configs.dbc` join on the type dword at `[realm+0x04]`, and the shipped rows make types 3 and 5 PvP and 7 RP where the 1/6/8 match had all three Normal; and the default sort is `[characters, load, name, mode]`, all ascending, written once per process by `0x46e300` — a four-key fallthrough where "ascending" characters means higher count first, a column click being move-to-front (`0x46e9b0`) that keeps the column's direction unless it was already in front — with the reference's non-antisymmetric characters comparator (its tie-breaking `strcmp` result clobbered one instruction later at `0x46e846`) deliberately not reproduced; `RealmWizard`, the `REALM_IS_FULL` confirm and the category tab strip are deliberately not built, each for a stated reason (2056, 2062) - a re-played effect REPLACES the same record at the same attach point: the director's report that the reference draws far fewer impact effects when many mobs beat on one warrior — and named the star burst on both hands on every crit — is not a limiter but a replace, recorded in wow-re for weeks: `CEffect::AddEffect` (0x61fdd0), the path every kit slot and the melee blood spurt take, opens with `0x6208e0(owner, rec, tag)`, which walks the owner's `+0xb4` list and destroys every node carrying the same `SpellVisualEffectName` record at the same attach tag (bounded `[0x6208e0, 0x62092c]`), skipping `tag == -1`; benilla replaced only persistent instances, on the narrower `(spell_id, class)` key of the spell-id reap `0x614150`, and pushed every one-shot unconditionally — a gap seen and scoped to one caller four times (0850, 0927, 1149, 1518) without being generalised — so five attackers landing swings on one body held five blood spurts at `(UnitBlood, 0xf)` where the reference holds one, linear in the attacker count on additively-blended emitters where the reference's is flat, and near-identical in a 1v1, which is why it survived; the sharpest case is Flurry (12966–12970), applied on every critical strike, whose visual 2759 hangs record 416 — `Spells\Strike_Impact_Chest.mdx`, the gold chest-impact flare reused at the hand tags 0x15/0x16 — on both hands via cast kit 3010 over a persistent `BloodLust_State_Hand` glow from state kit 3009, and every crit stacked another pair; `spell_fx::replace_same_slot` now runs per slot at every Begin for persistent and self-terminating instances alike, keyed on the RECORD id threaded through the fx lane (`FxSlot { tag, effect, path }`, the hardcoded map becoming name → (id, path)) and not the path, because two rows may name one .mdx and the reference lets those coexist, with both exclusions kept — the world plant and an already-decaying node — and four tests pinning the key's two halves and both; the other half of the report, that the burst is too large and too long, is a separate mechanism and is not touched — and the instrument that would have measured it was lying: `m2part` read a burst emitter's `peak_rate()` as its particle count, while the reference fires on the rising edge of `enabled != 0 && rate > 0` sampled on one clock, and `Strike_Impact_Chest.m2`'s gold flare steps its gate 1 → 0 on the very keyframe its rate steps 0 → 50, so the two are never true together and that emitter emits NOTHING; the tool printed "burst of ~50 particles, life 0.50s" and a diagnosis started 2.7× over on count and 1.7× over on duration off that one line; `EmitTiming::first_burst` walks the slot's keyed span at 60 Hz and returns the first instant the predicate holds or `None`, the dump prints the real count and time or `NEVER FIRES`, and a real-asset test pins that model's emitter #0 as never and #1 as exactly 30 (2057) - a harmful spell's impact flinches its victim: benilla had one spell-side wound path, `play_kit`'s `[8,10]` branch — the kit player's own `0x60f3ad` test — carrying the KIT's anim id, so a spell whose impact kit names no wound anim, which is nearly every one (Fireball, Frostbolt, Shadow Bolt, Sunder Armor, a mob's Strike), twitched nothing, and a kit naming CombatCritical played 10 where the client plays 9 or 8; `0x60ea70` has six callers, not the three an earlier sweep found (`grep "call 0x60ea70"` over the full objdump was the one command it never ran): the missile hand-off `0x61dc50` lays the flinch before the impact kit for every living target (`0x61dc74`, HEALTH > 0; the already-dead arm `0x60ea30` plays Death/SwimDeath instead), the instant-hit ImpactKit loop `0x6e8bf0` lays it after the kit when `0x6ea280(SpellRec) == 2` (`0x6e8c89`), the reflect kit `0x6e8cb0` does the same on the caster, and every spell-side call pushes severity 0, so the id is always `0x60ea70`'s own `(severity, engaged)` pick — CombatWound(9) engaged, StandWound(8) otherwise — never the kit column; `0x6ea280` is the spell's hostility class and `== 2` ⇔ the spell targets enemies: Targets bit `0x100` → never, `0x80` → yes, else `EffectImplicitTargetA[i]` then `B[i]` for effects 0..3 indexed into the byte tables `0x6ea338`/`0x6ea378`, whose case-0 ids `{2, 6, 15, 16, 24, 28, 53, 54}` are exactly the modern enum's `*_ENEMY` set; so `WoundAnim { entity }` loses its id and the driver resolves every spell edge as `wound_anim(0, engaged)`, `play_impact` takes an `ImpactPlay` with `flinch` — the instant hits pass `SpellDisplay::is_harmful()`, a missile arrival passes true — `SpellDisplay` gains `effect_implicit_target_a/b` (columns 82–84 / 85–87) and `is_harmful()`, and the flinch is laid as the same secondary-blend overlay as a melee wound, over a cast, a swing or a run, never replacing them; pinned against the real 5875 `Spell.dbc` — Fireball, Charge and Sunder Armor harmful through A, Frost Nova through B alone, Renew, Healing Touch, Arcane Intellect and Battle Shout not — and the driver never picks CombatCritical for a spell edge; `WOW_MOVE_TRACE` gains `fct wound trigger/evict/expire` lines, since the whole flinch lifecycle was invisible in a trace; and the report this was chased under — the warrior's swings reading as cut short by incoming impacts in a many-mob brawl — stays open, with one reference gate found on the way that benilla does not model: `0x60ea70` aborts while any attached CEffect on the victim carries `+0x2c & 0x4` (`0x60eab6`), and which kit-spawned nodes carry that bit is the question dispatched to wow-re (2058) - other players' roots, levitation and blinks now relay to onlookers: a report that a nearby player getting rooted, water-walking or Blinking slid or stood in the wrong place until their next movement packet was exactly right — a movement mode has three legs and benilla carried two, the mover's own acked handshake (`SMSG_FORCE_MOVE_ROOT → CMSG_FORCE_MOVE_ROOT_ACK`, 0866) and the server-driven `SMSG_SPLINE_MOVE_*` twelve (1780), while the third, what observers are told once the mover acks (vmangos `MovementPacketSender.h:30-60`, broadcast by `SendMovementFlagChangeToObservers` / `SendTeleportToObservers`), fell through `parse_server` to `ServerPacket::Other` and was dropped — so a rooted player kept sliding for the ~500 ms to their next heartbeat, a Levitate on someone else was never rendered, and a Blink left them standing at the old spot; six opcodes — `MSG_MOVE_ROOT`/`UNROOT` (236/237), `MSG_MOVE_HOVER` (247), `MSG_MOVE_FEATHER_FALL` (688), `MSG_MOVE_WATER_WALK` (689), `MSG_MOVE_TELEPORT` (197) — all the ordinary `[packed guid][MovementInfo]` shape and all sharing the client's one relay handler `0x603bb0` with the 22 already carried (wow-re's opcode-handler table has thirty rows on it; the remaining two are cheat opcodes vmangos never constructs), so admitting them to `is_movement_relay` is the whole fix for five: apply/unapply rides the flags word rather than the opcode, since vmangos runs `SetHoverReal` &co. before the broadcast and the word on the wire already carries the bit's new state, and `apply_move` already commits it wholesale into the union the ground resolve reads for hover's offset and the water-walk plane — the pose the packet carries beside the mode is half the value, a rooted player's stopping place being news too, and the root arrives already post-wipe with `MOVEFLAG_ROOT` forced back on, so no wipe of ours is invented; the teleport is a relay in shape and a discontinuity in meaning, so `RelayMove::reconciles()` excludes it from the pre-fire reconcile beside the heartbeat, for the opposite reason — the heartbeat because the dead-reckon has already converged, the teleport because there is nothing to converge to, and lerping toward a Blink's destination would drag the mover across the twenty yards the Blink exists to skip, sweeping the shared capsule through every wall between — while it still rides the unit's relay chain rather than snapping at arrival, because replay is paced against the sender's own stamps and applying it early would put the player at the destination while their walk to the launch point was still replaying; a `tp` tag joins `hb`/`tr` on the `rly` trace line; and parse and decode of all six are pinned against golden bodies asserted four bytes shorter than the counter-carrying `MSG_MOVE_TELEPORT_ACK` form, so a counter is provably not being read (2061) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/char_select/input.rs | 13 + crates/benilla-app/src/char_select/mod.rs | 8 +- crates/benilla-app/src/char_select/refresh.rs | 26 +- crates/benilla-app/src/char_select/screen.rs | 11 +- crates/benilla-app/src/creature_anim.rs | 21 +- crates/benilla-app/src/creature_anim/blood.rs | 18 +- .../benilla-app/src/creature_anim/breath.rs | 10 +- .../benilla-app/src/creature_anim/driver.rs | 19 +- .../src/creature_anim/driver/tests.rs | 77 ++- .../src/creature_anim/driver/wound.rs | 45 +- .../src/creature_anim/spell_visual.rs | 139 +++- .../src/creature_anim/spell_visual/tests.rs | 31 +- .../benilla-app/src/entities/spell_fx/mod.rs | 183 ++++- crates/benilla-app/src/glue/art.rs | 12 +- crates/benilla-app/src/lib.rs | 4 + crates/benilla-app/src/net.rs | 37 ++ crates/benilla-app/src/net/apply.rs | 15 +- crates/benilla-app/src/net/io.rs | 203 +++++- crates/benilla-app/src/net/motion/relay.rs | 23 + crates/benilla-app/src/net/motion/remote.rs | 19 +- crates/benilla-app/src/net/motion/tests.rs | 126 ++++ crates/benilla-app/src/realm_select/input.rs | 167 +++++ crates/benilla-app/src/realm_select/load.rs | 344 ++++++++++ crates/benilla-app/src/realm_select/mod.rs | 626 ++++++++++++++++++ crates/benilla-app/src/realm_select/screen.rs | 557 ++++++++++++++++ .../src/bin/benilla-extract/m2dump.rs | 13 +- crates/benilla-formats/src/emit_timing.rs | 41 ++ .../benilla-formats/src/spell_visual/mod.rs | 39 +- .../benilla-formats/src/spell_visual/tests.rs | 4 +- .../src/spells/catalog_tests.rs | 46 ++ crates/benilla-formats/src/spells/display.rs | 63 ++ crates/benilla-formats/src/spells/mod.rs | 11 + .../tests/particle_burst_gate.rs | 61 ++ crates/benilla-protocol/src/auth.rs | 128 +++- .../benilla-protocol/src/bin/benilla-auth.rs | 13 +- crates/benilla-protocol/src/events.rs | 19 + crates/benilla-protocol/src/events/decode.rs | 2 + crates/benilla-protocol/src/lib.rs | 85 ++- .../benilla-protocol/src/messages/opcode.rs | 39 ++ crates/benilla-protocol/src/messages/parse.rs | 29 +- crates/benilla-protocol/tests/movement.rs | 114 ++++ crates/benilla-ui/src/script/client.rs | 14 +- 42 files changed, 3275 insertions(+), 180 deletions(-) create mode 100644 crates/benilla-app/src/realm_select/input.rs create mode 100644 crates/benilla-app/src/realm_select/load.rs create mode 100644 crates/benilla-app/src/realm_select/mod.rs create mode 100644 crates/benilla-app/src/realm_select/screen.rs create mode 100644 crates/benilla-formats/tests/particle_burst_gate.rs 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