the player picks the realm, and a harmful spell flinches its victim
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ pub(super) fn select_input(
|
||||
clicks: Res<crate::glue::GlueClicks>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut roster: ResMut<Roster>,
|
||||
mut realms: ResMut<crate::realm_select::Realms>,
|
||||
pick: Res<CharPick>,
|
||||
mut dialog: ResMut<DeleteDialog>,
|
||||
mut panel: ResMut<super::addons::AddonsPanel>,
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Vec<(OneShotReq, u64)>> = 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<WoundEdge> = 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,
|
||||
|
||||
@@ -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::<AnimDriver>()
|
||||
.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
|
||||
|
||||
@@ -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)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SpellKitFx> {
|
||||
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<FxSlot>,
|
||||
},
|
||||
/// 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<FxSlot> {
|
||||
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<u32>,
|
||||
/// 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<u32>,
|
||||
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<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
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(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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::<SpellKitFx>();
|
||||
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<super::FxSlot> {
|
||||
let mut out = Vec::new();
|
||||
let world = app.world_mut();
|
||||
let mut msgs = world.resource_mut::<Messages<SpellKitFx>>();
|
||||
@@ -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
|
||||
|
||||
@@ -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<FxInstance>,
|
||||
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::<WowModelMaterial>()
|
||||
.init_asset::<benilla_assets::M2Model>()
|
||||
.init_resource::<SpellFx>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.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::<Messages<SpellKitFx>>()
|
||||
.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::<FxAttached>()
|
||||
.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::<FxAttached>()
|
||||
.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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,13 @@ pub(crate) struct GlueArt {
|
||||
pub(crate) dropdown_arrow_up: Option<Handle<Image>>,
|
||||
pub(crate) dropdown_arrow_down: Option<Handle<Image>>,
|
||||
pub(crate) quest_hilight: Option<Handle<AddUiMaterial>>,
|
||||
/// 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<Handle<Image>>,
|
||||
pub(crate) check_disabled: Option<Handle<Image>>,
|
||||
pub(crate) tooltip_border: Option<BackdropEdges>,
|
||||
pub(crate) char_scrollbar: Option<(Handle<Image>, 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(),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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::<PetTalkMessage>()
|
||||
.add_message::<PetDismissSoundMessage>()
|
||||
.add_message::<WorldportMessage>()
|
||||
.add_message::<RealmListMessage>()
|
||||
.add_message::<CharListMessage>()
|
||||
.add_message::<CharActionResultMessage>()
|
||||
.add_message::<EnteredWorldMessage>()
|
||||
@@ -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<RealmRequest>);
|
||||
|
||||
/// 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<benilla_protocol::RealmInfo>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -123,7 +123,13 @@ pub(crate) fn apply_net_updates(
|
||||
session_msgs: (
|
||||
MessageWriter<TeleportMessage>,
|
||||
MessageWriter<WorldportMessage>,
|
||||
MessageWriter<CharListMessage>,
|
||||
// **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<CharListMessage>,
|
||||
MessageWriter<crate::net::RealmListMessage>,
|
||||
),
|
||||
MessageWriter<CharActionResultMessage>,
|
||||
MessageWriter<EnteredWorldMessage>,
|
||||
MessageWriter<LoggedOutMessage>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u64>) {
|
||||
/// 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<LoginRequest>,
|
||||
pub(super) realm_rx: Receiver<RealmRequest>,
|
||||
pub(super) pick_rx: Receiver<CharRequest>,
|
||||
}
|
||||
|
||||
/// 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<SessionEvent>,
|
||||
pub(super) commands: Sender<ClientCommand>,
|
||||
pub(super) pick: Sender<CharRequest>,
|
||||
pub(super) realm: Sender<RealmRequest>,
|
||||
pub(super) login: Sender<LoginRequest>,
|
||||
pub(super) login_abandon: Arc<AtomicU64>,
|
||||
pub(super) ping: Arc<Mutex<PingClock>>,
|
||||
@@ -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::<CharRequest>();
|
||||
let (realm_tx, realm_rx) = crossbeam_channel::unbounded::<RealmRequest>();
|
||||
let (login_tx, login_rx) = crossbeam_channel::unbounded::<LoginRequest>();
|
||||
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<HeldLogon> = 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<SessionEvent>,
|
||||
writer_tx: &Sender<WorldWriter>,
|
||||
pick_rx: &Receiver<CharRequest>,
|
||||
login_rx: &Receiver<LoginRequest>,
|
||||
parks: &Parks,
|
||||
abandon: &AtomicU64,
|
||||
ping_clock: &Mutex<PingClock>,
|
||||
held: &mut Option<HeldLogon>,
|
||||
) -> Result<Cycle> {
|
||||
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::<benilla_protocol::DialFailure>() {
|
||||
return fail_dial(dial.clone(), format!("{e:#}"));
|
||||
}
|
||||
let refusal = e
|
||||
.downcast_ref::<AuthReject>()
|
||||
.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::<benilla_protocol::DialFailure>() {
|
||||
return fail_dial(dial.clone(), format!("{e:#}"));
|
||||
}
|
||||
let refusal = e
|
||||
.downcast_ref::<AuthReject>()
|
||||
.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)?),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>, 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::<bevy::ecs::message::Messages<crate::creature_anim::HardLanding>>();
|
||||
let e = world.spawn_empty().id();
|
||||
world
|
||||
.run_system_once(
|
||||
move |mut commands: bevy::prelude::Commands,
|
||||
mut landings: bevy::prelude::MessageWriter<crate::creature_anim::HardLanding>| {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<crate::glue::GlueClicks>,
|
||||
mut realms: ResMut<Realms>,
|
||||
choice: Res<RealmChoice>,
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
mut sounds: MessageWriter<GlueSound>,
|
||||
time: Res<Time>,
|
||||
mut last_click: Local<Option<(String, f32)>>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
let mut enter = false;
|
||||
let mut cancel = false;
|
||||
for (entity, action) in &buttons {
|
||||
if !hits.hit(entity) {
|
||||
continue;
|
||||
}
|
||||
match *action {
|
||||
RealmAction::Row(row) => {
|
||||
let Some(name) = realm_at(&realms, row) else {
|
||||
continue;
|
||||
};
|
||||
// `RealmSelectButton_OnClick` also resets the refresh timer — a player working
|
||||
// down the list should not have it re-sort under them every five seconds.
|
||||
realms.refresh_in = super::REFRESH_SECS;
|
||||
let double = last_click
|
||||
.as_ref()
|
||||
.is_some_and(|(n, at)| *n == name && now - at < DOUBLE_CLICK_SECS);
|
||||
*last_click = Some((name.clone(), now));
|
||||
realms.select(&name);
|
||||
if double {
|
||||
enter = true;
|
||||
}
|
||||
}
|
||||
RealmAction::Ok => enter = true,
|
||||
RealmAction::Cancel => cancel = true,
|
||||
// `realm_set_primary_key` (0x46e9b0) — move-to-front, and the clicked column keeps
|
||||
// the direction it already had unless it was already primary. See `Sort::click`.
|
||||
RealmAction::Sort(key) => realms.sort.click(key),
|
||||
}
|
||||
}
|
||||
if enter {
|
||||
try_enter(&mut realms, &choice, &mut sounds);
|
||||
}
|
||||
if cancel {
|
||||
// `RealmList_OnCancel`. Cancelling the realm list drops the logon: there is no screen
|
||||
// between here and the login one.
|
||||
sounds.write(GlueSound("gsLoginChangeRealmCancel"));
|
||||
let _ = choice.0.send(RealmRequest::Abandon);
|
||||
next.set(ClientState::Login);
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmList_OnKeyDown` (ESCAPE / ENTER), plus arrow-key row cycling and the wheel.
|
||||
pub(super) fn keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut wheel: MessageReader<MouseWheel>,
|
||||
mut realms: ResMut<Realms>,
|
||||
choice: Res<RealmChoice>,
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
mut sounds: MessageWriter<GlueSound>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
sounds.write(GlueSound("gsLoginChangeRealmCancel"));
|
||||
let _ = choice.0.send(RealmRequest::Abandon);
|
||||
next.set(ClientState::Login);
|
||||
return;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter) {
|
||||
try_enter(&mut realms, &choice, &mut sounds);
|
||||
return;
|
||||
}
|
||||
|
||||
let rows = realms.rows();
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let back = keys.just_pressed(KeyCode::ArrowUp);
|
||||
let fwd = keys.just_pressed(KeyCode::ArrowDown);
|
||||
if back || fwd {
|
||||
let cur = realms
|
||||
.selected()
|
||||
.and_then(|sel| rows.iter().position(|&i| realms.realms[i].name == sel.name))
|
||||
.unwrap_or(0);
|
||||
let n = rows.len();
|
||||
let to = if back {
|
||||
(cur + n - 1) % n
|
||||
} else {
|
||||
(cur + 1) % n
|
||||
};
|
||||
let name = realms.realms[rows[to]].name.clone();
|
||||
realms.select(&name);
|
||||
scroll_into_view(&mut realms, to, n);
|
||||
}
|
||||
|
||||
// The wheel scrolls the window over the list, in the reference's own 16 px steps translated
|
||||
// back to rows (`RealmListScrollFrame_OnVerticalScroll` divides the bar value by
|
||||
// `REALM_BUTTON_HEIGHT`, so one notch is one row).
|
||||
let mut notches = 0i32;
|
||||
for ev in wheel.read() {
|
||||
notches -= ev.y.signum() as i32;
|
||||
}
|
||||
if notches != 0 {
|
||||
let max = rows.len().saturating_sub(MAX_ROWS);
|
||||
let next_off = (realms.offset as i32 + notches).clamp(0, max as i32) as usize;
|
||||
realms.offset = next_off;
|
||||
}
|
||||
}
|
||||
|
||||
/// The realm on a given **screen** row, honouring the scroll offset.
|
||||
fn realm_at(realms: &Realms, row: usize) -> Option<String> {
|
||||
let rows = realms.rows();
|
||||
rows.get(realms.offset + row)
|
||||
.map(|&i| realms.realms[i].name.clone())
|
||||
}
|
||||
|
||||
/// Keep the selected row on screen when the arrows walk off the top or the bottom.
|
||||
fn scroll_into_view(realms: &mut Realms, row: usize, total: usize) {
|
||||
let max = total.saturating_sub(MAX_ROWS);
|
||||
if row < realms.offset {
|
||||
realms.offset = row;
|
||||
} else if row >= realms.offset + MAX_ROWS {
|
||||
realms.offset = (row + 1 - MAX_ROWS).min(max);
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmList_OnOk`: play the click and answer the park.
|
||||
///
|
||||
/// **Deferred: the `REALM_IS_FULL` confirm.** The reference raises a Yes/No dialog first when the
|
||||
/// chosen realm's load band reads `Full` *and* you have no characters on it
|
||||
/// (`GlueDialogTypes["REALM_IS_FULL"]`). Ours enters directly, because the two-button `GlueDialog`
|
||||
/// that would ask lives inside the login screen (`crate::login::screen::spawn_dialog`) and lifting
|
||||
/// it into `crate::glue` is its own change — one this screen should not smuggle in. Shipping the
|
||||
/// check without the dialog would be worse than not having it: OK would silently do nothing.
|
||||
///
|
||||
/// The gap is narrow. `Full` is the `0x80` flag sentinel, which vmangos does not set, so the
|
||||
/// dialog is unreachable against the servers benilla connects to today.
|
||||
fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWriter<GlueSound>) {
|
||||
let Some(realm) = realms.selected() else {
|
||||
return;
|
||||
};
|
||||
if is_down(realm) {
|
||||
return; // the reference disables OK for an offline realm
|
||||
}
|
||||
let name = realm.name.clone();
|
||||
sounds.write(GlueSound("gsLoginChangeRealmOK"));
|
||||
answer(realms, choice, name);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! **The realm list's two computed columns** — the load band and the realm type — and the row
|
||||
//! colours that go with them.
|
||||
//!
|
||||
//! The load word a player reads (`Low` / `Medium` / `High` / `Full` / `New` / `Recommended`) is
|
||||
//! *not* the population the auth server sent. It is a **band**: the client takes the populations of
|
||||
//! **every realm it knows** — all categories, not the selected tab — computes their mean and a
|
||||
//! scaled standard deviation, and places each realm's population in that distribution. So the same realm reads `High` on a quiet
|
||||
//! list and `Low` on a busy one. Anything that printed the raw float would be showing a different
|
||||
//! word from the real client on every list.
|
||||
//!
|
||||
//! **A one-realm list is not a special case that reads `Medium`** — a tempting thing to assume, and
|
||||
//! wrong. The reference short-circuits `n <= 1` to `(mean = 1.0, stddev = 0.0)`, which is a real
|
||||
//! distribution of zero width, so the single realm is banded against the literal 1.0: below it
|
||||
//! reads `Low`, above it reads `High`, and only an exact 1.0 reads `Medium`. Our own vmangos realm
|
||||
//! advertises 0.06 and therefore reads **Low**, which is what the real client would show too.
|
||||
//!
|
||||
//! [`realm_load_stats`] and [`realm_load_classify`] are ports of wow-re's byte-exact
|
||||
//! `crates/glue/src/realm.rs` (`CGlue::RealmLoadStats` `0x46e510` and `CGlue::RealmLoadClassify`
|
||||
//! `0x46ec60`), including their x87 rounding behaviour — the accumulators round to `f32` every
|
||||
//! iteration because the binary stores them to `f32` slots inside the loop, while the mean, the
|
||||
//! sqrt and the band comparisons stay 53-bit. Kept faithful rather than tidied because the band
|
||||
//! edges are exactly where a rounding difference would flip a visible word.
|
||||
//!
|
||||
//! The band → string → colour mapping is the reference `GlueXML/RealmList.lua`'s
|
||||
//! `RealmListUpdate`, read literally.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
// ── The six glue font colours (`GlueXML/GlueFonts.xml` l.4-9) ──────────────────────────────────
|
||||
//
|
||||
// Glue has its own copies of the FrameXML globals; `BLUE_FONT_COLOR` exists ONLY here (FrameXML's
|
||||
// `Fonts.xml` ships five, not six) and the realm list is its one consumer in the whole glue tree.
|
||||
|
||||
/// `NORMAL_FONT_COLOR` — `{1.0, 0.82, 0}`.
|
||||
pub(super) const NORMAL: Color = Color::srgb(1.0, 0.82, 0.0);
|
||||
/// `HIGHLIGHT_FONT_COLOR` — `{1.0, 1.0, 1.0}`.
|
||||
pub(super) const HIGHLIGHT: Color = Color::srgb(1.0, 1.0, 1.0);
|
||||
/// `GRAY_FONT_COLOR` — `{0.5, 0.5, 0.5}`.
|
||||
pub(super) const GRAY: Color = Color::srgb(0.5, 0.5, 0.5);
|
||||
/// `GREEN_FONT_COLOR` — `{0.1, 1.0, 0.1}`.
|
||||
pub(super) const GREEN: Color = Color::srgb(0.1, 1.0, 0.1);
|
||||
/// `RED_FONT_COLOR` — `{1.0, 0.1, 0.1}`.
|
||||
pub(super) const RED: Color = Color::srgb(1.0, 0.1, 0.1);
|
||||
/// `BLUE_FONT_COLOR` — `{0, 0.749, 0.953}`.
|
||||
pub(super) const BLUE: Color = Color::srgb(0.0, 0.749, 0.953);
|
||||
|
||||
/// `[0x8038d8]` = 0.6, the std-dev scale (`realm_load_stats`).
|
||||
const STDDEV_MULT: f32 = f32::from_bits(0x3f19_999a);
|
||||
|
||||
/// `realm_load_stats` (`CGlue::RealmLoadStats`, `0x46e510`): over the `populations` of **every**
|
||||
/// realm, compute `(mean, scaled_stddev)` where `mean = Σpop / n` and `scaled_stddev =
|
||||
/// sqrt(Σ(pop − mean)² / (n − 1)) · 0.6`.
|
||||
///
|
||||
/// The caller passes the flat list deliberately — see [`super::Realms::stats`] for why this is not
|
||||
/// the per-category statistic it looks like.
|
||||
///
|
||||
/// The sum and the variance accumulator are each rounded to `f32` every iteration (the binary
|
||||
/// stores them to `f32` slots in the loop); the mean and std-dev are 53-bit through the
|
||||
/// divide/sqrt/scale and stored as `f32`. With `n ≤ 1` the binary short-circuits to
|
||||
/// `(mean = 1.0, stddev = 0.0)`: a zero-width distribution centred on 1.0, not a "no opinion".
|
||||
pub(super) fn realm_load_stats(populations: &[f32]) -> (f32, f32) {
|
||||
let n = populations.len();
|
||||
if n <= 1 {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
// Σ population — accumulated and stored as f32 each step (`fld sum; fadd pop; fstp sum`).
|
||||
let mut sum: f32 = 0.0;
|
||||
for &pop in populations {
|
||||
sum = (f64::from(sum) + f64::from(pop)) as f32;
|
||||
}
|
||||
// mean = sum / n (`fild n; fdivr sum; fstp mean`).
|
||||
let mean = (f64::from(sum) / n as f64) as f32;
|
||||
// Σ(pop − mean)² — accumulated and stored as f32 each step.
|
||||
let mut acc: f32 = 0.0;
|
||||
for &pop in populations {
|
||||
let d = f64::from(pop) - f64::from(mean);
|
||||
acc = (d * d + f64::from(acc)) as f32;
|
||||
}
|
||||
// stddev = sqrt(acc / (n − 1)) · 0.6 (`fild n-1; fdivr acc; fsqrt; fmul 0.6; fstp stddev`).
|
||||
let stddev = ((f64::from(acc) / (n - 1) as f64).sqrt() * f64::from(STDDEV_MULT)) as f32;
|
||||
(mean, stddev)
|
||||
}
|
||||
|
||||
/// `realm_load_classify` (`CGlue::RealmLoadClassify`, `0x46ec60`): map one realm's `population` to
|
||||
/// the load indicator `GetRealmInfo` returns, given the `mean`/`stddev` from [`realm_load_stats`]
|
||||
/// and the realm's flags byte.
|
||||
///
|
||||
/// Note the three sentinel bits are **not** wire flags: `benilla_protocol`'s parser synthesizes
|
||||
/// them from the magic populations the server sends instead, exactly as the reference's parser
|
||||
/// does, so by the time a realm reaches here the bit is set and the population is the rewritten
|
||||
/// one. See `auth::MAGIC_POPULATIONS`.
|
||||
///
|
||||
/// The three flag sentinels take priority, tested in this order, then the mean±stddev band. The
|
||||
/// band comparisons are 53-bit and **strict** — a population exactly on a band edge reads as
|
||||
/// normal, matching the x87 `fcomp` flag tests.
|
||||
pub(super) fn realm_load_classify(flags: u8, population: f32, mean: f32, stddev: f32) -> f32 {
|
||||
if flags & 0x20 != 0 {
|
||||
return -3.0;
|
||||
}
|
||||
if flags & 0x40 != 0 {
|
||||
return -2.0;
|
||||
}
|
||||
if flags & 0x80 != 0 {
|
||||
return 2.0;
|
||||
}
|
||||
if (f64::from(mean) - f64::from(stddev)) > f64::from(population) {
|
||||
return -1.0;
|
||||
}
|
||||
if (f64::from(mean) + f64::from(stddev)) < f64::from(population) {
|
||||
return 1.0;
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
/// The **Population** column: the authored key and colour for one realm's load indicator.
|
||||
///
|
||||
/// `RealmListUpdate` reads the classifier's float back as a ladder of exact comparisons against the
|
||||
/// sentinels, then signs — so the arms are the reference's, in the reference's order. `down`
|
||||
/// overrides every band: an offline realm reads `Offline` in grey whatever its population says.
|
||||
pub(super) fn load_column(down: bool, load: f32) -> (&'static str, Color) {
|
||||
if down {
|
||||
return ("REALM_DOWN", GRAY);
|
||||
}
|
||||
// The three sentinels are compared for exact equality in the Lua (`load == -3.0`), which is
|
||||
// sound because they are the classifier's own literals travelling unmodified.
|
||||
if load == -3.0 {
|
||||
("LOAD_RECOMMENDED", BLUE)
|
||||
} else if load == -2.0 {
|
||||
("LOAD_NEW", GREEN)
|
||||
} else if load == 2.0 {
|
||||
("LOAD_FULL", RED)
|
||||
} else if load > 0.0 {
|
||||
("LOAD_HIGH", RED)
|
||||
} else if load < 0.0 {
|
||||
("LOAD_LOW", GREEN)
|
||||
} else {
|
||||
("LOAD_MEDIUM", NORMAL)
|
||||
}
|
||||
}
|
||||
|
||||
/// The **Type** column: the authored key and colour for one realm's game type.
|
||||
///
|
||||
/// `pvp` and `rp` are two independent booleans in the reference's `GetRealmInfo`, and the Lua tests
|
||||
/// them in the order `pvp and rp` → `rp` → `pvp` → neither, so `RPPVP` is not "the RP colour" — it
|
||||
/// takes `NORMAL`, the same as a normal realm. Read literally rather than collapsed.
|
||||
pub(super) fn type_column(realm_type: u32) -> (&'static str, Color) {
|
||||
let (pvp, rp) = pvp_rp(realm_type);
|
||||
match (pvp, rp) {
|
||||
(true, true) => ("RPPVP_PARENTHESES", NORMAL),
|
||||
(false, true) => ("RP_PARENTHESES", GREEN),
|
||||
(true, false) => ("PVP_PARENTHESES", RED),
|
||||
(false, false) => ("GAMETYPE_NORMAL", NORMAL),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire's realm **type** split into the reference's two booleans — **the one place** the
|
||||
/// mapping lives. `GetRealmInfo` returns `pvp`/`rp` for the realm list and `GetServerName` returns
|
||||
/// the same pair for the character screen's banner, so a second copy of this table is a second
|
||||
/// thing to get wrong.
|
||||
///
|
||||
/// **This is a `Cfg_Configs.dbc` join, not an enumeration** — `0x46efda` takes the realm's type
|
||||
/// dword and linearly scans the DBC for the row whose `RealmType` column matches, reading
|
||||
/// `PlayerKillingAllowed` → `pvp` and `RoleplayingRealm` → `rp`. (Note the key is the type at
|
||||
/// `[realm+0x04]`, the *first* dword on the wire; wow-re's earlier band sweep had attributed the
|
||||
/// type to `[+0x130]`, which is really `numCharacters`.)
|
||||
///
|
||||
/// The table below is the shipped `DBFilesClient\Cfg_Configs.dbc` transcribed — a frozen table in
|
||||
/// the same spirit as the `*_ICON_TCOORDS` in `crate::glue::art`, and the honest way to read it is
|
||||
/// as *data*, not as a rule. It is also why the natural guess is wrong: types **3 and 5 are PvP**
|
||||
/// and **7 is RP**, which an "0 normal / 1 pvp / 6 rp / 8 rppvp" reading silently gets wrong for
|
||||
/// any server that uses them.
|
||||
///
|
||||
/// A type with no row falls to `(false, false)` — the scan finds nothing and neither column is
|
||||
/// read. That is also what the two rows this table does not carry (the shipped DBC has 11; nine
|
||||
/// map a distinct `RealmType`) degrade to, and it is the safe answer for a server inventing one.
|
||||
///
|
||||
/// VERIFIED, wow-re `system/glue/scratch/realm-list-bindings.md` §3.
|
||||
pub(crate) fn pvp_rp(realm_type: u32) -> (bool, bool) {
|
||||
match realm_type {
|
||||
// RealmType → (PlayerKillingAllowed, RoleplayingRealm)
|
||||
0 | 2 | 4 => (false, false),
|
||||
1 | 3 | 5 => (true, false),
|
||||
6 | 7 => (false, true),
|
||||
8 => (true, true),
|
||||
_ => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The realm **name**'s colour, and the colour it takes under the cursor
|
||||
/// (`SetTextColor`/`SetHighlightTextColor` in `RealmListUpdate`).
|
||||
///
|
||||
/// The ladder is the reference's: offline first, then invalid, then "you have characters here",
|
||||
/// then the default gold. Note the reference's own gold here is `1.0, 0.78, 0` — a *different*
|
||||
/// value from `NORMAL_FONT_COLOR`'s `1.0, 0.82, 0`, hardcoded at the four call sites rather than
|
||||
/// taken from the global. Kept as it is written.
|
||||
pub(super) fn name_colors(down: bool, invalid: bool, characters: u8) -> (Color, Color) {
|
||||
if down {
|
||||
(GRAY, Color::srgb(0.8, 0.8, 0.8))
|
||||
} else if invalid {
|
||||
(RED, Color::srgb(1.0, 0.5, 0.5))
|
||||
} else if characters > 0 {
|
||||
(GREEN, HIGHLIGHT)
|
||||
} else {
|
||||
(crate::glue::art::GOLD, HIGHLIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour the selection band behind the chosen row takes
|
||||
/// (`RealmListHighlightTexture:SetVertexColor`) — the name colour's ladder minus the offline arm,
|
||||
/// because an offline row cannot be the chosen one (`RealmListHighlight:Hide()`).
|
||||
pub(super) fn highlight_color(invalid: bool, characters: u8) -> Color {
|
||||
if invalid {
|
||||
RED
|
||||
} else if characters > 0 {
|
||||
GREEN
|
||||
} else {
|
||||
crate::glue::art::GOLD
|
||||
}
|
||||
}
|
||||
|
||||
/// The player count beside the realm name — `"(3)"`, or nothing at all when they have none there.
|
||||
pub(super) fn players_text(characters: u8) -> String {
|
||||
if characters > 0 {
|
||||
format!("({characters})")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The reference's `n <= 1` short circuit bands the single realm against the literal 1.0
|
||||
/// rather than against itself, so `Medium` is the knife edge and not the default — the
|
||||
/// assumption this pins against. The 0.06 case is our own vmangos realm, read off the live
|
||||
/// realm list: it reads **Low**.
|
||||
#[test]
|
||||
fn a_single_realm_is_banded_against_the_literal_one_not_against_itself() {
|
||||
assert_eq!(realm_load_stats(&[]), (1.0, 0.0));
|
||||
assert_eq!(realm_load_stats(&[400.0]), (1.0, 0.0));
|
||||
let word = |pop: f32| {
|
||||
let (mean, stddev) = realm_load_stats(&[pop]);
|
||||
load_column(false, realm_load_classify(0, pop, mean, stddev)).0
|
||||
};
|
||||
assert_eq!(word(0.06), "LOAD_LOW", "the live vmangos realm");
|
||||
assert_eq!(word(1.0), "LOAD_MEDIUM", "the knife edge");
|
||||
assert_eq!(word(400.0), "LOAD_HIGH");
|
||||
}
|
||||
|
||||
/// The band is relative, which is the whole point of computing it: the SAME population reads
|
||||
/// `High` against quiet neighbours and `Low` against busy ones.
|
||||
#[test]
|
||||
fn the_same_population_reads_differently_against_different_neighbours() {
|
||||
let quiet = [1.0f32, 1.0, 1.0, 9.0];
|
||||
let (mean, stddev) = realm_load_stats(&quiet);
|
||||
assert_eq!(
|
||||
load_column(false, realm_load_classify(0, 9.0, mean, stddev)).0,
|
||||
"LOAD_HIGH"
|
||||
);
|
||||
|
||||
let busy = [90.0f32, 90.0, 90.0, 9.0];
|
||||
let (mean, stddev) = realm_load_stats(&busy);
|
||||
assert_eq!(
|
||||
load_column(false, realm_load_classify(0, 9.0, mean, stddev)).0,
|
||||
"LOAD_LOW"
|
||||
);
|
||||
}
|
||||
|
||||
/// `mean = Σpop/n`, and `stddev = sqrt(Σ(pop−mean)²/(n−1)) · 0.6` — the sample std-dev, scaled.
|
||||
/// Pinned against a hand-computed case so a "simplification" of the accumulator loop shows up.
|
||||
#[test]
|
||||
fn the_stats_are_the_sample_stddev_scaled_by_six_tenths() {
|
||||
let (mean, stddev) = realm_load_stats(&[2.0, 4.0, 4.0, 6.0]);
|
||||
assert_eq!(mean, 4.0);
|
||||
// Σ(pop−mean)² = 4+0+0+4 = 8; 8/3 = 2.666…; sqrt = 1.63299…; ×0.6 = 0.97979…
|
||||
assert!((stddev - 0.979_795_9).abs() < 1e-6, "stddev was {stddev}");
|
||||
}
|
||||
|
||||
/// The three flag sentinels beat the band, and they are tested in the reference's order — a
|
||||
/// realm flagged both recommended (0x20) and full (0x80) reads `Recommended`.
|
||||
#[test]
|
||||
fn the_flag_sentinels_outrank_the_band_and_each_other_in_order() {
|
||||
assert_eq!(realm_load_classify(0x20, 999.0, 1.0, 0.5), -3.0);
|
||||
assert_eq!(realm_load_classify(0x40, 999.0, 1.0, 0.5), -2.0);
|
||||
assert_eq!(realm_load_classify(0x80, 0.0, 1.0, 0.5), 2.0);
|
||||
assert_eq!(realm_load_classify(0x20 | 0x80, 0.0, 1.0, 0.5), -3.0);
|
||||
assert_eq!(load_column(false, -3.0).0, "LOAD_RECOMMENDED");
|
||||
assert_eq!(load_column(false, -2.0).0, "LOAD_NEW");
|
||||
assert_eq!(load_column(false, 2.0).0, "LOAD_FULL");
|
||||
}
|
||||
|
||||
/// An exactly-on-edge population reads as normal: the binary's comparisons are strict.
|
||||
#[test]
|
||||
fn a_population_exactly_on_a_band_edge_is_normal() {
|
||||
assert_eq!(realm_load_classify(0, 0.5, 1.0, 0.5), 0.0);
|
||||
assert_eq!(realm_load_classify(0, 1.5, 1.0, 0.5), 0.0);
|
||||
assert_eq!(realm_load_classify(0, 0.4, 1.0, 0.5), -1.0);
|
||||
assert_eq!(realm_load_classify(0, 1.6, 1.0, 0.5), 1.0);
|
||||
}
|
||||
|
||||
/// Offline beats every band, including the sentinels — the Lua tests `realmDown` first.
|
||||
#[test]
|
||||
fn an_offline_realm_reads_offline_whatever_its_population_says() {
|
||||
assert_eq!(load_column(true, -3.0), ("REALM_DOWN", GRAY));
|
||||
assert_eq!(load_column(true, 2.0), ("REALM_DOWN", GRAY));
|
||||
}
|
||||
|
||||
/// The four wire types, and the colour each takes. `RPPVP` deliberately takes `NORMAL` — the
|
||||
/// Lua's `pvp and rp` arm sets `NORMAL_FONT_COLOR`, not the RP green.
|
||||
#[test]
|
||||
fn the_type_column_is_the_shipped_cfg_configs_rows() {
|
||||
for t in [0, 2, 4] {
|
||||
assert_eq!(type_column(t), ("GAMETYPE_NORMAL", NORMAL), "type {t}");
|
||||
}
|
||||
// 3 and 5 are the rows a "0/1/6/8" reading gets wrong.
|
||||
for t in [1, 3, 5] {
|
||||
assert_eq!(type_column(t), ("PVP_PARENTHESES", RED), "type {t}");
|
||||
}
|
||||
for t in [6, 7] {
|
||||
assert_eq!(type_column(t), ("RP_PARENTHESES", GREEN), "type {t}");
|
||||
}
|
||||
assert_eq!(type_column(8), ("RPPVP_PARENTHESES", NORMAL));
|
||||
// No row: the scan reads neither column.
|
||||
assert_eq!(type_column(77), ("GAMETYPE_NORMAL", NORMAL));
|
||||
}
|
||||
|
||||
/// The name ladder, and the gold that is NOT `NORMAL_FONT_COLOR`.
|
||||
#[test]
|
||||
fn the_name_ladder_is_offline_then_invalid_then_has_characters() {
|
||||
assert_eq!(name_colors(true, true, 5).0, GRAY);
|
||||
assert_eq!(name_colors(false, true, 5).0, RED);
|
||||
assert_eq!(name_colors(false, false, 5).0, GREEN);
|
||||
assert_eq!(name_colors(false, false, 0).0, crate::glue::art::GOLD);
|
||||
assert_ne!(crate::glue::art::GOLD, NORMAL);
|
||||
}
|
||||
|
||||
/// No characters on a realm means no count beside its name — not `"(0)"`.
|
||||
#[test]
|
||||
fn a_realm_with_no_characters_shows_no_count() {
|
||||
assert_eq!(players_text(0), "");
|
||||
assert_eq!(players_text(3), "(3)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
//! **Realm selection** — the glue screen between the login and character select, and the policy
|
||||
//! that decides whether the player ever sees it.
|
||||
//!
|
||||
//! For the project's whole life the client took `logon.realms.first()`: whichever realm the auth
|
||||
//! server happened to list first was the realm you played on, the reference's `Change Realm` button
|
||||
//! was drawn deliberately disabled, and the character screen's realm banner had nothing to show
|
||||
//! until the character list arrived carrying the realm as a passenger — which is why it read
|
||||
//! `Connecting…`, a string the real client does not have. The banner was the symptom; this module
|
||||
//! is the missing subsystem.
|
||||
//!
|
||||
//! **Not to be confused with [`crate::realmlist`]**, which is the *address of the logon server* —
|
||||
//! the reference's `realmList` CVar and its `realmlist.wtf`. The collision is the reference's own
|
||||
//! (`realmList` the CVar, `RealmList` the window) and is kept rather than invented away: one is
|
||||
//! where you dial, this is what answers.
|
||||
//!
|
||||
//! ## The park
|
||||
//!
|
||||
//! The IO thread parks three times, and this is the middle one (`crate::net::io`): logon → **which
|
||||
//! realm?** → world handshake → **which character?**. At each park the thread publishes facts and
|
||||
//! blocks; the app owns the policy. Here the policy is:
|
||||
//!
|
||||
//! 1. **`WOW_REALM`**, when set — the dev fast path past the screen, the realm-side twin of
|
||||
//! `WOW_CHAR`. Matched case-insensitively; a name that is not on the list falls through rather
|
||||
//! than failing, so a stale env var cannot strand a session.
|
||||
//! 2. **The remembered realm** — the `realmName` CVar, which 1.12 registers as a *persisted* CVar
|
||||
//! for exactly this ("last realm connected to"). A launch that recognises its realm goes
|
||||
//! straight to character select, and `Change Realm` is how you get back here.
|
||||
//! 3. **An unattended run takes the first realm that is up** — `crate::run_mode::unattended`
|
||||
//! (`WOW_UNATTENDED`/`WOW_CAPTURE`/`WOW_RIG`). A smoke run, a capture and a rig leg have nobody
|
||||
//! in the room to click OK, and a screen that waits forever is how a park becomes a hang. This
|
||||
//! is deliberately `unattended` and not `env_login` — 1769's distinction: credentials in the
|
||||
//! environment do not mean nobody is watching, but these three cannot be a person.
|
||||
//! 4. **Otherwise the screen**, and the player picks.
|
||||
//!
|
||||
//! ## Change Realm
|
||||
//!
|
||||
//! Costs a world dial, not a login. The SRP6 session key authenticates against any world server on
|
||||
//! the account's realm list and the list itself is already in hand, so the IO thread hands its
|
||||
//! logon forward (`net::io`'s `HeldLogon`) and reopens the list. That is also the reference's
|
||||
//! shape: its realmd connection outlives the screen.
|
||||
|
||||
mod input;
|
||||
mod load;
|
||||
mod screen;
|
||||
|
||||
pub(crate) use load::pvp_rp;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::ClientState;
|
||||
use crate::net::{RealmChoice, RealmListMessage, RealmRequest};
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
/// The CVar naming the realm we are on — a **real 1.12 CVar** (`0x83f2d0`, persisted; the client
|
||||
/// builds its SavedVariables path from it, wow-re `savedvariables-protocol.md`), already registered
|
||||
/// by [`crate::cvars`] and pushed into the VM at world entry by
|
||||
/// `benilla_ui::script::UiScript::set_realm_name`.
|
||||
///
|
||||
/// Read here for the remembered-realm auto-pick. Its persisted value is the *previous* session's
|
||||
/// realm, which is precisely the question this policy asks.
|
||||
pub(crate) const CVAR_REALM_NAME: &str = "realmName";
|
||||
|
||||
/// How often the realm list is re-requested while the screen is up — `REALM_LIST_REFRESH_TIME`
|
||||
/// (`GlueXML/RealmList.lua` l.3), the reference's own 5 seconds. `RealmList_OnUpdate` counts it
|
||||
/// down and fires `RequestRealmList`, which is how a realm going offline or filling up shows up
|
||||
/// without a re-login.
|
||||
const REFRESH_SECS: f32 = 5.0;
|
||||
|
||||
/// The realm list, the selection on it, and the policy's memory.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Realms {
|
||||
/// Every realm the auth server advertised, in wire order.
|
||||
pub(super) realms: Vec<RealmInfo>,
|
||||
/// The highlighted row, **by realm name** — the list is re-published every few seconds and a
|
||||
/// row index would drift under the player when a realm appears or disappears.
|
||||
selected: Option<String>,
|
||||
/// The category tab in front (`RealmList.selectedCategory`), by the wire's category byte.
|
||||
pub(super) category: Option<u8>,
|
||||
/// First visible row within the selected category (`RealmList.offset`).
|
||||
pub(super) offset: usize,
|
||||
/// Which column the list is sorted on, and which way.
|
||||
pub(super) sort: Sort,
|
||||
/// The realm we answered the park with — set when we send, cleared when a fresh list arrives,
|
||||
/// so a re-published list cannot be answered twice.
|
||||
answered: Option<String>,
|
||||
/// Seconds until the next refresh request.
|
||||
refresh_in: f32,
|
||||
/// **Show the screen for the next list, whatever the auto-answers say.** Armed by Change
|
||||
/// Realm, and the reason it is needed: the remembered realm is the realm you are standing on,
|
||||
/// so without this the policy would answer the list by re-entering the realm you just asked to
|
||||
/// leave — a button that looks like it does nothing. Cleared as soon as it is honoured.
|
||||
forced: bool,
|
||||
/// `WOW_REALM`, read once.
|
||||
env_realm: Option<String>,
|
||||
env_read: bool,
|
||||
}
|
||||
|
||||
/// One of the realm list's four sort columns — `SortRealms`' string argument, as a type.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum SortKey {
|
||||
/// `SortRealms("characters")`, comparator case 0 — the per-account character count.
|
||||
Characters,
|
||||
/// `SortRealms("load")`, case 1 — the computed load band, not the raw population.
|
||||
Load,
|
||||
/// `SortRealms("name")`, case 2 — a case-folding compare.
|
||||
Name,
|
||||
/// `SortRealms("mode")`, case 3 — the `(pvp, rp)` pair.
|
||||
Type,
|
||||
}
|
||||
|
||||
/// **The sort config: four `{key, descending}` records, walked in order.**
|
||||
///
|
||||
/// Not one key with a name tie-break, which is what this was first written as. The reference keeps
|
||||
/// all four (`0xb41f40`), and the comparator (`0x46e790`) walks them in order, stops at the first
|
||||
/// that does not tie, and negates the result if *that record's own* `descending` is set.
|
||||
///
|
||||
/// **The default is `[characters, load, name, mode]`, all ascending** — written once per process at
|
||||
/// `0x46e430`, so it is what a player sees on a fresh launch, and a column click persists for the
|
||||
/// whole session rather than resetting when the list refreshes. It is emphatically not
|
||||
/// name-ascending, which is the natural guess and the one this code shipped with.
|
||||
///
|
||||
/// "Ascending" for `Characters` means **higher count first**: the comparator returns `-1` iff
|
||||
/// `B.chars < A.chars`. Realms you already play on float to the top, which is the point.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Sort(pub(super) [(SortKey, bool); 4]);
|
||||
|
||||
impl Default for Sort {
|
||||
fn default() -> Self {
|
||||
Self([
|
||||
(SortKey::Characters, false),
|
||||
(SortKey::Load, false),
|
||||
(SortKey::Name, false),
|
||||
(SortKey::Type, false),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl Sort {
|
||||
/// A column header click — the reference's `realm_set_primary_key` (`0x46e9b0`), which is
|
||||
/// **move-to-front**: clicking the key already in record 0 toggles its own direction; clicking
|
||||
/// any other shifts the records above it down and moves it to the front *keeping* the
|
||||
/// direction it already had.
|
||||
pub(super) fn click(&mut self, key: SortKey) {
|
||||
if self.0[0].0 == key {
|
||||
self.0[0].1 = !self.0[0].1;
|
||||
return;
|
||||
}
|
||||
let Some(at) = self.0.iter().position(|(k, _)| *k == key) else {
|
||||
return;
|
||||
};
|
||||
let record = self.0[at];
|
||||
self.0.copy_within(0..at, 1);
|
||||
self.0[0] = record;
|
||||
}
|
||||
}
|
||||
|
||||
impl Realms {
|
||||
/// The realms in the selected category, already ordered — the rows the screen draws, as
|
||||
/// indices into [`Self::realms`].
|
||||
pub(super) fn rows(&self) -> Vec<usize> {
|
||||
let category = self.category;
|
||||
let (mean, stddev) = self.stats();
|
||||
let mut rows: Vec<usize> = self
|
||||
.realms
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| category.is_none_or(|c| r.category == c))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
rows.sort_by(|&a, &b| {
|
||||
let (ra, rb) = (&self.realms[a], &self.realms[b]);
|
||||
// Walk the four records in order; the first that does not tie decides, negated by
|
||||
// *its own* direction rather than by a single global one.
|
||||
for (key, descending) in self.sort.0 {
|
||||
let ord = match key {
|
||||
// Higher count first — see `Sort`.
|
||||
SortKey::Characters => rb.characters.cmp(&ra.characters),
|
||||
SortKey::Load => {
|
||||
let la = load::realm_load_classify(ra.flags, ra.population, mean, stddev);
|
||||
let lb = load::realm_load_classify(rb.flags, rb.population, mean, stddev);
|
||||
la.total_cmp(&lb)
|
||||
}
|
||||
SortKey::Name => ra.name.to_lowercase().cmp(&rb.name.to_lowercase()),
|
||||
SortKey::Type => load::pvp_rp(ra.realm_type).cmp(&load::pvp_rp(rb.realm_type)),
|
||||
};
|
||||
if ord != std::cmp::Ordering::Equal {
|
||||
return if descending { ord.reverse() } else { ord };
|
||||
}
|
||||
}
|
||||
// All four tied. The reference returns 0 here too — but its `characters` case is
|
||||
// **not antisymmetric** (on equal nonzero counts it answers `+1` for both `(a,b)` and
|
||||
// `(b,a)`, because the strcmp meant to break that tie is clobbered one instruction
|
||||
// later — a real, reachable defect, byte-verified). We do not reproduce it: a
|
||||
// comparator that disagrees with itself is a logic error Rust's sort is entitled to
|
||||
// punish, and the visible cost of the divergence is that two realms with the same
|
||||
// character count keep a stable order instead of an arbitrary one.
|
||||
std::cmp::Ordering::Equal
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
/// The category bytes present, ascending — one tab each. The reference hides the tab strip
|
||||
/// entirely when there is only one (`RealmList_UpdateTabs`: `if arg.n == 1 then tab:Hide()`),
|
||||
/// which is every ordinary private server.
|
||||
pub(super) fn categories(&self) -> Vec<u8> {
|
||||
let mut cats: Vec<u8> = self.realms.iter().map(|r| r.category).collect();
|
||||
cats.sort_unstable();
|
||||
cats.dedup();
|
||||
cats
|
||||
}
|
||||
|
||||
/// The population mean and scaled std-dev — the distribution each row's load band is placed in.
|
||||
///
|
||||
/// **Over EVERY realm the client knows, not over the selected category.** This looked like a
|
||||
/// per-category statistic and was written that way; `realm_load_stats` (`0x46e510`) is in fact
|
||||
/// the whole realm-list rebuild, and its accumulator loop walks the flat all-categories array
|
||||
/// unconditionally — the per-category bucketing in the same loop is a side effect, never a
|
||||
/// bound. So a realm's word is measured against every other realm on the account's list, and
|
||||
/// switching tabs does not move any row's band. (VERIFIED, wow-re
|
||||
/// `system/glue/scratch/realm-list-bindings.md` §5 — which corrected wow-re's own doc comment
|
||||
/// in the same pass.)
|
||||
pub(super) fn stats(&self) -> (f32, f32) {
|
||||
let pops: Vec<f32> = self.realms.iter().map(|r| r.population).collect();
|
||||
load::realm_load_stats(&pops)
|
||||
}
|
||||
|
||||
/// The highlighted realm, if it is still on the list.
|
||||
pub(super) fn selected(&self) -> Option<&RealmInfo> {
|
||||
let name = self.selected.as_deref()?;
|
||||
self.realms.iter().find(|r| r.name == name)
|
||||
}
|
||||
|
||||
/// Highlight a realm by name.
|
||||
pub(super) fn select(&mut self, name: &str) {
|
||||
self.selected = Some(name.to_string());
|
||||
}
|
||||
|
||||
/// Whether the OK button is live: a realm is highlighted and it is not offline.
|
||||
pub(super) fn can_enter(&self) -> bool {
|
||||
self.selected().is_some_and(|r| !is_down(r))
|
||||
}
|
||||
}
|
||||
|
||||
/// **Offline** — the realm is up in the list but not accepting connections (`realmDown` in
|
||||
/// `GetRealmInfo`, the row the reference greys out and refuses to enter).
|
||||
///
|
||||
/// Bit 0x02 of the wire's realm-flags byte. The client's realm record keeps the same byte at
|
||||
/// `[realm+0x08]` and `realm_select` (0x46ea20) skips a realm on its low bits when auto-picking.
|
||||
pub(crate) fn is_down(realm: &RealmInfo) -> bool {
|
||||
realm.flags & 0x02 != 0
|
||||
}
|
||||
|
||||
/// **Invalid** — the realm rejects this client build (`invalidRealm`, the row the reference paints
|
||||
/// red but still lets you try). Bit 0x01 of the same byte.
|
||||
pub(super) fn is_invalid(realm: &RealmInfo) -> bool {
|
||||
realm.flags & 0x01 != 0
|
||||
}
|
||||
|
||||
/// The realm-selection subsystem: the park's policy plus the screen.
|
||||
pub(crate) struct RealmSelectPlugin;
|
||||
|
||||
impl Plugin for RealmSelectPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Realms>()
|
||||
.add_systems(Update, apply_realm_policy)
|
||||
.add_systems(OnExit(ClientState::RealmList), screen::exit_realm_list)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
screen::materialize_screen,
|
||||
screen::refresh_rows,
|
||||
input::clicks,
|
||||
input::keys,
|
||||
tick_refresh,
|
||||
crate::glue::sync_outlines,
|
||||
)
|
||||
.run_if(in_state(ClientState::RealmList)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer the realm park — or show the screen and let the player answer it.
|
||||
///
|
||||
/// Runs in every state, like the roster policy it mirrors: a `Change Realm` from character select
|
||||
/// re-publishes the list while we are still in [`ClientState::CharSelect`], and this is what moves
|
||||
/// us off it.
|
||||
fn apply_realm_policy(
|
||||
mut msgs: MessageReader<RealmListMessage>,
|
||||
mut realms: ResMut<Realms>,
|
||||
choice: Res<RealmChoice>,
|
||||
persist: Res<crate::cvars::CvarPersist>,
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
) {
|
||||
if !realms.env_read {
|
||||
realms.env_read = true;
|
||||
realms.env_realm = std::env::var("WOW_REALM").ok().filter(|s| !s.is_empty());
|
||||
}
|
||||
for msg in msgs.read() {
|
||||
realms.realms = msg.realms.clone();
|
||||
realms.answered = None;
|
||||
realms.refresh_in = REFRESH_SECS;
|
||||
// Keep the tab and the highlight across a refresh when they still name something real;
|
||||
// a first list (or one that dropped what we had) falls back to the first of each.
|
||||
let cats = realms.categories();
|
||||
if !realms.category.is_some_and(|c| cats.contains(&c)) {
|
||||
realms.category = cats.first().copied();
|
||||
}
|
||||
if realms.selected().is_none() {
|
||||
realms.selected = realms
|
||||
.rows()
|
||||
.first()
|
||||
.map(|&i| realms.realms[i].name.clone());
|
||||
}
|
||||
|
||||
// A deliberate Change Realm outranks every auto-answer — see `Realms::forced`.
|
||||
if std::mem::take(&mut realms.forced) {
|
||||
next.set(ClientState::RealmList);
|
||||
continue;
|
||||
}
|
||||
let remembered = persist.stored(CVAR_REALM_NAME);
|
||||
match auto_answer(&realms, remembered, crate::run_mode::unattended()) {
|
||||
Some(name) => {
|
||||
realms.select(&name);
|
||||
answer(&mut realms, &choice, name);
|
||||
}
|
||||
None => next.set(ClientState::RealmList),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Which realm answers this list without the player** — or `None`, meaning show the screen.
|
||||
///
|
||||
/// The arms, in order: an explicit `WOW_REALM`, the realm remembered from last time, then (only
|
||||
/// when nobody is in the room) the first realm the list draws. Pure, and lifted out of the system
|
||||
/// around it, because this ladder *is* the policy — the rest is plumbing.
|
||||
///
|
||||
/// Every arm requires the realm to be **up**. Auto-entering a realm the server says is offline
|
||||
/// would replace a screen that can explain itself with a dial that just fails.
|
||||
fn auto_answer(realms: &Realms, remembered: Option<&str>, unattended: bool) -> Option<String> {
|
||||
// The two named arms, matched case-insensitively — vmangos does not normalise realm names and
|
||||
// a config file is hand-editable.
|
||||
let named = [realms.env_realm.as_deref(), remembered]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|want| {
|
||||
realms
|
||||
.realms
|
||||
.iter()
|
||||
.find(|r| r.name.eq_ignore_ascii_case(want) && !is_down(r))
|
||||
.map(|r| r.name.clone())
|
||||
});
|
||||
named.or_else(|| {
|
||||
// Nobody to click OK: take the first realm that will have us, in the order the list is
|
||||
// drawn in, rather than parking on a screen no one will answer.
|
||||
unattended
|
||||
.then(|| {
|
||||
realms
|
||||
.rows()
|
||||
.into_iter()
|
||||
.map(|i| &realms.realms[i])
|
||||
.find(|r| !is_down(r))
|
||||
.map(|r| r.name.clone())
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Arm the screen for the next realm list, whatever the policy would otherwise auto-answer with.
|
||||
///
|
||||
/// The character screen's Change Realm calls this on its way out: a deliberate ask for the list
|
||||
/// has to outrank every remembered answer, or the one arm that fires would be the realm being left.
|
||||
pub(crate) fn force_screen(realms: &mut Realms) {
|
||||
realms.forced = true;
|
||||
}
|
||||
|
||||
/// Send the pick down the park's channel, once.
|
||||
pub(super) fn answer(realms: &mut Realms, choice: &RealmChoice, name: String) {
|
||||
if realms.answered.is_some() {
|
||||
return;
|
||||
}
|
||||
realms.answered = Some(name.clone());
|
||||
let _ = choice.0.send(RealmRequest::Enter(name));
|
||||
}
|
||||
|
||||
/// The reference's `RealmList_OnUpdate`: count down `REALM_LIST_REFRESH_TIME` and re-request the
|
||||
/// list. Only while the screen is up — the park is not refreshed behind the player's back.
|
||||
fn tick_refresh(time: Res<Time>, mut realms: ResMut<Realms>, choice: Res<RealmChoice>) {
|
||||
if realms.answered.is_some() {
|
||||
return;
|
||||
}
|
||||
realms.refresh_in -= time.delta_secs();
|
||||
if realms.refresh_in <= 0.0 {
|
||||
realms.refresh_in = REFRESH_SECS;
|
||||
let _ = choice.0.send(RealmRequest::Refresh);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn realm(
|
||||
name: &str,
|
||||
category: u8,
|
||||
realm_type: u32,
|
||||
flags: u8,
|
||||
chars: u8,
|
||||
pop: f32,
|
||||
) -> RealmInfo {
|
||||
RealmInfo {
|
||||
name: name.into(),
|
||||
address: "127.0.0.1:8085".into(),
|
||||
population: pop,
|
||||
characters: chars,
|
||||
realm_type,
|
||||
flags,
|
||||
category,
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The realm names in the order the screen would draw them.
|
||||
fn names(r: &Realms) -> Vec<String> {
|
||||
r.rows().iter().map(|&i| r.realms[i].name.clone()).collect()
|
||||
}
|
||||
|
||||
fn list(realms: Vec<RealmInfo>) -> Realms {
|
||||
let category = realms.first().map(|r| r.category);
|
||||
Realms {
|
||||
realms,
|
||||
category,
|
||||
..Realms::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The two flag bits the row's look hangs on, and the fact they are independent.
|
||||
#[test]
|
||||
fn offline_and_invalid_are_separate_bits() {
|
||||
assert!(is_down(&realm("a", 1, 0, 0x02, 0, 1.0)));
|
||||
assert!(!is_invalid(&realm("a", 1, 0, 0x02, 0, 1.0)));
|
||||
assert!(is_invalid(&realm("a", 1, 0, 0x01, 0, 1.0)));
|
||||
assert!(!is_down(&realm("a", 1, 0, 0x01, 0, 1.0)));
|
||||
assert!(
|
||||
is_down(&realm("a", 1, 0, 0x03, 0, 1.0)) && is_invalid(&realm("a", 1, 0, 0x03, 0, 1.0))
|
||||
);
|
||||
// The load sentinels live in the high bits and must not read as either.
|
||||
let sentinel = realm("a", 1, 0, 0xE0, 0, 1.0);
|
||||
assert!(!is_down(&sentinel) && !is_invalid(&sentinel));
|
||||
}
|
||||
|
||||
/// A category tab scopes the **rows** — and deliberately NOT the load distribution.
|
||||
///
|
||||
/// This is the correction that matters most here, because the wrong version looks right: the
|
||||
/// mean and std-dev a row's word is measured against come from every realm the client knows,
|
||||
/// so switching tabs re-filters the list without relabelling a single band. Scoping the stats
|
||||
/// to the tab (which is what this code did first) makes the same realm read a different word
|
||||
/// depending on which tab you are standing on.
|
||||
#[test]
|
||||
fn a_category_tab_scopes_the_rows_but_not_the_load_distribution() {
|
||||
let mut r = list(vec![
|
||||
realm("Alpha", 1, 0, 0, 0, 1.0),
|
||||
realm("Beta", 1, 0, 0, 0, 1.0),
|
||||
realm("Gamma", 2, 0, 0, 0, 400.0),
|
||||
]);
|
||||
r.category = Some(1);
|
||||
assert_eq!(r.rows().len(), 2, "the tab scopes the rows");
|
||||
|
||||
let (mean_on_tab_one, _) = r.stats();
|
||||
r.category = Some(2);
|
||||
assert_eq!(r.rows().len(), 1);
|
||||
let (mean_on_tab_two, _) = r.stats();
|
||||
assert_eq!(
|
||||
mean_on_tab_one, mean_on_tab_two,
|
||||
"the distribution is global — the tab must not move it"
|
||||
);
|
||||
// And it really is the global mean: (1 + 1 + 400) / 3, not the visible category's.
|
||||
assert!((mean_on_tab_one - 134.0).abs() < 1e-3, "{mean_on_tab_one}");
|
||||
}
|
||||
|
||||
/// A single-category list draws no tabs — the reference hides the whole strip.
|
||||
#[test]
|
||||
fn one_category_is_the_ordinary_case_and_has_no_tabs() {
|
||||
let r = list(vec![
|
||||
realm("Alpha", 1, 0, 0, 0, 1.0),
|
||||
realm("Beta", 1, 0, 0, 0, 1.0),
|
||||
]);
|
||||
assert_eq!(r.categories(), vec![1]);
|
||||
}
|
||||
|
||||
/// **The default order is characters → load → name → mode, all ascending** — the config the
|
||||
/// binary writes once per process, and not the name-ascending this first shipped with. A realm
|
||||
/// the account has characters on floats to the top; that is what "ascending" means for case 0.
|
||||
#[test]
|
||||
fn the_default_sort_puts_realms_you_play_on_first() {
|
||||
let mut r = list(vec![
|
||||
realm("Zeta", 1, 0, 0, 0, 1.0),
|
||||
realm("Alpha", 1, 0, 0, 0, 1.0),
|
||||
realm("Mu", 1, 0, 0, 3, 1.0),
|
||||
]);
|
||||
assert_eq!(names(&r), ["Mu", "Alpha", "Zeta"], "Mu has characters");
|
||||
// With the counts equal the first record ties and the walk falls through to load, then to
|
||||
// the name — so the remainder is alphabetical rather than arbitrary.
|
||||
r.realms[2].characters = 0;
|
||||
assert_eq!(names(&r), ["Alpha", "Mu", "Zeta"]);
|
||||
}
|
||||
|
||||
/// A column click is **move-to-front**, and the clicked column keeps the direction it already
|
||||
/// had; only re-clicking the column already in front flips it. The records behind it survive,
|
||||
/// which is what makes the fallthrough meaningful.
|
||||
#[test]
|
||||
fn a_column_click_moves_it_to_the_front_and_only_a_re_click_flips_it() {
|
||||
let mut sort = Sort::default();
|
||||
assert_eq!(sort.0[0], (SortKey::Characters, false));
|
||||
|
||||
sort.click(SortKey::Name);
|
||||
assert_eq!(sort.0[0], (SortKey::Name, false));
|
||||
assert_eq!(
|
||||
sort.0[1..],
|
||||
[
|
||||
(SortKey::Characters, false),
|
||||
(SortKey::Load, false),
|
||||
(SortKey::Type, false)
|
||||
],
|
||||
"the records it passed shift down rather than being dropped"
|
||||
);
|
||||
|
||||
sort.click(SortKey::Name);
|
||||
assert_eq!(sort.0[0], (SortKey::Name, true), "a re-click flips it");
|
||||
|
||||
sort.click(SortKey::Load);
|
||||
assert_eq!(sort.0[0], (SortKey::Load, false));
|
||||
assert_eq!(
|
||||
sort.0[1],
|
||||
(SortKey::Name, true),
|
||||
"Name keeps the direction it was toggled to"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sort direction is **per record**, not one global flag: reversing the primary column must
|
||||
/// not also reverse the tie-breaks behind it.
|
||||
#[test]
|
||||
fn each_sort_record_carries_its_own_direction() {
|
||||
let mut r = list(vec![
|
||||
realm("Alpha", 1, 0, 0, 1, 1.0),
|
||||
realm("Mu", 1, 0, 0, 1, 1.0),
|
||||
realm("Zeta", 1, 0, 0, 5, 1.0),
|
||||
]);
|
||||
r.sort.click(SortKey::Characters); // already primary → descending
|
||||
assert_eq!(
|
||||
names(&r),
|
||||
["Alpha", "Mu", "Zeta"],
|
||||
"counts reversed (fewest first), but the name tie-break stays ascending"
|
||||
);
|
||||
}
|
||||
|
||||
/// The highlight is a NAME, so a realm appearing above it on a refresh does not move the
|
||||
/// selection onto a different realm — the bug an index would have.
|
||||
#[test]
|
||||
fn the_selection_survives_a_realm_appearing_above_it() {
|
||||
let mut r = list(vec![realm("Mu", 1, 0, 0, 0, 1.0)]);
|
||||
r.select("Mu");
|
||||
r.realms.insert(0, realm("Alpha", 1, 0, 0, 0, 1.0));
|
||||
assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Mu"));
|
||||
}
|
||||
|
||||
/// A realm that vanished from the list is not selected any more, and OK is dead.
|
||||
#[test]
|
||||
fn a_realm_that_left_the_list_is_no_longer_the_selection() {
|
||||
let mut r = list(vec![realm("Mu", 1, 0, 0, 0, 1.0)]);
|
||||
r.select("Mu");
|
||||
r.realms.clear();
|
||||
assert!(r.selected().is_none());
|
||||
assert!(!r.can_enter());
|
||||
}
|
||||
|
||||
/// The policy ladder: `WOW_REALM` first, then the remembered realm, then — only when nobody
|
||||
/// is in the room — the first row. Attended and with nothing remembered, the answer is the
|
||||
/// screen, which is the whole point of having one.
|
||||
#[test]
|
||||
fn the_auto_answer_ladder_is_env_then_remembered_then_unattended() {
|
||||
let mut r = list(vec![
|
||||
realm("Alpha", 1, 0, 0, 0, 1.0),
|
||||
realm("Mu", 1, 0, 0, 0, 1.0),
|
||||
]);
|
||||
assert_eq!(
|
||||
auto_answer(&r, None, false),
|
||||
None,
|
||||
"attended: show the screen"
|
||||
);
|
||||
assert_eq!(auto_answer(&r, None, true).as_deref(), Some("Alpha"));
|
||||
assert_eq!(auto_answer(&r, Some("mu"), false).as_deref(), Some("Mu"));
|
||||
r.env_realm = Some("ALPHA".into());
|
||||
assert_eq!(
|
||||
auto_answer(&r, Some("Mu"), false).as_deref(),
|
||||
Some("Alpha"),
|
||||
"WOW_REALM outranks the remembered realm"
|
||||
);
|
||||
}
|
||||
|
||||
/// A remembered realm that has left the list, or gone offline, must not strand the session: it
|
||||
/// falls through to the screen, or — unattended — to a realm that is actually up.
|
||||
#[test]
|
||||
fn a_stale_or_offline_remembered_realm_falls_through() {
|
||||
let r = list(vec![
|
||||
realm("Down", 1, 0, 0x02, 0, 1.0),
|
||||
realm("Up", 1, 0, 0, 0, 1.0),
|
||||
]);
|
||||
assert_eq!(auto_answer(&r, Some("Gone"), false), None);
|
||||
assert_eq!(auto_answer(&r, Some("Down"), false), None);
|
||||
assert_eq!(
|
||||
auto_answer(&r, Some("Down"), true).as_deref(),
|
||||
Some("Up"),
|
||||
"unattended skips the offline row rather than dialing it"
|
||||
);
|
||||
}
|
||||
|
||||
/// An offline realm can be highlighted but not entered — the reference disables its row AND
|
||||
/// its OK button.
|
||||
#[test]
|
||||
fn an_offline_realm_cannot_be_entered() {
|
||||
let mut r = list(vec![realm("Down", 1, 0, 0x02, 0, 1.0)]);
|
||||
r.select("Down");
|
||||
assert!(r.selected().is_some());
|
||||
assert!(!r.can_enter());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
//! The realm list's **layout** — the reference `GlueXML/RealmList.xml` arrangement rebuilt in Bevy
|
||||
//! UI, scaled to the window the way every glue screen is (the 1024×768 virtual screen times
|
||||
//! `height / 768`).
|
||||
//!
|
||||
//! A full-screen black-at-0.75 dim (the reference's own BACKGROUND layer, which also stops a click
|
||||
//! reaching the screen behind), then the 640×512 `HelpFrame` plate centred at the authored `+24`
|
||||
//! offset — the same plate the AddOns list uses, because `RealmList.xml` and `AddonList.xml` are
|
||||
//! the same panel. On it: the `UI-DialogBox-Header` title plate reading `SERVER_SELECTION`, the
|
||||
//! four sort-column headers, eighteen 512×16 realm rows at the authored 20 px pitch, the
|
||||
//! `UI-QuestLogTitleHighlight` selection band, the close X, and Okay / Cancel along the bottom.
|
||||
//!
|
||||
//! **Each row is four columns, and three of them are computed** — the type suffix, the character
|
||||
//! count, and the load word — see [`super::load`]. The row is spawned once with empty strings and
|
||||
//! [`refresh_rows`] writes it every frame from [`super::Realms`], which is what lets the list
|
||||
//! answer a five-second refresh without rebuilding the tree.
|
||||
//!
|
||||
//! **The category tab strip is not drawn.** The reference hides it whenever the list has a single
|
||||
//! category (`RealmList_UpdateTabs`), which is every server benilla connects to; with more than
|
||||
//! one we show every realm rather than stranding some behind a tab whose *name* we cannot yet
|
||||
//! source — see [`super`]'s note.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use crate::glue::art::{GlueArt, GOLD};
|
||||
use crate::glue::widgets::{
|
||||
abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight,
|
||||
};
|
||||
use crate::glue_strings::GlueStrings;
|
||||
|
||||
use super::load;
|
||||
use super::{Realms, SortKey};
|
||||
|
||||
use crate::char_select::wow_font;
|
||||
|
||||
/// The panel plate, straight off `RealmList.xml`.
|
||||
const BG_W: f32 = 640.0;
|
||||
const BG_H: f32 = 512.0;
|
||||
/// `RealmListBackground`'s authored CENTER offset.
|
||||
const BG_CENTER_OFF_X: f32 = 24.0;
|
||||
|
||||
/// `MAX_REALMS_DISPLAYED` (`RealmList.lua` l.2).
|
||||
pub(super) const MAX_ROWS: usize = 18;
|
||||
/// The authored row pitch: a 16-tall button plus the 4 px anchor offset to the next.
|
||||
///
|
||||
/// Note this is NOT `REALM_BUTTON_HEIGHT` (`RealmList.lua` l.1 = 16), which the reference uses for
|
||||
/// the scrollbar's step while its buttons sit 20 apart. We scroll by whole rows, so only the pitch
|
||||
/// is load-bearing here.
|
||||
const ROW_PITCH: f32 = 20.0;
|
||||
/// `RealmListRealmButton1` at TOPLEFT (22, −56).
|
||||
const ROW0_LEFT: f32 = 22.0;
|
||||
const ROW0_TOP: f32 = 56.0;
|
||||
const ROW_W: f32 = 512.0;
|
||||
const ROW_H: f32 = 16.0;
|
||||
/// `RealmListHighlight` — wider than the row it sits behind.
|
||||
const HILIGHT_W: f32 = 557.0;
|
||||
|
||||
/// The four sort columns: `(key, string key, left, width)`. The lefts chain off
|
||||
/// `RealmNameSort`'s TOPLEFT (21) through each button's authored width.
|
||||
const SORT_COLUMNS: [(SortKey, &str, f32, f32); 4] = [
|
||||
(SortKey::Name, "REALM_NAME", 21.0, 223.0),
|
||||
(SortKey::Type, "REALM_TYPE", 244.0, 80.0),
|
||||
(SortKey::Characters, "REALM_CHARACTERS", 324.0, 110.0),
|
||||
(SortKey::Load, "REALM_LOAD", 434.0, 144.0),
|
||||
];
|
||||
/// The sort header row's top edge: anchored BOTTOMLEFT to the plate's TOPLEFT at −50, so the
|
||||
/// 19-tall button's *bottom* is at 50.
|
||||
const SORT_TOP: f32 = 50.0 - 19.0;
|
||||
|
||||
/// A row's four column boxes, chained off the `RealmListRealmButtonTemplate` anchors:
|
||||
/// `NormalText` 220 wide at LEFT +5, `PVP` 50 wide at its RIGHT +10, `Players` 32 wide at that
|
||||
/// RIGHT +51, `Load` 115 wide at that RIGHT +50.
|
||||
const COL_NAME: (f32, f32) = (5.0, 220.0);
|
||||
const COL_TYPE: (f32, f32) = (235.0, 50.0);
|
||||
const COL_PLAYERS: (f32, f32) = (336.0, 32.0);
|
||||
const COL_LOAD: (f32, f32) = (418.0, 115.0);
|
||||
|
||||
/// Root of the realm-list screen (despawned whole on exit). `with_art`/`s` drive the same rebuild
|
||||
/// rule every glue screen uses: an artless early spawn upgrades when the client art lands, and a
|
||||
/// window resize rebuilds at the new glue scale.
|
||||
#[derive(Component)]
|
||||
pub(super) struct RealmListUi {
|
||||
with_art: bool,
|
||||
s: f32,
|
||||
}
|
||||
|
||||
/// One clickable control on the screen.
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum RealmAction {
|
||||
/// A realm row (0-based *screen* row, resolved against the scroll offset at click time).
|
||||
Row(usize),
|
||||
Ok,
|
||||
/// Cancel and the close X are the same action — the reference's `RealmList_OnCancel`.
|
||||
Cancel,
|
||||
Sort(SortKey),
|
||||
}
|
||||
|
||||
/// Which screen row an entity belongs to — on the row button and on each of its four texts.
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub(super) struct RowOf(pub(super) usize);
|
||||
|
||||
/// Which of a row's four columns a text entity is. One component with four values rather than four
|
||||
/// marker types: the refresh then reads every column in **one** query and one loop, instead of four
|
||||
/// that differ only in which marker they filter on and which disjointness they have to declare.
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Column {
|
||||
Name,
|
||||
Type,
|
||||
Players,
|
||||
Load,
|
||||
}
|
||||
/// The selection band, moved and tinted rather than respawned.
|
||||
#[derive(Component)]
|
||||
pub(super) struct RowHighlight;
|
||||
/// The Okay button, so the refresh can grey it out.
|
||||
#[derive(Component)]
|
||||
pub(super) struct OkButton;
|
||||
|
||||
/// Spawn the screen once its prerequisites exist, and rebuild it when the art lands or the glue
|
||||
/// scale moves — the same shape as the select screen's own materialize.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn materialize_screen(
|
||||
mut commands: Commands,
|
||||
existing: Query<(Entity, &RealmListUi)>,
|
||||
assets: Res<AssetServer>,
|
||||
mut art: ResMut<GlueArt>,
|
||||
world_assets: Option<ResMut<benilla_assets::WorldAssets>>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut add_mats: ResMut<Assets<crate::glue::add_material::AddUiMaterial>>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
if let Some(mut wa) = world_assets {
|
||||
art.ensure_loaded(&mut wa, &mut images, &mut add_mats);
|
||||
}
|
||||
let with_art = art.help_frame.is_some();
|
||||
let s = crate::glue::screen_scale(window.single().ok());
|
||||
match existing.single() {
|
||||
Ok((root, ui)) => {
|
||||
if (!ui.with_art && with_art) || ui.s != s {
|
||||
commands.entity(root).despawn();
|
||||
spawn_screen(
|
||||
&mut commands,
|
||||
&assets,
|
||||
&art,
|
||||
strings.as_deref(),
|
||||
s,
|
||||
with_art,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if with_art || time.elapsed_secs() > 1.0 {
|
||||
spawn_screen(
|
||||
&mut commands,
|
||||
&assets,
|
||||
&art,
|
||||
strings.as_deref(),
|
||||
s,
|
||||
with_art,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear the screen down on the way out of the state.
|
||||
pub(super) fn exit_realm_list(mut commands: Commands, ui: Query<Entity, With<RealmListUi>>) {
|
||||
for e in &ui {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_screen(
|
||||
commands: &mut Commands,
|
||||
assets: &AssetServer,
|
||||
art: &GlueArt,
|
||||
strings: Option<&GlueStrings>,
|
||||
s: f32,
|
||||
with_art: bool,
|
||||
) {
|
||||
let px = |v: f32| Val::Px(v * s);
|
||||
let font = wow_font(assets);
|
||||
let text = |key: &'static str| match strings {
|
||||
Some(g) => g.text(key, key).to_string(),
|
||||
None => key.to_string(),
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
RealmListUi { with_art, s },
|
||||
GlobalZIndex(1200),
|
||||
// The reference's own full-screen BACKGROUND layer: black at 0.75.
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.75)),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|screen| {
|
||||
let mut boxed = screen.spawn(Node {
|
||||
width: px(BG_W),
|
||||
height: px(BG_H),
|
||||
left: px(BG_CENTER_OFF_X),
|
||||
..default()
|
||||
});
|
||||
boxed.with_children(|b| {
|
||||
spawn_plate(b, art, s);
|
||||
spawn_header(b, art, &font, &text("SERVER_SELECTION"), s);
|
||||
spawn_close(b, art, &font, s);
|
||||
spawn_sort_headers(b, &font, strings, s);
|
||||
spawn_highlight(b, art, s);
|
||||
for row in 0..MAX_ROWS {
|
||||
spawn_row(b, &font, row, s);
|
||||
}
|
||||
// Okay / Cancel — `GlueDialogButtonTemplate` at 125×35, Cancel at BOTTOMRIGHT
|
||||
// (−46, +13) and Okay hung off its left edge with an 8 px overlap.
|
||||
let cancel_left = BG_W - 46.0 - 125.0;
|
||||
let btn_top = BG_H - 13.0 - 35.0;
|
||||
for (action, key, left) in [
|
||||
(RealmAction::Ok, "OKAY", cancel_left - 125.0 + 8.0),
|
||||
(RealmAction::Cancel, "CANCEL", cancel_left),
|
||||
] {
|
||||
let mut wrap = b.spawn(abs(s, left, btn_top, 125.0, 35.0));
|
||||
wrap.with_children(|w| {
|
||||
let e = glue_button(
|
||||
w,
|
||||
art,
|
||||
&font,
|
||||
action,
|
||||
&text(key),
|
||||
125.0,
|
||||
35.0,
|
||||
GlueBtnKind::Dialog,
|
||||
s,
|
||||
);
|
||||
if action == RealmAction::Ok {
|
||||
w.commands().entity(e).insert(OkButton);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The six-piece `HelpFrame` plate — 640×512 in two rows of three.
|
||||
fn spawn_plate(b: &mut ChildSpawnerCommands, art: &GlueArt, s: f32) {
|
||||
match &art.help_frame {
|
||||
Some(hf) => {
|
||||
for (img, l, t, w, h) in [
|
||||
(&hf.tl, 0.0, 0.0, 256.0, 256.0),
|
||||
(&hf.top, 256.0, 0.0, 256.0, 256.0),
|
||||
(&hf.tr, 512.0, 0.0, 128.0, 256.0),
|
||||
(&hf.bl, 0.0, 256.0, 256.0, 256.0),
|
||||
(&hf.bottom, 256.0, 256.0, 256.0, 256.0),
|
||||
(&hf.br, 512.0, 256.0, 128.0, 256.0),
|
||||
] {
|
||||
b.spawn((ImageNode::new(img.clone()), abs(s, l, t, w, h)));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
b.spawn((
|
||||
BackgroundColor(Color::srgba(0.05, 0.05, 0.08, 0.95)),
|
||||
overlay(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `UI-DialogBox-Header` (256×64) at TOP (−12, +12), with `SERVER_SELECTION` 14 below its top.
|
||||
fn spawn_header(
|
||||
b: &mut ChildSpawnerCommands,
|
||||
art: &GlueArt,
|
||||
font: &Handle<Font>,
|
||||
title: &str,
|
||||
s: f32,
|
||||
) {
|
||||
let left = (BG_W - 256.0) / 2.0 - 12.0;
|
||||
if let Some((header, _)) = &art.dialog_header {
|
||||
b.spawn((
|
||||
ImageNode::new(header.clone()),
|
||||
abs(s, left, -12.0, 256.0, 64.0),
|
||||
));
|
||||
}
|
||||
outlined_text(
|
||||
b,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(left * s),
|
||||
width: Val::Px(256.0 * s),
|
||||
top: Val::Px(2.0 * s),
|
||||
justify_content: JustifyContent::Center,
|
||||
..default()
|
||||
},
|
||||
(),
|
||||
(),
|
||||
GlueText {
|
||||
text: title,
|
||||
size: 12.0, // GlueFontNormalSmall
|
||||
color: GOLD,
|
||||
wrap: false,
|
||||
},
|
||||
font,
|
||||
s,
|
||||
);
|
||||
}
|
||||
|
||||
/// `GlueCloseButton` at TOPRIGHT (−42, −3) — the same action as Cancel.
|
||||
fn spawn_close(b: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>, s: f32) {
|
||||
let mut x = b.spawn((
|
||||
RealmAction::Cancel,
|
||||
Button,
|
||||
abs(s, BG_W - 42.0 - 32.0, 3.0, 32.0, 32.0),
|
||||
));
|
||||
match &art.close_btn {
|
||||
Some(cb) => {
|
||||
x.insert((
|
||||
ImageNode::new(cb.up.clone()),
|
||||
ArtSwap {
|
||||
up: cb.up.clone(),
|
||||
down: cb.down.clone(),
|
||||
},
|
||||
));
|
||||
if let Some(hi) = &cb.hi {
|
||||
x.with_children(|x| {
|
||||
x.spawn((
|
||||
Hilight,
|
||||
Visibility::Hidden,
|
||||
MaterialNode(hi.clone()),
|
||||
overlay(),
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
x.with_children(|x| {
|
||||
outlined_text(
|
||||
x,
|
||||
Node {
|
||||
left: Val::Px(10.0 * s),
|
||||
top: Val::Px(6.0 * s),
|
||||
..default()
|
||||
},
|
||||
(),
|
||||
(),
|
||||
GlueText {
|
||||
text: "X",
|
||||
size: 14.0,
|
||||
color: GOLD,
|
||||
wrap: false,
|
||||
},
|
||||
font,
|
||||
s,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The four clickable column headers (`RealmSortButtonTemplate`).
|
||||
fn spawn_sort_headers(
|
||||
b: &mut ChildSpawnerCommands,
|
||||
font: &Handle<Font>,
|
||||
strings: Option<&GlueStrings>,
|
||||
s: f32,
|
||||
) {
|
||||
for (key, string_key, left, w) in SORT_COLUMNS {
|
||||
let label = strings
|
||||
.map(|g| g.text(string_key, string_key).to_string())
|
||||
.unwrap_or_else(|| string_key.to_string());
|
||||
b.spawn((
|
||||
RealmAction::Sort(key),
|
||||
Button,
|
||||
abs(s, left, SORT_TOP, w, 19.0),
|
||||
))
|
||||
.with_children(|h| {
|
||||
outlined_text(
|
||||
h,
|
||||
Node {
|
||||
left: Val::Px(4.0 * s),
|
||||
top: Val::Px(3.0 * s),
|
||||
..default()
|
||||
},
|
||||
(),
|
||||
(),
|
||||
GlueText {
|
||||
text: &label,
|
||||
size: 12.0, // GlueFontHighlightSmall
|
||||
color: load::HIGHLIGHT,
|
||||
wrap: false,
|
||||
},
|
||||
font,
|
||||
s,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmListHighlight` — one 557×16 band, moved to the selected row and vertex-coloured to match
|
||||
/// it (`RealmListHighlightTexture:SetVertexColor`).
|
||||
fn spawn_highlight(b: &mut ChildSpawnerCommands, art: &GlueArt, s: f32) {
|
||||
let mut band = b.spawn((
|
||||
RowHighlight,
|
||||
Visibility::Hidden,
|
||||
abs(s, ROW0_LEFT, ROW0_TOP, HILIGHT_W, ROW_H),
|
||||
));
|
||||
match &art.title_highlight {
|
||||
Some(tex) => {
|
||||
band.insert(ImageNode::new(tex.clone()));
|
||||
}
|
||||
None => {
|
||||
band.insert(BackgroundColor(Color::srgba(1.0, 0.78, 0.0, 0.25)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One realm row: a 512×16 button carrying four text columns.
|
||||
fn spawn_row(b: &mut ChildSpawnerCommands, font: &Handle<Font>, row: usize, s: f32) {
|
||||
let top = ROW0_TOP + row as f32 * ROW_PITCH;
|
||||
b.spawn((
|
||||
RealmAction::Row(row),
|
||||
RowOf(row),
|
||||
Button,
|
||||
Visibility::Hidden,
|
||||
abs(s, ROW0_LEFT, top, ROW_W, ROW_H),
|
||||
))
|
||||
.with_children(|r| {
|
||||
// The name is `GlueFontNormal` (15); the three computed columns are the Small fonts (12).
|
||||
for ((col_left, col_w), size, column) in [
|
||||
(COL_NAME, 15.0, Column::Name),
|
||||
(COL_TYPE, 12.0, Column::Type),
|
||||
(COL_PLAYERS, 12.0, Column::Players),
|
||||
(COL_LOAD, 12.0, Column::Load),
|
||||
] {
|
||||
let node = Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(col_left * s),
|
||||
width: Val::Px(col_w * s),
|
||||
top: Val::Px(0.0),
|
||||
..default()
|
||||
};
|
||||
let e = outlined_text(
|
||||
r,
|
||||
node,
|
||||
(),
|
||||
(),
|
||||
GlueText {
|
||||
text: "",
|
||||
size,
|
||||
color: GOLD,
|
||||
wrap: false,
|
||||
},
|
||||
font,
|
||||
s,
|
||||
);
|
||||
r.commands().entity(e).insert((RowOf(row), column));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Write every visible row from the realm list — the reference's `RealmListUpdate`.
|
||||
///
|
||||
/// Runs every frame rather than on change, because the list is re-requested every five seconds and
|
||||
/// a realm's load band moves when *any other* realm's population does.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_rows(
|
||||
realms: Res<Realms>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
mut rows: Query<(&RowOf, &mut Visibility), (With<RealmAction>, Without<RowHighlight>)>,
|
||||
mut cols: Query<(&RowOf, &Column, &mut Text, &mut TextColor)>,
|
||||
mut band: Query<
|
||||
(&mut Node, &mut Visibility, Option<&mut ImageNode>),
|
||||
(With<RowHighlight>, Without<RealmAction>),
|
||||
>,
|
||||
mut ok: Query<&mut crate::glue::widgets::GlueDisabled, With<OkButton>>,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
let s = crate::glue::screen_scale(window.single().ok());
|
||||
let visible = realms.rows();
|
||||
let (mean, stddev) = realms.stats();
|
||||
let selected = realms.selected().map(|r| r.name.clone());
|
||||
let text = |key: &'static str| match strings.as_deref() {
|
||||
Some(g) => g.text(key, key).to_string(),
|
||||
None => key.to_string(),
|
||||
};
|
||||
// The screen row → realm index map for this frame, honouring the scroll offset.
|
||||
let at = |row: usize| visible.get(realms.offset + row).copied();
|
||||
|
||||
for (RowOf(row), mut vis) in &mut rows {
|
||||
*vis = match at(*row) {
|
||||
Some(_) => Visibility::Inherited,
|
||||
None => Visibility::Hidden,
|
||||
};
|
||||
}
|
||||
|
||||
// Where the selection band goes, decided while walking the name column.
|
||||
let mut band_row = None;
|
||||
for (RowOf(row), column, mut t, mut color) in &mut cols {
|
||||
let Some(realm) = at(*row).map(|i| &realms.realms[i]) else {
|
||||
continue;
|
||||
};
|
||||
let down = super::is_down(realm);
|
||||
let invalid = super::is_invalid(realm);
|
||||
let (new, c) = match column {
|
||||
Column::Name => {
|
||||
if selected.as_deref() == Some(realm.name.as_str()) && !down {
|
||||
band_row = Some((*row, load::highlight_color(invalid, realm.characters)));
|
||||
}
|
||||
(
|
||||
realm.name.clone(),
|
||||
load::name_colors(down, invalid, realm.characters).0,
|
||||
)
|
||||
}
|
||||
Column::Type => {
|
||||
let (key, c) = load::type_column(realm.realm_type);
|
||||
(text(key), c)
|
||||
}
|
||||
Column::Players => (load::players_text(realm.characters), load::HIGHLIGHT),
|
||||
Column::Load => {
|
||||
let level = load::realm_load_classify(realm.flags, realm.population, mean, stddev);
|
||||
let (key, c) = load::load_column(down, level);
|
||||
(text(key), c)
|
||||
}
|
||||
};
|
||||
if t.0 != new {
|
||||
t.0 = new;
|
||||
}
|
||||
if color.0 != c {
|
||||
color.0 = c;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok((mut node, mut vis, tint)) = band.single_mut() {
|
||||
match band_row {
|
||||
Some((row, color)) => {
|
||||
node.top = Val::Px((ROW0_TOP + row as f32 * ROW_PITCH) * s);
|
||||
*vis = Visibility::Inherited;
|
||||
if let Some(mut image) = tint {
|
||||
if image.color != color {
|
||||
image.color = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => *vis = Visibility::Hidden,
|
||||
}
|
||||
}
|
||||
if let Ok(mut disabled) = ok.single_mut() {
|
||||
let now = !realms.can_enter();
|
||||
if disabled.0 != now {
|
||||
disabled.0 = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1505,6 +1505,11 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> {
|
||||
|
||||
// The derived read: what this record actually puts on screen.
|
||||
let rate = e.timing.peak_rate();
|
||||
// A burst emitter's count is NOT its peak rate: the burst fires on the rising edge of
|
||||
// `enabled && rate > 0`, and an emitter whose gate closes on the same keyframe its rate
|
||||
// opens fires nothing at all. Reading `peak_rate` here reported 50 phantom particles for
|
||||
// `Strike_Impact_Chest`'s flare and sent a whole diagnosis 2.7x over on count.
|
||||
let burst = e.timing.first_burst(Some(0));
|
||||
let life = e.params.peak_lifespan();
|
||||
let speed = views[0]
|
||||
.1
|
||||
@@ -1524,7 +1529,13 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> {
|
||||
println!(
|
||||
" derived: {} · reach ~{reach:.2} yd · size {size_lo:.3}..{size_hi:.3} yd · peak alpha {:.2}",
|
||||
if e.burst() {
|
||||
format!("burst of ~{rate:.0} particles, life {life:.2}s")
|
||||
match burst {
|
||||
Some((t, n)) => {
|
||||
format!("burst of {n:.0} particles at t={t:.2}s, life {life:.2}s")
|
||||
}
|
||||
None => "NEVER FIRES — the enabled gate never opens while the rate is nonzero"
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"steady ~{:.0} live (rate {rate:.1}/s × life {life:.2}s)",
|
||||
|
||||
@@ -98,6 +98,47 @@ impl EmitTiming {
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// The **burst this emitter actually fires** on sequence slot `seq`, as
|
||||
/// `(seconds into the slot, particle count)` — or `None` when it never fires one.
|
||||
///
|
||||
/// The reference's burst gate is `enabled != 0 && sampledRate > 0`, both sampled from the
|
||||
/// same clock in the same frame, and it triggers on that predicate's **rising edge**, emitting
|
||||
/// `ftol(rate)` particles (wow-re `part-emission-burst-flag.md` §1, `0x718ed2`–`0x718ef6`;
|
||||
/// benilla's `particles::accumulate_emission` runs the same rule). Both tracks are STEP, so
|
||||
/// this walks a 60 Hz grid over the slot's keyed span — the same resolution a running frame
|
||||
/// gives it — and reports the first instant the predicate holds.
|
||||
///
|
||||
/// **`None` is a real shipped shape, not a parse failure.** An emitter whose enabled track
|
||||
/// falls to 0 on the very keyframe its rate track rises off 0 never emits anything: the two
|
||||
/// conditions are never true together. `Spells\\Strike_Impact_Chest.m2`'s gold flare emitter
|
||||
/// is exactly that, and reading its [`Self::peak_rate`] as a particle count overstates the
|
||||
/// effect by 50 particles that neither client draws.
|
||||
///
|
||||
/// A gseq-tagged track is sampled against `shared_now = 0` — this is a static dump/inspection
|
||||
/// face, not a running clock.
|
||||
pub fn first_burst(&self, seq: Option<usize>) -> Option<(f32, f32)> {
|
||||
let i = self.idx(seq);
|
||||
let span = |o: &Option<ScalarAnim>| -> f32 {
|
||||
o.as_ref()
|
||||
.and_then(|a| a.keys.last())
|
||||
.map_or(0.0, |&(t, _)| t)
|
||||
};
|
||||
let end = span(self.rate.get(i)?).max(span(self.enabled.get(i)?));
|
||||
const STEP: f32 = 1.0 / 60.0;
|
||||
let mut frame = 0;
|
||||
loop {
|
||||
let t = frame as f32 * STEP;
|
||||
if t > end + STEP {
|
||||
return None;
|
||||
}
|
||||
let rate = self.rate(seq, t, 0.0);
|
||||
if rate > 0.0 && self.emitting(seq, t, 0.0) {
|
||||
return Some((t, rate.trunc()));
|
||||
}
|
||||
frame += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Some(rate)` when every slot bakes the same single-key rate — the overwhelmingly common
|
||||
/// shape, and the dump instruments' quiet case.
|
||||
pub fn constant_rate(&self) -> Option<f32> {
|
||||
|
||||
@@ -419,14 +419,14 @@ pub struct SpellVisualCatalog {
|
||||
/// `SpellVisualEffectName` id → the effect model's `.mdx` path (field 2 — the table's one
|
||||
/// column the kit slots consume).
|
||||
effect_paths: HashMap<u32, String>,
|
||||
/// The `"HARDCODED *"`-named rows, name → path — the client's engine-spawned effect set,
|
||||
/// The `"HARDCODED *"`-named rows, name → (record id, path) — the client's engine-spawned effect set,
|
||||
/// resolved BY NAME once at boot exactly like this (`0x61f5b0` matches a 14-string baked
|
||||
/// table against the name column: loot art, footsteps, breath, level-up…; wow-re
|
||||
/// `loot-corpse-effect.md` + `levelup-ding.md`). Three consumers today: "HARDCODED Loot Art"
|
||||
/// (id 14 → `Particles\LootFX.mdl`, the corpse sparkle), "HARDCODED Unit Level Up"
|
||||
/// (id 21 → `Spells\LevelUp\LevelUp.mdl`, the ding) and "HARDCODED Mount Poof"
|
||||
/// (id 1185 → `Spells\DruidMorph_Impact_Base.mdx`, the mount-up cloud — decision 0927).
|
||||
hardcoded: HashMap<String, String>,
|
||||
hardcoded: HashMap<String, (u32, String)>,
|
||||
/// `SpellChainEffects` id → the beam's geometry/animation row ([`chain_effects`], decision 0955).
|
||||
/// Reached only through a kit's [`VisualKit::chain_proc`].
|
||||
chain_effects: HashMap<u32, ChainEffect>,
|
||||
@@ -464,12 +464,16 @@ impl SpellVisualCatalog {
|
||||
self
|
||||
}
|
||||
|
||||
/// Seed one `"HARDCODED …"` name → model path, for fixtures exercising the engine-spawned
|
||||
/// effects (the loot sparkle, the level-up ding, the mount poof). The live path is the boot
|
||||
/// name-resolve inside [`load_spell_visual_catalog`], which is what `0x61f5b0` does.
|
||||
/// Seed one `"HARDCODED …"` name → (record id, model path), for fixtures exercising the
|
||||
/// engine-spawned effects (the loot sparkle, the level-up ding, the mount poof). The live
|
||||
/// path is the boot name-resolve inside [`load_spell_visual_catalog`], which is what
|
||||
/// `0x61f5b0` does. The **id** is carried because it is half the reference's same-slot
|
||||
/// replace key (`0x6208e0`; decision 2057) — an engine-spawned effect dedups exactly like a
|
||||
/// kit slot's.
|
||||
#[must_use]
|
||||
pub fn with_hardcoded(mut self, name: &str, path: &str) -> Self {
|
||||
self.hardcoded.insert(name.to_string(), path.to_string());
|
||||
pub fn with_hardcoded(mut self, name: &str, id: u32, path: &str) -> Self {
|
||||
self.hardcoded
|
||||
.insert(name.to_string(), (id, path.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -521,21 +525,22 @@ impl SpellVisualCatalog {
|
||||
.filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// An engine-spawned hardcoded effect's model path, by the client's own baked name string
|
||||
/// ([`SpellVisualCatalog::hardcoded`]). Mirrors the client's boot name-resolve
|
||||
/// An engine-spawned hardcoded effect's `(record id, model path)`, by the client's own baked
|
||||
/// name string ([`SpellVisualCatalog::hardcoded`]). Mirrors the client's boot name-resolve
|
||||
/// (`0x61f5b0`, stricmp-family — hence the case-insensitive compare over the tiny set);
|
||||
/// `None` when the shipped table names no such row.
|
||||
pub fn hardcoded_effect(&self, name: &str) -> Option<&str> {
|
||||
/// `None` when the shipped table names no such row. The id rides along because every consumer
|
||||
/// hands it to the same-slot replace (`0x6208e0`, decision 2057).
|
||||
pub fn hardcoded_effect(&self, name: &str) -> Option<(u32, &str)> {
|
||||
self.hardcoded
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
.map(|(_, v)| v.as_str())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|(_, (id, path))| (*id, path.as_str()))
|
||||
.filter(|(_, p)| !p.is_empty())
|
||||
}
|
||||
|
||||
/// The `"HARDCODED Loot Art"` row's model path — the lootable-corpse sparkle, riding the
|
||||
/// shared hardcoded map (one name-resolve mechanism for the whole engine-spawned set).
|
||||
pub fn loot_art_path(&self) -> Option<&str> {
|
||||
/// The `"HARDCODED Loot Art"` row — the lootable-corpse sparkle, riding the shared hardcoded
|
||||
/// map (one name-resolve mechanism for the whole engine-spawned set).
|
||||
pub fn loot_art_effect(&self) -> Option<(u32, &str)> {
|
||||
self.hardcoded_effect("HARDCODED Loot Art")
|
||||
}
|
||||
|
||||
@@ -709,7 +714,7 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result<SpellVisualCatalog
|
||||
// the client's own boot name-resolve can hit (the "HARDCODED " prefix; its
|
||||
// matchers are stricmp-family, so lookups compare case-insensitively).
|
||||
if let Some(name) = str_at(&sven_set, r, 1).filter(|n| n.starts_with("HARDCODED ")) {
|
||||
hardcoded.insert(name, path.clone());
|
||||
hardcoded.insert(name, (id, path.clone()));
|
||||
}
|
||||
effect_paths.insert(id, path);
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ fn real_spell_visual_chain_resolves_fireball() {
|
||||
// (row 21, byte-verified `0x61f5b0`/`0x8618e0`, decision 0304's §5 fold-back).
|
||||
assert_eq!(
|
||||
cat.hardcoded_effect("HARDCODED Unit Level Up"),
|
||||
Some("Spells\\LevelUp\\LevelUp.mdl"),
|
||||
Some((21, "Spells\\LevelUp\\LevelUp.mdl")),
|
||||
"the level-up pillar resolves by name"
|
||||
);
|
||||
assert!(
|
||||
@@ -388,7 +388,7 @@ fn real_effect_name_table_resolves_the_loot_art_row() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_spell_visual_catalog(&mut chain).expect("load the visual catalog");
|
||||
assert_eq!(cat.loot_art_path(), Some("Particles\\LootFX.mdl"));
|
||||
assert_eq!(cat.loot_art_effect(), Some((14, "Particles\\LootFX.mdl")));
|
||||
// The model the row names ships in the chain (consumers rewrite .mdl → .m2 to load it).
|
||||
assert!(
|
||||
chain.read_file("Particles\\LootFX.m2").is_ok(),
|
||||
|
||||
@@ -1391,3 +1391,49 @@ fn the_language_declaring_spells_cover_nine_of_thirteen_languages() {
|
||||
// An ordinary ability declares nothing.
|
||||
assert_eq!(spells.declared_language(133), None); // Fireball
|
||||
}
|
||||
|
||||
/// The hostility classifier ([`SpellDisplay::is_harmful`] — the client's `0x6ea280 == 2`) on the
|
||||
/// real 5875 rows, both routes: the enemy implicit target in slot A (Fireball 133, Charge 100,
|
||||
/// Sunder Armor 7386 — all `A[0] = 6`), an enemy area reached only through slot B (Frost Nova
|
||||
/// 122: `A[0] = 22` caster coordinates, `B[0] = 15` src-area enemy — harmful through B alone),
|
||||
/// and the helpful/neutral rows that must not flinch their target (Renew 139 and Healing Touch
|
||||
/// 5185 = 21 single friend, Arcane Intellect 1459 = 21, Battle Shout 6673 = 20 party area).
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn real_is_harmful_pins() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let spells = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
|
||||
let row = |id: u32| {
|
||||
spells
|
||||
.get(id)
|
||||
.unwrap_or_else(|| panic!("spell {id} in the catalog"))
|
||||
};
|
||||
for (id, name) in [
|
||||
(133u32, "Fireball"),
|
||||
(100, "Charge"),
|
||||
(7386, "Sunder Armor"),
|
||||
(122, "Frost Nova"),
|
||||
] {
|
||||
assert!(row(id).is_harmful(), "{name} ({id}) targets enemies");
|
||||
}
|
||||
assert_eq!(
|
||||
(
|
||||
row(122).effect_implicit_target_a[0],
|
||||
row(122).effect_implicit_target_b[0]
|
||||
),
|
||||
(22, 15),
|
||||
"Frost Nova is harmful through its B slot, the A slot being the caster's own spot"
|
||||
);
|
||||
for (id, name) in [
|
||||
(139u32, "Renew"),
|
||||
(5185, "Healing Touch"),
|
||||
(1459, "Arcane Intellect"),
|
||||
(6673, "Battle Shout"),
|
||||
] {
|
||||
assert!(
|
||||
!row(id).is_harmful(),
|
||||
"{name} ({id}) does not target enemies"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,12 @@ pub struct SpellDisplay {
|
||||
/// enum the cast-arm's switch adjusts the flag_word by (and the usable walk's
|
||||
/// CanAttack/CanAssist fork inside the TargetAuraState leg keys on: 6 = enemy, 21 = friend).
|
||||
pub implicit_target_a1: u32,
|
||||
/// `EffectImplicitTargetA[3]` (columns 82–84, [`COL_IMPLICIT_TARGET_A1`]) and
|
||||
/// `EffectImplicitTargetB[3]` (columns 85–87, [`COL_IMPLICIT_TARGET_B1`]) — every effect's
|
||||
/// implicit-target pair, the input of [`Self::is_harmful`] (the client's hostility classifier
|
||||
/// walks all three slots, A then B). Slot 0 of A is also [`Self::implicit_target_a1`].
|
||||
pub effect_implicit_target_a: [u32; 3],
|
||||
pub effect_implicit_target_b: [u32; 3],
|
||||
/// `Stances` (column 11) — forms the spell is *explicitly* castable in, `1 << (form-1)` each
|
||||
/// (the form gate [`Self::usable_in_form`]). 0 = no form requirement of its own.
|
||||
pub stances: u32,
|
||||
@@ -383,6 +389,8 @@ impl Default for SpellDisplay {
|
||||
effect_real_points_per_level: [0.0; 3],
|
||||
effect_amplitude: [0; 3],
|
||||
effect_apply_aura: [0; 3],
|
||||
effect_implicit_target_a: [0; 3],
|
||||
effect_implicit_target_b: [0; 3],
|
||||
effect_mechanic: [0; 3],
|
||||
effect_radius_index: [0; 3],
|
||||
effect_chain_targets: [0; 3],
|
||||
@@ -457,6 +465,31 @@ impl SpellDisplay {
|
||||
/// Byte-verified in wow-re (`SpellRec+0x20&0x20 || +0x18&0x2` — the test every ranged trigger
|
||||
/// runs: the `SMSG_SPELL_START` stance/ammo sites `0x6e78b6`/`0x6e78f3` and the local cast-send
|
||||
/// site `0x6e5930`).
|
||||
/// The client's spell-hostility classifier `Spell_C::GetSpellVisualState` (`0x6ea280`),
|
||||
/// reduced to its `== 2` answer — **"this spell targets enemies"** — which is the gate on the
|
||||
/// victim's **wound flinch after a spell impact** (the instant-hit loop `0x6e8bf0` @
|
||||
/// `0x6e8c7b`, and the reflect impact `0x6e8cb0` @ `0x6e8cf1`; decision 2058). Byte-read
|
||||
/// 2026-09-07 off `WoW.exe`: `Targets & 0x100` (the ally flag) ⇒ 1, never harmful; else
|
||||
/// `Targets & 0x80` (the enemy flag) ⇒ 2; else, for each of the three effects, A then B, an
|
||||
/// implicit target in the byte tables `0x6ea338` / `0x6ea378` (identical) marked `0` ⇒ 2 —
|
||||
/// the set is `{2, 6, 15, 16, 24, 28, 53, 54}`, exactly the ids the modern enum names
|
||||
/// `*_ENEMY` (nearby enemy, target enemy, the two enemy areas, the two enemy cones, the enemy
|
||||
/// dest, the channeled enemy area). The function's remaining passes decide 1 (helpful) vs 0
|
||||
/// and can never yield 2, so this predicate is the whole `== 2` truth.
|
||||
pub fn is_harmful(&self) -> bool {
|
||||
const ENEMY_TARGETS: [u32; 8] = [2, 6, 15, 16, 24, 28, 53, 54];
|
||||
if self.targets & 0x100 != 0 {
|
||||
return false;
|
||||
}
|
||||
if self.targets & 0x80 != 0 {
|
||||
return true;
|
||||
}
|
||||
(0..3).any(|i| {
|
||||
ENEMY_TARGETS.contains(&self.effect_implicit_target_a[i])
|
||||
|| ENEMY_TARGETS.contains(&self.effect_implicit_target_b[i])
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ranged_attack(&self) -> bool {
|
||||
self.attributes_ex2 & ATTR_EX2_AUTO_REPEAT != 0 || self.attributes & ATTR_RANGED != 0
|
||||
}
|
||||
@@ -831,3 +864,33 @@ impl FormRefusal {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `0x6ea280 == 2` predicate on synthetic rows: the two `Targets` flags outrank the
|
||||
/// implicit-target walk (ally wins over everything, enemy wins next), and the walk reads all
|
||||
/// three effects' A and B slots against the byte tables' enemy set.
|
||||
#[test]
|
||||
fn is_harmful_follows_the_client_classifier() {
|
||||
let mut d = SpellDisplay::default();
|
||||
assert!(!d.is_harmful(), "an empty row targets nobody");
|
||||
d.targets = 0x80;
|
||||
assert!(d.is_harmful(), "the enemy target flag alone is harmful");
|
||||
d.targets = 0x80 | 0x100;
|
||||
assert!(!d.is_harmful(), "the ally flag wins over the enemy flag");
|
||||
d.targets = 0;
|
||||
d.effect_implicit_target_a = [6, 0, 0];
|
||||
assert!(d.is_harmful(), "TARGET_UNIT_TARGET_ENEMY in slot 0");
|
||||
d.effect_implicit_target_a = [21, 0, 0];
|
||||
assert!(!d.is_harmful(), "a single-friend target is not harmful");
|
||||
d.effect_implicit_target_b = [0, 16, 0];
|
||||
assert!(d.is_harmful(), "an enemy area in a B slot counts too");
|
||||
d.effect_implicit_target_b = [0, 0, 0];
|
||||
d.effect_implicit_target_a = [22, 0, 54];
|
||||
assert!(d.is_harmful(), "the enemy cone in the third effect");
|
||||
d.targets = 0x100;
|
||||
assert!(!d.is_harmful(), "the ally flag short-circuits the walk");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,11 @@ const COL_TARGETS: usize = 13;
|
||||
/// Ice Armor 7302 / Feign Death 5384 = 1 (self — clears bit 10), Arcane Intellect 1459 / Lesser
|
||||
/// Heal 2050 = 21 (→ assist bit 8), Battle Shout 6673 = 20 (party-area — a no-op arm).
|
||||
const COL_IMPLICIT_TARGET_A1: usize = 82;
|
||||
/// `EffectImplicitTargetB[0]` (`SpellRec+0x154`, `0x154/4 == 85`) — the second implicit-target
|
||||
/// column, walked beside A by the hostility classifier `0x6ea280` ([`SpellDisplay::is_harmful`]).
|
||||
/// Empirical pin: Frost Nova 122 carries A = 22 (caster coordinates) and B = 15 (src-area enemy)
|
||||
/// — harmful through B alone.
|
||||
const COL_IMPLICIT_TARGET_B1: usize = 85;
|
||||
/// The usable-walk columns (`IsSpellUsableNow 0x6e3d60`'s §2a gate table, wow-re
|
||||
/// `action-button-state-api.md`, byte-verified 2026-07-10; column = SpellRec-offset/4).
|
||||
/// Empirical pins on the real 5875 data: Claw 1082 Stances `0x1` (cat = form 1), Ambush 8676
|
||||
@@ -807,6 +812,12 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
modal_next_spell: u32_at(r, COL_MODAL_NEXT_SPELL).unwrap_or(0),
|
||||
targets: u32_at(r, COL_TARGETS).unwrap_or(0),
|
||||
implicit_target_a1: u32_at(r, COL_IMPLICIT_TARGET_A1).unwrap_or(0),
|
||||
effect_implicit_target_a: std::array::from_fn(|i| {
|
||||
u32_at(r, COL_IMPLICIT_TARGET_A1 + i).unwrap_or(0)
|
||||
}),
|
||||
effect_implicit_target_b: std::array::from_fn(|i| {
|
||||
u32_at(r, COL_IMPLICIT_TARGET_B1 + i).unwrap_or(0)
|
||||
}),
|
||||
stances: u32_at(r, COL_STANCES).unwrap_or(0),
|
||||
stances_not: u32_at(r, COL_STANCES_NOT).unwrap_or(0),
|
||||
caster_aura_state: u32_at(r, COL_CASTER_AURA_STATE).unwrap_or(0),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The **burst gate**, pinned against a shipped asset whose gate never opens.
|
||||
//!
|
||||
//! A burst emitter fires on the rising edge of `enabled != 0 && sampledRate > 0`, both tracks
|
||||
//! sampled from the same clock in the same frame (wow-re `part-emission-burst-flag.md` §1,
|
||||
//! `0x718ed2`–`0x718ef6`). `Spells\Strike_Impact_Chest.m2` — the gold flare every warrior ability
|
||||
//! impact plays (kit 437 → `SpellVisualEffectName` 416, attached at the target's chest `0x22`) —
|
||||
//! carries two burst emitters, and **only one of them ever emits**:
|
||||
//!
|
||||
//! - #0 (`WEAPON\FLARE.BLP`): the enabled track steps `1 → 0` at the exact keyframe the rate track
|
||||
//! steps `0 → 50`. The two conditions are never true together, so it fires nothing, ever.
|
||||
//! - #1 (`FirePlume64.blp`): gate held ON by a single key, rate `0 → 30 → 0`. It fires once.
|
||||
//!
|
||||
//! This is here because the *instrument* got it wrong first: the `m2part` dump derived a burst
|
||||
//! count from `peak_rate()` alone and reported "burst of ~50 particles" for #0, which put a
|
||||
//! diagnosis of "our impact bursts are too big" 2.7x over on particle count before anyone noticed.
|
||||
//! An emitter that never fires is a real shipped shape, not a parse failure, and the reader must
|
||||
//! be able to see the difference.
|
||||
//!
|
||||
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
|
||||
|
||||
use benilla_formats::{open_chain, parse_m2_particle_emitters};
|
||||
|
||||
const STRIKE_IMPACT: &str = "Spells\\Strike_Impact_Chest.m2";
|
||||
|
||||
#[test]
|
||||
fn the_flare_emitter_never_fires_and_the_plume_fires_once() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||||
let bytes = chain
|
||||
.read_file(STRIKE_IMPACT)
|
||||
.expect("read the impact model");
|
||||
|
||||
let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters");
|
||||
assert_eq!(emitters.len(), 2, "two emitters on the impact flash");
|
||||
for e in &emitters {
|
||||
assert!(
|
||||
e.burst(),
|
||||
"both carry the burst flag (shipped flags 0x8429)"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
emitters[0].timing.first_burst(Some(0)),
|
||||
None,
|
||||
"the flare's gate closes on the same keyframe its rate opens — it emits nothing",
|
||||
);
|
||||
assert!(
|
||||
emitters[0].timing.peak_rate() > 0.0,
|
||||
"and its peak rate is nonzero, which is exactly why peak_rate is not a particle count",
|
||||
);
|
||||
|
||||
let (t, n) = emitters[1]
|
||||
.timing
|
||||
.first_burst(Some(0))
|
||||
.expect("the plume does fire");
|
||||
assert_eq!(n, 30.0, "one burst of 30 particles");
|
||||
assert!(
|
||||
(0.0..=0.067).contains(&t),
|
||||
"fired within the first two frames of the clip, got t={t}",
|
||||
);
|
||||
}
|
||||
@@ -261,7 +261,32 @@ pub fn write_realm_list_request(w: &mut impl Write) -> std::io::Result<()> {
|
||||
w.write_all(&packet)
|
||||
}
|
||||
|
||||
/// The three **magic populations** the reference's realm-list parser rewrites, and the flag bit
|
||||
/// each one stands in for: `(population as sent, population rewritten to, flag OR'd)`.
|
||||
///
|
||||
/// Only bits `0x01`/`0x02`(/`0x04`) are real server flags on the wire. Recommended, New and Full
|
||||
/// travel as *populations* — the parser recognises the exact float, replaces it, and ORs in the bit
|
||||
/// the load band later reads. Compared by bit pattern because that is what the binary compares, and
|
||||
/// because it says plainly that these are sentinels rather than a numeric range.
|
||||
///
|
||||
/// VERIFIED, wow-re `system/glue/scratch/realm-list-bindings.md` §5 (the parser is `0x5b2230`).
|
||||
pub(crate) const MAGIC_POPULATIONS: [(u32, u32, u8); 3] = [
|
||||
(0x4416_0000, 0x0000_0000, 0x20), // 600.0 → 0.0 — Recommended
|
||||
(0x4348_0000, 0x3a83_126f, 0x40), // 200.0 → 0.001 — New
|
||||
(0x43c8_0000, 0x4100_0000, 0x80), // 400.0 → 8.0 — Full
|
||||
];
|
||||
|
||||
/// Read `CMD_REALM_LIST_Server` into the advertised realms.
|
||||
///
|
||||
/// **Every per-realm field is kept.** The flags byte, the category byte and the realm id were
|
||||
/// read-and-dropped for as long as benilla connected to `realms.first()` without ever drawing a
|
||||
/// list; the realm-list screen displays or groups on all three, and the population is the input to
|
||||
/// the load band rather than a string to print.
|
||||
///
|
||||
/// **And the population is rewritten on the way in** — see [`MAGIC_POPULATIONS`]. This has to
|
||||
/// happen *here*, in the parser, exactly as the reference does it: the realm-list screen computes a
|
||||
/// mean over every realm's population, and a realm advertised as Recommended arrives carrying
|
||||
/// `600.0`, which would drag that mean far enough to mislabel every other row on the list.
|
||||
pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
|
||||
let opcode = read_u8(r)?;
|
||||
if opcode != CMD_REALM_LIST {
|
||||
@@ -273,19 +298,30 @@ pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
|
||||
let mut realms = Vec::with_capacity(number_of_realms as usize);
|
||||
for _ in 0..number_of_realms {
|
||||
let realm_type = read_u32_le(r)?;
|
||||
let _flag = read_u8(r)?;
|
||||
let mut flags = read_u8(r)?;
|
||||
let name = read_cstring(r)?;
|
||||
let address = read_cstring(r)?;
|
||||
let population = read_f32_le(r)?;
|
||||
let mut population = read_f32_le(r)?;
|
||||
let characters = read_u8(r)?;
|
||||
let _category = read_u8(r)?;
|
||||
let _realm_id = read_u8(r)?;
|
||||
let category = read_u8(r)?;
|
||||
let id = read_u8(r)?;
|
||||
// The sentinel swap, before anyone can average this number.
|
||||
if let Some(&(_, rewritten, bit)) = MAGIC_POPULATIONS
|
||||
.iter()
|
||||
.find(|(magic, _, _)| *magic == population.to_bits())
|
||||
{
|
||||
population = f32::from_bits(rewritten);
|
||||
flags |= bit;
|
||||
}
|
||||
realms.push(RealmInfo {
|
||||
name,
|
||||
address,
|
||||
population: format!("{population}"),
|
||||
population,
|
||||
characters,
|
||||
realm_type,
|
||||
flags,
|
||||
category,
|
||||
id,
|
||||
});
|
||||
}
|
||||
let _footer_padding = read_u16_le(r)?; // consume so the stream stays aligned (see challenge reply)
|
||||
@@ -371,4 +407,86 @@ mod tests {
|
||||
);
|
||||
assert_eq!(&packet[73..], &[0, 0]);
|
||||
}
|
||||
|
||||
/// Every per-realm field the wire carries survives the read.
|
||||
///
|
||||
/// The flags byte, the category byte and the realm id were read into `_`-prefixed locals and
|
||||
/// dropped for as long as nothing drew a realm list; the population was stringified on the way
|
||||
/// in. All four are load-bearing for the realm-list screen, so pin the byte layout *and* the
|
||||
/// capture — a field silently going back to `_` is the failure this catches.
|
||||
#[test]
|
||||
fn the_realm_list_keeps_every_field_the_wire_carries() {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&8u32.to_le_bytes()); // realm_type: RPPVP
|
||||
body.push(0x42); // flags: 0x02 offline | 0x40 sentinel
|
||||
body.extend_from_slice(b"Onyxia\0");
|
||||
body.extend_from_slice(b"127.0.0.1:8085\0");
|
||||
body.extend_from_slice(&1.75f32.to_le_bytes()); // population
|
||||
body.push(3); // characters
|
||||
body.push(2); // category
|
||||
body.push(9); // realm id
|
||||
|
||||
let mut packet = vec![CMD_REALM_LIST];
|
||||
packet.extend_from_slice(&(body.len() as u16 + 3).to_le_bytes()); // size
|
||||
packet.extend_from_slice(&0u32.to_le_bytes()); // header padding
|
||||
packet.push(1); // number_of_realms
|
||||
packet.extend_from_slice(&body);
|
||||
packet.extend_from_slice(&0u16.to_le_bytes()); // footer padding
|
||||
|
||||
let realms = read_realm_list(&mut packet.as_slice()).unwrap();
|
||||
assert_eq!(realms.len(), 1);
|
||||
let r = &realms[0];
|
||||
assert_eq!(r.name, "Onyxia");
|
||||
assert_eq!(r.address, "127.0.0.1:8085");
|
||||
assert_eq!(r.realm_type, 8);
|
||||
assert_eq!(r.flags, 0x42);
|
||||
assert_eq!(r.population, 1.75);
|
||||
assert_eq!(r.characters, 3);
|
||||
assert_eq!(r.category, 2);
|
||||
assert_eq!(r.id, 9);
|
||||
}
|
||||
|
||||
/// **Recommended / New / Full arrive as populations, not as flags**, and the parser swaps them
|
||||
/// before anyone can average the number.
|
||||
///
|
||||
/// This is the one place it can be done. The realm list's load band is computed from a mean
|
||||
/// over every realm's population, so a single realm advertised as Recommended (600.0) sitting
|
||||
/// unswapped in a list of realms at ~1.0 drags the mean past every other row and relabels the
|
||||
/// whole screen. The rewritten values are the reference's own, bit for bit.
|
||||
#[test]
|
||||
fn the_three_magic_populations_become_flags_and_are_rewritten() {
|
||||
let one = |pop: f32| {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0u32.to_le_bytes());
|
||||
body.push(0x02); // a real wire flag, which must survive the OR
|
||||
body.extend_from_slice(b"R\0");
|
||||
body.extend_from_slice(b"h:1\0");
|
||||
body.extend_from_slice(&pop.to_le_bytes());
|
||||
body.extend_from_slice(&[0, 1, 0]);
|
||||
let mut packet = vec![CMD_REALM_LIST];
|
||||
packet.extend_from_slice(&(body.len() as u16 + 3).to_le_bytes());
|
||||
packet.extend_from_slice(&0u32.to_le_bytes());
|
||||
packet.push(1);
|
||||
packet.extend_from_slice(&body);
|
||||
packet.extend_from_slice(&0u16.to_le_bytes());
|
||||
read_realm_list(&mut packet.as_slice()).unwrap().remove(0)
|
||||
};
|
||||
|
||||
let recommended = one(600.0);
|
||||
assert_eq!(recommended.flags, 0x02 | 0x20, "the wire flag survives");
|
||||
assert_eq!(recommended.population, 0.0);
|
||||
|
||||
let new = one(200.0);
|
||||
assert_eq!(new.flags, 0x02 | 0x40);
|
||||
assert_eq!(new.population.to_bits(), 0x3a83_126f, "0.001f exactly");
|
||||
|
||||
let full = one(400.0);
|
||||
assert_eq!(full.flags, 0x02 | 0x80);
|
||||
assert_eq!(full.population, 8.0);
|
||||
|
||||
// An ordinary population is left alone, flags and all.
|
||||
let plain = one(1.5);
|
||||
assert_eq!(plain.flags, 0x02);
|
||||
assert_eq!(plain.population, 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,20 @@ fn main() -> Result<()> {
|
||||
println!("no realms advertised");
|
||||
}
|
||||
for (i, realm) in logon.realms.iter().enumerate() {
|
||||
// Every field the wire carries: the three that used to be dropped (flags, category,
|
||||
// realm id) are what the realm-list screen greys rows out and groups tabs by, and the
|
||||
// population is the raw float the load band is computed FROM, not the word it shows.
|
||||
println!(
|
||||
"[Realm {}] {} — {} @ {} ({} characters)",
|
||||
"[Realm {}] {} @ {} — type {} flags {:#04x} pop {} chars {} category {} id {}",
|
||||
i + 1,
|
||||
realm.name,
|
||||
realm.population,
|
||||
realm.address,
|
||||
realm.characters
|
||||
realm.realm_type,
|
||||
realm.flags,
|
||||
realm.population,
|
||||
realm.characters,
|
||||
realm.category,
|
||||
realm.id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,18 @@ pub enum SessionEvent {
|
||||
/// A login attempt progressed to `stage` (decision 0539) — IO-thread-emitted, like
|
||||
/// [`Self::CharacterList`], never wire-decoded.
|
||||
LoginStage { stage: LoginStage },
|
||||
/// **The realms this account may enter** (`CMD_REALM_LIST`), and the IO thread's third park.
|
||||
///
|
||||
/// Emitted the moment the SRP6 logon succeeds, *before* any world socket is dialed: which
|
||||
/// realm to dial is the answer this park is waiting for. Like [`Self::CharacterList`] it is a
|
||||
/// pure IO-thread emit and the thread blocks on its channel until the app answers — the app
|
||||
/// owns the policy (a remembered realm, `WOW_REALM`, or the player on the realm-list screen),
|
||||
/// exactly as it owns the character pick.
|
||||
///
|
||||
/// Re-emitted on every refresh while the screen is up (the reference re-requests the list
|
||||
/// every 5 s) and again when the player asks to change realm, so the app never has to cache a
|
||||
/// list across parks.
|
||||
RealmList { realms: Vec<crate::RealmInfo> },
|
||||
/// **We are queued for a full realm** (`SMSG_AUTH_RESPONSE(AUTH_WAIT_QUEUE)`) — a wait, not
|
||||
/// an outcome. Emitted once per queue packet while the world handshake is parked; the attempt
|
||||
/// is still live and ends normally with a roster (admitted) or a failure. `position` is `None`
|
||||
@@ -321,6 +333,13 @@ pub enum SessionEvent {
|
||||
/// lerp is armed only for NON-heartbeat events (`0x619090` excludes tag `0x26`); a
|
||||
/// heartbeat applies as an outright snap (decision 0601).
|
||||
heartbeat: bool,
|
||||
/// True for `MSG_MOVE_TELEPORT` — somebody else **blinked** (decision 2061): the observer
|
||||
/// leg of the near-teleport, broadcast by `MovementPacketSender::SendTeleportToObservers`
|
||||
/// once the mover acked its own `MSG_MOVE_TELEPORT_ACK`. Same body as any relay, but
|
||||
/// `position` is a **discontinuity, not a step** — so it applies as an outright snap and is
|
||||
/// excluded from the pre-fire reconcile, which exists to make a *continuous* pose land
|
||||
/// smoothly and would otherwise glide a blinking mage across the intervening 20 yards.
|
||||
teleport: bool,
|
||||
fall_time: u32,
|
||||
jump: Option<JumpInfo>,
|
||||
transport: Option<TransportPose>,
|
||||
|
||||
@@ -703,6 +703,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
pitch,
|
||||
time,
|
||||
heartbeat: opcode == crate::messages::opcode::MSG_MOVE_HEARTBEAT,
|
||||
teleport: opcode == crate::messages::opcode::MSG_MOVE_TELEPORT,
|
||||
fall_time,
|
||||
jump,
|
||||
transport,
|
||||
@@ -973,6 +974,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
pitch,
|
||||
time,
|
||||
heartbeat: false,
|
||||
teleport: false,
|
||||
fall_time,
|
||||
jump,
|
||||
transport,
|
||||
|
||||
@@ -121,23 +121,97 @@ fn dial(host: &str, port: u16) -> Result<TcpStream> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A realm as advertised by the auth server's realm list.
|
||||
/// A realm as advertised by the auth server's realm list — **every** field the wire carries.
|
||||
///
|
||||
/// Three of them used to be dropped on the floor (`_flag`, `_category`, `_realm_id`) and
|
||||
/// `population` was stringified on arrival, which was survivable only while benilla connected to
|
||||
/// `realms.first()` and never drew a list. The realm-list screen needs all of them, and it needs
|
||||
/// the population as the float it is.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RealmInfo {
|
||||
pub name: String,
|
||||
/// `host:port` of the world server, as the client would connect to it.
|
||||
pub address: String,
|
||||
pub population: String,
|
||||
/// The population float — **not** what the realm list displays. The displayed word
|
||||
/// (`Low`/`Medium`/`High`/`Full`/…) is a *band* computed against the mean and standard
|
||||
/// deviation of **every** realm; see `realm_select::load` on the app side.
|
||||
///
|
||||
/// Already **rewritten** if the server sent one of the three sentinel values — see
|
||||
/// [`auth::MAGIC_POPULATIONS`]. What is stored here is what the reference's own parser would
|
||||
/// hold, which is the only value it is safe to average.
|
||||
pub population: f32,
|
||||
/// How many characters this account has on that realm — a genuine count, printed by the list as
|
||||
/// `"(3)"`. (The client's realm record keeps it at `[realm+0x130]`; wow-re's earlier band sweep
|
||||
/// had read that offset as the realm type, which is really the dword at `[+0x04]` below.)
|
||||
pub characters: u8,
|
||||
/// The wire's `realm_type` (the list "icon"): 0 Normal, 1 PvP, 6 RP, 8 RPPvP — the select
|
||||
/// screen's `(PVP)`/`(RP)` realm-name suffix keys on it.
|
||||
/// The realm **type** — the join key for the game-type columns, *not* an enumeration to match
|
||||
/// on directly. `GetRealmInfo` resolves `(pvp, rp)` by scanning `Cfg_Configs.dbc` for the row
|
||||
/// whose `RealmType` equals this; `realm_select::load::pvp_rp` is that table.
|
||||
pub realm_type: u32,
|
||||
/// The realm-flags byte — the client's realm record `[realm+0x08]`.
|
||||
///
|
||||
/// **Only `0x01` (invalid) and `0x02` (offline) — plus `0x04` — actually travel on the wire.**
|
||||
/// The `0x20`/`0x40`/`0x80` sentinels that mean Recommended / New / Full are *synthesized on
|
||||
/// arrival* from the magic populations the server sends instead, exactly as the reference's
|
||||
/// parser does it ([`auth::MAGIC_POPULATIONS`]) — so this byte is the client's view of the
|
||||
/// flags, not the server's.
|
||||
pub flags: u8,
|
||||
/// The wire's category (timezone) byte — the realm list's category tabs group on it. Matched
|
||||
/// against a category id by **equality**; it is not an index into anything.
|
||||
pub category: u8,
|
||||
/// The wire's realm id.
|
||||
pub id: u8,
|
||||
}
|
||||
|
||||
/// Result of a successful logon: the SRP6 session key (carried into the world server) + realms.
|
||||
/// Result of a successful logon: the SRP6 session key (carried into the world server), the realms,
|
||||
/// and the **still-open realmd socket** the list was read from.
|
||||
///
|
||||
/// The socket is kept because the realm list is not a one-shot: the reference's `RealmList.lua`
|
||||
/// re-requests it every `REALM_LIST_REFRESH_TIME` (5 s) for as long as its window is open, which
|
||||
/// is how a realm going offline or filling up shows up without a re-login. [`Logon::refresh_realms`]
|
||||
/// is that request. It is private so the only thing anyone can do with the connection is ask it
|
||||
/// the one question it still answers.
|
||||
pub struct Logon {
|
||||
pub session_key: [u8; SESSION_KEY_LENGTH],
|
||||
pub realms: Vec<RealmInfo>,
|
||||
/// `None` once a refresh has failed — the list we hold is then the last word, and we stop
|
||||
/// asking rather than retrying a dead socket every five seconds.
|
||||
stream: Option<TcpStream>,
|
||||
}
|
||||
|
||||
impl Logon {
|
||||
/// Re-request the realm list on the realmd connection — the reference's `RequestRealmList`.
|
||||
///
|
||||
/// Returns whether the list was refreshed. A failed refresh **drops the connection and keeps
|
||||
/// the realms we already have**: a stale list the player can still pick from beats an empty
|
||||
/// one, and realmd closing an idle socket is ordinary. Bounded by `timeout` so a park that
|
||||
/// calls this can never block on a server that accepted the request and said nothing.
|
||||
pub fn refresh_realms(&mut self, timeout: std::time::Duration) -> bool {
|
||||
let Some(stream) = self.stream.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let refreshed = (|| -> Result<Vec<RealmInfo>> {
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
auth::write_realm_list_request(stream).context("requesting realm list")?;
|
||||
auth::read_realm_list(stream).context("reading realm list")
|
||||
})();
|
||||
match refreshed {
|
||||
Ok(realms) => {
|
||||
self.realms = realms;
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
self.stream = None;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the realmd connection is still up, i.e. whether [`Self::refresh_realms`] can do
|
||||
/// anything. Lets a caller stop scheduling refreshes rather than calling into a no-op.
|
||||
pub fn realmd_live(&self) -> bool {
|
||||
self.stream.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the full SRP6 logon against a vanilla `realmd` and fetch the realm list.
|
||||
@@ -216,6 +290,7 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
|
||||
Ok(Logon {
|
||||
session_key,
|
||||
realms,
|
||||
stream: Some(stream),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1307,6 +1307,45 @@ pub const SMSG_MOVE_UNSET_HOVER: u16 = 0x00F5; // 245
|
||||
pub const CMSG_MOVE_HOVER_ACK: u16 = 0x00F6; // 246
|
||||
pub const CMSG_MOVE_FEATHER_FALL_ACK: u16 = 0x02CF; // 719
|
||||
|
||||
// **The OBSERVER leg of that same family** — what everyone *else* is told once the mover acks
|
||||
// (decision 2061). The ack'd family above and the `SMSG_SPLINE_MOVE_*` twelve below are only two
|
||||
// of the three legs, and this is the one that was missing: `SMSG_SPLINE_MOVE_*` reaches a unit the
|
||||
// **server** drives (a creature), and the ack'd leg reaches only the mover's **own** client — so a
|
||||
// *player* being rooted, levitated or blinked had no leg at all, and every such change was invisible
|
||||
// until that player's next ordinary pose packet.
|
||||
//
|
||||
// vmangos names the three legs itself, in one table (`Movement/MovementPacketSender.h:30-60`):
|
||||
// `{ SMSG_FORCE_MOVE_ROOT → CMSG_FORCE_MOVE_ROOT_ACK → MSG_MOVE_ROOT }` and its three siblings,
|
||||
// plus `{ MSG_MOVE_TELEPORT_ACK → MSG_MOVE_TELEPORT_ACK → MSG_MOVE_TELEPORT }`. `SendMovementFlagChange
|
||||
// ToObservers` / `SendTeleportToObservers` (`MovementPacketSender.cpp:221-239`, `:368-397`) write
|
||||
// `[packed guid][MovementInfo]` and stop — **the ordinary relay shape**, decoded by
|
||||
// [`super::parse`]'s relay arm like a heartbeat.
|
||||
//
|
||||
// **The client agrees they are one family.** All six register the *same* handler as the 22 relay
|
||||
// opcodes we already carry — `0x603bb0` (wow-re `re/net/opcode-handlers.tsv`; 30 rows, one handler),
|
||||
// which reads a packed guid, resolves it under `TYPEMASK_UNIT` and hands the body to
|
||||
// `OnUnitMoveEvent 0x601580`. There is nothing to ack: the ack already happened, on the mover's own
|
||||
// client.
|
||||
//
|
||||
// **Apply/unapply is in the flags word, not the opcode.** Only root splits into two opcodes; hover,
|
||||
// feather-fall and water-walk each use ONE for both directions, because the server has already
|
||||
// written the bit into the `m_movementInfo` it is about to broadcast (`SetHoverReal` &co. run
|
||||
// *before* the send, `Handlers/MovementHandler.cpp:626-638`/`:743-744`). So the receiver folds the
|
||||
// whole word and asks nothing of the opcode — which is what our relay arm already does.
|
||||
//
|
||||
// **`MSG_MOVE_TELEPORT` carries no counter** (unlike `MSG_MOVE_TELEPORT_ACK`, 0x00C7, whose
|
||||
// `[packed guid][u32 counter][MovementInfo]` is the *mover's* handshake): an observer has nothing to
|
||||
// ack, so the dword isn't there. It is sent **twice** per near-teleport — once before the relocation
|
||||
// and once after (`Player::ExecuteTeleportNear`) — so observers around both the old and the new
|
||||
// position hear it; both carry the destination, so applying both is idempotent. It is also how a
|
||||
// **creature** blink arrives (`Unit::NearTeleportTo`, which `DisableSpline()`s first).
|
||||
pub const MSG_MOVE_TELEPORT: u16 = 0x00C5; // 197
|
||||
pub const MSG_MOVE_ROOT: u16 = 0x00EC; // 236
|
||||
pub const MSG_MOVE_UNROOT: u16 = 0x00ED; // 237
|
||||
pub const MSG_MOVE_HOVER: u16 = 0x00F7; // 247
|
||||
pub const MSG_MOVE_FEATHER_FALL: u16 = 0x02B0; // 688
|
||||
pub const MSG_MOVE_WATER_WALK: u16 = 0x02B1; // 689
|
||||
|
||||
// **The knockback handshake** — the server aims a launch at our own mover, the mover flies it, and
|
||||
// the ack is what the server relays onward (decision 1702). Numbers VERIFIED vmangos
|
||||
// `Opcodes_1_11_2.h:242-244` (unchanged into 1.12.1) and cross-checked against the client's own name
|
||||
|
||||
@@ -122,10 +122,21 @@ fn read_move_set_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result<ServerP
|
||||
})
|
||||
}
|
||||
|
||||
/// True for a relayed player-movement opcode — one the server rebroadcasts as
|
||||
/// `[packed guid][MovementInfo]` (every opcode bound to vmangos `HandleMovementOpcodes`, VERIFIED
|
||||
/// `Opcodes.cpp`). Excludes `MSG_MOVE_TELEPORT_ACK` / `MSG_MOVE_WORLDPORT_ACK`, which share the family
|
||||
/// but carry different bodies and are decoded by their own arms.
|
||||
/// True for a relayed movement opcode — one the server rebroadcasts as `[packed guid][MovementInfo]`.
|
||||
/// Two groups, one wire shape and one client handler (`0x603bb0`, wow-re `re/net/opcode-handlers.tsv`
|
||||
/// — 30 rows pointing at it, of which these are 23):
|
||||
///
|
||||
/// - the **echoed input stream**, every opcode bound to vmangos `HandleMovementOpcodes` (VERIFIED
|
||||
/// `Opcodes.cpp`): another player's walking, turning, jumping, swimming, facing;
|
||||
/// - the **observer leg of the movement-mode family** (decision 2061): root/unroot, hover,
|
||||
/// feather-fall, water-walk and the near-teleport, broadcast by
|
||||
/// `MovementPacketSender::Send{MovementFlagChange,Teleport}ToObservers` once the mover's own
|
||||
/// client has acked. Same body, no ack, and the apply/unapply direction rides the `MovementInfo`
|
||||
/// flags word rather than the opcode (see [`super::opcode`]'s block).
|
||||
///
|
||||
/// Excludes `MSG_MOVE_TELEPORT_ACK` / `MSG_MOVE_WORLDPORT_ACK`, which share the family but carry
|
||||
/// different bodies and are decoded by their own arms — note `MSG_MOVE_TELEPORT` (197) is the
|
||||
/// observer's teleport and is NOT `MSG_MOVE_TELEPORT_ACK` (199): no counter dword, so it parses here.
|
||||
const fn is_movement_relay(o: u16) -> bool {
|
||||
matches!(
|
||||
o,
|
||||
@@ -155,6 +166,16 @@ const fn is_movement_relay(o: u16) -> bool {
|
||||
// need to: the server built that `MovementInfo` from the victim's own ack, so its jump
|
||||
// tail already IS the quad, and the arc replays from it like any other relayed jump.
|
||||
| opcode::MSG_MOVE_KNOCK_BACK
|
||||
// The observer leg of the movement-mode family (decision 2061): somebody else was
|
||||
// rooted, levitated (hover + feather fall + water walk), or blinked. The mode lands in
|
||||
// the `MovementInfo` flags word this body already carries, so no arm of its own is
|
||||
// needed — only the opcode's admission to the family.
|
||||
| opcode::MSG_MOVE_ROOT
|
||||
| opcode::MSG_MOVE_UNROOT
|
||||
| opcode::MSG_MOVE_HOVER
|
||||
| opcode::MSG_MOVE_FEATHER_FALL
|
||||
| opcode::MSG_MOVE_WATER_WALK
|
||||
| opcode::MSG_MOVE_TELEPORT
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -719,3 +719,117 @@ fn spline_move_mode_family_parses_golden() {
|
||||
);
|
||||
assert_eq!(SplineMode::Hover.flag(), messages::MoveMode::Hover.flag());
|
||||
}
|
||||
|
||||
/// **The observer leg of the movement-mode family, byte-exact** (decision 2061) — the six opcodes
|
||||
/// that tell everyone *else* a player was rooted, levitated or blinked. Until this landed they hit
|
||||
/// [`ServerPacket::Other`] and were dropped, so a watched player kept sliding through a root and
|
||||
/// stood in the wrong place after a Blink until their next ordinary pose packet.
|
||||
///
|
||||
/// Four things are asserted, because each is how this leg differs from a sibling it is otherwise a
|
||||
/// twin of:
|
||||
///
|
||||
/// 1. **The body is the ordinary relay shape** — `[packed guid][MovementInfo]`, no counter. That is
|
||||
/// the whole difference from the ack'd `SMSG_FORCE_MOVE_ROOT` leg (`[packed guid][u32 counter]`)
|
||||
/// and from `MSG_MOVE_TELEPORT_ACK` (`[packed guid][u32 counter][MovementInfo]`); reading a
|
||||
/// counter that isn't there would shift the pose by four bytes and desync the rest of the read.
|
||||
/// 2. **Apply/unapply rides the flags word, not the opcode.** Only root splits in two; hover,
|
||||
/// feather-fall and water-walk use ONE opcode for both directions, because vmangos runs
|
||||
/// `SetHoverReal` &co. *before* the broadcast (`MovementHandler.cpp:626-638`, `:743-744`) so the
|
||||
/// `m_movementInfo` it sends already carries the bit's new state.
|
||||
/// 3. **The whole word reaches the app**, which is what makes (2) work: `UnitMove.flags` is the
|
||||
/// wire word verbatim, and Levitate — feather fall + hover + water walk at once (decision 1706)
|
||||
/// — arrives as three packets whose flags words accumulate server-side.
|
||||
/// 4. **`MSG_MOVE_TELEPORT` is tagged as a teleport and nothing else is.** The pose is a
|
||||
/// discontinuity; the app's pre-fire reconcile has to know not to glide the mover into it.
|
||||
#[test]
|
||||
fn observer_move_mode_family_parses_golden() {
|
||||
// Packed guid 0xAA (mask 0x01, one byte) + a 28-byte MovementInfo with no conditional tails:
|
||||
// flags, time 12345, pos (1, 2, 3), orientation 0.5, fall_time 0. 30 bytes total — FOUR SHORT
|
||||
// of `MSG_MOVE_TELEPORT_ACK`'s same-content body, which is the point of the length assertion.
|
||||
let info = |flags: u32| {
|
||||
let mut b = hx("01aa");
|
||||
b.extend_from_slice(&flags.to_le_bytes());
|
||||
b.extend_from_slice(&hx("393000000000803f00000040000040400000003f00000000"));
|
||||
b
|
||||
};
|
||||
|
||||
const ROOT: u32 = 0x0000_1000;
|
||||
const WATER_WALK: u32 = 0x1000_0000;
|
||||
const FEATHER_FALL: u32 = 0x2000_0000;
|
||||
const HOVER: u32 = 0x4000_0000;
|
||||
|
||||
// (opcode, the flags word the server would have written into it, is-a-teleport)
|
||||
let expected = [
|
||||
(messages::opcode::MSG_MOVE_ROOT, ROOT, false),
|
||||
(messages::opcode::MSG_MOVE_UNROOT, 0, false),
|
||||
(messages::opcode::MSG_MOVE_WATER_WALK, WATER_WALK, false),
|
||||
// The same opcode, the other direction — the bit is simply absent.
|
||||
(messages::opcode::MSG_MOVE_WATER_WALK, 0, false),
|
||||
(messages::opcode::MSG_MOVE_FEATHER_FALL, FEATHER_FALL, false),
|
||||
(messages::opcode::MSG_MOVE_HOVER, HOVER, false),
|
||||
// Levitate grants all three at once (decision 1706) — one word, three bits.
|
||||
(
|
||||
messages::opcode::MSG_MOVE_HOVER,
|
||||
HOVER | FEATHER_FALL | WATER_WALK,
|
||||
false,
|
||||
),
|
||||
(messages::opcode::MSG_MOVE_TELEPORT, 0, true),
|
||||
];
|
||||
|
||||
for (op, flags, is_teleport) in expected {
|
||||
let body = info(flags);
|
||||
assert_eq!(body.len(), 30, "the relay shape, with no counter dword");
|
||||
let packet = messages::parse_server(op, &body)
|
||||
.unwrap_or_else(|e| panic!("{op:#06x} must parse as an ordinary relay: {e}"));
|
||||
match &packet {
|
||||
ServerPacket::PlayerMove {
|
||||
guid: 0xAA,
|
||||
opcode,
|
||||
flags: f,
|
||||
position,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*opcode, op);
|
||||
assert_eq!(*f, flags, "the whole flags word survives for {op:#06x}");
|
||||
// Four bytes off and this would read (2.0, 3.0, 0.5) — the counter-eating bug.
|
||||
assert_eq!(
|
||||
(position.x, position.y, position.z),
|
||||
(1.0, 2.0, 3.0),
|
||||
"the pose starts right after the guid for {op:#06x}"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected a relayed PlayerMove for {op:#06x}, got {}",
|
||||
other.name()
|
||||
),
|
||||
}
|
||||
match decode(packet).as_slice() {
|
||||
[SessionEvent::UnitMove {
|
||||
guid: 0xAA,
|
||||
flags: f,
|
||||
teleport,
|
||||
heartbeat,
|
||||
..
|
||||
}] => {
|
||||
assert_eq!(*f, flags);
|
||||
assert_eq!(
|
||||
*teleport, is_teleport,
|
||||
"only MSG_MOVE_TELEPORT is a discontinuity ({op:#06x})"
|
||||
);
|
||||
assert!(!*heartbeat, "none of these is the periodic pulse");
|
||||
}
|
||||
other => panic!("expected one UnitMove event for {op:#06x}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// The numbers themselves (vmangos `Opcodes_1_12_1.h`; cross-checked against the client's own
|
||||
// name table). `MSG_MOVE_TELEPORT` (197) is NOT `MSG_MOVE_TELEPORT_ACK` (199) — the pair this
|
||||
// family is easiest to get wrong on, and the reason (1)'s length assertion exists.
|
||||
assert_eq!(messages::opcode::MSG_MOVE_TELEPORT, 197);
|
||||
assert_eq!(messages::opcode::MSG_MOVE_TELEPORT_ACK, 199);
|
||||
assert_eq!(messages::opcode::MSG_MOVE_ROOT, 236);
|
||||
assert_eq!(messages::opcode::MSG_MOVE_UNROOT, 237);
|
||||
assert_eq!(messages::opcode::MSG_MOVE_HOVER, 247);
|
||||
assert_eq!(messages::opcode::MSG_MOVE_FEATHER_FALL, 688);
|
||||
assert_eq!(messages::opcode::MSG_MOVE_WATER_WALK, 689);
|
||||
}
|
||||
|
||||
@@ -204,12 +204,14 @@ impl super::UiScript {
|
||||
// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which EVERY Ace addon runs at
|
||||
// PLAYER_ENTERING_WORLD, so the nil became `gsub(nil, ...)` and took the family down.
|
||||
//
|
||||
// Written straight into the slot rather than through `set_cvar_host` because that
|
||||
// borrows the model again, and warns on an unknown name — this is the host declaring
|
||||
// the value, not looking it up.
|
||||
if let Some(slot) = model.cvars.get_mut("realmname") {
|
||||
slot.value = realm.to_string();
|
||||
}
|
||||
// Through the **engine-write** path, not a bare slot poke. `realmName` is a persisted
|
||||
// CVar — its whole documented job is to be the last realm connected to — and a slot
|
||||
// written in place never reaches `cvar_changes`, so the host never hears it and never
|
||||
// dirties the config file. The value was therefore correct for exactly as long as the
|
||||
// process lived and absent from `config.toml` forever, which is the one thing a
|
||||
// persisted CVar must not be. `set_from_engine` is the same silent-on-unknown-name
|
||||
// write (a bare test VM registers nothing) and additionally queues the change.
|
||||
super::cvars::set_from_engine(&mut model, "realmName", realm.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user