unselectable units take no target, quieter wounds, a realm-list dialog

- a NOT_SELECTABLE unit takes no target: Stratholme's Baron Rivendare, pre-event, is spawned by vmangos with `UNIT_FLAG_NOT_SELECTABLE | UNIT_FLAG_SPAWNING`, and where the reference can neither damage nor target him benilla refused the damage (`CanAttack` already read bit 25) and took him as a target anyway — most visibly through the ATTACKERSTATEUPDATE self-defence acquire, which hands you your attacker the moment he opens on you with no click and no hover involved, and every other writer (left-click, right-click, TAB, `TargetUnit`/`/target`/`/assist`, the attack auto-acquire) had the same hole; the reference's gate is the third early-out of `SetSelection` `0x493540` (`0x4935ee`/`0x4935f3`): the resolved object's `+0x58` vcall to `0x60be60` `IsSelectable` = `!(UNIT_FIELD_FLAGS & 0x02000000) || UNIT_FIELD_CREATEDBY == your guid`, sitting BEFORE the dedup so a refusal is a complete no-op — no `CMSG_ATTACKSTOP`, no `CMSG_SET_SELECTION`, the target already in hand survives — and skipped entirely for an unresolved object, which is what keeps an out-of-range party member selectable off the roster; `target::relations::is_selectable` transcribes it whole, the CREATEDBY clause (your own totem stays clickable, somebody else's does not) and the xor-eax base stub every non-unit vtable carries at `+0x58` included, and `scan::commit` runs it first behind a mandatory `target_store` parameter so none of its eight call sites can forget a gate it does not compute; the hover is the SAME predicate at a second site, byte-verified by the wow-re round this dispatched: the hover grader `0x4828d0` calls slot 21 directly on the pick's winner at `0x482982`, and a false answer jumps to `0x482090(0,0)` + `ResetCursor` `0x523d30`, skipping the publisher `0x492890` that holds the mouseover globals' only two writers image-wide — so no tooltip, no cursor, no emissive brighten and no `mouseover` token, all from one rule; it is a GRADER, not a filter, and the difference is observable — a census over the pick itself (`0x480a50`, `0x480d90`, `0x713cb0`, `0x481190`, `0x480df0`) finds zero reads of the unit flags against four inside `CanAttack`, so the flagged unit wins the trace and is then thrown away, which `Hovered::refused` carries with the pick's distance kept so a trigger stalker in front of a chest takes the pick and the chest behind it does not inherit the mouseover; graded at `update_hover`'s publish rather than in a system of its own, because `drive_mouseover_tooltip` is ordered only after the net stage and a second system would have bought a one-frame name plate for a unit that must never have one; the V-plate suppression turns out to be load-bearing, since a plate hover publishes the mouseover directly (`0x7cb869 call 0x492890`) with no grading at all; and a left-click on a refused unit no longer clears what you had — the grader had dropped it into `select_on_click`'s arm beside "clicked the sky", where the reference's deselect lives in the terrain and NOTHING legs only: the down-edge pick `0x481f00` still found the object, so the release takes the object leg `0x4925d0` whose tail hits `SetSelection`'s early-out and changes nothing, the reasoning the corpse guard beside it already carried and which gets its first test here (2060)
- three things a landed hit showed that the reference does not: a lootable corpse took blood, flinch, impact sounds and a floating number — the victim dispatcher `0x624530`, which owns every victim-side consequence of a swing, opens with `test byte [[victim+0x110]+0x224],1` (`UNIT_DYNAMIC_FLAGS` bit `0x1`) and jumps past all four — but the obvious fix, gating the victim, is wrong: benilla fans that dispatcher out into one message emitted on three occasions and the reference gates the DISPATCHER, and an image-wide census of the number emitter `0x6243e0` (exactly three call sites, zero tail-jumps, zero address-takes, earned against a positive control) puts `0x624566` inside the gate but the `SMSG_ATTACKSTOP` flush `0x624ea7` and the supersede flush `0x625893` outside it, so a lootable victim still floats a number for a superseded record; gated: the impact-tag dispatch (record still consumed first, as `0x6247d0` clears the GUIDs before calling) and the unresolved-attacker fallback `0x625a6d`; ungated: both flushes and the whiff slow-down, `0x624ca0` being its own function past the dispatcher's end; the flush's own predicate `0x625e40` reads an unnamed `[rec+0xac]` and stays unbuilt rather than guessed (2066); a crit played a full CombatCritical clip on a fighting unit — vmangos sends `SMSG_EMOTE(EMOTE_ONESHOT_WOUNDCRITICAL)` to the victim of every damaging crit (`Unit.cpp:1601`), `Emotes.dbc` resolves it to AnimID 10, and on the director's 90 s brawl trace that was 7 one-shots plus 9 fast-path entries on the warrior cutting its own swing (`fastpath cur=87 req=10`), 19 across all units; the reference's shared emote player `0x5fcd20` refuses at `0x5fcd96` when `0x60ecd0` is true — `UNIT_FIELD_FLAGS` bit `0x800` OR an auto-attack target `[+0xc48]` — and 1469 had built the flag half alone, dormant on vmangos (its `0x800` is `PET_IN_COMBAT`; a fighting player or creature carries `IN_COMBAT 0x80000`, a bit the client never tests), so `emote_anim::play_eligible` takes `engaged` and both producers, `SMSG_EMOTE` and the chat gesture, pass `Has<Engaged>` through one `PerformerQuery`: a crit on a fighting unit shows only as the wound overlay, and the wound trace lines carry `unit=` (2067); and an ending effect's particles popped — the teardown `0x6203e0` that both the completion callback and the same-slot replace reach hides the node and queues it rather than freeing it, and whether that is visible turned on a half no wow-re note covered, dispatched rather than assumed: the draw path's own admission test is the live-particle count (`0x7b4b46 mov eax,[esi+0x64]`) and the model enqueues onto two scene lists on two fields — MESH on the visible flag (`0x719207`), PARTICLE on the aggregate `CM2Model+0x3d8` (`0x71922c`), set while any particle lives and the same word `0x61f680` frees on — so for `Strike_Impact_Chest` a node destroyed at 0.10 s still shows ~30 particles at 0.20 s, gone by 0.334 s, and benilla cut up to 0.30 s of a 0.334 s effect; the three points where an effect ENDS — expiry, the same-slot replace, the reap's immediate destroy — now hand their emitters to the drain by the instance root they already carry as anchor (`drain_instance_emitters`, `ParticleEmitter::drain_on_owner_loss`) and the root still despawns, the reference dropping the mesh the same frame; flipped at the ending rather than set at spawn because the other way an owner vanishes is the model dtor (gear change, display swap, stream-out), where the reference frees synchronously and draining that case is what stranded ghost clouds in the air (0826/0833), and the collateral path still frees (2070)
- a creature whose template carries `DO_NOT_PLAY_WOUND_ANIM` takes no flinch and no parry, and the wound grunt is rolled: the creature template's `type_flags` carries the bit at `0x8`, benilla consumed the two bits either side — `0x10 NO_FACTION_TOOLTIP` in the unit tooltip, `0x20 MORE_AUDIBLE` in the parked-event gate — with neither reader naming its bit, and `wound.rs` carried the gap in a doc comment claiming "no template cache" that had been false since the cache landed; the reference refuses the flinch inside `0x60ea70` itself (`0x60ea9f`, so every trigger is covered) through the getter `0x6125f0`, whose null-record leg fails OPEN, so `names::type_flags` names all eight bits (a call site could say `& 0x10` and look right — that is what let it be wrong) and the wound trigger refuses a flagged victim as its first gate, keyed off the descriptor's `OBJECT_FIELD_ENTRY` the way `0x60b160` keys the cache; animation only, since no creature-type mechanism suppresses the blood spurt — a flagged creature bleeds (2068); the vocal half was dispatched rather than built, because wow-re's published "makes no pain sound" called the flag's second consumer `0x60ec00` a pain-sound dispatcher while three of its own notes said otherwise, and the cross-check came back with all seven questions VERIFIED: `0x60ec00` is an ANIMATION dispatcher — `GetWeapon(0,0)`, the mainhand subclass through the 18-entry remap `0x60ec98` and jump table `0x60ec84` to Parry1H/2H/2HL/Unarmed, handed to `0x5fe2f0` = `PlayAnimation`, whose 40 call sites push literals in `[16, 202]` and which reaches no sound band — so the flag gates two animations and no sound, and the parry now rides it (victimState 3 only; Dodge 30 and Block 24 reach `PlayAnimation` directly from `0x624a90`/`0x624a74`, and our subclass map agrees with the raw remap on all 18 rows); nothing suppresses the injury vocal — of the fourteen functions in the wound-vocal chain the only one dereferencing `[unit+0xb30]` is `0x623b70`, testing bit 5 for a bus — but the ordinary grunt IS rolled: `0x623490` runs the class chance roll at `0x8626d4[2] = 60` for a creature and `0x86424c[2] = 30` for a player, by the VICTIM's type, while classes 3 (critical) and 9 (crushing) carry 100 and always sound, so `INJURY_CHANCE_CREATURE`/`INJURY_CHANCE_PLAYER` thin the grunt under sustained melee where benilla had played all three every time — 40 grunts per 100 too many on a creature, 70 on a player — and a crushing blow's class 9 punches through a server-pushed object sound's suppression, which stops at class 8 (`0x6234bb`/`0x6234bf`/`0x6234c4`); a pet gets a template record at last, since `NameCache::resolve` routes a `HIGHGUID_PET` guid to the pet-NAME query and a tamed unit had none — 18 of the 471 flagged templates on the world DB carry a `pet_family` — so the create arm also asks by the descriptor's entry, the reference's own key (`[[unit+8]+0xc]`); 2068's claim that templates are queried lazily is corrected, the create arm having warmed the cache for every streamed unit all along, at the reference's own timing (`0x60afb0` → `0x60b157`, on the create path) rather than as the convenience its comment called it; and the skeletons of the report still flinch, and should — neither vmangos nor cmangos flags them (2 of 62 templates in each), `Skeleton.m2` authors CombatWound and CombatCritical, so what changes for them is the grunt's rate, not its existence (2073)
- the realm list is a dialog, not a screen: the first live run of 2056's realm selection reported no hover highlight, Cancel and the X throwing you out of the session instead of back to character select, the character screen gone from behind it, and then a login that hung on "Connecting" forever with nothing in the log — three of the four are one mistake, since `GlueScreenInfo` names all seven glue screens and `RealmList` is not among them: `RealmList.xml` is a `frameStrata="DIALOG"` frame with a full-screen 75%-black quad that `Show()`s over whatever screen is up and `RealmList_OnCancel` only `Hide()`s it, while 2056 had built it as a `ClientState`, so Cancel had to pick somewhere to go, the screen behind was torn down, and — the one that mattered — a roster arriving while that state was up moved nothing because `to_select_on_roster` only ever leaves `Login`; Okay looked inert while the IO thread walked on to the character park, the next Cancel answered a park two ahead of the thread, and every later login queued behind a `pick_rx.recv()` that was never coming, silently, reproduced with a scripted walk before anything changed; the realm list is a `shown` flag now, served in place by the character park over `select!` — refreshing on the realmd socket the cycle still holds, since `RequestRealmList` (`0x46b8d0`) begins `COP_GET_REALMS` on a socket closed only at world entry (`0x46b70c`) and only `ConnectToRealm` (`0x46b210`) drops the world connection — and leaving only when a realm is picked, `HeldLogon` and the resumed cycle gone with it; Cancel sends one `Abandon` and the park that hears it decides, which is the reference's own shape: `RealmListDialogCancelled` (`0x46ed20` → `0x46b810`) compares the current screen name against "login" and returns immediately when it differs, tail-jumping `CLoginMgr::Cancel` (`0x5b3320`) only from the login screen; the hover was never a realm-list oversight — `Hilight` was "the owning screen's to drive", four screens drove it, two folding selection into the same expression and one scoped so the `GlueCloseButton` X on every panel could never light, and the realm list made five by having none — so one owner now, `glue_hilights`, lit by hover or `LockHighlight` and walking UP to the nearest enclosing button because a modal dialog's root is itself a button, with `art_swaps`, `glue_button_visuals`, `glue_hilights` and `sync_outlines` registered once in `GluePlugin` instead of hand-copied per screen; the IO thread names each park it stops at, a logout comes back through the same park a first login does, so the policy ladder puts the realm the player is currently on ahead of the persisted `realmName`, which benilla writes only at world entry, the reproduction is now a smoke leg (Change Realm → Okay → Change Realm → Cancel → Okay against the live server) that fails on the old code by construction, and two divergences are stated: the X abandons like Cancel because our thread parks on the question rather than polling for it, and Change Realm shows the list already in hand at once rather than waiting a round trip behind a status dialog (2072)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:08:46 +07:00
co-authored by Claude Fable 5.1
parent 38e9daf668
commit 1d7252aec2
43 changed files with 2560 additions and 741 deletions
+1 -3
View File
@@ -76,15 +76,13 @@ impl Plugin for CharCreatePlugin {
refresh::refresh_dynamic,
refresh_name_box,
refresh::refresh_hover,
crate::glue::art_swaps,
crate::glue::glue_button_visuals,
refresh::scroll_info,
refresh::scroll_drive,
refresh::scroll_visuals,
create_result,
crate::glue::sync_outlines,
)
.chain()
.before(crate::glue::GlueVisuals)
.run_if(in_state(ClientState::CharCreate))
.after(benilla_world::schedule::WorldStage::Net),
);
@@ -228,6 +228,9 @@ fn scrollbar(
step: track_h * s / 2.0,
},
Button,
// At the end of its travel this arrow is disabled — `scroll_visuals` writes it
// and `crate::glue::glue_hilights` reads it, so the sheen dies with the arrow.
crate::glue::widgets::GlueDisabled(false),
ImageNode {
image: art.up.clone(),
rect: Some(tc_rect(art.size, SCROLL_BTN_TC)),
+18 -21
View File
@@ -19,7 +19,7 @@ use crate::glue::art::{
class_tc, race_tc, tc_rect, GlueArt, ALLIANCE_FILL, BACKDROP_ALLIANCE, BACKDROP_HORDE, BTN_BG,
BTN_HOVER, FALLBACK_ALPHA, HORDE_FILL,
};
use crate::glue::widgets::{FallbackFace, GlueDisabled, Hilight, HoverLabel};
use crate::glue::widgets::{FallbackFace, GlueDisabled, Hilight, HoverLabel, LockHighlight};
/// Refill everything that follows the selection — icon rects, dial labels, info texts, faction
/// tints, class-slot mapping — on selection change or a fresh spawn.
@@ -238,10 +238,10 @@ pub(super) fn refresh_hover(
&Children,
&mut BackgroundColor,
Has<FallbackFace>,
&mut LockHighlight,
),
(With<Button>, Without<crate::glue::widgets::GlueBtn>),
>,
mut hilights: Query<&mut Visibility, (With<Hilight>, Without<HoverLabel>)>,
mut labels: Query<&mut Visibility, (With<HoverLabel>, Without<Hilight>)>,
mut disables: Query<(&CreateAction, &mut GlueDisabled)>,
) {
@@ -254,23 +254,20 @@ pub(super) fn refresh_hover(
_ => false,
};
for (action, interaction, children, mut bg, fallback) in &mut buttons {
for (action, interaction, children, mut bg, fallback, mut locked) in &mut buttons {
let is_sel = selected(action);
let hovered = *interaction != Interaction::None;
let lit = is_sel || hovered;
// The sheen itself is `crate::glue::glue_hilights`' — this says only which row is chosen.
if locked.0 != is_sel {
locked.0 = is_sel;
}
// No-art fallback only: buttons spawned with a plain face get a hover shade. (Every node
// *has* a `BackgroundColor` — only a `FallbackFace`'s belongs to us.)
if fallback {
bg.0 = if lit { BTN_HOVER } else { BTN_BG };
}
for child in children {
if let Ok(mut vis) = hilights.get_mut(*child) {
*vis = if lit {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
if let Ok(mut vis) = labels.get_mut(*child) {
// Without icon art the label IS the button face — always visible.
let show = lit || art.races.is_none();
@@ -358,15 +355,19 @@ pub(super) fn scroll_drive(
pub(super) fn scroll_visuals(
art: Res<GlueArt>,
scrolls: Query<(&ComputedNode, &ScrollPosition), With<InfoScroll>>,
mut arrows: Query<(&ScrollArrow, &Interaction, &mut ImageNode, &Children)>,
mut arrows: Query<(
&ScrollArrow,
&Interaction,
&mut ImageNode,
&mut GlueDisabled,
)>,
mut thumbs: Query<(&ScrollThumb, &mut Node)>,
mut hides: Query<(&ScrollHides, &mut Visibility), Without<Hilight>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
) {
let Some(sc) = &art.scroll else {
return;
};
for (arrow, interaction, mut img, children) in &mut arrows {
for (arrow, interaction, mut img, mut off) in &mut arrows {
let Ok((node, pos)) = scrolls.get(arrow.scroll) else {
continue;
};
@@ -386,14 +387,10 @@ pub(super) fn scroll_visuals(
if img.image != *face {
img.image = face.clone();
}
for child in children {
if let Ok(mut vis) = hilights.get_mut(*child) {
*vis = if !disabled && *interaction != Interaction::None {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
// A scroll arrow at the end of its travel is genuinely disabled — said once, in the
// component the shared sheen pass already reads, rather than re-derived beside it.
if off.0 != disabled {
off.0 = disabled;
}
}
for (thumb, mut node) in &mut thumbs {
+4 -28
View File
@@ -566,12 +566,6 @@ struct AddonsUi;
#[derive(Component)]
pub(super) struct ScrollBand;
/// A panel button whose child [`Hilight`] lights on hover (the checkboxes' `UI-CheckBox-Highlight`,
/// the close X, the dropdown arrow, the open list's `UI-QuestTitleHighlight` rows, the scroll
/// arrows) — driven per frame by [`drive_addons_panel`] with **no respawn**, which is the point.
#[derive(Component)]
pub(super) struct HoverLit;
/// Spawn/despawn the panel, run its flows, and repaint when anything it shows has changed.
///
/// Ordered before the select screen's own click handling so a click that lands on the panel is
@@ -590,8 +584,6 @@ pub(super) fn drive_addons_panel(
mut sounds: MessageWriter<GlueSound>,
clicks: Res<crate::glue::GlueClicks>,
hovers: Query<(Entity, &AddonsAction, Ref<Interaction>)>,
lit: Query<(&Interaction, &Children), With<HoverLit>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
band: Query<(&ComputedNode, &UiGlobalTransform), With<ScrollBand>>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
) {
@@ -754,21 +746,9 @@ pub(super) fn drive_addons_panel(
return;
}
// ── hover: highlights + the tooltip, with the tree left alone ─────────────────────────────
for (interaction, children) in &lit {
let want = if *interaction == Interaction::None {
Visibility::Hidden
} else {
Visibility::Inherited
};
for &child in children {
if let Ok(mut vis) = hilights.get_mut(child) {
if *vis != want {
*vis = want;
}
}
}
}
// ── hover: the tooltip, with the tree left alone ──────────────────────────────────────────
// (The sheens are `crate::glue::glue_hilights`' — this panel used to drive its own, which is
// one of the four copies that let the realm list ship with none.)
let hover = hovers.iter().find_map(|(_, a, i)| {
if !matches!(*i, Interaction::Hovered | Interaction::Pressed) {
@@ -837,7 +817,7 @@ fn checkbox_button<A: Component>(
state: BoxState,
node: Node,
) {
let mut b = parent.spawn((action, Button, HoverLit, node));
let mut b = parent.spawn((action, Button, node));
match &art.checkbox {
Some(c) => {
b.insert((
@@ -1013,7 +993,6 @@ fn spawn_panel(
let mut x = b.spawn((
AddonsAction::Cancel,
Button,
HoverLit,
abs(BG_W - 42.0 - 32.0, 3.0, 32.0, 32.0),
));
if let Some(cb) = &art.close_btn {
@@ -1136,7 +1115,6 @@ fn spawn_panel(
let mut arrow = d.spawn((
AddonsAction::DropdownToggle,
Button,
HoverLit,
abs(DROP_W - 16.0 - 24.0, 18.0, 24.0, 24.0),
));
match (&art.dropdown_arrow_up, &art.dropdown_arrow_down) {
@@ -1351,7 +1329,6 @@ fn spawn_panel(
b.spawn((
action,
Button,
HoverLit,
ImageNode {
image: up.up.clone(),
rect: Some(tc_rect(up.size, crate::glue::art::SCROLL_BTN_TC)),
@@ -1557,7 +1534,6 @@ fn spawn_panel(
let mut row = list.spawn((
AddonsAction::DropdownPick(view),
Button,
HoverLit,
Node {
height: px(ROW_H),
min_width: px(120.0),
+10 -10
View File
@@ -34,14 +34,16 @@ pub(super) fn select_input(
pick: Res<CharPick>,
mut dialog: ResMut<DeleteDialog>,
mut panel: ResMut<super::addons::AddonsPanel>,
choice: Res<crate::net::RealmChoice>,
mut next: ResMut<NextState<ClientState>>,
mut sounds: MessageWriter<GlueSound>,
mut intent: ResMut<crate::login::LoginIntent>,
time: Res<Time>,
mut last_click: Local<Option<(usize, f32)>>,
) {
// A modal owns the input while it is up — the delete confirm, or the AddOns list.
if dialog.open || panel.open {
// A modal owns the input while it is up — the delete confirm, the AddOns list, or the realm
// list, which stands over this screen rather than replacing it.
if dialog.open || panel.open || realms.shown {
return;
}
let now = time.elapsed_secs();
@@ -99,17 +101,15 @@ 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.
// The reference's `CHANGE_REALM`: raise the realm list **over** this screen. Nothing
// is torn down and nothing is disconnected — the character park serves the list in
// place and only leaves when a realm is picked, so Cancel puts the player back here
// with the session they never left. The pending pick still 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);
crate::realm_select::open_over_char_select(&mut realms, &choice);
}
_ => {}
}
+7 -8
View File
@@ -43,13 +43,14 @@ pub(crate) enum ClientState {
/// Parked pre-logon at the login screen (decision 0539): the IO thread waits for credentials;
/// [`crate::login`]'s policy decides what answers it (the env fast path, the reconnect
/// resubmit, or the director's typed submit).
///
/// **The realm list is not one of these.** 0193 planned a `RealmList` variant and 2056 built
/// it; the reference has no such screen (`GlueParent.lua`'s `GlueScreenInfo` lists every glue
/// screen and the realm list is not among them — it is a `frameStrata="DIALOG"` frame shown
/// over whichever screen is up). It is [`crate::realm_select::Realms::shown`] now, and the
/// variant is gone rather than left unconstructed.
#[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,
@@ -136,13 +137,11 @@ impl Plugin for CharSelectPlugin {
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
refresh::feed_glue_preview,
crate::glue::art_swaps,
crate::glue::glue_button_visuals,
delete_result,
debug_select_shot,
crate::glue::sync_outlines,
)
.chain()
.before(crate::glue::GlueVisuals)
.run_if(in_state(ClientState::CharSelect)),
)
.chain()
+11 -15
View File
@@ -7,7 +7,7 @@
use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::glue::widgets::{GlueDisabled, Hilight};
use crate::glue::widgets::{GlueDisabled, Hilight, LockHighlight};
use crate::glue_strings::GlueStrings;
use crate::net::NetStatus;
use crate::portrait::{GlueLook, GluePreview, SelectLook};
@@ -148,32 +148,28 @@ fn realm_banner(name: &str, suffix: &str, down: Option<&str>) -> String {
/// Per-frame interaction visuals + button states: the row highlight (hover ∪ selected — the ref's
/// `LockHighlight` on the selected row), and the enabled states — Enter World / Delete disable on
/// an empty list (the ref's `UpdateCharacterList`), Create hides at the 10-cap or disconnected,
/// Change Realm stays disabled (decision 0465 §6).
/// an empty list (the ref's `UpdateCharacterList`), Create hides at the 10-cap or disconnected.
/// (Change Realm was drawn deliberately dead under 0465 §6, until 2056 gave it something to do and
/// 2072 made it work.)
#[allow(clippy::type_complexity)]
pub(super) fn refresh_banner_and_buttons(
roster: Res<Roster>,
mut rows: Query<(&SelectAction, &Interaction, &Children), With<Button>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
mut rows: Query<(&SelectAction, &mut LockHighlight), With<Button>>,
mut disables: Query<(&SelectAction, &mut GlueDisabled)>,
mut create_vis: Query<
(&SelectAction, &mut Visibility),
(With<crate::glue::widgets::GlueBtn>, Without<Hilight>),
>,
) {
for (action, interaction, children) in &mut rows {
// `LockHighlight` on the chosen row and nothing about *visibility*: hovering is
// `crate::glue::glue_hilights`' question, and the two used to be one expression here.
for (action, mut locked) in &mut rows {
let SelectAction::Row(i) = action else {
continue;
};
let lit = roster.selected() == Some(*i) || *interaction != Interaction::None;
for child in children {
if let Ok(mut vis) = hilights.get_mut(*child) {
*vis = if lit {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
let want = roster.selected() == Some(*i);
if locked.0 != want {
locked.0 = want;
}
}
let have_chars = !roster.chars.is_empty();
+3 -2
View File
@@ -20,7 +20,7 @@ use bevy::window::PrimaryWindow;
use crate::glue::art::{GlueArt, BACKDROP, DIM, GOLD, NAME_EDGE};
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
use crate::glue::widgets::{
abs, glue_button, outlined_text, overlay, GlueBtnKind, GlueText, Hilight,
abs, glue_button, outlined_text, overlay, GlueBtnKind, GlueText, Hilight, LockHighlight,
};
use crate::glue_strings::GlueStrings;
use crate::portrait::{GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
@@ -459,7 +459,8 @@ fn row_button(
.spawn((
SelectAction::Row(row),
Button,
Visibility::Hidden, // shown by the refresh while the roster has this row
LockHighlight::default(), // `LockHighlight` on the selected row (refresh.rs)
Visibility::Hidden, // shown by the refresh while the roster has this row
Node {
position_type: PositionType::Absolute,
left: px(24.0),
+58 -12
View File
@@ -22,6 +22,7 @@ use bevy::animation::graph::AnimationNodeIndex;
use bevy::animation::transition::AnimationTransitions;
use bevy::prelude::*;
use crate::names::type_flags::DO_NOT_PLAY_WOUND_ANIM;
use crate::net::{
ClientCommand, FacingStep, NetCommands, ObjectStore, RemoteMotion, SelfPlayer, Spline,
UnitSpeeds,
@@ -351,6 +352,11 @@ pub(super) fn drive_animations(
// retiring node is *frozen* on the clip's final frame, so unlike the wound's decay there
// is no playback clock to read the λ window off.
Res<Time>,
// The creature-template cache — read for ONE bit, the victim's `DO_NOT_PLAY_WOUND_ANIM`
// (decision 2068), which is the wound trigger's first entry gate below. `Option` for the
// headless test worlds that build no net seam; a missing cache reads exactly like a
// template we have not received, which is the reference's own null-record leg.
Option<Res<crate::names::NameCache>>,
),
// The variation roll's LCG state (decision 0114 — the client's single CRT `_rand` stream,
// shared by every play; [`select::msvc_rand`]).
@@ -359,7 +365,7 @@ pub(super) fn drive_animations(
// diff-only filter; see the trace block after the mode machine).
mut anim_trace_last: Local<std::collections::HashMap<Entity, String>>,
) {
let (emote_sounds, loot_kneel, time) = aux;
let (emote_sounds, loot_kneel, time, names) = aux;
let dt = time.delta_secs();
// This frame's one-shot PLAY CALLS (swings + anim-emotes), gathered per unit and replayed
// in the client's call order below ([`PlaySeq`] stamps — the net drain stamps packet order,
@@ -645,7 +651,7 @@ pub(super) fn drive_animations(
let outgoing = drv.active_anim().unwrap_or(STAND);
let relaxed = !select::arm_forces_head(engaged, cast_hold.is_some(), outgoing);
wound_upkeep(&mut drv, &mut player);
wound_upkeep(entity, &mut drv, &mut player);
// Death overrides every state (a corpse doesn't transition); play Death and hold.
if dead {
@@ -909,6 +915,16 @@ pub(super) fn drive_animations(
let mut base_played = false;
let mut masked_played = false;
let mut played_oneshot: Option<u16> = None;
// The victim's cached creature template carries `DO_NOT_PLAY_WOUND_ANIM` (`type_flags`
// bit `0x8`) — the reference's `0x6125f0`, read at its two consumers below: the parry
// pick `0x60ec1f` and the wound flinch `0x60ea9f` (decision 2068). Keyed on the
// descriptor's `OBJECT_FIELD_ENTRY`, the way `0x60b160` keys the query record, and false
// on a template we have not received — `0x6125f0`'s own null leg.
let no_wound_anim = store
.and_then(|s| s.0.object_entry())
.zip(names.as_deref())
.and_then(|(entry, n)| n.creature_record(entry))
.is_some_and(|r| r.type_flags & DO_NOT_PLAY_WOUND_ANIM != 0);
// Defense outranks a same-frame own swing/emote: the client's `$CPP` arm is the later
// PlayAnimation call in that (rare) frame, and the being-hit reaction is the one the
// player must read. Gated alive (`0x60ec00` checks IsDead / stand-state 7 before the LUT).
@@ -916,6 +932,15 @@ pub(super) fn drive_animations(
if dead {
return None;
}
// `DO_NOT_PLAY_WOUND_ANIM` takes the **parry** with the flinch (`0x60ec1f`, the bit's
// second and last consumer — wow-re `wound-parry-gate-and-injury-vocal.md` Q1/Q6).
// The gate is inside `0x60ec00`, which the `$CPP` ladder enters only on
// victimState 3: DODGE/DEFLECT (30) and BLOCK (24) go straight to PlayAnimation from
// `0x624a90`/`0x624a74` and are NOT gated — a flagged creature still dodges and still
// raises its shield, it just never parries.
if vs == 3 && no_wound_anim {
return None;
}
// The parry LUT `0x60ec00` reads the victim's mainhand through `GetWeapon(0, 0)`
// (1863), so a disarmed victim's parry finds no weapon and the client bails — no
// clip, which is this function's own empty-hand leg.
@@ -1371,7 +1396,7 @@ pub(super) fn drive_animations(
let masked_played = masked_played || hold_played.is_some();
let base_played = base_played || (drv.mode, drv.gait) != pre_state;
wound_evict(&mut drv, &mut player, masked_played, base_played);
wound_evict(entity, &mut drv, &mut player, masked_played, base_played);
if let Some(&edge) = pending_wound.get(&entity) {
// Both edges pick the wound id by severity + the victim's engagement (decision
@@ -1381,22 +1406,43 @@ pub(super) fn drive_animations(
WoundEdge::Melee(hit_info) => select::wound_anim(hit_info, engaged),
WoundEdge::Spell => select::wound_anim(0, engaged),
};
// The trigger's fourth entry gate (`0x60eaac`–`0x60eac8`, wow-re
// `charproc-rate-override-wound-gate.md`, decision 2063): a CharProc-11 rate-override
// node on the unit's effect list — the freeze auras' node, Freezing Trap / Ice Block /
// petrify / web wrap — refuses EVERY flinch, melee and spell alike, for as long as it
// lives. The gate is the node's presence, not its rate (kit 3071's 1.0 gates too), so
// it reads the node list, not the pause the rate applies.
let rate_node = aura_nodes.is_some_and(|n| n.head_anim_rate().is_some());
if rate_node {
// The trigger's entry gates, in the reference's own order inside `0x60ea70`:
//
// 1. `0x60ea9f` → `0x6125f0`: the victim's cached creature template carries
// `type_flags` bit `0x8`, **DO_NOT_PLAY_WOUND_ANIM** — a skeleton, a ghost, a
// bone golem has no flesh to recoil, and the reference refuses every flinch it
// would ever take, melee and spell alike (decision 2068; wow-re
// `melee-blood-spurt-suppression.md` §6). It gates the ANIMATION only: the blood
// spurt is a separate system reading [`SwingImpact`] itself, and wow-re §8 Q1 is
// explicit that nothing keyed on creature type suppresses it — a skeleton bleeds.
// A record we have not received yet reads as NOT flagged, which is `0x6125f0`'s
// own null-record leg (`return record ? … : false`), so a creature whose query is
// still in flight flinches there too.
// 2. `0x60eaac`–`0x60eac8` (wow-re `charproc-rate-override-wound-gate.md`, decision
// 2063): a CharProc-11 rate-override node on the unit's effect list — the freeze
// auras' node, Freezing Trap / Ice Block / petrify / web wrap — refuses EVERY
// flinch for as long as it lives. The gate is the node's presence, not its rate
// (kit 3071's 1.0 gates too), so it reads the node list, not the pause it applies.
//
// `no_wound_anim` is read once above, where the parry pick — the flag's other
// consumer — needs the same answer.
let refusal = if no_wound_anim {
Some("the template carries DO_NOT_PLAY_WOUND_ANIM")
} else if aura_nodes.is_some_and(|n| n.head_anim_rate().is_some()) {
Some("a proc-11 rate node is attached")
} else {
None
};
if let Some(why) = refusal {
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!("wound trigger id={id} REFUSED (a proc-11 rate node is attached)"),
&format!("wound trigger unit={entity} id={id} REFUSED ({why})"),
);
}
} else {
wound_trigger(
entity,
&mut drv,
&mut player,
anims,
@@ -18,7 +18,9 @@ use super::super::{
SheathSwapMessage, SwingMessage, Wielded, WoundAnim,
};
use super::drive_animations;
use crate::names::type_flags::{DO_NOT_PLAY_WOUND_ANIM, MORE_AUDIBLE, NO_FACTION_TOOLTIP};
use crate::net::NetCommands;
use benilla_protocol::ObjectFields;
/// A hand holding something that is **not a weapon** — a torch, a held-in-off-hand trinket.
///
@@ -180,6 +182,7 @@ fn app() -> App {
// and no test unit is the self player anyway.
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
app.init_resource::<crate::names::NameCache>();
app.insert_resource(catalog());
app.add_systems(Update, drive_animations);
app
@@ -3855,3 +3858,199 @@ fn a_special_goes_unarmed_only_when_both_hands_are_empty() {
"both hands empty to the gate — SpecialUnarmed(118)"
);
}
/// A creature whose template carries **DO_NOT_PLAY_WOUND_ANIM** (`type_flags & 0x8`) takes no
/// wound flinch at all — the reference's `0x60ea9f` gate inside the flinch itself, so it covers
/// every trigger: the melee hit, the `$HIT` echo and the spell-side severity-0 call alike
/// (decision 2068). This is the skeleton case: no flesh, no recoil.
///
/// The control beside it is the whole point — the gate must key on bit `0x8` and nothing else,
/// and benilla read the *neighbouring* bit for months. So the unflagged victim here carries
/// `NO_FACTION_TOOLTIP | MORE_AUDIBLE` (`0x30`, the two bits we already consume) and must still
/// flinch: a gate on either neighbour, or on "any flag at all", fails right here.
#[test]
fn a_no_wound_creature_takes_no_flinch() {
fn model() -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true), // Stand
clip(8, 2, false), // StandWound — the unengaged victim's severity-0 pick
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
// The victim's descriptor, carrying the one field the gate keys on.
fn streamed(entry: u32) -> crate::net::ObjectStore {
crate::net::ObjectStore(ObjectFields::from_pairs(&[(OBJECT_FIELD_ENTRY, entry)]))
}
const OBJECT_FIELD_ENTRY: u16 = 3;
const SKELETON: u32 = 1783; // a Scarlet Monastery skeleton's template entry
const WOLF: u32 = 69;
let mut app = app();
let record = |type_flags: u32| crate::names::CreatureRecord {
name: "victim".into(),
subname: None,
creature_type: 6, // Undead
pet_family: 0,
rank: 0,
type_flags,
civilian: false,
racial_leader: false,
display_id: 0,
};
{
let mut names = app.world_mut().resource_mut::<crate::names::NameCache>();
names.insert_creature(SKELETON, Some(record(DO_NOT_PLAY_WOUND_ANIM)));
// The control's flags are the two NEIGHBOURS, both set.
names.insert_creature(WOLF, Some(record(NO_FACTION_TOOLTIP | MORE_AUDIBLE)));
}
let spawn = |app: &mut App, entry: u32| {
app.world_mut()
.spawn((
model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
streamed(entry),
))
.id()
};
let skeleton = spawn(&mut app, SKELETON);
let wolf = spawn(&mut app, WOLF);
// A creature whose query has not answered yet: the reference's null-record leg passes, so it
// flinches like anything else.
let unqueried = spawn(&mut app, 4242);
app.update(); // settle every base on Stand
for e in [skeleton, wolf, unqueried] {
app.world_mut().write_message(WoundAnim { entity: e });
}
app.update();
let wound = |e: Entity| {
app.world()
.entity(e)
.get::<AnimDriver>()
.unwrap()
.wound
.is_some()
};
assert!(
!wound(skeleton),
"DO_NOT_PLAY_WOUND_ANIM refuses the flinch"
);
assert!(
wound(wolf),
"the neighbouring bits (0x10 / 0x20) must not gate the flinch"
);
assert!(
wound(unqueried),
"a template we have not received reads as unflagged — `0x6125f0`'s null leg"
);
}
/// The flag's **second** consumer: `DO_NOT_PLAY_WOUND_ANIM` takes the victim's **parry** with its
/// flinch (`0x60ec1f` inside the parry pick `0x60ec00`, wow-re
/// `wound-parry-gate-and-injury-vocal.md` Q1/Q6 — the bit has exactly two callers and this is the
/// other one). The `$CPP` ladder enters `0x60ec00` only on victimState 3, so DODGE and BLOCK
/// reach `PlayAnimation` directly and are **not** gated: a flagged creature still dodges, it just
/// never parries. Both halves are asserted here — the gate without its control is the bug this
/// whole record is about.
#[test]
fn the_no_wound_flag_takes_the_parry_but_not_the_dodge() {
fn model() -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true), // Stand
clip(21, 2, false), // Parry1H — a 1H sword's parry pick
clip(30, 3, false), // Dodge
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
const OBJECT_FIELD_ENTRY: u16 = 3;
const FLAGGED: u32 = 3870; // Stone Sleeper — really carries the bit on our world DB
const PLAIN: u32 = 69;
let mut app = app();
let record = |type_flags: u32| crate::names::CreatureRecord {
name: "victim".into(),
subname: None,
creature_type: 6,
pet_family: 0,
rank: 0,
type_flags,
civilian: false,
racial_leader: false,
display_id: 0,
};
{
let mut names = app.world_mut().resource_mut::<crate::names::NameCache>();
names.insert_creature(FLAGGED, Some(record(DO_NOT_PLAY_WOUND_ANIM)));
names.insert_creature(PLAIN, Some(record(0)));
}
let spawn = |app: &mut App, entry: u32| {
app.world_mut()
.spawn((
model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
// A 1H sword in the mainhand: `defense_anim` sends class 2 subclass 7 to
// Parry1H(21).
Wielded {
main: Some((2, 7)),
..Default::default()
},
crate::net::ObjectStore(ObjectFields::from_pairs(&[(OBJECT_FIELD_ENTRY, entry)])),
))
.id()
};
let flagged = spawn(&mut app, FLAGGED);
let plain = spawn(&mut app, PLAIN);
let dodger = spawn(&mut app, FLAGGED);
app.update(); // settle every base on Stand
for (victim, victim_state) in [(flagged, 3), (plain, 3), (dodger, 2)] {
app.world_mut()
.write_message(crate::creature_anim::DefenseAnim {
victim,
victim_state,
});
}
app.update();
let base = |e: Entity| {
app.world()
.entity(e)
.get::<AnimationTransitions>()
.unwrap()
.get_main_animation()
.map(|n| n.index())
};
assert_eq!(
base(flagged),
Some(1),
"flagged: no parry — the base holds Stand"
);
assert_eq!(base(plain), Some(2), "unflagged: Parry1H(21) plays");
assert_eq!(
base(dodger),
Some(3),
"the flag does not reach DODGE — it enters `0x60ec00` only on victimState 3"
);
}
@@ -26,7 +26,7 @@ pub(super) enum WoundEdge {
/// and λ = 0 the same frame). So this runs above the death override and touches nothing
/// but its own node: λ = smoothstep(remaining)·0.75 over the clip's own span, blended out
/// and gone — never a snap, never a stop of what plays underneath.
pub(super) fn wound_upkeep(drv: &mut AnimDriver, player: &mut AnimationPlayer) {
pub(super) fn wound_upkeep(entity: Entity, drv: &mut AnimDriver, player: &mut AnimationPlayer) {
if let Some(wd) = drv.wound {
let finished = match player.animation_mut(wd.node) {
Some(a) if !a.is_finished() => {
@@ -50,7 +50,10 @@ pub(super) fn wound_upkeep(drv: &mut AnimDriver, player: &mut AnimationPlayer) {
player.stop(wd.node);
drv.wound = None;
if benilla_assets::trace::enabled() {
benilla_assets::trace::line("fct", "wound expire (λ reached 0, slot released)");
benilla_assets::trace::line(
"fct",
&format!("wound expire unit={entity} (λ reached 0, slot released)"),
);
}
}
}
@@ -68,6 +71,7 @@ pub(super) fn wound_upkeep(drv: &mut AnimDriver, player: &mut AnimationPlayer) {
/// plays — a change with no play (Land's re-pick) merely evicts one frame before the play
/// that follows it.
pub(super) fn wound_evict(
entity: Entity,
drv: &mut AnimDriver,
player: &mut AnimationPlayer,
masked_played: bool,
@@ -86,7 +90,7 @@ pub(super) fn wound_evict(
benilla_assets::trace::line(
"fct",
&format!(
"wound evict masked={} (a blended re-arm took the bone's secondary)",
"wound evict unit={entity} masked={} (a blended re-arm took the bone's secondary)",
wd.masked
),
);
@@ -103,15 +107,17 @@ pub(super) fn wound_evict(
/// gate — both §5 trigger agents refuted that hypothesis), and the upkeep above blends it
/// out and self-releases — until a same-bone re-arm evicts it (the block above; a wound
/// triggered here is this frame's *last* write, matching the client's packet order).
/// Client gates without a benilla counterpart yet: the creature-template no-wound
/// type-flag 0x8 (no template cache; the client itself passes on a null template) and the
/// attached-spell-effect marker (no CEffect system until the VFX phases). The client
/// The trigger's own entry gates live in the caller ([`super::drive_animations`]): the victim's
/// `DO_NOT_PLAY_WOUND_ANIM` template flag (decision 2068) and the CharProc-11 rate-override node
/// (2063), in the reference's own order. The one client gate still without a benilla counterpart
/// is the attached-spell-effect marker (no CEffect system until the VFX phases). The client
/// calls op4 directly — not PlayAnimation — so the flinch is faithfully invisible to the
/// sheath reconcile and the event scan. `id` is the wound anim to lay (8–10), already resolved
/// by the caller — melee by severity/engagement ([`select::wound_anim`]), a spell impact by its
/// kit's own column ([`WoundEdge`]).
#[allow(clippy::too_many_arguments)] // the trigger's full live-state + rng input set
pub(super) fn wound_trigger(
entity: Entity,
drv: &mut AnimDriver,
player: &mut AnimationPlayer,
anims: &ModelAnimations,
@@ -177,7 +183,7 @@ pub(super) fn wound_trigger(
benilla_assets::trace::line(
"fct",
&format!(
"wound trigger id={id} masked={masked} span={:.3} base={base} others={others}",
"wound trigger unit={entity} id={id} masked={masked} span={:.3} base={base} others={others}",
c.duration
),
);
@@ -185,13 +191,17 @@ pub(super) fn wound_trigger(
} else if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!("wound trigger id={id} SKIPPED (the base track owns the node)"),
&format!(
"wound trigger unit={entity} 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)"),
&format!(
"wound trigger unit={entity} id={id} SKIPPED (no playable clip, or a zero span)"
),
);
}
}
@@ -31,7 +31,23 @@ use bevy::prelude::*;
use crate::net::{EmoteKind, EmoteMessage, ObjectStore, RemoteMotion};
use crate::sound::EmoteSounds;
use super::{move_flags, EmoteAnim, MovementState};
use super::{move_flags, EmoteAnim, Engaged, MovementState};
/// The performer's live gate inputs, as both producers query them: stand-state (`ObjectStore`,
/// any streamed unit incl. self), the movement flags (`MovementState` on our own avatar,
/// `RemoteMotion` on a remote player — see `creature_anim`'s `unify`; a creature's spline carries
/// no swim bit, so it defaults false), and the [`Engaged`] marker (the client's auto-attack
/// target, decision 2067).
pub(super) type PerformerQuery<'w, 's> = Query<
'w,
's,
(
Option<&'static ObjectStore>,
Option<&'static MovementState>,
Option<&'static RemoteMotion>,
Has<Engaged>,
),
>;
/// Route a bridged `SMSG_EMOTE` (an anim emote) to the general one-shot player: resolve its
/// `Emotes.dbc` id through the shared catalog, gate on the performer's live posture
@@ -43,14 +59,7 @@ pub(super) fn emote_to_anim(
mut out: MessageWriter<EmoteAnim>,
mut play_seq: ResMut<super::PlaySeq>,
emotes: Option<Res<EmoteSounds>>,
// The performer's live stand-state (`ObjectStore`, any streamed unit incl. self) + movement
// flags (`MovementState` on our own avatar, `RemoteMotion` on a remote player — see
// `creature_anim`'s `unify`; a creature's spline carries no swim bit, so it defaults false).
units: Query<(
Option<&ObjectStore>,
Option<&MovementState>,
Option<&RemoteMotion>,
)>,
units: PerformerQuery,
) {
let Some(emotes) = emotes else { return };
for m in msgs.read() {
@@ -59,8 +68,9 @@ pub(super) fn emote_to_anim(
else {
continue;
};
let (store, movement, remote) = units.get(entity).unwrap_or((None, None, None));
if !play_eligible(store, movement, remote) {
let (store, movement, remote, engaged) =
units.get(entity).unwrap_or((None, None, None, false));
if !play_eligible(store, movement, remote, engaged) {
debug!("emote_anim: suppressed anim {anim_id} for {entity:?}");
continue;
}
@@ -96,10 +106,13 @@ fn player_eligible(channeling: bool, in_combat: bool) -> bool {
}
/// The whole gate for one unit, from its live components — the predicate both producers call.
/// `engaged` is the unit's [`super::Engaged`] marker: the client's auto-attack-target GUID
/// `[unit+0xc48]`, the half of the in-combat test that actually fires (below).
pub(super) fn play_eligible(
store: Option<&ObjectStore>,
movement: Option<&MovementState>,
remote: Option<&RemoteMotion>,
engaged: bool,
) -> bool {
let fields = store.map(|s| &s.0);
let stand_state = fields.map_or(0, |f| f.unit_stand_state());
@@ -110,14 +123,19 @@ pub(super) fn play_eligible(
& move_flags::SWIMMING
!= 0;
let channeling = fields.is_some_and(|f| f.unit_channel_spell() != 0);
let in_combat = fields.is_some_and(|f| f.unit_flags() & UNIT_FLAG_IN_COMBAT != 0);
let in_combat = engaged || fields.is_some_and(|f| f.unit_flags() & UNIT_FLAGS_COMBAT_BIT != 0);
receive_eligible(stand_state, swimming) && player_eligible(channeling, in_combat)
}
/// `UNIT_FIELD_FLAGS & 0x800` — `UNIT_FLAG_IN_COMBAT`. The client's gate-12 test is "this flag **or**
/// a local auto-attack target is set"; benilla checks the wire flag alone, so a unit that has swung
/// but whose combat flag has not yet streamed can still gesture for a moment.
const UNIT_FLAG_IN_COMBAT: u32 = 0x800;
/// The flag half of the client's in-combat predicate `0x60ecd0` (`shr edx,0xb; test dl,1` on
/// `UNIT_FIELD_FLAGS` — bit 11, `0x800`; the other half is `0x60ecb0`, the auto-attack target).
/// **vmangos never sets this bit on a fighting player or creature**: its in-combat flag is
/// `UNIT_FLAG_IN_COMBAT = 0x80000`, and `0x800` is `UNIT_FLAG_PET_IN_COMBAT`, a pet-only bit. So
/// on our server the flag half is dormant and the attack-target half carries the whole gate — which
/// is why checking the wire flag alone (decision 1469) let every crit's `EMOTE_ONESHOT_WOUNDCRITICAL`
/// play as a full CombatCritical one-shot on a fighting unit, cutting or fast-pathing its own swing
/// (decision 2067). The bit is kept for fidelity to the byte, not because it fires here.
const UNIT_FLAGS_COMBAT_BIT: u32 = 0x800;
/// The pure mapping: an `Anim` emote resolves through `lookup` (the catalog's `Emotes.dbc` →
/// `AnimID`); a `Text` emote never carries an anim id through this path — its animation, when it
@@ -169,6 +187,19 @@ mod tests {
assert!(!player_eligible(false, true), "in combat suppresses");
}
/// The attack-target half of `0x60ecd0` (decision 2067): a unit with the [`super::super::Engaged`]
/// marker — ATTACKSTART..ATTACKSTOP, the client's `[+0xc48]` — refuses the play even with no
/// store at all (vmangos's `0x80000` in-combat flag is not the bit the client tests, so the
/// marker is what makes a brawl's crit emote silent, exactly like the reference).
#[test]
fn an_engaged_unit_refuses_the_play_without_the_flag_bit() {
assert!(
play_eligible(None, None, None, false),
"idle, no store: plays"
);
assert!(!play_eligible(None, None, None, true), "engaged: refused");
}
#[test]
fn merely_seated_is_not_suppressed_on_receive() {
// Unlike the send-side gate, sit/sit-chair/chair/kneel all pass here — only SLEEP does not.
+9 -14
View File
@@ -8,13 +8,7 @@ use bevy::animation::graph::AnimationNodeIndex;
use bevy::prelude::*;
use super::{resolved_id, AnimData, AnimDriver};
/// Bit `0x20` of the cached creature template's `type_flags` word — the one flag the reference's
/// pass-2 election re-links a model for tick-only (`0x607da0`'s `0x623b70` arm), so an off-screen
/// flagged creature's combat stays audible. The *name* is vmangos corroboration
/// (`CREATURE_TYPEFLAGS_MORE_AUDIBLE`, `CreatureDefines.h:151`); the byte fact is the bit
/// (wow-re `outdoor-object-pass-election.md` §4).
const CREATURE_TYPEFLAGS_MORE_AUDIBLE: u32 = 0x20;
use crate::names::type_flags::MORE_AUDIBLE;
/// A model **event keyframe** crossed during playback this frame (the M2 `$xxx` tags — decision
/// 0070 slice 3): the animation timeline's outbound trigger surface. The sound subsystem routes
@@ -182,7 +176,9 @@ pub(super) fn fire_anim_events(
&AnimationPlayer,
&AnimDriver,
Has<benilla_world::rig_anim::AnimParked>,
Option<&crate::net::Guid>,
// The unit's descriptor — read for ONE field, `OBJECT_FIELD_ENTRY`, the key its cached
// creature template hangs off (see the `MORE_AUDIBLE` read below).
Option<&crate::net::ObjectStore>,
&GlobalTransform,
Option<&benilla_world::rig_anim::RigPose>,
)>,
@@ -199,7 +195,7 @@ pub(super) fn fire_anim_events(
names: Res<crate::names::NameCache>,
) {
let catalog = anim_data.as_deref().map(|d| &d.0);
for (entity, anims, player, drv, parked, guid, world, pose) in &units {
for (entity, anims, player, drv, parked, store, world, pose) in &units {
// The election's TICK half (decision 1482): a parked unit's event tracks are not
// scanned — the reference's pass-2 walk never inserts the model into the tick worklist
// (`0x683dd0` walk 2 skips `0x710b90`) — unless its cached template carries
@@ -210,11 +206,10 @@ pub(super) fn fire_anim_events(
// nothing on the wake frame — instead of scanning the whole parked gap as one
// crossing; the reference's re-admission is likewise a fresh record.
if parked
&& !guid.is_some_and(|g| {
names
.peek_type_flags(g.0)
.is_some_and(|f| f & CREATURE_TYPEFLAGS_MORE_AUDIBLE != 0)
})
&& !store
.and_then(|s| s.0.object_entry())
.and_then(|entry| names.creature_record(entry))
.is_some_and(|r| r.type_flags & MORE_AUDIBLE != 0)
{
last.remove(&entity);
last_overlay.remove(&entity);
@@ -16,9 +16,9 @@
use bevy::prelude::*;
use crate::net::{GuidIndex, ObjectStore, RemoteMotion};
use crate::net::GuidIndex;
use super::{EmoteAnim, MovementState, PlaySeq};
use super::{EmoteAnim, PlaySeq};
/// A gesture code — an index into `Emotes.dbc`'s five hard-coded `EmoteFlags` slots
/// (`benilla_formats::emotes::GESTURE_FLAG_BITS`), which is literally the integer the client's
@@ -129,11 +129,7 @@ pub(super) fn drive_gestures(
mut play_seq: ResMut<PlaySeq>,
index: Res<GuidIndex>,
emotes: Option<Res<crate::sound::EmoteSounds>>,
units: Query<(
Option<&ObjectStore>,
Option<&MovementState>,
Option<&RemoteMotion>,
)>,
units: super::emote_anim::PerformerQuery,
) {
let asked = std::mem::take(&mut queue.0);
let Some(emotes) = emotes else { return };
@@ -149,8 +145,9 @@ pub(super) fn drive_gestures(
let Some(anim_id) = emotes.anim(emote_id) else {
continue;
};
let (store, movement, remote) = units.get(entity).unwrap_or((None, None, None));
if !super::emote_anim::play_eligible(store, movement, remote) {
let (store, movement, remote, engaged) =
units.get(entity).unwrap_or((None, None, None, false));
if !super::emote_anim::play_eligible(store, movement, remote, engaged) {
debug!("gesture: {gesture:?} suppressed for {entity:?}");
continue;
}
+157 -1
View File
@@ -38,6 +38,32 @@ use bevy::prelude::*;
use super::events::AnimSoundEvent;
use super::SwingMessage;
use crate::net::ObjectStore;
/// **The LOOTABLE front gate on the victim dispatcher** (`0x624552 test byte
/// [[victim+0x110]+0x224],1` / `0x62455a jne 0x624689` — `UNIT_DYNAMIC_FLAGS` bit `0x1`; wow-re
/// `melee-impact-timing.md`, byte-census landed 2026-09-07).
///
/// `0x624530` bails **before every consequence it owns** — blood, wound flinch, impact sounds and
/// the floating damage number alike (`0x624689` is the bare epilogue, and the number's call at
/// `0x624566` sits after the gate). A corpse you can loot takes no visible hits: swinging at it
/// shows nothing at all.
///
/// **It does NOT cover the flushes, and that asymmetry is the whole reason this is a function
/// rather than one `if` at the top.** An image-wide census of the floating-number emitter
/// `0x6243e0` finds exactly three call sites — `0x624566` *inside* the gated dispatcher, plus
/// `0x624ea7` (`SMSG_ATTACKSTOP`, `0x624e40`) and `0x625893` (the `0x14a` supersede arm) which
/// call it **directly**, bypassing `0x624530` and its gate. So a superseded or attack-stopped
/// record still floats its number over a lootable victim. Our single [`SwingImpact`] message
/// carries both occasions, so gating it uniformly — the obvious implementation — would be wrong
/// on the flush: only a **full** dispatch (`text_only == false`) is suppressed.
///
/// Nor does it cover the whiff slow-down: `0x624ca0` is its own function past the dispatcher's
/// `0x624694` end, reached from the impact router, so a swing that whiffs a lootable corpse still
/// drops to half speed.
pub(crate) fn lootable_victim(stores: &Query<&ObjectStore>, victim: Option<Entity>) -> bool {
victim.is_some_and(|v| stores.get(v).is_ok_and(|s| s.0.unit_lootable()))
}
/// A melee swing's **impact moment** — the victim-feedback trigger, re-emitted from
/// [`route_swing_impacts`] at the swing clip's attack-hit keyframe (or a flush).
@@ -125,6 +151,7 @@ pub(super) fn route_swing_impacts(
mut flushes: MessageReader<SwingFlush>,
mut pending: ResMut<PendingImpacts>,
entities: Query<(), With<crate::net::NetEntity>>,
stores: Query<&ObjectStore>,
mut out: MessageWriter<SwingImpact>,
mut defenses: MessageWriter<DefenseAnim>,
mut slows: MessageWriter<SwingSlowdown>,
@@ -173,9 +200,18 @@ pub(super) fn route_swing_impacts(
);
}
if is_whiff(p.swing.victim_state) {
// The whiff slow-down rides the same crossing (`0x624ca0` gate).
// The whiff slow-down rides the same crossing (`0x624ca0` gate) — and it is
// NOT behind the LOOTABLE gate below: its function sits past the victim
// dispatcher's end, so a swing that whiffs a lootable corpse still slows.
slows.write(SwingSlowdown(ev.entity));
}
// The victim dispatcher's front gate ([`lootable_victim`]): a lootable victim
// takes no feedback at all — no blood, no flinch, no impact sound, not even the
// number. The record is still consumed above, exactly as the reference's
// `0x6247d0` clears the GUIDs before it calls the dispatcher at all.
if lootable_victim(&stores, p.swing.victim) {
continue;
}
out.write(SwingImpact {
swing: p.swing,
text_only: false,
@@ -318,6 +354,126 @@ mod tests {
cursor.read(msgs).count()
}
/// `UNIT_DYNAMIC_FLAGS`, absolute UpdateField index — bit `0x1` is LOOTABLE.
const FIELD_DYNAMIC_FLAGS: u16 = 143;
/// Give `entity` a store carrying `flags` in `UNIT_DYNAMIC_FLAGS`.
fn dynamic_flags(app: &mut App, entity: Entity, flags: u32) {
app.world_mut()
.entity_mut(entity)
.insert(crate::net::ObjectStore(
benilla_protocol::messages::ObjectFields::from_pairs(&[(
FIELD_DYNAMIC_FLAGS,
flags,
)]),
));
}
/// **The LOOTABLE front gate** (`0x624552`): a victim you can loot takes no visible hit at
/// all — the dispatcher `0x624530` bails past blood, flinch, sounds AND the floating number.
/// The tag still consumes the record, exactly as `0x6247d0` clears the GUIDs before calling
/// the dispatcher.
#[test]
fn a_lootable_victim_takes_no_full_dispatch() {
let mut app = app();
let (attacker, victim) = (unit(&mut app), unit(&mut app));
dynamic_flags(&mut app, victim, 0x1);
let mut cursor = MessageCursor::<SwingImpact>::default();
app.world_mut()
.write_message(swing(attacker, Some(victim), 1));
app.update();
drain(&mut app, &mut cursor);
tag(&mut app, attacker, *b"$CAH");
assert!(
drain(&mut app, &mut cursor).is_empty(),
"a lootable victim gets nothing — not even the number",
);
assert!(
!app.world()
.resource::<PendingImpacts>()
.0
.contains_key(&attacker),
"the record is still consumed by the tag",
);
}
/// The control the gate would be meaningless without: clear the bit and the same swing
/// dispatches in full.
#[test]
fn a_non_lootable_victim_still_takes_the_full_dispatch() {
let mut app = app();
let (attacker, victim) = (unit(&mut app), unit(&mut app));
dynamic_flags(&mut app, victim, 0x20); // dead-looking, NOT lootable
let mut cursor = MessageCursor::<SwingImpact>::default();
app.world_mut()
.write_message(swing(attacker, Some(victim), 1));
app.update();
drain(&mut app, &mut cursor);
tag(&mut app, attacker, *b"$CAH");
assert_eq!(drain(&mut app, &mut cursor), vec![(0x2, 1, false, None)]);
}
/// **The asymmetry that makes this a per-occasion gate.** The floating-number emitter
/// `0x6243e0` has exactly three call sites: one INSIDE the gated dispatcher (`0x624566`) and
/// two that call it directly — `0x624ea7` (`SMSG_ATTACKSTOP`) and `0x625893` (the `0x14a`
/// supersede arm). So a superseded record still floats its number over a lootable victim, and
/// suppressing our one message uniformly would be wrong.
#[test]
fn a_lootable_victim_still_gets_the_supersede_and_stop_flushes() {
let mut app = app();
let (attacker, victim) = (unit(&mut app), unit(&mut app));
dynamic_flags(&mut app, victim, 0x1);
let mut cursor = MessageCursor::<SwingImpact>::default();
// Two swings in a row: the second supersedes the first, which flushes text-only.
app.world_mut()
.write_message(swing(attacker, Some(victim), 1));
app.update();
drain(&mut app, &mut cursor);
app.world_mut()
.write_message(swing(attacker, Some(victim), 1));
app.update();
assert_eq!(
drain(&mut app, &mut cursor),
vec![(0x2, 1, true, None)],
"the superseded record still texts, gate or no gate",
);
// And SMSG_ATTACKSTOP flushes the survivor the same way.
app.world_mut().write_message(SwingFlush(attacker));
app.update();
assert_eq!(drain(&mut app, &mut cursor), vec![(0x2, 1, true, None)]);
}
/// The whiff slow-down is NOT the dispatcher's: `0x624ca0` is its own function past
/// `0x624694`, so a swing that whiffs a lootable corpse still drops to half speed.
#[test]
fn the_whiff_slowdown_survives_the_lootable_gate() {
let mut app = app();
let (attacker, victim) = (unit(&mut app), unit(&mut app));
dynamic_flags(&mut app, victim, 0x1);
let mut impacts = MessageCursor::<SwingImpact>::default();
let mut slows = MessageCursor::<SwingSlowdown>::default();
app.world_mut()
.write_message(swing(attacker, Some(victim), 2)); // dodged
app.update();
drain(&mut app, &mut impacts);
drain_slows(&mut app, &mut slows);
tag(&mut app, attacker, *b"$CAH");
assert_eq!(
drain_slows(&mut app, &mut slows),
1,
"the whiff still slows"
);
assert!(
drain(&mut app, &mut impacts).is_empty(),
"but nothing dispatches"
);
}
/// A swing waits for its tag; the first `$AH`/`$CAH` fires it FULL exactly once; `$HIT` and
/// `$CSS` never consume it (the byte-verified matching set — `$HIT` is flinch-only and rides
/// hit-REACTION clips too).
+7 -6
View File
@@ -481,14 +481,15 @@ mod tests {
let mut app = app();
spawn_camera(&mut app);
let (behind, _) = spawn_rig(&mut app, Vec3::Z * 50.0);
// A creature guid whose entry the cache answers with the flag set — composed the way
// vmangos composes one (`counter | entry << 24 | high << 48`).
// A streamed creature whose descriptor names an entry the cache answers with the flag
// set. The gate keys on `OBJECT_FIELD_ENTRY` (field 3), the way the reference keys its
// creature-query record (decision 2068) — not on the entry inside the guid.
const ENTRY: u32 = 69;
let guid =
7u64 | (u64::from(ENTRY) << 24) | (u64::from(benilla_protocol::guid::HIGH_UNIT) << 48);
app.world_mut()
.entity_mut(behind)
.insert(crate::net::Guid(guid));
.insert(crate::net::ObjectStore(
benilla_protocol::ObjectFields::from_pairs(&[(3, ENTRY)]),
));
app.world_mut()
.resource_mut::<crate::names::NameCache>()
.insert_creature(
@@ -499,7 +500,7 @@ mod tests {
creature_type: 1,
pet_family: 0,
rank: 0,
type_flags: 0x20,
type_flags: crate::names::type_flags::MORE_AUDIBLE,
civilian: false,
racial_leader: false,
display_id: 0,
@@ -674,6 +674,7 @@ pub(super) fn resolve_spell_fx(
fx: Option<ResMut<SpellFx>>,
time: Res<Time>,
asset_server: Res<AssetServer>,
mut emitters: Query<&mut benilla_world::particles::ParticleEmitter>,
) {
let Some(mut fx) = fx else { return };
let now = time.elapsed_secs();
@@ -711,13 +712,27 @@ pub(super) fn resolve_spell_fx(
// decays out exactly as a reaped one does: in the reference these are two
// nodes, the old one dying on its own clock while the new one is born.
if *persistent {
reap_matching(&mut instances, *spell_id, *class, &fx, now, &mut commands);
reap_matching(
&mut instances,
*spell_id,
*class,
&fx,
now,
&mut commands,
&mut emitters,
);
}
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);
replace_same_slot(
&mut instances,
*effect,
*tag,
&mut commands,
&mut emitters,
);
fx.models
.entry(path.clone())
.or_insert_with(|| DisplayModel {
@@ -741,7 +756,15 @@ pub(super) fn resolve_spell_fx(
SpellKitFx::Reap {
spell_id, class, ..
} => {
reap_matching(&mut instances, *spell_id, *class, &fx, now, &mut commands);
reap_matching(
&mut instances,
*spell_id,
*class,
&fx,
now,
&mut commands,
&mut emitters,
);
}
}
}
@@ -754,6 +777,35 @@ pub(super) fn resolve_spell_fx(
}
}
/// **Hand an ending instance's emitters to the drain** — the half of the reference's teardown
/// `0x6203e0` that is not a despawn (wow-re `ceffect-particle-drain.md`, §4a byte-settled
/// 2026-09-07).
///
/// An ending `CEffect` is *hidden*, not freed: emission stops, the already-emitted particles keep
/// drawing and age out one at a time, and the node is released only on the frame the last one dies
/// — the draw path's own admission test is the live-particle count itself (`0x7b4b46 mov
/// eax,[esi+0x64]`), which nothing in the teardown touches. Despawning the root outright, as this
/// lane did before, cut every live particle at once: for `Strike_Impact_Chest` a replace landing
/// mid-burst threw away up to 0.30 s of a 0.334 s effect, as a pop mid-ramp.
///
/// The meshes are right to go with the root: the reference drops the model off the MESH draw list
/// the same frame (`0x719207` gates it on the visible flag the teardown clears) while the PARTICLE
/// list is gated on the busy mask (`0x71922c`), which the survivors keep set.
///
/// Emitters are free entities carrying their instance root as [`ParticleEmitter::anchor`], so that
/// is the identity here — a linear scan, run only when an instance actually ends (a few per second
/// in a fight, against a pool the sim already walks every frame).
fn drain_instance_emitters(
emitters: &mut Query<&mut benilla_world::particles::ParticleEmitter>,
root: Entity,
) {
for mut e in emitters.iter_mut() {
if e.anchor() == Some(root) {
e.drain_on_owner_loss();
}
}
}
/// **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
@@ -775,6 +827,7 @@ fn replace_same_slot(
effect: u32,
tag: u16,
commands: &mut Commands,
emitters: &mut Query<&mut benilla_world::particles::ParticleEmitter>,
) {
if tag == benilla_formats::WORLD_EFFECT_TAG {
return;
@@ -784,6 +837,10 @@ fn replace_same_slot(
return true;
}
if let Some(root) = i.root {
// The replaced node's particles finish — the replace bounds the number of EMITTING
// nodes, not the number of live particles (`ceffect-particle-drain.md` §1's note on
// the shared teardown).
drain_instance_emitters(emitters, root);
commands.entity(root).despawn();
}
false
@@ -805,6 +862,7 @@ fn reap_matching(
fx: &SpellFx,
now: f32,
commands: &mut Commands,
emitters: &mut Query<&mut benilla_world::particles::ParticleEmitter>,
) {
instances.retain_mut(|i| {
if i.decaying || !i.persistent || i.spell_id != spell_id || i.class != class {
@@ -816,6 +874,9 @@ fn reap_matching(
.and_then(|dm| decay_span(dm.animations.as_ref()));
let (Some(root), Some(span)) = (i.root, span) else {
if let Some(root) = i.root {
// A reap with no `Decay` to play destroys at once — and that destroy is
// `0x6203e0` like any other, so its particles still drain.
drain_instance_emitters(emitters, root);
commands.entity(root).despawn();
}
return false;
@@ -852,6 +913,7 @@ pub(super) fn attach_spell_fx(
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
mut emitters: Query<&mut benilla_world::particles::ParticleEmitter>,
) {
let Some(fx) = fx else {
return;
@@ -863,6 +925,10 @@ pub(super) fn attach_spell_fx(
if let Some(expires) = inst.expires {
if now >= expires {
if let Some(root) = inst.root {
// The flash ran its span — but its particles have their own clocks and
// outlive it (`drain_instance_emitters`). This is the completion
// callback's leg of the same `0x6203e0` teardown the replace takes.
drain_instance_emitters(&mut emitters, root);
commands.entity(root).despawn();
}
if benilla_assets::trace::enabled() {
+36 -1
View File
@@ -160,8 +160,26 @@ pub(crate) struct GlueArt {
pub(crate) check_disabled: Option<Handle<Image>>,
pub(crate) tooltip_border: Option<BackdropEdges>,
pub(crate) char_scrollbar: Option<(Handle<Image>, Vec2)>,
/// The realm list's own additions to that set (`RealmList.xml`): the sort headers'
/// `RealmSortButtonTemplate` — a three-slice `WhoFrame-ColumnTabs` plate, the
/// `UI-Character-Tab-Highlight` sheen it lights on hover, and the `UI-SortArrow` that sits
/// beside each label.
pub(crate) column_tabs: Option<(Handle<Image>, Vec2)>,
pub(crate) tab_highlight: Option<Handle<AddUiMaterial>>,
pub(crate) sort_arrow: Option<(Handle<Image>, Vec2)>,
}
/// `RealmSortButtonTemplate`'s three `WhoFrame-ColumnTabs` slices, `[left, right, top, bottom]`
/// texcoords each — the left cap (5 wide), the stretched middle, and the right cap (4 wide).
pub(crate) const COLUMN_TAB_TC: [[f32; 4]; 3] = [
[0.0, 0.078_125, 0.0, 0.593_75],
[0.078_125, 0.906_25, 0.0, 0.593_75],
[0.906_25, 0.968_75, 0.0, 0.593_75],
];
/// `$parentArrow`'s texcoords into `UI-SortArrow` (the down-pointing half).
pub(crate) const SORT_ARROW_TC: [f32; 4] = [0.0, 0.5625, 0.0, 1.0];
/// The `Interface\HelpFrame\HelpFrame-*` plate: six pieces tiling a 640×512 framed panel
/// (TopLeft/Top 256², TopRight 128×256 across the top row; BotLeft/Bottom/BotRight below).
pub(crate) struct HelpFrameArt {
@@ -420,9 +438,23 @@ impl GlueArt {
"Interface\\PaperDollInfoFrame\\UI-Character-ScrollBar",
images,
);
self.column_tabs = sized(
assets,
"Interface\\FriendsFrame\\WhoFrame-ColumnTabs",
images,
);
self.tab_highlight = add_overlay(
assets,
"Interface\\PaperDollInfoFrame\\UI-Character-Tab-Highlight",
FULL_TC,
images,
add_mats,
);
self.sort_arrow = sized(assets, "Interface\\Buttons\\UI-SortArrow", images);
debug!(
"glue art: addonlist set — helpframe {} header {} close {} droparrow {}/{} \
questhl {} titlehl {} greycheck {} tipborder {} scrolltrack {}",
questhl {} titlehl {} greycheck {} tipborder {} scrolltrack {} \
coltabs {} tabhl {} sortarrow {}",
self.help_frame.is_some(),
self.dialog_header.is_some(),
self.close_btn.is_some(),
@@ -433,6 +465,9 @@ impl GlueArt {
self.check_disabled.is_some(),
self.tooltip_border.is_some(),
self.char_scrollbar.is_some(),
self.column_tabs.is_some(),
self.tab_highlight.is_some(),
self.sort_arrow.is_some(),
);
debug!(
"glue art: races {} classes {} gender {} factions {} banners {} hilight {} logo {} \
+163 -14
View File
@@ -16,6 +16,20 @@ use bevy::prelude::*;
use art::{tc_rect, GlueArt, BTN_BG, BTN_HOVER, BUTTON_TC, GOLD};
use widgets::{ArtSwap, GlueBtn, GlueCaption, GlueDisabled, OutlineCopy};
/// **The glue widgets' look, run once for every glue screen.**
///
/// These four passes are screen-agnostic by construction — they find their work by component, not
/// by state — and every screen registering its own copy was a standing invitation to forget one.
/// The realm list did exactly that: it shipped with `sync_outlines` and without
/// [`glue_button_visuals`]/[`art_swaps`], so no button on it lit on hover, none took its pressed
/// art, and the Okay it carefully marked [`GlueDisabled`] never greyed. Registered here, in
/// [`GluePlugin`], a new screen gets them by existing.
///
/// A screen orders its own refresh **before** this set (`.before(GlueVisuals)`) so the disabled
/// flags and captions it writes are rendered the same frame it writes them.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct GlueVisuals;
/// The shared glue infrastructure both screens stand on: the ADD-mode UI material pipeline, the
/// [`GlueArt`] resource (loaded on first screen entry), and the GlueStrings table. Registered
/// before either screen plugin (`main.rs`).
@@ -36,6 +50,10 @@ impl Plugin for GluePlugin {
.add_systems(
Update,
(backdrop::fit_backdrop_borders, seat_outline_copies),
)
.add_systems(
Update,
(art_swaps, glue_button_visuals, glue_hilights, sync_outlines).in_set(GlueVisuals),
);
}
}
@@ -224,7 +242,6 @@ pub(crate) fn glue_button_visuals(
>,
children: Query<&Children>,
mut captions: Query<&mut TextColor, With<GlueCaption>>,
mut hilights: Query<&mut Visibility, With<widgets::Hilight>>,
) {
for (btn, disabled, interaction, mut node, mut bg, fallback, tc) in &mut glue_btns {
let disabled = disabled.0;
@@ -255,10 +272,9 @@ pub(crate) fn glue_button_visuals(
}
// DESCENDANTS, not children. The caption is a **grandchild's** child: `outlined_text`
// wraps every glue string in a layout wrapper + the −1px trim node before the real string,
// so a direct-children scan finds the `Hilight` overlay and the wrapper and never the
// `GlueCaption` — which is why the sheen lit on hover for a year while the caption stayed
// gold, and why a disabled button's caption never grayed either (1533). The walk is over a
// ~11-entity subtree; depth is the primitive's business, not this pass's.
// so a direct-children scan finds the wrapper and never the `GlueCaption` — which is why a
// disabled button's caption never grayed (1533). The walk is over a ~11-entity subtree;
// depth is the primitive's business, not this pass's.
for child in children.iter_descendants(btn) {
if let Ok(mut caption) = captions.get_mut(child) {
// The disabled caption grays (`GlueFontDisable`), hover whitens, rest is gold.
@@ -270,21 +286,67 @@ pub(crate) fn glue_button_visuals(
GOLD
};
}
if let Ok(mut vis) = hilights.get_mut(child) {
*vis = if hovered {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
}
}
}
/// What [`glue_hilights`] needs off a button to decide whether its sheen is lit: is the cursor on
/// it, is it disabled, and is the screen holding it.
type SheenSource = (
&'static Interaction,
Option<&'static GlueDisabled>,
Option<&'static widgets::LockHighlight>,
);
/// **Every button's highlight sheen, in one pass.** A [`widgets::Hilight`] lights while its owning
/// [`Button`] is hovered — or while the screen holds it with [`widgets::LockHighlight`], the
/// reference's own verb for a selected row.
///
/// There were **four** of these before, one per screen, and the realm list made five by having
/// none: no button on it lit at all. Worse, the four had drifted — two folded selection into the
/// same expression as hover, one folded in a scroll-position disable, and the one that lived
/// inside [`glue_button_visuals`] was scoped `With<GlueBtn>, Without<ArtSwap>`, so the
/// `GlueCloseButton` X on every panel in the client had a sheen that could never light. Splitting
/// "is it lit?" (here) from "is it chosen?" (the screen's, as a `LockHighlight` flag) is what
/// makes one owner possible.
///
/// **Walks UP, from each sheen to its nearest button** — not down from each button, which is the
/// obvious shape and the wrong one: buttons nest. A modal dialog's own root is a `Button` (that is
/// how it eats the clicks that miss its controls), so a descendant walk from there would light
/// every sheen in the dialog at once. The nearest enclosing button is the one whose sheen it is,
/// and there is exactly one of those.
pub(crate) fn glue_hilights(
mut hilights: Query<(Entity, &mut Visibility), With<widgets::Hilight>>,
parents: Query<&ChildOf>,
buttons: Query<SheenSource, With<Button>>,
) {
for (sheen, mut vis) in &mut hilights {
let lit = parents
.iter_ancestors(sheen)
.find_map(|up| buttons.get(up).ok())
.is_some_and(|(interaction, disabled, locked)| {
let live = !disabled.is_some_and(|d| d.0);
live && (*interaction != Interaction::None || locked.is_some_and(|l| l.0))
});
let want = if lit {
Visibility::Inherited
} else {
Visibility::Hidden
};
if *vis != want {
*vis = want;
}
}
}
#[cfg(test)]
mod tests {
use super::widgets::{FallbackFace, GlueBtn, GlueCaption, GlueDisabled};
use super::{glue_button_visuals, glue_clicks, screen_scale, GlueArt, GlueClicks, GOLD};
use super::widgets::{
FallbackFace, GlueBtn, GlueCaption, GlueDisabled, Hilight, LockHighlight,
};
use super::{
glue_button_visuals, glue_clicks, glue_hilights, screen_scale, GlueArt, GlueClicks, GOLD,
};
use bevy::prelude::*;
use bevy::window::WindowResolution;
@@ -447,4 +509,91 @@ mod tests {
);
}
}
// ── The sheen ────────────────────────────────────────────────────────────────────────────
/// Spawn `button > wrapper > sheen`, so the sheen is a **grand**child — the shape
/// `outlined_text` and every art overlay actually produce, and the one a direct-children scan
/// would miss.
fn sheen_app(disabled: bool, locked: bool) -> (App, Entity, Entity) {
let mut app = App::new();
app.add_systems(Update, glue_hilights);
let sheen = app.world_mut().spawn((Hilight, Visibility::Hidden)).id();
let wrapper = app.world_mut().spawn(Node::default()).add_child(sheen).id();
let button = app
.world_mut()
.spawn((
Button,
Interaction::None,
GlueDisabled(disabled),
LockHighlight(locked),
))
.add_child(wrapper)
.id();
(app, button, sheen)
}
fn sheen_lit(app: &App, sheen: Entity) -> bool {
*app.world().get::<Visibility>(sheen).unwrap() == Visibility::Inherited
}
/// Hover lights it, leaving darkens it — and it reaches a sheen nested below a wrapper.
#[test]
fn a_sheen_follows_its_nearest_button() {
let (mut app, button, sheen) = sheen_app(false, false);
app.update();
assert!(!sheen_lit(&app, sheen), "at rest, dark");
*app.world_mut().get_mut::<Interaction>(button).unwrap() = Interaction::Hovered;
app.update();
assert!(sheen_lit(&app, sheen), "hovered, lit");
*app.world_mut().get_mut::<Interaction>(button).unwrap() = Interaction::None;
app.update();
assert!(
!sheen_lit(&app, sheen),
"and dark again when the cursor leaves"
);
}
/// **`LockHighlight` holds it lit with the cursor elsewhere** — the reference's own verb, and
/// what lets a selected row stay marked without the screen touching `Visibility`.
#[test]
fn a_locked_sheen_stays_lit_unhovered() {
let (mut app, _, sheen) = sheen_app(false, true);
app.update();
assert!(sheen_lit(&app, sheen));
}
/// A disabled button lights for neither reason. The reference's `Disable()` takes the
/// highlight with it, and an offline realm row (or a scroll arrow at the end of its travel)
/// must not glow under the cursor.
#[test]
fn a_disabled_button_never_lights() {
let (mut app, button, sheen) = sheen_app(true, true);
app.update();
assert!(!sheen_lit(&app, sheen), "locked but disabled: dark");
*app.world_mut().get_mut::<Interaction>(button).unwrap() = Interaction::Hovered;
app.update();
assert!(!sheen_lit(&app, sheen), "and hovering does not revive it");
}
/// **The walk stops at the nearest button.** A modal dialog's root is itself a `Button` (that
/// is how it eats the clicks that miss its controls), so hovering the dim must not light every
/// sheen inside it — which is exactly what a descendant walk from each button would do.
#[test]
fn an_outer_modal_button_does_not_light_the_sheens_inside_it() {
let (mut app, button, sheen) = sheen_app(false, false);
let root = app
.world_mut()
.spawn((Button, Interaction::Hovered))
.add_child(button)
.id();
app.update();
assert!(
!sheen_lit(&app, sheen),
"the dim is hovered, the inner button is not"
);
let _ = root;
}
}
+15 -2
View File
@@ -15,10 +15,23 @@ use super::backdrop::{backdrop_border, tiled_bg_node};
// ── The shared widget vocabulary ─────────────────────────────────────────────────────────────────
/// A button's highlight overlay (shown on hover — and held while selected where the screen locks
/// it, the ref's `LockHighlight`). Visibility is the owning screen's to drive.
/// A button's highlight overlay: the reference's `HighlightTexture`, lit while the cursor is on
/// the button — or held lit by [`LockHighlight`].
///
/// **Its visibility belongs to [`super::glue_hilights`] and to nothing else.** It used to be "the
/// owning screen's to drive", which is how four screens ended up with four hand-rolled hover
/// loops, two of them subtly different, and how the realm list ended up with none at all — no
/// sheen on any of its buttons, because nobody remembered to write the fifth.
#[derive(Component)]
pub(crate) struct Hilight;
/// **`Button:LockHighlight()`** — hold this button's [`Hilight`] lit whether or not the cursor is
/// on it, which is how every glue list marks its selected row.
///
/// The reference's own verb, and the reason the sheen can have one owner: "lit" is
/// `hovered || locked`, and a screen that knows which row is chosen says exactly that much and
/// nothing about visibility.
#[derive(Component, Default)]
pub(crate) struct LockHighlight(pub(crate) bool);
/// A button spawned with a plain-fill face because client art is missing — the only buttons whose
/// `BackgroundColor` a hover pass may shade (every `Node` carries one since Bevy 0.15's required
/// components, so presence alone can't distinguish the fallback).
+19 -3
View File
@@ -80,11 +80,9 @@ impl Plugin for LoginPlugin {
// Both after `drive_dialog`: it is what spawns the dialog's edit box, and
// what a realmlist Okay changes the address in.
(screen::refresh_dialog_box, screen::refresh_realmlist),
crate::glue::art_swaps,
crate::glue::glue_button_visuals,
crate::glue::sync_outlines,
)
.chain()
.before(crate::glue::GlueVisuals)
.run_if(in_state(ClientState::Login)),
(smoke::debug_login_smoke, screen::debug_login_shot),
)
@@ -440,6 +438,7 @@ fn logon_refusal_text(strings: &GlueStrings, code: Option<u8>) -> &str {
#[allow(clippy::too_many_arguments)]
fn drive_policy(
mut attempt: Attempt,
realm_list_up: Res<crate::realm_select::Realms>,
mut dialog: ResMut<LoginDialog>,
strings: Option<Res<GlueStrings>>,
time: Res<Time>,
@@ -508,6 +507,13 @@ fn drive_policy(
dialog.set_text(stage_text(strings, msg.stage));
}
}
// The realm list answers the same question the status dialog is asking ("what are we
// connecting to?"), and the reference does not stack them: reaching the realm list means the
// login state moved past `LOGIN_STATE_CONNECTING`, and `CGlueMgr::UpdateLoginDialog` clears
// the dialog when it does. Ours would otherwise sit behind the list saying "Connecting".
if realm_list_up.shown && matches!(dialog.kind, Some(DialogKind::Status)) {
dialog.close();
}
// **The queue** (decision 1681): each packet is one sample, and the first one turns the
// connecting dialog into the queue dialog. A queue is not a failure — the attempt is still in
// flight and `in_flight` deliberately stays set, so nothing resubmits underneath it.
@@ -768,6 +774,7 @@ fn enter_login(mut form: ResMut<LoginForm>, mut preview: ResMut<GluePreview>) {
/// boxes / press the buttons / toggle the checkbox.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn login_input(
realms: Res<crate::realm_select::Realms>,
presses: Query<(Entity, &LoginAction, Ref<Interaction>)>,
clicks: Res<crate::glue::GlueClicks>,
mut keyboard: MessageReader<KeyboardInput>,
@@ -784,6 +791,12 @@ fn login_input(
mut commands: Commands,
time: Res<Time>,
) {
// The realm list stands **over** this screen rather than replacing it (the reference's
// `RealmList` is a DIALOG-strata frame, not a glue screen), so while it is up the boxes,
// buttons and keys underneath are inert — including ESCAPE, which is its Cancel, not our Quit.
if realms.shown {
return;
}
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
@@ -1369,6 +1382,9 @@ mod tests {
app.init_resource::<Time>()
.init_resource::<LoginIntent>()
.init_resource::<LoginDialog>()
// The policy asks whether the realm list is standing over this screen; in a harness
// that never raises one the answer is a default `Realms` — always "no".
.init_resource::<crate::realm_select::Realms>()
// Literal, not Default: `Realmlist::default()` reads `$WOW_HOST`, and every probe
// recipe in this repo exports it — a suite run from such a shell would otherwise
// assert against whatever that shell happened to be pointing at.
+56 -17
View File
@@ -20,6 +20,47 @@ use benilla_protocol::guid;
use crate::net::{ClientCommand, NetCommands, ObjectStore};
/// The creature template's **`type_flags`** word, bit by bit — the dword the server ships in
/// `SMSG_CREATURE_QUERY_RESPONSE` and the reference caches at `[CGUnit+0xb30] + 0x14` (its async
/// creature-query record, **not** a DBC row and **not** `CreatureType.dbc`).
///
/// Named because the bits are one-apart neighbours with wholly unrelated meanings, and reading a
/// literal `& 0x10` at a call site is how the wound gate below spent months testing the faction
/// tooltip's bit instead of its own (decision 2068). The reference gives each bit its own getter,
/// which is the shape mirrored here — every consumer names the bit it wants:
///
/// | bit | reference getter | meaning |
/// |---|---|---|
/// | `0x1` | `0x529d15` | TAMEABLE |
/// | `0x2` | `0x605f70` | VISIBLE_TO_GHOSTS |
/// | `0x4` | `0x612530` | BOSS_MOB |
/// | `0x8` | `0x6125f0` | [`DO_NOT_PLAY_WOUND_ANIM`] |
/// | `0x10` | `0x612610` (inverted) | [`NO_FACTION_TOOLTIP`] |
/// | `0x20` | `0x623b70` | [`MORE_AUDIBLE`] |
/// | `0x40` | `0x60d840` | SPELL_ATTACKABLE / no harmful vertex colouring |
/// | `0x80` | `0x613230` = `CanInteractWhileDead` | INTERACT_WHILE_DEAD |
///
/// (wow-re `object-layer/scratch/melee-blood-spurt-suppression.md` §6, bit numbering VERIFIED at
/// each reader; the names corroborated by vmangos `CreatureDefines.h:146-152`.)
pub(crate) mod type_flags {
/// `0x8` — **DO_NOT_PLAY_WOUND_ANIM**: this creature has nothing to flinch with. The
/// reference refuses the victim wound-flinch overlay outright for it (`0x60ea9f` inside the
/// flinch `0x60ea70`), whatever the trigger — a skeleton, a ghost, an elemental takes hits
/// without recoiling. It gates **only** the animation: the blood spurt is unaffected
/// (wow-re §8 Q1: "no blood-, creature-type- or display-record-keyed mechanism exists" —
/// a skeleton bleeds), and so is the floating combat text. Decision 2068.
pub(crate) const DO_NOT_PLAY_WOUND_ANIM: u32 = 0x8;
/// `0x10` — **NO_FACTION_TOOLTIP**: the unit tooltip drops its faction-name line
/// (the reference's `0x612610`, which returns the bit inverted).
pub(crate) const NO_FACTION_TOOLTIP: u32 = 0x10;
/// `0x20` — **MORE_AUDIBLE**: the pass-2 election re-links an off-screen creature for
/// tick-only so its combat stays audible (`0x607da0`'s `0x623b70` arm — wow-re
/// `outdoor-object-pass-election.md` §4, decision 1482).
pub(crate) const MORE_AUDIBLE: u32 = 0x20;
}
/// The name cache: players by guid, creatures by template entry, plus the in-flight ask-once sets.
/// Filled by the net bridge; read (and query-triggered) through [`Self::resolve`].
#[derive(Resource, Default)]
@@ -68,8 +109,8 @@ pub(crate) struct CreatureRecord {
/// Elite rank 0..4 **as the template declares it** — read it through [`gated_rank`], never
/// directly, unless you specifically want the ungated template value.
pub(crate) rank: u32,
/// Template type flags — bit `0x10` (HIDE_FACTION_TOOLTIP) suppresses the tooltip's
/// faction-name line (the client's `0x612610` gate).
/// Template type flags — the wire dword, read bit by bit through [`type_flags`]. Never test a
/// literal against it: its bits are unrelated neighbours (decision 2068).
pub(crate) type_flags: u32,
pub(crate) civilian: bool,
/// Racial leader — the tooltip's white LEADER line (`0x6125c0`).
@@ -207,19 +248,6 @@ impl NameCache {
}
}
/// The cached creature template's `type_flags` for a live guid — read-only, no query on a
/// miss. `None` = not a creature guid, or its template answer hasn't landed; the caller that
/// exists for (the parked-event gate, decision 1482) fails CLOSED on `None`, exactly as the
/// reference's `0x623b70` does on a null cached query record.
pub(crate) fn peek_type_flags(&self, guid_val: u64) -> Option<u32> {
if guid::pet_number(guid_val).is_some() || !guid::is_creature_or_pet(guid_val) {
return None; // a pet's cache entry is name-only; players/GOs carry no template flags
}
self.creatures
.get(&guid::entry(guid_val)?)
.and_then(|n| n.as_ref().map(|r| r.type_flags))
}
/// Record a player-name answer. An empty wire name means the server doesn't know the guid —
/// cached as a negative answer.
///
@@ -289,8 +317,19 @@ impl NameCache {
}
/// The whole cached record for a creature entry — the unit tooltip's read (subtitle, type,
/// rank word, civilian — decision 0276's level-line law). Read-only, the subname's ask-once
/// discipline.
/// rank word, civilian — decision 0276's level-line law), and the one route to a live unit's
/// [`type_flags`]. Read-only, the subname's ask-once discipline.
///
/// **Key it off the unit's descriptor `OBJECT_FIELD_ENTRY`, never the entry in its guid**
/// (decision 2068): the reference registers this record under `[[unit+8]+0xc]` (`0x60b160`),
/// there is exactly one record per unit, and only the descriptor carries a template entry for
/// a `HIGHGUID_PET` guid — whose entry slot holds a pet number instead. A guid-keyed twin of
/// this read used to exist and is gone: two keys onto one field is how the readers drift.
///
/// `None` = no template answer yet, and **each consumer decides what that means, because the
/// reference's per-bit getters do**: the parked-event gate (`MORE_AUDIBLE`, decision 1482)
/// fails CLOSED like `0x623b70`'s null leg, the wound gate (`DO_NOT_PLAY_WOUND_ANIM`, 2068)
/// fails OPEN like `0x6125f0`'s.
pub(crate) fn creature_record(&self, entry: u32) -> Option<&CreatureRecord> {
self.creatures.get(&entry)?.as_ref()
}
+17 -10
View File
@@ -387,20 +387,24 @@ 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.
/// The **realm channel**: the app's answer to each [`RealmListMessage`], whichever park is asking.
///
/// **Two parks listen on it**, because the reference's realm list is a dialog rather than a screen
/// (`RealmList.xml` is `frameStrata="DIALOG"` and `GlueParent.lua`'s `GlueScreenInfo` has no
/// `realmlist` entry): the *login-side* realm park, between the logon and the first world dial,
/// and the *character* park, where Change Realm raises the same list over the select screen. The
/// app sends the same three requests either way and never has to know which one is listening —
/// which is the point, since the answer to Cancel ("hide the dialog") is the same in both.
///
/// Sent by [`crate::realm_select`]'s policy (a remembered realm, `WOW_REALM`, or the player's
/// click); 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.
#[derive(Debug)]
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
@@ -410,8 +414,11 @@ pub(crate) enum RealmRequest {
/// 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.
/// The realm list's Cancel — `RealmList_OnCancel`, which only hides the frame. What that means
/// depends on which park hears it, and in both cases it means "leave the screen underneath
/// alone": at the login-side park there is no session yet, so the thread returns to the
/// pre-logon park and the login screen is what the player is left looking at; at the character
/// park the parked world session is untouched and the select screen simply reappears.
Abandon,
}
+1
View File
@@ -1534,6 +1534,7 @@ pub(crate) fn apply_net_updates(
&mut audio.15 .1,
&mut audio.15 .2,
&mut audio.15 .7,
&stores,
play_seq.next(),
)
}
+8 -1
View File
@@ -116,6 +116,7 @@ pub(super) fn attacker_state(
center: &mut MessageWriter<CombatTextEvent>,
sheaths: &mut MessageWriter<SheathRequest>,
edges: &mut MessageWriter<SwingRefusalEdge>,
stores: &Query<&mut crate::net::ObjectStore>,
seq: u64,
) {
let victim = index.0.get(&s.victim).copied();
@@ -191,7 +192,13 @@ pub(super) fn attacker_state(
attacker: e,
..swing
});
} else if swing.victim.is_some() {
} else if swing.victim.is_some_and(|v| {
// The receive-time arm goes THROUGH the gated dispatcher — `0x625823 je 0x625a3e` takes
// the unresolved-attacker leg, which resolves the victim and calls `0x625a6d call
// 0x624530`, so the LOOTABLE front gate applies here exactly as it does to the tag path
// (`creature_anim::impact::lootable_victim`). It calls no consequence directly.
!stores.get(v).is_ok_and(|s| s.0.unit_lootable())
}) {
// The client's SMSG-arm fallback: an attacker we can't resolve (out of range)
// can't animate a swing — its victim feedback fires immediately and in FULL
// (`0x625a6d`, the only receive-time victim dispatch). The PLACEHOLDER
+19 -5
View File
@@ -160,13 +160,27 @@ pub(super) fn object_create(
local_pos: [t.pos.x, t.pos.y, t.pos.z],
local_orientation: t.orientation,
});
// Warm the name cache the moment a unit streams in — the real client's cache is
// demand-driven too, but persisted (`CreatureCache.wdb`, wow-re dbcache node), so in
// practice it always answers instantly; asking at first *sight* rather than first
// *target* gives our session-lifetime cache the same instant feel (the ask-once
// discipline makes re-creates and shared templates free).
// Warm the name cache the moment a unit streams in. **This is the reference's own timing,
// not a convenience** (decision 2073): `0x60afb0` ResolveDisplayInfo registers the unit in
// the creature-query cache at `0x60b157`, on the create path (`0x5fb880` ← the `UPDATETYPE`
// driver's per-type table) and behind `0x60b134 cmp [OBJECT_FIELD_TYPE], 0x9` — so every
// creature it streams is asked for, whether or not anything ever shows its name. And for a
// creature the name IS that record's field 0 (`0x60934b`), so template and name are one
// query; only players use the separate guid-keyed ask. A `CreatureCache.wdb` hit answers it
// with no wire traffic at all there, which our session-lifetime cache approximates.
if matches!(kind, EntityKind::Unit | EntityKind::Player) {
let _ = names.resolve(guid, net_commands);
// …and for a **pet**, that ask is not the template ask. `resolve` routes a `HIGHGUID_PET`
// guid to the pet-NAME query, because a pet guid carries a pet number where a creature's
// carries its entry — so a tamed unit would never get a template record at all, and
// everything read off one (`type_flags`, rank, creature type) would silently degrade for
// it. The reference has no such split: its cache key is the DESCRIPTOR's entry
// (`[[unit+8]+0xc]`), which vmangos fills with the real `cinfo->entry` for pets too. Ask
// by that key as well; the ask-once discipline makes it free for every non-pet, whose
// descriptor entry and guid entry are the same number.
if let Some(entry) = fields.object_entry().filter(|&e| e != 0) {
let _ = names.resolve_creature(entry, guid, net_commands);
}
}
// Warm the lock cache the moment a GameObject streams in (decision 0239), so a
// right-click resolves use-vs-cast instantly — the same ask-once, ask-at-sight
+377 -319
View File
@@ -99,24 +99,12 @@ pub(super) struct Parks {
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.
/// The refresh runs **inside whichever park is serving the realm list**, so this is a bound on how
/// long that 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,
@@ -253,6 +241,23 @@ impl NetConfig {
}
}
/// What one wake-up at the character park asked for — the `select!`'s answer, so that every jump
/// out of that park is made in one readable `match` rather than inside a macro's expansion.
enum Parked {
/// `CMSG_PLAYER_LOGIN` with this guid: leave the park for the world.
Play(u64),
/// A create/delete was serviced in place; its result byte still has to go out.
Acted(CharAction, u8),
/// Nothing to do — stay parked (a realm-list refresh, or a Cancel over this screen).
StayPut,
/// Dial this realm instead: drop the parked world session and go round again.
Realm(benilla_protocol::RealmInfo),
/// Select's Back — return to the pre-logon park.
Repark,
/// The app dropped a channel end.
Exit,
}
/// How one connection cycle ended (the `Err` case — a stream failure — rides `Result` instead).
enum Cycle {
/// The app dropped a channel end (exit) — end the read thread.
@@ -314,13 +319,6 @@ 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,
@@ -334,15 +332,7 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
// fresh writer arrives, and still has to: between the old socket dying and
// that handover the keepalive tick can still fire on the stale writer.)
read_clock.lock().expect("ping clock").clear();
match run(
&cfg,
&events_tx,
&writer_tx,
&parks,
&abandon,
&read_clock,
&mut held,
) {
match run(&cfg, &events_tx, &writer_tx, &parks, &abandon, &read_clock) {
Ok(Cycle::Exit) => return,
Ok(Cycle::Repark) => {}
Ok(Cycle::LoggedOut) => {
@@ -409,7 +399,6 @@ fn run(
parks: &Parks,
abandon: &AtomicU64,
ping_clock: &Mutex<PingClock>,
held: &mut Option<HeldLogon>,
) -> Result<Cycle> {
let Parks {
login_rx,
@@ -417,16 +406,10 @@ fn run(
pick_rx,
} = parks;
// ── The pre-logon park (decision 0539): block for credentials. ──────────────────────────────
//
// 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),
},
bevy::log::info!("net: parked at the login screen — waiting for credentials");
let req = 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
@@ -468,29 +451,25 @@ fn run(
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:#}"));
// Logon (the dial + SRP6 exchange — one blocking sequence against realmd).
let mut logon = {
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:#}"));
}
}
};
@@ -505,12 +484,18 @@ fn run(
// 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.
//
// **This is the LOGIN-side realm list** — the one whose Cancel means "back to the login
// screen", because the login screen is what the reference's dialog is standing on
// (`GlueScreenInfo` has no `realmlist` entry: `RealmList` is a `frameStrata="DIALOG"` frame
// shown over the current glue screen, and `RealmList_OnCancel` only hides it). The *other*
// one — Change Realm — is served without leaving the character park, below.
//
// 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() {
let mut realm = if logon.realms.is_empty() {
None
} else {
// A pick queued during a dead cycle must not answer THIS list.
// An answer queued during a dead cycle must not answer THIS list.
while realm_rx.try_recv().is_ok() {}
loop {
if events_tx
@@ -521,6 +506,10 @@ fn run(
{
return Ok(Cycle::Exit);
}
bevy::log::info!(
"net: parked at the realm list — {} realm(s) published",
logon.realms.len()
);
let Ok(req) = realm_rx.recv() else {
return Ok(Cycle::Exit);
};
@@ -528,7 +517,8 @@ fn run(
return Ok(Cycle::Repark);
}
match req {
// The realm list's Cancel — back to the login screen.
// The realm list's Cancel, over the login screen — the dialog hides and the
// screen underneath is the one we came from.
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
@@ -541,293 +531,361 @@ fn run(
// 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())
// Strip any explicit auth `:port` off the realmlist — the fallback world port is its own.
.unwrap_or_else(|| format!("{}:{}", host_port(&req.host, WORLD_PORT).0, WORLD_PORT));
stage(LoginStage::Handshaking);
// The realm we are dialing, for the queue dialog to name — the roster that would otherwise
// carry it is on the far side of the queue, which is exactly when the name is wanted.
let realm_name = realm.as_ref().map(|r| r.name.clone());
// Report our place, and keep waiting only while the attempt is still wanted. A queue can
// last minutes, so unlike every other handshake stage it has to test the abandon generation
// itself — otherwise a Cancel would close the dialog while this thread quietly held its place
// in line and then walked into the world anyway.
let mut on_queue = |position: Option<u32>| {
let _ = events_tx.send(SessionEvent::LoginQueued {
position,
realm: realm_name.clone(),
});
!canceled()
};
let mut session = match WorldSession::connect_queued(
&world_addr,
&req.user,
logon.session_key,
&mut on_queue,
) {
Ok(s) => s,
Err(e) => {
if canceled() {
return Ok(Cycle::Repark);
}
// A Warden refusal is the server's own answer, not a transport fault — say it plainly
// rather than wrapping it in handshake noise.
if let Some(w) = e.downcast_ref::<WardenRequired>() {
return fail_terminal(w.to_string());
}
// The world server's own refusal, in its own enum — the screen owes the player the
// authored `AUTH_*` string for it, which it cannot recover from a formatted message.
if let Some(r) = e.downcast_ref::<benilla_protocol::WorldAuthReject>() {
return fail(
Some(benilla_protocol::LoginRefusal::World(r.code)),
format!("{e:#}"),
);
}
return fail(None, format!("world handshake with {world_addr}: {e:#}"));
// ── The world half, once per realm. ─────────────────────────────────────────────────────────
//
// **Change Realm re-enters this loop; it does not end the cycle.** The SRP6 session key
// authenticates against any world server on the account's list and the list is already in
// hand, so switching realms costs one world dial — no re-authentication, no return to the
// login screen, and (this is the part that mattered) no window in which the app is looking at
// a realm list while this thread has already walked on to the character park.
'realm: loop {
// Named, because "connected to 127.0.0.1:8085" cannot tell a chosen realm from the
// realm-less fallback that dials the same address.
if let Some(r) = &realm {
bevy::log::info!(
"net: realm {:?} ({} of {})",
r.name,
r.address,
logon.realms.len()
);
}
};
// The handshake can now block for minutes (the queue), so a cancel that landed while it did
// must not be overtaken by the roster it is about to fetch.
if canceled() {
return Ok(Cycle::Repark);
}
let world_addr = realm
.as_ref()
.map(|r| r.address.clone())
// Strip any explicit auth `:port` off the realmlist — the fallback world port is its own.
.unwrap_or_else(|| format!("{}:{}", host_port(&req.host, WORLD_PORT).0, WORLD_PORT));
// The roster (creating a starter character on a fresh account so PLAYER_LOGIN has a target).
// Failures here are still pre-roster: surface as LoginFailed, re-park. (An immediately-run
// closure, so the `?`-shaped sequence borrows `session` only for the call.)
let mut characters = match (|| -> Result<Vec<benilla_protocol::Character>> {
let mut characters = session.char_enum()?;
if characters.is_empty() {
let name = cfg.character.as_deref().unwrap_or("One");
let starter = messages::CharCreateReq {
name: name.to_string(),
race: messages::RACE_HUMAN,
class: messages::CLASS_WARRIOR,
gender: messages::GENDER_MALE,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
};
match session.create_character(&starter)? {
messages::CHAR_CREATE_SUCCESS | messages::CHAR_CREATE_NAME_IN_USE => {}
other => bail!("character creation failed: result {other:#x}"),
stage(LoginStage::Handshaking);
// The realm we are dialing, for the queue dialog to name — the roster that would otherwise
// carry it is on the far side of the queue, which is exactly when the name is wanted.
let realm_name = realm.as_ref().map(|r| r.name.clone());
// Report our place, and keep waiting only while the attempt is still wanted. A queue can
// last minutes, so unlike every other handshake stage it has to test the abandon generation
// itself — otherwise a Cancel would close the dialog while this thread quietly held its place
// in line and then walked into the world anyway.
let mut on_queue = |position: Option<u32>| {
let _ = events_tx.send(SessionEvent::LoginQueued {
position,
realm: realm_name.clone(),
});
!canceled()
};
let mut session = match WorldSession::connect_queued(
&world_addr,
&req.user,
logon.session_key,
&mut on_queue,
) {
Ok(s) => s,
Err(e) => {
if canceled() {
return Ok(Cycle::Repark);
}
// A Warden refusal is the server's own answer, not a transport fault — say it plainly
// rather than wrapping it in handshake noise.
if let Some(w) = e.downcast_ref::<WardenRequired>() {
return fail_terminal(w.to_string());
}
// The world server's own refusal, in its own enum — the screen owes the player the
// authored `AUTH_*` string for it, which it cannot recover from a formatted message.
if let Some(r) = e.downcast_ref::<benilla_protocol::WorldAuthReject>() {
return fail(
Some(benilla_protocol::LoginRefusal::World(r.code)),
format!("{e:#}"),
);
}
return fail(None, format!("world handshake with {world_addr}: {e:#}"));
}
characters = session.char_enum()?;
};
// The handshake can now block for minutes (the queue), so a cancel that landed while it did
// must not be overtaken by the roster it is about to fetch.
if canceled() {
return Ok(Cycle::Repark);
}
Ok(characters)
})() {
Ok(c) => c,
Err(e) => {
if canceled() {
return Ok(Cycle::Repark);
}
if let Some(w) = e.downcast_ref::<WardenRequired>() {
return fail_terminal(w.to_string());
}
return fail(None, format!("character roster: {e:#}"));
}
};
if canceled() {
return Ok(Cycle::Repark);
}
// A pick queued during a dead cycle must not answer THIS roster — the app re-sends what it
// still wants (its pending pick), and a deliberate logout must land on the list, not bounce
// straight back into the world off a stale pick.
while pick_rx.try_recv().is_ok() {}
if events_tx
.send(SessionEvent::CharacterList {
characters: characters.clone(),
realm: realm.clone(),
})
.is_err()
{
return Ok(Cycle::Exit);
}
// Park at character select until the app answers (its pick policy: auto-relogin on reconnect,
// the WOW_CHAR fast path, or the director's click). Create/delete requests are serviced *in
// place* (decision 0423): send, read the one result byte, on success re-enum + re-emit the
// roster, emit the result, and loop back to the park — the thread stays a policy-free blocking
// sequencer. The channel only closes on app exit. If the server kicked the parked socket
// 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 {
// 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 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);
// The roster (creating a starter character on a fresh account so PLAYER_LOGIN has a target).
// Failures here are still pre-roster: surface as LoginFailed, re-park. (An immediately-run
// closure, so the `?`-shaped sequence borrows `session` only for the call.)
let mut characters = match (|| -> Result<Vec<benilla_protocol::Character>> {
let mut characters = session.char_enum()?;
if characters.is_empty() {
let name = cfg.character.as_deref().unwrap_or("One");
let starter = messages::CharCreateReq {
name: name.to_string(),
race: messages::RACE_HUMAN,
class: messages::CLASS_WARRIOR,
gender: messages::GENDER_MALE,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
};
match session.create_character(&starter)? {
messages::CHAR_CREATE_SUCCESS | messages::CHAR_CREATE_NAME_IN_USE => {}
other => bail!("character creation failed: result {other:#x}"),
}
characters = session.char_enum()?;
}
CharRequest::Create(create) => (CharAction::Create, session.create_character(&create)?),
CharRequest::Delete(target) => (CharAction::Delete, session.delete_character(target)?),
};
// Success (create's SUCCESS or delete's SUCCESS) changed the roster — re-enumerate and
// re-emit it *before* the result, so the screen already has the fresh list when it reacts.
let changed = match action {
CharAction::Create => code == messages::CHAR_CREATE_SUCCESS,
CharAction::Delete => code == messages::CHAR_DELETE_SUCCESS,
};
if changed {
characters = session.char_enum()?;
if events_tx
.send(SessionEvent::CharacterList {
characters: characters.clone(),
realm: realm.clone(),
})
.is_err()
{
return Ok(Cycle::Exit);
Ok(characters)
})() {
Ok(c) => c,
Err(e) => {
if canceled() {
return Ok(Cycle::Repark);
}
if let Some(w) = e.downcast_ref::<WardenRequired>() {
return fail_terminal(w.to_string());
}
return fail(None, format!("character roster: {e:#}"));
}
};
if canceled() {
return Ok(Cycle::Repark);
}
// A pick queued during a dead cycle must not answer THIS roster — the app re-sends what it
// still wants (its pending pick), and a deliberate logout must land on the list, not bounce
// straight back into the world off a stale pick. Same for a realm answer aimed at a list this
// park has already left behind.
while pick_rx.try_recv().is_ok() {}
while realm_rx.try_recv().is_ok() {}
if events_tx
.send(SessionEvent::CharActionResult { action, code })
.send(SessionEvent::CharacterList {
characters: characters.clone(),
realm: realm.clone(),
})
.is_err()
{
return Ok(Cycle::Exit);
}
};
let name = characters
.iter()
.find(|c| c.guid == guid)
.map(|c| c.name.clone())
.unwrap_or_default();
session.player_login(guid)?;
session.set_active_mover(guid)?;
let billing_time_rested = session.billing_time_rested();
let tutorial_flags = session.take_tutorial_flags();
let (mut reader, writer) = session.into_split()?;
if events_tx
.send(SessionEvent::Connected {
self_guid: guid,
name,
billing_time_rested,
tutorial_flags,
})
.is_err()
{
return Ok(Cycle::Exit);
}
if writer_tx.send(writer).is_err() {
// The writer thread only ends when the app drops every command sender — app exit.
return Ok(Cycle::Exit);
}
bevy::log::info!("net: connected to {world_addr}");
// Blocking read loop. `poll` skips packets the message layer can't parse (keeping the stream
// aligned); a long run of consecutive skips means the stream desynced — guard against a busy spin.
let (mut skip_run, mut skip_logged) = (0u32, 0u32);
loop {
let polled = reader.poll()?;
note_inbound(); // one packet off the wire, parsed or not — the census counts liveness
match polled {
Poll::Events { opcode, events } => {
skip_run = 0;
// The full inbound opcode stream (tag `in`, decision 0624) — the last place a
// packet could hide. `skip` covers what failed to parse and `rly` covers what
// reached the mover replay; between them sits the packet that parsed into *no*
// event, which no instrument could see. With this line every packet off the wire
// is accounted for by name, so "the server stopped relaying" and "we dropped it on
// the floor" are finally different pictures instead of the same silence.
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"in",
&format!(
"{opcode:#06x} {} ev={}",
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?"),
events.len()
),
);
}
for ev in events {
// **The pong bypass** (B346), the reference's own shape: `OnData 0x537b10`
// peeks the opcode and hands `SMSG_PONG` straight to `HandlePong 0x537d60`
// inline, instead of copying it onto the queue the game thread drains. So do
// we — the round trip is measured here, against the clock the write thread
// stamped, and the event stops here. Measuring it after a drain instead added
// a whole client frame to every reading, which is a frame's worth of the
// client's own slowness reported as the server's distance.
if let SessionEvent::Pong { sequence } = ev {
if let Some(rtt) =
ping_clock.lock().expect("ping clock").record_pong(sequence)
// Park at character select until the app answers (its pick policy: auto-relogin on reconnect,
// the WOW_CHAR fast path, or the director's click). Create/delete requests are serviced *in
// place* (decision 0423): send, read the one result byte, on success re-enum + re-emit the
// roster, emit the result, and loop back to the park — the thread stays a policy-free blocking
// sequencer. The channel only closes on app exit. If the server kicked the parked socket
// 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).
bevy::log::info!("net: parked at character select");
let guid = loop {
// **Two channels, one park.** The realm list is a dialog, not a screen (`RealmList.xml`
// is `frameStrata="DIALOG"` over whatever glue screen is up), so Change Realm raises it
// *over* character select and this thread must keep serving both while it is open —
// refreshing the list on the still-live realmd connection, and staying exactly where it
// is if the player cancels. The version that ended the cycle here is what stranded the
// app: it walked on to this park while the app was still looking at a realm list, so the
// next Cancel sent an `Abandon` no one was listening for and every later login queued
// behind a thread parked two parks away.
//
// Named `pick` rather than `req`: the cycle's own `req` (the credentials) is still live.
//
// **No `break` or `continue` inside the `select!` arms.** The macro expands into a
// loop of its own, so loop control written in an arm would bind to *that* loop rather
// than this park — a silent spin. The arms answer with a [`Parked`] and every jump is
// made below, in plain sight.
let answer = crossbeam_channel::select! {
recv(realm_rx) -> req => match req {
Err(_) => Parked::Exit,
// `RequestRealmList` — refresh on the realmd socket this cycle still holds,
// then republish. Also how Change Realm asks for its first list.
Ok(RealmRequest::Refresh) => {
logon.refresh_realms(REALM_REFRESH_TIMEOUT);
if events_tx
.send(SessionEvent::RealmList { realms: logon.realms.clone() })
.is_err()
{
bevy::log::debug!("net: pong seq={sequence} rtt={rtt}ms");
Parked::Exit
} else {
Parked::StayPut
}
continue;
}
// A confirmed logout ends the cycle *after* the app hears about it.
let logged_out = matches!(ev, SessionEvent::LoggedOut);
// Receiver dropped → the app exited; end the thread cleanly.
if events_tx.send(ev).is_err() {
return Ok(Cycle::Exit);
// `RealmList_OnCancel` over character select: the dialog hides and **nothing
// else happens**. The session it is standing on is untouched, which is the
// whole point of the reference making this a frame rather than a screen.
Ok(RealmRequest::Abandon) => Parked::StayPut,
// `ChangeRealm(category, index)`: drop this world session and dial the chosen
// realm with the session key we already hold. A name that is no longer on the
// list changes nothing, which is the honest answer.
Ok(RealmRequest::Enter(name)) => logon
.realms
.iter()
.find(|r| r.name == name)
.cloned()
.map_or(Parked::StayPut, Parked::Realm),
},
recv(pick_rx) -> pick => match pick {
Err(_) => Parked::Exit,
Ok(CharRequest::Enter(guid)) => Parked::Play(guid),
// Select's Back (decision 0539): drop the parked session, return to the login park.
Ok(CharRequest::Abandon) => Parked::Repark,
Ok(CharRequest::Create(create)) => {
Parked::Acted(CharAction::Create, session.create_character(&create)?)
}
if logged_out {
return Ok(Cycle::LoggedOut);
Ok(CharRequest::Delete(target)) => {
Parked::Acted(CharAction::Delete, session.delete_character(target)?)
}
},
};
let (action, code) = match answer {
Parked::Exit => return Ok(Cycle::Exit),
Parked::Repark => return Ok(Cycle::Repark),
Parked::StayPut => continue,
Parked::Play(guid) => break guid,
Parked::Realm(chosen) => {
realm = Some(chosen);
continue 'realm;
}
}
Poll::Skipped { opcode, reason } => {
skip_run += 1;
// **Every** skip, uncapped, into the trace (tag `skip`, decision 0623). A packet that
// arrives and fails to parse is indistinguishable, from outside, from one that never
// arrived: the inbound census counts it either way, and no `rly` line is emitted
// either way. That ambiguity is what made a starving remote mover unattributable —
// so the skips get their own line, with the opcode that died.
if benilla_assets::trace::enabled() {
benilla_assets::trace::line("skip", &format!("opcode={opcode:#06x} {reason}"));
}
// Log which packet we dropped (opcode + message name), capped so it can't itself
// flood — enough to capture a post-teleport burst of unparseable object updates.
if skip_logged < 40 {
bevy::log::warn!("net: skipping unparseable packet — {reason}");
skip_logged += 1;
}
// Feed the app's dropped-packet tally (the debug panel instrument) — a parse
// *error* is a coverage gap the same as an unknown opcode, just a worse one.
Parked::Acted(action, code) => (action, code),
};
// Success (create's SUCCESS or delete's SUCCESS) changed the roster — re-enumerate and
// re-emit it *before* the result, so the screen already has the fresh list when it reacts.
let changed = match action {
CharAction::Create => code == messages::CHAR_CREATE_SUCCESS,
CharAction::Delete => code == messages::CHAR_DELETE_SUCCESS,
};
if changed {
characters = session.char_enum()?;
if events_tx
.send(SessionEvent::PacketDropped {
opcode,
unparseable: true,
.send(SessionEvent::CharacterList {
characters: characters.clone(),
realm: realm.clone(),
})
.is_err()
{
return Ok(Cycle::Exit);
}
if skip_run > 1024 {
return Err(anyhow!("world stream desynced after 1024 skipped packets"));
}
if events_tx
.send(SessionEvent::CharActionResult { action, code })
.is_err()
{
return Ok(Cycle::Exit);
}
};
let name = characters
.iter()
.find(|c| c.guid == guid)
.map(|c| c.name.clone())
.unwrap_or_default();
session.player_login(guid)?;
session.set_active_mover(guid)?;
let billing_time_rested = session.billing_time_rested();
let tutorial_flags = session.take_tutorial_flags();
let (mut reader, writer) = session.into_split()?;
if events_tx
.send(SessionEvent::Connected {
self_guid: guid,
name,
billing_time_rested,
tutorial_flags,
})
.is_err()
{
return Ok(Cycle::Exit);
}
if writer_tx.send(writer).is_err() {
// The writer thread only ends when the app drops every command sender — app exit.
return Ok(Cycle::Exit);
}
bevy::log::info!("net: connected to {world_addr}");
// Blocking read loop. `poll` skips packets the message layer can't parse (keeping the stream
// aligned); a long run of consecutive skips means the stream desynced — guard against a busy spin.
let (mut skip_run, mut skip_logged) = (0u32, 0u32);
loop {
let polled = reader.poll()?;
note_inbound(); // one packet off the wire, parsed or not — the census counts liveness
match polled {
Poll::Events { opcode, events } => {
skip_run = 0;
// The full inbound opcode stream (tag `in`, decision 0624) — the last place a
// packet could hide. `skip` covers what failed to parse and `rly` covers what
// reached the mover replay; between them sits the packet that parsed into *no*
// event, which no instrument could see. With this line every packet off the wire
// is accounted for by name, so "the server stopped relaying" and "we dropped it on
// the floor" are finally different pictures instead of the same silence.
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"in",
&format!(
"{opcode:#06x} {} ev={}",
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?"),
events.len()
),
);
}
for ev in events {
// **The pong bypass** (B346), the reference's own shape: `OnData 0x537b10`
// peeks the opcode and hands `SMSG_PONG` straight to `HandlePong 0x537d60`
// inline, instead of copying it onto the queue the game thread drains. So do
// we — the round trip is measured here, against the clock the write thread
// stamped, and the event stops here. Measuring it after a drain instead added
// a whole client frame to every reading, which is a frame's worth of the
// client's own slowness reported as the server's distance.
if let SessionEvent::Pong { sequence } = ev {
if let Some(rtt) =
ping_clock.lock().expect("ping clock").record_pong(sequence)
{
bevy::log::debug!("net: pong seq={sequence} rtt={rtt}ms");
}
continue;
}
// A confirmed logout ends the cycle *after* the app hears about it.
let logged_out = matches!(ev, SessionEvent::LoggedOut);
// Receiver dropped → the app exited; end the thread cleanly.
if events_tx.send(ev).is_err() {
return Ok(Cycle::Exit);
}
if logged_out {
return Ok(Cycle::LoggedOut);
}
}
}
Poll::Skipped { opcode, reason } => {
skip_run += 1;
// **Every** skip, uncapped, into the trace (tag `skip`, decision 0623). A packet that
// arrives and fails to parse is indistinguishable, from outside, from one that never
// arrived: the inbound census counts it either way, and no `rly` line is emitted
// either way. That ambiguity is what made a starving remote mover unattributable —
// so the skips get their own line, with the opcode that died.
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"skip",
&format!("opcode={opcode:#06x} {reason}"),
);
}
// Log which packet we dropped (opcode + message name), capped so it can't itself
// flood — enough to capture a post-teleport burst of unparseable object updates.
if skip_logged < 40 {
bevy::log::warn!("net: skipping unparseable packet — {reason}");
skip_logged += 1;
}
// Feed the app's dropped-packet tally (the debug panel instrument) — a parse
// *error* is a coverage gap the same as an unknown opcode, just a worse one.
if events_tx
.send(SessionEvent::PacketDropped {
opcode,
unparseable: true,
})
.is_err()
{
return Ok(Cycle::Exit);
}
if skip_run > 1024 {
return Err(anyhow!("world stream desynced after 1024 skipped packets"));
}
}
}
}
}
} // 'realm
}
/// Drain the writer's sent-packet log into the trace as `out` lines — one per packet that reached
+47 -18
View File
@@ -1,15 +1,19 @@
//! The realm list's input — the reference's `RealmSelectButton_OnClick`/`OnDoubleClick`,
//! `RealmList_OnOk`/`OnCancel`, `RealmListTab_OnClick`, `SortRealms`, and `RealmList_OnKeyDown`.
//!
//! **Every exit hides the dialog and touches nothing else.** `RealmList_OnOk` is
//! `PlaySound; RealmList:Hide(); ChangeRealm(...)` and `RealmList_OnCancel` is
//! `PlaySound; RealmList:Hide(); RealmListDialogCancelled()` — neither names a screen, because the
//! screen it is standing on is the one the player goes back to (see [`super`]).
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};
use super::{is_down, Realms};
/// The double-click window — the same conventional interval the select screen uses.
const DOUBLE_CLICK_SECS: f32 = 0.4;
@@ -22,14 +26,13 @@ pub(super) fn clicks(
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;
let mut leave: Option<bool> = None; // Some(with_sound)
for (entity, action) in &buttons {
if !hits.hit(entity) {
continue;
@@ -52,7 +55,8 @@ pub(super) fn clicks(
}
}
RealmAction::Ok => enter = true,
RealmAction::Cancel => cancel = true,
RealmAction::Cancel => leave = Some(true),
RealmAction::Close => leave = Some(false),
// `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),
@@ -61,12 +65,8 @@ pub(super) fn clicks(
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);
if let Some(with_sound) = leave {
do_cancel(&mut realms, &choice, &mut sounds, with_sound);
}
}
@@ -76,13 +76,10 @@ pub(super) fn keys(
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);
do_cancel(&mut realms, &choice, &mut sounds, true);
return;
}
if keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter) {
@@ -143,7 +140,38 @@ fn scroll_into_view(realms: &mut Realms, row: usize, total: usize) {
}
}
/// `RealmList_OnOk`: play the click and answer the park.
/// `RealmList_OnCancel` — and `RealmListCloseButton`, which differs only in the sound
/// (`with_sound`).
///
/// **The park's answer to `Abandon` is the whole difference between the two contexts**, and
/// neither costs this function a branch — which is exactly the reference's own shape. Its
/// `RealmListDialogCancelled` (`0x46ed20` → `0x46b810`, VERIFIED) opens by comparing the current
/// glue screen's name against `"login"`: **not equal and it returns immediately**, so from
/// character select the native does *nothing at all* — the realmd link, the world session and the
/// operation record are untouched and `Hide()` is the entire effect. Equal, and it tail-jumps
/// `CLoginMgr::Cancel` (`0x5b3320`), closing the realmd socket — **without any `SetGlueScreen`**,
/// because the login screen was never left. Ours matches on both legs: the character park ignores
/// `Abandon`, and the login-side park re-parks, which drops the `Logon` and its socket.
///
/// **One divergence, stated.** In the reference the X reaches only `CancelRealmListQuery`
/// (`0x46ed10` → `0x46b7e0`: cancel the pending `COP_GET_REALMS` record, send nothing, close
/// nothing), so from a login it leaves the realmd link up. Ours abandons on both, because our IO
/// thread *parks* on the question rather than polling for it: a hidden dialog with the thread
/// still blocked at the realm park is precisely the desync this whole redesign exists to remove.
fn do_cancel(
realms: &mut Realms,
choice: &RealmChoice,
sounds: &mut MessageWriter<GlueSound>,
with_sound: bool,
) {
if with_sound {
sounds.write(GlueSound("gsLoginChangeRealmCancel"));
}
realms.hide();
let _ = choice.0.send(RealmRequest::Abandon);
}
/// `RealmList_OnOk`: play the click, hide the frame, 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
@@ -156,12 +184,13 @@ fn scroll_into_view(realms: &mut Realms, row: usize, total: usize) {
/// 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;
return; // nothing highlighted — the reference's Okay is disabled here
};
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);
realms.hide();
realms.enter(choice, name);
}
+8 -2
View File
@@ -193,6 +193,12 @@ pub(crate) fn pvp_rp(realm_type: u32) -> (bool, bool) {
/// 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.
/// The row gold `RealmListUpdate` writes — **a literal in the Lua** (`SetTextColor(1.0, 0.78,
/// 0.0)`), not `NORMAL_FONT_COLOR`, which is 0.82. Four hundredths, and the reference is explicit
/// about it in both places it appears (the name and the selection band's vertex colour), so it is
/// pinned rather than folded into [`NORMAL`].
pub(super) const ROW_GOLD: Color = Color::srgb(1.0, 0.78, 0.0);
pub(super) fn name_colors(down: bool, invalid: bool, characters: u8) -> (Color, Color) {
if down {
(GRAY, Color::srgb(0.8, 0.8, 0.8))
@@ -201,7 +207,7 @@ pub(super) fn name_colors(down: bool, invalid: bool, characters: u8) -> (Color,
} else if characters > 0 {
(GREEN, HIGHLIGHT)
} else {
(crate::glue::art::GOLD, HIGHLIGHT)
(ROW_GOLD, HIGHLIGHT)
}
}
@@ -214,7 +220,7 @@ pub(super) fn highlight_color(invalid: bool, characters: u8) -> Color {
} else if characters > 0 {
GREEN
} else {
crate::glue::art::GOLD
ROW_GOLD
}
}
+243 -82
View File
@@ -13,41 +13,71 @@
//! (`realmList` the CVar, `RealmList` the window) and is kept rather than invented away: one is
//! where you dial, this is what answers.
//!
//! ## The park
//! ## Not a screen — a dialog
//!
//! 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:
//! `RealmList` is **not** one of the glue screens. `GlueParent.lua`'s `GlueScreenInfo` table has
//! entries for `login`, `charselect`, `realmwizard`, `charcreate`, `patchdownload`, `movie` and
//! `credits` — and none for the realm list; `RealmList.xml` declares a `frameStrata="DIALOG"`
//! frame that `RealmList_OnEvent` simply `Show()`s, and `RealmList_OnCancel` simply `Hide()`s.
//! Whatever screen it went up over is still there underneath and is what the player is returned to.
//!
//! This shipped as a `ClientState` instead, and every symptom the director reported came out of
//! that one error: Cancel had to pick a state to go to (it picked the login screen, so Change
//! Realm's Cancel threw you out of the session), the character screen was torn down behind it, and
//! — the bad one — a roster arriving while that state was up moved nothing, so Okay looked inert
//! while the IO thread walked on to the character park. The next Cancel then sent an answer no
//! park was listening for and every later login queued behind a thread two parks away: the client
//! hung on "Connecting" with nothing in the log. It is a `shown` flag now, like the AddOns panel
//! it shares a plate with, and the screen underneath is never touched.
//!
//! ## The parks
//!
//! The IO thread publishes the list from **two** parks (`crate::net::io`) and takes its answer on
//! one channel: the *login-side* realm park (logon → **which realm?** → world handshake), and the
//! *character* park, where Change Realm raises the same list over the select screen and the
//! session underneath stays live. 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
//! 2. **The realm this session is on** — a logout or a dropped connection comes back through this
//! park, and the answer is the realm you were already playing on, not a question.
//! 3. **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`
//! 4. **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.
//! 5. **Otherwise the dialog**, 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.
//! the account's realm list and the list itself is already in hand, so the character park serves
//! the list **in place** — refreshing it on the realmd connection the cycle still holds — and only
//! leaves when a realm is chosen. Cancel there does nothing at all, which is what makes it Cancel.
//!
//! That is the reference's shape, byte for byte (VERIFIED, wow-re):
//! `CharacterSelect_ChangeRealm()` is `PlaySound + RequestRealmList(1)` and **names no screen**;
//! `RequestRealmList` (`0x46ecf0` → `0x46b8d0`) sets `screenState = 4` and begins a
//! `COP_GET_REALMS` on the realmd socket that is still open (it is closed at *world entry*,
//! `0x46b70c`, not on the way to character select); and the world connection is **not** dropped by
//! the button — only `ConnectToRealm` (`0x46b210`) drops it, once a realm is confirmed, deferring
//! its dial to the disconnect handler. So the whole cost of opening the list is a list request,
//! and the whole cost of cancelling is nothing.
mod input;
mod load;
mod screen;
mod smoke;
pub(crate) use load::pvp_rp;
use bevy::prelude::*;
use crate::char_select::ClientState;
use crate::net::{RealmChoice, RealmListMessage, RealmRequest};
use benilla_protocol::RealmInfo;
@@ -80,16 +110,32 @@ pub(crate) struct Realms {
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>,
/// **Is the dialog up?** The reference's `RealmList:IsVisible()`, and the whole of its
/// screen-state: a frame that is shown over the current glue screen and hidden again.
///
/// It is also the auto-answer's off switch. While the player is looking at the list, no
/// remembered realm may answer it for them — without that, the five-second refresh would
/// re-publish and the policy would enter the very realm they opened the dialog to leave.
pub(super) shown: bool,
/// The realm this client considers **current** — the reference's `GetRealmInfo` `currentRealm`
/// return, which is the row `RealmListUpdate` highlights when nothing else is selected.
///
/// **It is a string compare against the `realmName` CVar, and nothing else** (VERIFIED, wow-re:
/// `0x46ef9e` reads that CVar's current value through `0x5ab7d0` — the handle registered at
/// `0x63db90`, help `"Last realm connected to"` — and case-folds it against the realm record's
/// inline name at `realm+9`, pushing `1.0` on a match and `nil` otherwise; a 15-site census of
/// that getter found no competing mechanism anywhere in the image). It marks the realm you are
/// *connected to* on the character screen only because `ConnectToRealm` (`0x46b217`) writes the
/// CVar **before** it dials.
///
/// Ours is that same fact tracked one step earlier: set when we answer a park with a realm,
/// seeded from the persisted CVar before we have. benilla's own `realmName` write lands at
/// world entry (`benilla_ui::script::UiScript::set_realm_name`), which is after the point the
/// highlight needs it, and moving that write is a change to the SavedVariables path this
/// screen has no business making.
current: 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,
@@ -236,9 +282,42 @@ impl Realms {
}
/// Whether the OK button is live: a realm is highlighted and it is not offline.
///
/// `RealmListUpdate` opens with `RealmListOkButton:Disable()` and only the highlighted-row
/// branches re-enable it, so a list with nothing selected has a dead Okay — which is what a
/// fresh login shows, since there is no current realm to preselect.
pub(super) fn can_enter(&self) -> bool {
self.selected().is_some_and(|r| !is_down(r))
}
/// Raise the dialog (`RealmList:Show()` → `RealmList_OnShow`): reset the scroll, seat the
/// highlight on [`Self::current`], and start the refresh countdown.
fn open(&mut self) {
self.shown = true;
self.offset = 0;
self.selected = self
.current
.as_deref()
.and_then(|want| {
self.realms
.iter()
.find(|r| r.name.eq_ignore_ascii_case(want))
})
.map(|r| r.name.clone());
self.refresh_in = REFRESH_SECS;
}
/// Answer a park with a realm — and remember it as [`Self::current`], the way the reference's
/// `ConnectToRealm` writes `realmName` on its way to the dial.
pub(super) fn enter(&mut self, choice: &RealmChoice, name: String) {
self.current = Some(name.clone());
let _ = choice.0.send(RealmRequest::Enter(name));
}
/// `RealmList:Hide()` — and nothing else. The screen underneath was never touched.
pub(super) fn hide(&mut self) {
self.shown = false;
}
}
/// **Offline** — the realm is up in the list but not accepting connections (`realmDown` in
@@ -261,35 +340,41 @@ 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,
app.init_resource::<Realms>().add_systems(
Update,
(
apply_realm_policy,
// Ungated by state on purpose: the dialog stands over the login screen and over
// character select alike, and `shown` is the only thing that decides.
screen::drive_screen,
// Input first, then the row refresh — a click that moved the highlight shows on
// the frame it landed, not the next one.
smoke::debug_realm_smoke,
(
screen::materialize_screen,
screen::refresh_rows,
input::clicks,
input::keys,
tick_refresh,
crate::glue::sync_outlines,
screen::refresh_rows,
)
.run_if(in_state(ClientState::RealmList)),
);
.chain()
.run_if(|realms: Res<Realms>| realms.shown),
)
.chain()
.after(benilla_world::schedule::WorldStage::Net)
.before(crate::glue::GlueVisuals),
);
}
}
/// Answer the realm park — or show the screen and let the player answer it.
/// Answer the realm park — or raise the dialog 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.
/// Runs in every state: the list is published from the login-side park *and* from the character
/// park, and this one system is the policy for both.
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;
@@ -297,33 +382,34 @@ fn apply_realm_policy(
}
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.
// Keep the tab across a refresh when it still names something real; a first list (or one
// that dropped what we had) falls back to the first.
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);
// **A list published while the dialog is up is a refresh, never a question.** The player
// is looking at it; answering over their shoulder with the realm they are standing on is
// the button-that-does-nothing bug in its purest form.
if realms.shown {
continue;
}
let remembered = persist.stored(CVAR_REALM_NAME);
match auto_answer(&realms, remembered, crate::run_mode::unattended()) {
let remembered = persist.stored(CVAR_REALM_NAME).map(str::to_string);
if realms.current.is_none() {
realms.current = remembered.clone();
}
match auto_answer(
&realms,
remembered.as_deref(),
crate::run_mode::unattended(),
) {
Some(name) => {
realms.select(&name);
answer(&mut realms, &choice, name);
realms.enter(&choice, name);
}
None => next.set(ClientState::RealmList),
None => realms.open(),
}
}
}
@@ -337,18 +423,29 @@ fn apply_realm_policy(
/// 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())
});
// The named arms, matched case-insensitively — vmangos does not normalise realm names and a
// config file is hand-editable.
//
// **`current` sits ahead of the persisted CVar**, and it is not decoration: a clean logout and
// a lost session both come back through this park, and the realm to return to is the one this
// session is standing on. benilla writes `realmName` at world entry, so a session that never
// reached the world has nothing persisted — without this arm, a logout mid-way would answer
// the relist by *raising the dialog* over character select, which is not a question anybody
// asked.
let named = [
realms.env_realm.as_deref(),
realms.current.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.
@@ -365,29 +462,25 @@ fn auto_answer(realms: &Realms, remembered: Option<&str>, unattended: bool) -> O
})
}
/// Arm the screen for the next realm list, whatever the policy would otherwise auto-answer with.
/// The character screen's **Change Realm**: raise the dialog over the select screen and ask for a
/// fresh list.
///
/// 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 order is the engine's — `CHANGE_REALM` requests the list and the arriving
/// `OPEN_REALM_LIST` shows the frame. We show it immediately against the list already in hand and
/// let the refresh land underneath, because the list *is* already in hand and a dialog that waits
/// a round trip to appear reads as a dropped click.
///
/// Nothing here touches the session. The character park serves the refresh in place and stays
/// exactly where it is, which is what lets Cancel mean nothing at all.
pub(crate) fn open_over_char_select(realms: &mut Realms, choice: &RealmChoice) {
realms.open();
let _ = choice.0.send(RealmRequest::Refresh);
}
/// 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.
/// list. Only while the dialog is up — `RealmList_OnHide` calls `CancelRealmListQuery`, and a park
/// is never 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;
@@ -573,9 +666,57 @@ mod tests {
assert!(!r.can_enter());
}
/// **Raising the dialog seats the highlight on the realm you are on** — the reference's
/// `GetRealmInfo` `currentRealm`, which `RealmListUpdate` highlights when nothing else is
/// selected. With no current realm nothing is selected and Okay is dead, which is exactly what
/// `RealmListUpdate` does: it opens with `RealmListOkButton:Disable()` and only the
/// highlighted-row branches turn it back on.
#[test]
fn opening_the_dialog_highlights_the_realm_you_are_on() {
let mut r = list(vec![
realm("Alpha", 1, 0, 0, 0, 1.0),
realm("Mu", 1, 0, 0, 3, 1.0),
]);
r.current = Some("Mu".into());
r.open();
assert!(r.shown);
assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Mu"));
assert!(r.can_enter());
let mut fresh = list(vec![realm("Alpha", 1, 0, 0, 0, 1.0)]);
fresh.open();
assert!(fresh.selected().is_none(), "nothing to preselect");
assert!(
!fresh.can_enter(),
"and Okay is dead until a row is clicked"
);
// A remembered realm that is no longer listed cannot seat a highlight either.
let mut gone = list(vec![realm("Alpha", 1, 0, 0, 0, 1.0)]);
gone.current = Some("Elsewhere".into());
gone.open();
assert!(gone.selected().is_none());
}
/// **Cancel hides the dialog and touches nothing else** — no screen change, because the
/// reference's `RealmList_OnCancel` is `Hide()` and the screen underneath was never left.
#[test]
fn hide_is_the_whole_of_cancel() {
let mut r = list(vec![realm("Alpha", 1, 0, 0, 0, 1.0)]);
r.current = Some("Alpha".into());
r.open();
r.hide();
assert!(!r.shown);
assert_eq!(
r.selected().map(|r| r.name.as_str()),
Some("Alpha"),
"and the list keeps its state for the next time it is raised"
);
}
/// 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.
/// dialog, 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![
@@ -585,7 +726,7 @@ mod tests {
assert_eq!(
auto_answer(&r, None, false),
None,
"attended: show the screen"
"attended: show the dialog"
);
assert_eq!(auto_answer(&r, None, true).as_deref(), Some("Alpha"));
assert_eq!(auto_answer(&r, Some("mu"), false).as_deref(), Some("Mu"));
@@ -597,6 +738,26 @@ mod tests {
);
}
/// **A logout comes back to the realm it left**, without asking. The relist arrives through the
/// same park a first login does, and the session's own realm has to outrank the persisted CVar
/// — which benilla only writes at world entry, so a session that never got there has nothing
/// persisted at all and would otherwise be asked to pick again.
#[test]
fn a_relogin_returns_to_the_realm_this_session_is_on() {
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, "nothing to go on: ask");
r.current = Some("Mu".into());
assert_eq!(auto_answer(&r, None, false).as_deref(), Some("Mu"));
assert_eq!(
auto_answer(&r, Some("Alpha"), false).as_deref(),
Some("Mu"),
"and it outranks the persisted realmName, which is the PREVIOUS session's"
);
}
/// 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]
+135 -46
View File
@@ -22,7 +22,7 @@
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use crate::glue::art::{GlueArt, GOLD};
use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, SORT_ARROW_TC};
use crate::glue::widgets::{
abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight,
};
@@ -33,6 +33,10 @@ use super::{Realms, SortKey};
use crate::char_select::wow_font;
/// Over the glue screen it stands on (1100), over that screen's own dialogs (1200), and over the
/// AddOns panel's tooltip (1220) — the reference's `frameStrata="DIALOG"` with `toplevel="true"`.
const REALM_Z: i32 = 1250;
/// The panel plate, straight off `RealmList.xml`.
const BG_W: f32 = 640.0;
const BG_H: f32 = 512.0;
@@ -63,9 +67,13 @@ const SORT_COLUMNS: [(SortKey, &str, f32, f32); 4] = [
(SortKey::Characters, "REALM_CHARACTERS", 324.0, 110.0),
(SortKey::Load, "REALM_LOAD", 434.0, 144.0),
];
/// `RealmSortButtonTemplate`'s height, and the widths of its two `WhoFrame-ColumnTabs` end caps.
const SORT_H: f32 = 19.0;
const SORT_CAP_L: f32 = 5.0;
const SORT_CAP_R: f32 = 4.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;
const SORT_TOP: f32 = 50.0 - SORT_H;
/// 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
@@ -90,8 +98,13 @@ 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`.
/// The Cancel button, and ESCAPE — `RealmList_OnCancel`.
Cancel,
/// The close X, which is **not** the Cancel button. `RealmListCloseButton`'s whole OnClick is
/// `RealmList:Hide()`, and `GlueCloseButton` (`GlueTemplates.xml` l.4) declares no sound — so
/// the X is silent where Cancel plays `gsLoginChangeRealmCancel`. Same outcome for the player,
/// one fewer click in the room.
Close,
Sort(SortKey),
}
@@ -116,11 +129,17 @@ pub(super) struct RowHighlight;
#[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.
/// Raise, rebuild and tear down the dialog — `RealmList:Show()` / `:Hide()`, plus the rebuild rule
/// every glue tree here follows (an artless early spawn upgrades when the client art lands, and a
/// window resize rebuilds at the new glue scale).
///
/// **Not a state transition.** The frame is shown over whatever glue screen is current and hidden
/// again; nothing about that screen changes, which is the whole of `RealmList`'s lifecycle in the
/// reference (see [`super`]).
#[allow(clippy::too_many_arguments)]
pub(super) fn materialize_screen(
pub(super) fn drive_screen(
mut commands: Commands,
realms: Res<Realms>,
existing: Query<(Entity, &RealmListUi)>,
assets: Res<AssetServer>,
mut art: ResMut<GlueArt>,
@@ -131,6 +150,12 @@ pub(super) fn materialize_screen(
window: Query<&Window, With<PrimaryWindow>>,
time: Res<Time>,
) {
if !realms.shown {
for (root, _) in &existing {
commands.entity(root).despawn();
}
return;
}
if let Some(mut wa) = world_assets {
art.ensure_loaded(&mut wa, &mut images, &mut add_mats);
}
@@ -165,13 +190,6 @@ pub(super) fn materialize_screen(
}
}
/// 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,
@@ -190,7 +208,11 @@ fn spawn_screen(
commands
.spawn((
RealmListUi { with_art, s },
GlobalZIndex(1200),
GlobalZIndex(REALM_Z),
// `enableMouse="true"` on a `setAllPoints` frame: the dialog eats every click that
// misses its own controls, so the screen underneath cannot be operated through it.
// (A `Button` with no `FocusPolicy` blocks, which is what we want here.)
Button,
// The reference's own full-screen BACKGROUND layer: black at 0.75.
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.75)),
Node {
@@ -212,7 +234,7 @@ fn spawn_screen(
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_sort_headers(b, art, &font, strings, s);
spawn_highlight(b, art, s);
for row in 0..MAX_ROWS {
spawn_row(b, &font, row, s);
@@ -309,10 +331,10 @@ fn spawn_header(
);
}
/// `GlueCloseButton` at TOPRIGHT (−42, −3) — the same action as Cancel.
/// `GlueCloseButton` at TOPRIGHT (−42, −3) — see [`RealmAction::Close`] for why it is not Cancel.
fn spawn_close(b: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>, s: f32) {
let mut x = b.spawn((
RealmAction::Cancel,
RealmAction::Close,
Button,
abs(s, BG_W - 42.0 - 32.0, 3.0, 32.0, 32.0),
));
@@ -361,9 +383,13 @@ fn spawn_close(b: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>,
}
}
/// The four clickable column headers (`RealmSortButtonTemplate`).
/// The four clickable column headers (`RealmSortButtonTemplate`): a three-slice
/// `WhoFrame-ColumnTabs` plate, the label 8 in from the left with the `UI-SortArrow` beside it, and
/// the `UI-Character-Tab-Highlight` sheen the shared [`crate::glue::glue_hilights`] pass lights on
/// hover.
fn spawn_sort_headers(
b: &mut ChildSpawnerCommands,
art: &GlueArt,
font: &Handle<Font>,
strings: Option<&GlueStrings>,
s: f32,
@@ -375,27 +401,76 @@ fn spawn_sort_headers(
b.spawn((
RealmAction::Sort(key),
Button,
abs(s, left, SORT_TOP, w, 19.0),
abs(s, left, SORT_TOP, w, SORT_H),
))
.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,
);
// The plate: 5-wide left cap, 4-wide right cap, the middle stretched between them.
if let Some((tex, size)) = &art.column_tabs {
for (tc, l, cw) in [
(COLUMN_TAB_TC[0], 0.0, SORT_CAP_L),
(COLUMN_TAB_TC[1], SORT_CAP_L, w - SORT_CAP_L - SORT_CAP_R),
(COLUMN_TAB_TC[2], w - SORT_CAP_R, SORT_CAP_R),
] {
h.spawn((
ImageNode {
image: tex.clone(),
rect: Some(tc_rect(*size, tc)),
..default()
},
abs(s, l, 0.0, cw, SORT_H),
));
}
}
// The sheen, LEFT..RIGHT+4 and 24 tall — vertically centred on a 19-tall button.
if let Some(hi) = &art.tab_highlight {
h.spawn((
Hilight,
Visibility::Hidden,
MaterialNode(hi.clone()),
abs(s, 0.0, (SORT_H - 24.0) / 2.0, w + 4.0, 24.0),
));
}
// `$parentText` at LEFT +8, with `$parentArrow` 3 to the right of its right edge.
let mut row = h.spawn(Node {
position_type: PositionType::Absolute,
left: Val::Px(8.0 * s),
top: Val::Px(0.0),
height: Val::Px(SORT_H * s),
align_items: AlignItems::Center,
column_gap: Val::Px(3.0 * s),
..default()
});
row.with_children(|r| {
outlined_text(
r,
Node::default(),
(),
(),
GlueText {
text: &label,
size: 12.0, // GlueFontHighlightSmall
color: load::HIGHLIGHT,
wrap: false,
},
font,
s,
);
if let Some((tex, size)) = &art.sort_arrow {
r.spawn((
ImageNode {
image: tex.clone(),
rect: Some(tc_rect(*size, SORT_ARROW_TC)),
..default()
},
Node {
width: Val::Px(9.0 * s),
height: Val::Px(8.0 * s),
top: Val::Px(-2.0 * s),
..default()
},
));
}
});
});
}
}
@@ -470,7 +545,10 @@ fn spawn_row(b: &mut ChildSpawnerCommands, font: &Handle<Font>, row: usize, s: f
pub(super) fn refresh_rows(
realms: Res<Realms>,
strings: Option<Res<GlueStrings>>,
mut rows: Query<(&RowOf, &mut Visibility), (With<RealmAction>, Without<RowHighlight>)>,
mut rows: Query<
(&RowOf, &Interaction, &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>),
@@ -490,11 +568,17 @@ pub(super) fn refresh_rows(
// 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 {
// The row under the cursor, if any — `RealmListRealmButtonTemplate`'s `HighlightFont`, which
// is the only hover state a row has (the template carries no HighlightTexture).
let mut hovered = None;
for (RowOf(row), interaction, mut vis) in &mut rows {
*vis = match at(*row) {
Some(_) => Visibility::Inherited,
None => Visibility::Hidden,
};
if *interaction != Interaction::None {
hovered = Some(*row);
}
}
// Where the selection band goes, decided while walking the name column.
@@ -505,25 +589,30 @@ pub(super) fn refresh_rows(
};
let down = super::is_down(realm);
let invalid = super::is_invalid(realm);
// `LockHighlight()` on the chosen row, `button:Disable()` on an offline one: the selected
// row wears its highlight font just as a hovered row does, and a disabled row wears
// neither.
let is_selected = selected.as_deref() == Some(realm.name.as_str()) && !down;
let lit = !down && (is_selected || hovered == Some(*row));
let (new, c) = match column {
Column::Name => {
if selected.as_deref() == Some(realm.name.as_str()) && !down {
if is_selected {
band_row = Some((*row, load::highlight_color(invalid, realm.characters)));
}
(
realm.name.clone(),
load::name_colors(down, invalid, realm.characters).0,
)
let (normal, highlight) = load::name_colors(down, invalid, realm.characters);
(realm.name.clone(), if lit { highlight } else { normal })
}
// `RealmListUpdate` recolours the selected row's type and load columns to
// HIGHLIGHT_FONT_COLOR — the two computed words go white under the band.
Column::Type => {
let (key, c) = load::type_column(realm.realm_type);
(text(key), c)
(text(key), if is_selected { load::HIGHLIGHT } else { 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)
(text(key), if is_selected { load::HIGHLIGHT } else { c })
}
};
if t.0 != new {
@@ -0,0 +1,164 @@
//! **The realm-list boundary smoke** (`WOW_REALM_SMOKE=1`, run by `scripts/smoke.sh`) — the
//! scripted walk that reproduces the failure this subsystem shipped with, so it can never come
//! back silently.
//!
//! What broke, and why nothing caught it: the realm list was a `ClientState`, and the roster that
//! arrives after Okay only moved the app off `Login`. So Okay published the pick, the IO thread
//! entered the realm and walked on to the character park — and the app sat on the realm screen,
//! looking exactly as if the button had done nothing. The next Cancel then sent its answer to a
//! park two ahead of the thread, where nobody was listening, and every later login queued behind
//! it: the client hung on "Connecting" with **nothing in the log**. Three screens' worth of
//! symptoms, one missing transition, and no test in the workspace could see any of it, because
//! every one of them models the app or the thread and the bug lived in the seam.
//!
//! So this leg drives the seam, against a real server:
//!
//! 1. from character select, **Change Realm** — the dialog goes up *over* the screen, which stays;
//! 2. **Okay** on the realm we are already on — a real world re-dial, and the roster has to come
//! back with the dialog down and the screen still character select (the leg that was broken);
//! 3. **Change Realm again, then Cancel** — the dialog goes down and the session underneath is
//! untouched, which is the whole of `RealmList_OnCancel`;
//! 4. one more round trip through the park to prove the thread is still where the app thinks it
//! is: a second Okay that has to land.
//!
//! Inert without the env.
use bevy::prelude::*;
use crate::char_select::{ClientState, Roster};
use crate::net::{RealmChoice, RealmRequest};
use super::Realms;
/// How long each leg may take before the walk gives up and says which one stalled.
const LEG_TIMEOUT: f32 = 25.0;
/// A beat with the dialog up, so a human watching sees it and a stuck frame has time to show.
const DWELL: f32 = 1.0;
#[derive(Default)]
pub(super) struct Walk {
phase: u8,
mark: f32,
/// The roster generation we are waiting to move past — a realm entered is a roster replaced.
rosters: u32,
seen: u32,
}
/// The walk. Every leg names itself on the way in, so a timeout says which boundary stalled
/// instead of "the client did not exit".
#[allow(clippy::too_many_arguments)]
pub(super) fn debug_realm_smoke(
mut msgs: MessageReader<crate::net::CharListMessage>,
realms: ResMut<Realms>,
roster: Res<Roster>,
state: Res<State<ClientState>>,
choice: Res<RealmChoice>,
time: Res<Time>,
mut walk: Local<Walk>,
mut exit: MessageWriter<AppExit>,
) {
walk.seen += msgs.read().count() as u32;
if std::env::var_os("WOW_REALM_SMOKE").is_none() || walk.phase == u8::MAX {
return;
}
let now = time.elapsed_secs();
let at_select = *state.get() == ClientState::CharSelect && !roster.chars.is_empty();
let mut fail = |why: &str| {
error!("realm-smoke: FAILED — {why}");
exit.write(AppExit::error());
};
if walk.phase > 0 && now - walk.mark > LEG_TIMEOUT {
fail(&format!(
"leg {} stalled for {LEG_TIMEOUT:.0}s (state {:?}, dialog {}, rosters {}/{})",
walk.phase,
state.get(),
if realms.shown { "up" } else { "down" },
walk.seen,
walk.rosters,
));
walk.phase = u8::MAX;
return;
}
match walk.phase {
// ── 1 · Change Realm: the dialog goes up over the screen. ────────────────────────────
0 if at_select && !realms.realms.is_empty() => {
info!("realm-smoke: at character select → Change Realm");
open(realms, &choice);
(walk.phase, walk.mark) = (1, now);
}
1 if realms.shown && now - walk.mark > DWELL => {
if *state.get() != ClientState::CharSelect {
fail("the dialog replaced the character screen instead of standing over it");
walk.phase = u8::MAX;
return;
}
info!("realm-smoke: dialog up, character select still underneath → Okay");
okay(realms, &choice);
(walk.phase, walk.mark, walk.rosters) = (2, now, walk.seen + 1);
}
// ── 2 · Okay has to land: a fresh roster, dialog down, still at select. ──────────────
2 if walk.seen >= walk.rosters && !realms.shown && at_select => {
info!("realm-smoke: Okay landed — fresh roster at character select → Change Realm");
open(realms, &choice);
(walk.phase, walk.mark) = (3, now);
}
// ── 3 · Cancel changes nothing. ──────────────────────────────────────────────────────
3 if realms.shown && now - walk.mark > DWELL => {
info!("realm-smoke: dialog up → Cancel");
cancel(realms, &choice);
(walk.phase, walk.mark) = (4, now);
}
4 if !realms.shown && now - walk.mark > DWELL => {
if !at_select {
fail("Cancel left character select instead of just hiding the dialog");
walk.phase = u8::MAX;
return;
}
// ── 4 · And the park is still where the app thinks it is. ────────────────────────
info!("realm-smoke: Cancel kept the session → one more Change Realm + Okay");
open(realms, &choice);
(walk.phase, walk.mark) = (5, now);
}
5 if realms.shown && now - walk.mark > DWELL => {
okay(realms, &choice);
(walk.phase, walk.mark, walk.rosters) = (6, now, walk.seen + 1);
}
6 if walk.seen >= walk.rosters && !realms.shown && at_select => {
info!(
"realm-smoke: done — {} roster(s), {} char(s) at character select",
walk.seen,
roster.chars.len()
);
exit.write(AppExit::Success);
walk.phase = u8::MAX;
}
_ => {}
}
}
/// The character screen's Change Realm button, as the walk presses it.
fn open(mut realms: ResMut<Realms>, choice: &RealmChoice) {
super::open_over_char_select(&mut realms, choice);
}
/// `RealmList_OnOk` on whatever the dialog opened with highlighted, or the first row.
fn okay(mut realms: ResMut<Realms>, choice: &RealmChoice) {
let name = realms.selected().map(|r| r.name.clone()).or_else(|| {
realms
.rows()
.first()
.map(|&i| realms.realms[i].name.clone())
});
let Some(name) = name else {
return;
};
realms.hide();
let _ = choice.0.send(RealmRequest::Enter(name));
}
/// `RealmList_OnCancel`.
fn cancel(mut realms: ResMut<Realms>, choice: &RealmChoice) {
realms.hide();
let _ = choice.0.send(RealmRequest::Abandon);
}
+27 -6
View File
@@ -49,8 +49,9 @@
//! impact tag: the `$AHn` digit block "has zero effect on the victim dispatch", so a beast's
//! bite and the parry clang both sound.
//!
//! Plus the victim's injury vocal (`Injury`/`InjuryCritical`/`InjuryCrushing`) on every
//! damaging, undefended hit.
//! Plus the victim's injury vocal (`Injury`/`InjuryCritical`/`InjuryCrushing`) on a damaging,
//! undefended hit — the ordinary one **rolled** at the victim's own class-2 threshold, 60 for a
//! creature and 30 for a player, while a crit and a crushing blow always sound (decision 2073).
//!
//! A `text_only` flush (supersede/attack-stop) drops its sounds — only the floating number
//! flushes (decision 0149's flush law, inherited from the shared dispatch).
@@ -138,7 +139,8 @@ use benilla_world::schedule::WorldStage;
use super::creature::CreatureVoices;
use super::kit::{
bark_chance_pass, object_sound_playing, play_kit_ext, Bus, KitRef, PlayExtras, SoundCategory,
SoundKits, Volume, EXERTION_CHANCE_CREATURE, EXERTION_CHANCE_PLAYER,
SoundKits, Volume, EXERTION_CHANCE_CREATURE, EXERTION_CHANCE_PLAYER, INJURY_CHANCE_CREATURE,
INJURY_CHANCE_PLAYER,
};
use super::{AudioListener, SoundConfig, SoundOutput};
@@ -829,16 +831,35 @@ fn combat_sounds(
&& swing.hit_info & HITINFO_ABSORB_OR_RESIST == 0
&& !matches!(swing.victim_state, VICTIM_PARRY | VICTIM_BLOCK)
{
// The vocal's **class**, which decides both gates below: crushing `0x8000` → 9,
// else critical `0x80` → 3, else 2 (`0x62462c`/`0x624649`/`0x624666`).
let crushing = swing.hit_info & HITINFO_CRUSHING != 0;
// The `AISOUNDDESC` gate (`0x4591f0` from `0x6234cb`): a server-pushed object sound
// live on the victim suppresses its own vocal, classes 0-3 and 8. The CGPlayer twin
// live on the victim suppresses its own vocal — **classes 0-3 and 8 only**
// (`0x6234bb`/`0x6234bf`/`0x6234c4`), so a CRUSHING blow's class 9 punches through a
// scripted voice line where an ordinary hit and a crit do not. The CGPlayer twin
// `0x62f880` omits the gate, so a player is never suppressed. Filtered off the victim
// rather than `continue`d, because everything else in this iteration still stands.
let vocal_victim = victim.filter(|(_, _, net, ..)| {
net.kind == EntityKind::Player
crushing
|| net.kind == EntityKind::Player
|| !swing.victim.is_some_and(|v| object_sound_playing(&out, v))
});
if let Some((victim_tr, _, net, victim_is_you, _)) = vocal_victim {
let crushing = swing.hit_info & HITINFO_CRUSHING != 0;
// The class chance roll (decision 2073), inside the victim's own `[vt+0x88]` and
// therefore keyed on the VICTIM's type: class 2 is 60 for a creature and 30 for a
// player, while classes 3 and 9 carry 100 and always sound. So an ordinary wound
// grunt thins out under sustained melee and the big hits punch through it.
if !crushing && !crit {
let threshold = if net.kind == EntityKind::Player {
INJURY_CHANCE_PLAYER
} else {
INJURY_CHANCE_CREATURE
};
if !bark_chance_pass(threshold, kits.roll()) {
continue;
}
}
let vocal = net
.display_id
.and_then(|d| voices.0.for_display(d))
+58 -3
View File
@@ -217,9 +217,8 @@ pub(super) fn bark_chance_pass(threshold: u32, roll: u32) -> bool {
/// The rest of the creature table is `{70, 100, 60, 100, 100, 40, 100, …}` (player twin
/// `{35, 100, 30, 100, 100, 40, 100, …}`), and only classes 0, 2 and 5 are ever rolled — every
/// other class carries 100, i.e. `P = 1`, which is why `$WNG`/`$WGG` (classes 7 and 10) and the
/// ALERT bark (class 8) are faithfully unconditional. Class 2 (injury) is **not** encoded here:
/// its live trigger route is still unpinned. Class 0 (exertion) now is — see
/// [`EXERTION_CHANCE_CREATURE`].
/// ALERT bark (class 8) are faithfully unconditional. Classes 0 and 2 are encoded beside this one
/// — see [`EXERTION_CHANCE_CREATURE`] and [`INJURY_CHANCE_CREATURE`].
pub(super) const STAND_CHANCE: u32 = 40;
/// The class-0 (**exertion**) chance thresholds, and the one place in the vocal tables where the
@@ -236,6 +235,29 @@ pub(super) const EXERTION_CHANCE_CREATURE: u32 = 70;
/// The player twin of [`EXERTION_CHANCE_CREATURE`] — `0x86424c[0] = 35`.
pub(super) const EXERTION_CHANCE_PLAYER: u32 = 35;
/// The class-2 (**ordinary injury**) chance thresholds — the victim's wound grunt, and the third
/// and last rolled class (decision 2073). `0x8626d4[2] = 60` (creature) and `0x86424c[2] = 30`
/// (player): **P = 61/101 ≈ 60.4 %** and **31/101 ≈ 30.7 %**.
///
/// The twin is picked by the **victim's** own type, since the roll runs inside the victim's
/// `[vtable+0x88]` — `0x623490` for a creature, `0x62f880` for a player — so a player being hit
/// grunts about half as often as a creature taking the same blow, the same asymmetry class 0 has
/// on the attacker's side.
///
/// Classes **3** (InjuryCritical) and **9** (InjuryCrushingBlow) carry 100 in both twins and are
/// never rolled: a crit and a crushing blow always vocalise, an ordinary hit does not. That is the
/// audible shape — the wound grunt thins out under sustained melee while the big hits punch
/// through it — and playing class 2 unconditionally, which is what benilla did before 2073, is
/// about 40 % too many grunts on a creature and 70 % too many on a player.
///
/// The route that reaches these was the "still unpinned" clause above: `0x624530`'s tail →
/// `[vtable+0x88]` → the roll `0x623520` / `0x62f940` → the 13-way column selector `0x623020`
/// (`+0x0c/+0x10/+0x14` = DBC columns 3/4/5). wow-re
/// `object-layer/scratch/wound-parry-gate-and-injury-vocal.md`.
pub(super) const INJURY_CHANCE_CREATURE: u32 = 60;
/// The player twin of [`INJURY_CHANCE_CREATURE`] — `0x86424c[2] = 30`.
pub(super) const INJURY_CHANCE_PLAYER: u32 = 30;
/// The class-5 cooldown: **10 000 ms on ONE global timestamp** (`0x623290`,
/// `GetTickCount − [0xc4e0e4] − 0x2710`). Not per unit and not per class — a single window shared
/// by every creature in the world, so one crocodile's croak silences every other creature's stand
@@ -1393,6 +1415,39 @@ mod tests {
assert_eq!(admitted(100), 101, "a critical swing always grunts");
}
/// The injury pair's shape (decision 2073), and the reason the victim's grunt thins out:
/// **class 2 is rolled, classes 3 and 9 are not.** `0x8626d4[2] = 60` for a creature victim
/// and `0x86424c[2] = 30` for a player one — so a creature vocalises about three hits in
/// five, a player fewer than one in three, while every crit and every crushing blow sounds.
///
/// The numbers are the point: benilla played all three unconditionally before 2073, which is
/// 40 of every 100 creature grunts too many and 70 of every 100 on a player.
#[test]
fn an_ordinary_wound_grunt_is_rolled_but_a_crit_and_a_crush_always_sound() {
let bucket = |r: u32| ((101u64 * u64::from(r)) >> 32) as u32;
let admitted = |threshold: u32| {
(0..=100)
.filter(|&b| {
let r = ((u64::from(b) << 32) / 101) as u32 + 1;
bucket(r) == b && bark_chance_pass(threshold, r)
})
.count()
};
assert_eq!(admitted(INJURY_CHANCE_CREATURE), 61, "P = 61/101 ≈ 60.4 %");
assert_eq!(admitted(INJURY_CHANCE_PLAYER), 31, "P = 31/101 ≈ 30.7 %");
// Classes 3 (InjuryCritical) and 9 (InjuryCrushingBlow) carry 100 in both twins, which is
// why combat.rs skips the roll on a crit and a crushing blow rather than rolling a 100.
assert_eq!(
admitted(100),
101,
"a crit and a crushing blow always sound"
);
// The injury pair is strictly quieter than the exertion pair on the same twin — the
// attacker grunts more often than the victim does, in both directions.
assert!(admitted(INJURY_CHANCE_CREATURE) < admitted(EXERTION_CHANCE_CREATURE));
assert!(admitted(INJURY_CHANCE_PLAYER) < admitted(EXERTION_CHANCE_PLAYER));
}
/// **The two per-unit handles are disjoint** (decision 1399): the one-shot bark occupies
/// `[unit+0xb20]`, the greeting line `[unit+0xb1c]`, and neither may answer the other's
/// question. This is the regression guard for the way the voice slot was added — tagging the
+1
View File
@@ -660,6 +660,7 @@ impl SelectCommit<'_, '_> {
&mut self.seam,
entity,
guid,
self.stores.get(entity).ok(),
me.is_some_and(|(_, _, engaged)| engaged),
me.map(|(g, _, _)| g.0),
attackable,
+95 -1
View File
@@ -70,6 +70,10 @@ pub(super) fn select_on_click(
mut greeting: MessageWriter<crate::sound::NpcGreetingRequest>,
ground: Res<crate::ui_action::SpellTargeting>,
click_cfg: Res<ClickConfig>,
// The clicked unit's descriptor — `0x493540`'s `IsSelectable` gate reads it inside
// [`scan::commit`]. Read live rather than latched with the press pick: the reference resolves
// the object at the *commit*, not at the down edge.
stores: Query<&ObjectStore>,
) {
let (hovered, occlusion) = (press.hovered, press.occlusion);
// Drain the frame's clicks; act only if there was one and the inspector isn't holding left-click.
@@ -106,6 +110,7 @@ pub(super) fn select_on_click(
&mut seam,
entity,
guid,
stores.get(entity).ok(),
engaged,
self_guid,
press.attack,
@@ -125,7 +130,14 @@ pub(super) fn select_on_click(
// selection — so a left-click on a body must leave the target exactly where it was.
// Without this the corpse would arrive here (its guid is in the other slot) and read
// as "clicked nothing", dropping the player's target every time they clicked a body.
if hovered.corpse.is_some() {
//
// **A REFUSED pick is the same kind of object hit** (decision 2060). The hover grader
// `0x4828d0` takes the mouseover away from a `NOT_SELECTABLE` unit, but the *click*
// never went through it: the down-edge pick `0x481f00` still found the object, so the
// release takes the object leg `0x4925d0`, whose tail `0x49280c call 0x493540` hits
// `SetSelection`'s own `IsSelectable` early-out and returns having changed nothing.
// Selecting is refused; deselecting was never on the table.
if hovered.corpse.is_some() || hovered.refused {
return;
}
if click_cfg.deselect_on_click && (!payload_held.0 || occlusion.distance.is_finite()) {
@@ -674,6 +686,7 @@ pub(super) fn act_on_right_click(
&mut seam,
entity,
guid,
target,
me.is_some_and(|(_, _, e)| e),
me.map(|(_, g, _)| g.0),
attack,
@@ -2018,6 +2031,87 @@ mod tests {
assert!(!interaction_already_open_on(NPC, &closed));
}
/// **The nothing-leg deselect, and the two object hits that are not it** — `select_on_click`'s
/// `_` arm.
///
/// The reference's deselect lives in the terrain and *nothing* legs of the world click; an
/// **object** leg clears no selection, whatever it then decides to do with the object. Two
/// picks reach this arm carrying no `target`: a **corpse** (its guid is in the other slot,
/// decision 1723) and a **refused** one (a `NOT_SELECTABLE` unit the hover grader threw away,
/// decision 2060 — the click's own down-edge pick `0x481f00` still found the object, so the
/// release takes the object leg `0x4925d0` and its tail `0x493540` merely refuses to select).
/// Reading either as "clicked the sky" would drop the player's target: click a body mid-fight,
/// or click the flagged boss you are standing next to, and your target evaporates.
#[test]
fn an_object_hit_never_deselects_but_empty_world_does() {
use crate::net::NetCommands;
use bevy::ecs::system::RunSystemOnce;
const HELD: u64 = 0xF00D;
// One click of the left button over `pick`, returning the selection it left behind.
let click = |pick: Hovered| {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<InspectMode>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<crate::ui_script::CursorPayloadHeld>();
world.init_resource::<crate::ui_action::SpellTargeting>();
world.init_resource::<ClickConfig>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
world.init_resource::<Messages<crate::sound::NpcGreetingRequest>>();
world.init_resource::<Messages<WorldClick>>();
world.insert_resource(Selection {
target: Some(Entity::PLACEHOLDER),
guid: Some(HELD),
});
world.insert_resource(PressPick {
hovered: pick,
..PressPick::default()
});
world.spawn(SelfPlayer);
// The clean left-click the player controller emits.
world
.resource_mut::<Messages<WorldClick>>()
.write(WorldClick);
world
.run_system_once(select_on_click)
.expect("select_on_click runs as a one-shot system");
world.resource::<Selection>().guid
};
// Empty world: the deselect arm, which is the behaviour the guards are carved out of.
assert_eq!(
click(Hovered::default()),
None,
"clicking the sky deselects"
);
// A corpse.
assert_eq!(
click(Hovered {
corpse: Some(Entity::PLACEHOLDER),
corpse_guid: Some(0xB0DE),
distance: 5.0,
..Hovered::default()
}),
Some(HELD),
"a body is an object hit — the target must survive it"
);
// A refused unit.
assert_eq!(
click(Hovered {
refused: true,
distance: 5.0,
..Hovered::default()
}),
Some(HELD),
"so is a NOT_SELECTABLE unit the grader threw away"
);
}
/// **The selection queue, end to end** — Lua in, `Selection` out — over the four ways it is
/// reachable from a player macro. Every one of them is a shipped binding body: `AssistUnit`
/// is ASSISTTARGET's (default `F`), `TargetLastEnemy` is TARGETLASTHOSTILE's (default `G`).
+94 -8
View File
@@ -93,6 +93,22 @@ pub(crate) fn pick_model_roots(
.chain(mount)
}
/// **Where every model's skeleton is right now** — the owned palette rows and the per-part link
/// that indexes them, as one [`SystemParam`].
///
/// They are two halves of one lookup ([`update_hover`]'s `palette_of` reads both, in that order,
/// and neither has ever been wanted alone) and they are bundled because the picker sits exactly on
/// Bevy's 16-param function-system ceiling — the seat this freed went to the `IsSelectable` grader
/// (decision 2060), which has to run *inside* the pick rather than after it so [`Hovered`] is never
/// published in its ungraded state.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct PickPose<'w, 's> {
/// The owned palette table (decision 0720): the picker reads the same world-space matrices the
/// vertex stage skins with, straight from the CPU rows.
palettes: Res<'w, benilla_world::rig_palette::RigPalettes>,
rigs: Query<'w, 's, &'static benilla_world::rig_palette::RigSkin>,
}
/// Recompute the unit under the cursor each frame — the real client's two-phase pick (wow-re
/// pick-volume RE `bd630be` + `31562f1d`): **broad** = the cursor ray vs the *current animation's*
/// bounds sphere (world-placed + world-scaled, no pad); **pass 1** = the ray vs the unit's **posed
@@ -122,10 +138,9 @@ pub(super) fn update_hover(
plate_rects: Res<crate::vplates::PlateRects>,
mut hovered: ResMut<Hovered>,
mesh_assets: Res<Assets<Mesh>>,
// The owned palette table (decision 0720): the picker reads the same world-space matrices
// the vertex stage skins with, straight from the CPU rows.
palettes: Res<benilla_world::rig_palette::RigPalettes>,
rigs: Query<&benilla_world::rig_palette::RigSkin>,
pose: PickPose,
// `IsSelectable`'s second clause reads the active player's guid — see the grader at the publish.
self_guid: Res<crate::net::SelfGuid>,
// Last frame's pick, for pass 2's sticky-hover (the reference's anti-flicker cache: the previous
// pick outranks everything in the halo retry, so the hover doesn't strobe between two units).
mut last_pick: Local<Option<Entity>>,
@@ -183,6 +198,7 @@ pub(super) fn update_hover(
hovered.corpse = None;
hovered.corpse_guid = None;
hovered.distance = f32::MAX;
hovered.refused = false;
if rig.is_looking() || pointer_over_ui.0 {
*last_pick = None;
return;
@@ -285,8 +301,8 @@ pub(super) fn update_hover(
let palette_of =
|sk: &[(&Mesh3d, &benilla_world::rig_palette::RigPart)]| -> Option<Vec<Mat4>> {
sk.first().and_then(|(_, part)| {
let rig = rigs.get(part.0).ok()?;
palettes.world_palette(rig.slot, rig.bones() as usize)
let rig = pose.rigs.get(part.0).ok()?;
pose.palettes.world_palette(rig.slot, rig.bones() as usize)
})
};
// The halo ladder (alive 3 / dead 2), with the corpse rung now **byte-verified** rather
@@ -417,18 +433,88 @@ pub(super) fn update_hover(
if net.kind == EntityKind::Corpse {
hovered.corpse = Some(entity);
hovered.corpse_guid = Some(guid.0);
} else {
} else if selectable_pick(entity, &roots, self_guid.0) {
hovered.target = Some(entity);
hovered.guid = Some(guid.0);
} else {
hovered.refused = true;
}
} else {
} else if selectable_pick(entity, &roots, self_guid.0) {
hovered.target = Some(entity);
} else {
hovered.refused = true;
}
// Set on EVERY arm, the refusal included — see [`Hovered::refused`].
hovered.distance = t;
}
*last_pick = best.map(|(_, e)| e);
}
/// **The hover grader's `IsSelectable` verdict** — `0x4828d0`, the step between the pick and the
/// mouseover publish:
///
/// ```text
/// 48297d mov eax,[esi] ; the picked object's vtable
/// 482982 call [eax+0x54] ; slot 21 = 0x60be60 IsSelectable — the SAME predicate
/// 482985 test eax,eax ; SetSelection runs, reached there via the +0x58 thunk
/// 482987 je 0x4829ed ; FALSE -> 0x482090(0,0) + ResetCursor 0x523d30
/// ```
///
/// So the refusal is **total, and it is one rule**: the mouseover globals `[0xb4e2c8]/[0xb4e2cc]`
/// are cleared (their only two writers image-wide live in the publisher `0x492890`, which that jump
/// skips), the cursor is reset, and the type dispatch that would have chosen an Interact/Attack
/// cursor never runs. No tooltip, no cursor, no brighten and no click, out of one predicate rather
/// than four.
///
/// **It is a grader, not a filter, and the difference is observable.** A census over the pick itself
/// (`0x480a50`, `0x480d90`, `0x713cb0`, `0x481190`, `0x480df0`) finds **zero** reads of
/// `[obj+0x110]`/`[+0xa0]` — positive control: four such reads inside `CanAttack` — so the flagged
/// unit is registered as a candidate and *wins* the trace before anything looks at it. That is why
/// the caller records [`Hovered::refused`] and keeps the pick's distance instead of skipping the
/// candidate: a trigger stalker in front of a chest takes the pick, and the chest behind it must not
/// inherit the mouseover.
///
/// Applied to the **unit** arms only. A corpse reaches `0x482982` too, but slot 21 there is
/// `CGCorpse_C`'s, not `CGUnit_C`'s — the `xor eax,eax` base stub sits at slot **22** (`+0x58`),
/// which is what makes a non-unit unselectable for `SetSelection`, and is not what this call goes
/// through.
///
/// A **plate** hover is a deliberate non-case: the reference publishes it straight from
/// `0x7cb869 call 0x492890` with no grader at all, so a plate on a flagged unit *would* tooltip —
/// but the per-tick plate gate `0x60f600` refuses the same units one step earlier
/// (`0x60f622`/`0x60f628`, unconditional, re-run every OnUpdate), which `crate::vplates` already
/// models. There is no reachable state where the two disagree.
///
/// Run at the publish rather than as a system of its own, deliberately. The reference really does
/// pick and *then* grade, but a second Bevy system means [`Hovered`] is briefly readable in its
/// ungraded state by anything that forgot to order after `TargetUpdate` — a one-frame tooltip pop
/// for a unit that must never have one. Written once, already graded, that state does not exist.
///
/// wow-re `object-layer/scratch/not-selectable-mouse-refusal.md`; decision 2060.
#[allow(clippy::type_complexity)] // the picker's own root query, borrowed as-is
fn selectable_pick(
entity: Entity,
roots: &Query<
(
Entity,
&GlobalTransform,
&NetEntity,
Option<&ModelAnimations>,
Option<&AnimDriver>,
Option<&ObjectStore>,
Option<&Children>,
Option<&crate::entities::mount::MountChild>,
Option<&InheritedVisibility>,
Option<&HeldAttached>,
),
(With<Guid>, Without<SelfPlayer>),
>,
self_guid: Option<u64>,
) -> bool {
let store = roots.get(entity).ok().and_then(|r| r.5);
super::relations::is_selectable(store, self_guid)
}
/// Recompute the **GameObject** under the cursor each frame into [`HoveredObject`] (decision 0236):
/// a mesh-accurate ray pick against GameObject parts *only*, reusing the inspector's picker
/// (the resident-geometry caster of decision 0857, which hits the colliderless props a physics ray
+39 -3
View File
@@ -22,6 +22,16 @@
//! Clicking empty ground / a non-unit, or pressing **Esc**, clears the target; the target's death
//! clears it too (in [`ring::update_ring`], where death is already read).
//!
//! **A unit the classifier refuses is not the mouseover at all** ([`hover::refuse_unselectable`]).
//! `IsSelectable` (`UNIT_FIELD_FLAGS` bit 25 clear, or `UNIT_FIELD_CREATEDBY` == us) is tested by
//! the hover grader `0x4828d0` at `0x482982`, and a false answer jumps straight to
//! `0x482090(0,0)` + `ResetCursor 0x523d30` — the mouseover globals are cleared and the type
//! dispatch that would have chosen a cursor never runs. It is the **same predicate** the selection
//! commit runs (`0x60be60`, reached there through the `+0x58` thunk), not a second rule: no
//! tooltip, no cursor, no brighten, no click. The pick itself reads no unit field — the flagged
//! unit is a candidate and wins — which is why this is a grader and not a filter (wow-re
//! `object-layer/scratch/not-selectable-mouse-refusal.md`; decision 2060).
//!
//! The mouseover/target "light-up" has two consumers: the per-model **emissive lift**
//! ([`highlight`], the byte-verified additive term) and the V-plate's reaction-tinted **glow**
//! (`crate::vplates`, drawn later off [`Hovered`] + [`Selection`]). The plate rect is itself part
@@ -149,6 +159,16 @@ pub(crate) struct Hovered {
pub(crate) corpse: Option<Entity>,
/// The hovered corpse's guid — `CMSG_LOOT` / `CMSG_RECLAIM_CORPSE` carry it.
pub(crate) corpse_guid: Option<u64>,
/// **The pick won, and the grader threw it away** — [`hover::selectable_pick`], the
/// `IsSelectable` refusal at `0x482982`–`0x482987`. Both entity slots are empty, but
/// [`Self::distance`] still holds where the ray hit, because in the reference there is ONE
/// pick over all CGObjects: a non-selectable unit standing in front of a chest **wins** that
/// pick and is then discarded, so the chest behind it does not inherit the mouseover.
///
/// Two readers, and no others: [`go_is_nearest`], and [`click::select_on_click`]'s
/// nothing-leg guard — a refused pick is an **object** hit, and the reference's object leg
/// clears no selection. Everything else sees an empty hover, which is the point.
pub(crate) refused: bool,
}
impl Hovered {
@@ -273,9 +293,11 @@ pub(crate) fn latch_press_pick(
/// before corpses could be picked — would have handed the click, the cursor, the tooltip and the
/// highlight to any GameObject on screen the instant the ray landed on a body instead of a unit.
pub(crate) fn go_is_nearest(character: &Hovered, go: &HoveredObject) -> bool {
match (character.any(), go.target) {
(Some(_), Some(_)) => go.distance < character.distance,
(None, Some(_)) => true,
// A **refused** character pick still occupies the pick (see [`Hovered::refused`]): it wins on
// distance exactly as a kept one would, and then nothing is hovered at all.
match (character.any().is_some() || character.refused, go.target) {
(true, Some(_)) => go.distance < character.distance,
(false, Some(_)) => true,
_ => false,
}
}
@@ -474,6 +496,20 @@ mod tests {
assert!(!go_is_nearest(&corpse(true, 4.0), &obj(true, 10.0)));
assert!(!go_is_nearest(&corpse(true, 5.0), &obj(true, 5.0)));
assert!(go_is_nearest(&corpse(true, 10.0), &obj(true, 4.0)));
// A **refused** pick — a `NOT_SELECTABLE` unit the hover grader threw away
// (`hover::selectable_pick`; the predicate's own arms are pinned in
// `scan::tests::not_selectable_refuses_the_commit_and_keeps_the_old_target`) — holds the
// ground on distance exactly as a kept one does, and then nothing is hovered at all: the
// reference makes ONE pick over all CGObjects, so the flagged unit *wins* it and is
// discarded, and the chest standing behind it never inherits the mouseover.
let refused = |d: f32| Hovered {
refused: true,
distance: d,
..Hovered::default()
};
assert!(!go_is_nearest(&refused(4.0), &obj(true, 10.0)));
assert!(!go_is_nearest(&refused(5.0), &obj(true, 5.0)));
assert!(go_is_nearest(&refused(10.0), &obj(true, 4.0)));
}
/// The latch survives the hover being cleared — which is the whole reason it exists.
@@ -85,6 +85,51 @@ pub(crate) fn can_interact(
/// `UNIT_FLAG_NOT_SELECTABLE` (bit 25) — `CanAssist`'s own first disqualifier, and one of
/// `CanAttack`'s five refusal bits (vmangos `UnitDefines.h`).
const UNIT_FLAG_NOT_SELECTABLE: u32 = 1 << 25;
/// `IsSelectable` — CGUnit_C's **vtable slot 21** (`0x60be60`), the predicate every selection path
/// reaches through the `+0x58` thunk `0x5f1ec0` (`mov eax,[ecx]; jmp [eax+0x54]`):
///
/// ```text
/// 60be60 mov eax,[ecx+0x110] ; the descriptor block
/// 60be6f shr ecx,0x19 / test cl,1 ; UNIT_FIELD_FLAGS bit 25 (0x02000000) — clear ⇒ return 1
/// 60be82 cmp [eax+0x20],… ; else UNIT_FIELD_CREATEDBY == the active player's guid
/// 60be94 mov eax,1 / ret ; ⇒ return 1, otherwise 0
/// ```
///
/// So: **`NOT_SELECTABLE` clear, OR the unit is something *I* created.** The `CREATEDBY` clause is
/// the asymmetry worth carrying — your own flagged creations (your totems, your traps) stay
/// selectable, somebody else's do not.
///
/// Slot `+0x58` is the base stub `0x469fe0` (`xor eax,eax`) in **every** vtable but CGUnit_C's
/// (`0x80c4f8`) and CGPlayer_C's (`0x80af78`), so a GameObject, an item or a corpse answers
/// **false** here — which is the reason a non-unit guid can never become the selection.
///
/// **`None` is the reference's skipped vcall, not a refusal.** `SetSelection` only makes the call
/// on an object the manager actually resolved (`0x4935c6`/`0x4935c8` falls straight through to the
/// commit when it does not), which is how an out-of-range party member — on the roster, no
/// streamed object — stays selectable. A missing store here is that same state, so it passes; and
/// a store whose `OBJECT_FIELD_TYPE` has not streamed is read the same permissive way rather than
/// refusing a selection over an absent field.
///
/// Byte-verified in wow-re: `ui/scratch/unitisvisible-object-presence.md` §5 (the function body and
/// the three corroborating consumers of the same bit) and
/// `object-layer/scratch/selection-attack-seam.md` §3.1 (the `SetSelection 0x493540` early-out at
/// `0x4935ee`/`0x4935f3`, §5 trio + orchestrator byte-arbitration).
pub(crate) fn is_selectable(store: Option<&ObjectStore>, self_guid: Option<u64>) -> bool {
let Some(store) = store else {
return true; // no resolved object — the reference never makes the call (`0x4935c8`)
};
// The base stub: only a CGUnit_C / CGPlayer_C carries a real slot-21 override.
if !matches!(
store.0.object_type(),
None | Some(ObjectType::Unit) | Some(ObjectType::Player)
) {
return false;
}
store.0.unit_flags() & UNIT_FLAG_NOT_SELECTABLE == 0
|| (self_guid.is_some() && store.0.unit_created_by() == self_guid)
}
/// `UNIT_FLAG_PVP` (bit 12) — what `IsPvP 0x605ff0` tests, after resolving the unit's owner
/// (vmangos `UnitDefines.h:UNIT_FLAG_PVP = 0x1000`).
const UNIT_FLAG_PVP: u32 = 0x1000;
+171 -41
View File
@@ -370,6 +370,21 @@ impl TargetScan<'_, '_> {
self.self_q.single().ok().and_then(|(_, store, _)| store)
}
/// Our own guid — `IsSelectable`'s `CREATEDBY` clause needs the active player's, and so does
/// the commit's self-target exception.
fn self_guid(&self) -> Option<u64> {
self.self_q
.single()
.ok()
.and_then(|(_, _, g)| g)
.map(|g| g.0)
}
/// An entity's streamed descriptor — the commit gate's `IsSelectable` input.
fn store_at(&self, entity: Entity) -> Option<&ObjectStore> {
self.stores.get(entity).ok()
}
/// `0x6130a3`'s keep-or-drop test on a guid we are already holding: is the actor hostile to it?
///
/// The reference reads `0x6061e0(actor, target) >= 4` — the **reaction alone**, not the full
@@ -620,15 +635,31 @@ pub(super) struct CommitOutcome {
/// don't. Shared with the UI's `TargetUnit` drain (`super::target_unit_requests`) so every
/// non-mouse selection writer commits identically — a `TargetUnit("player")` mid-combat stops
/// the swing and does NOT re-point (the tail's self exception).
#[allow(clippy::too_many_arguments)] // `0x493540`'s own inputs, one press's worth
pub(super) fn commit(
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
entity: Entity,
guid: u64,
target_store: Option<&ObjectStore>,
engaged: bool,
self_guid: Option<u64>,
new_attackable: bool,
) -> CommitOutcome {
// **`IsSelectable`, and it comes BEFORE the dedup** — `0x4935ec`–`0x4935f3`, the third of
// `0x493540`'s early-outs (wow-re `object-layer/scratch/selection-attack-seam.md` §3.1): the
// resolved object's slot-`+0x58` vcall answering 0 is a bare RETURN, so a non-selectable unit
// is a **complete** no-op — no stop, no `CMSG_SET_SELECTION`, no `PLAYER_TARGET_CHANGED`, no
// re-swing, and the target you already had is left exactly where it was.
//
// This is the one gate, at the one place the reference puts it, so every writer — the click,
// the right-click, TAB, `TargetUnit`/`/target`/`/assist`, the attack auto-acquire and the
// ATTACKERSTATEUPDATE self-defence acquire — is covered without knowing it exists. Without it
// a Stratholme Baron Rivendare in his pre-event state (vmangos gives him `NOT_SELECTABLE |
// SPAWNING` until the ziggurats fall) took a target on the very swing he opened with.
if !super::relations::is_selectable(target_store, self_guid) {
return CommitOutcome::default();
}
if selection.guid == Some(guid) {
return CommitOutcome::default(); // the setter's dedup: bail if already current
}
@@ -733,8 +764,12 @@ fn cycle(
seam,
entity,
guid,
scan.store_at(entity),
engaged,
None,
// The real guid, where this used to pass `None`: `IsSelectable`'s `CREATEDBY` clause reads
// it. Inert for the swing leg it was already feeding — the candidate query excludes our own
// body, so a TAB pick can never *be* us.
scan.self_guid(),
// Attack `0x5ecb70`'s new-target validation, which is what this flag carries: a mode-2
// pick can never pass it. `CanAssist` demands reaction ≥ 4 and `CanAttack`'s mixed arm
// demands < 4, so the two candidate sets are disjoint on that leg — a CTRL-TAB in the
@@ -869,7 +904,16 @@ fn acquire_nearest_enemy(
// `engaged = false`: the switch law only fires when we were already swinging at the OLD
// selection, and every path that reaches here had no selection or a non-hostile one — neither
// is a thing you can be engaged with.
commit(selection, seam, c.entity, c.guid, false, None, false);
commit(
selection,
seam,
c.entity,
c.guid,
scan.store_at(c.entity),
false,
scan.self_guid(),
false,
);
Some((c.entity, c.guid))
}
@@ -1111,8 +1155,12 @@ pub(super) fn remember_last_enemy(
/// empty → `SetSelection(attacker)`. Selection only: no counter-attack, never overwrites.
pub(super) fn auto_acquire_attacker(
mut swings: MessageReader<SwingMessage>,
self_player: Query<Entity, With<SelfPlayer>>,
self_player: Query<(Entity, &Guid), With<SelfPlayer>>,
guids: Query<&Guid>,
// The attacker's descriptor — `0x493540`'s `IsSelectable` gate reads it. A mob that swings at
// us while flagged `NOT_SELECTABLE` must not become our target: this is the acquire the
// Stratholme Baron's opening hit came in on.
stores: Query<&ObjectStore>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
) {
@@ -1120,7 +1168,7 @@ pub(super) fn auto_acquire_attacker(
if selection.target.is_some() {
continue;
}
let Ok(me) = self_player.single() else {
let Ok((me, my_guid)) = self_player.single() else {
continue;
};
if s.victim != Some(me) {
@@ -1139,8 +1187,9 @@ pub(super) fn auto_acquire_attacker(
&mut seam,
s.attacker,
guid.0,
stores.get(s.attacker).ok(),
false,
None,
Some(my_guid.0),
false,
);
}
@@ -1151,6 +1200,61 @@ mod tests {
use super::*;
use crate::net::NetCommands;
/// One `commit` through a one-shot system, returning its outcome.
fn go(
world: &mut World,
guid: u64,
engaged: bool,
self_guid: Option<u64>,
attackable: bool,
) -> CommitOutcome {
go_with(world, guid, None, engaged, self_guid, attackable)
}
/// …and the same with a descriptor for the new target, which is what `IsSelectable` reads.
/// `None` is the reference's *unresolved* object (`0x4935c8`), which skips the vcall.
fn go_with(
world: &mut World,
guid: u64,
store: Option<ObjectStore>,
engaged: bool,
self_guid: Option<u64>,
attackable: bool,
) -> CommitOutcome {
use bevy::ecs::system::RunSystemOnce;
world
.run_system_once(
move |mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam| {
commit(
&mut selection,
&mut seam,
Entity::PLACEHOLDER,
guid,
store.as_ref(),
engaged,
self_guid,
attackable,
)
},
)
.expect("commit runs as a one-shot system")
}
/// A world with everything the seams need, and nothing else — both commit tests' fixture.
fn commit_world() -> (World, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
world.init_resource::<Selection>();
world.spawn(SelfPlayer);
(world, rx)
}
/// The `SetSelection` wire law, byte-read from `0x493540` (the director's "TAB while
/// auto-attacking kills the attack" bug): an engaged SWITCH is **stop → select → re-swing**
/// — the attack follows the new target — while a plain select sends only the selection, a
@@ -1165,17 +1269,7 @@ mod tests {
/// its own packets.
#[test]
fn commit_follows_the_stop_select_reswing_law() {
use bevy::ecs::system::RunSystemOnce;
let (tx, rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
world.init_resource::<Selection>();
world.spawn(SelfPlayer);
let (mut world, rx) = commit_world();
let drain = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter()
@@ -1189,31 +1283,6 @@ mod tests {
})
.collect::<Vec<_>>()
};
// One `commit` through a one-shot system, returning its outcome.
fn go(
world: &mut World,
guid: u64,
engaged: bool,
self_guid: Option<u64>,
attackable: bool,
) -> CommitOutcome {
world
.run_system_once(
move |mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam| {
commit(
&mut selection,
&mut seam,
Entity::PLACEHOLDER,
guid,
engaged,
self_guid,
attackable,
)
},
)
.expect("commit runs as a one-shot system")
}
// Not engaged: first select and a switch are selection-only; a same-guid re-commit dedups.
assert!(go(&mut world, 0xA, false, Some(1), true).changed);
@@ -1269,6 +1338,67 @@ mod tests {
);
}
/// **`IsSelectable` refuses the whole commit** — `0x493540`'s third early-out
/// (`0x4935ee`/`0x4935f3`), which sits *before* the dedup, so the refusal is a complete no-op:
/// nothing on the wire, no `PLAYER_TARGET_CHANGED`, and the target already in hand survives.
///
/// The reported case is Stratholme's Baron Rivendare before the ziggurats fall — vmangos gives
/// him `UNIT_FLAG_NOT_SELECTABLE | UNIT_FLAG_SPAWNING` (`instance_stratholme.cpp`), so on the
/// real client he can be neither clicked, tabbed, nor auto-acquired when he opens on you.
#[test]
fn not_selectable_refuses_the_commit_and_keeps_the_old_target() {
use benilla_protocol::ObjectFields;
const FLAGS: u16 = 46;
const CREATEDBY: u16 = 14;
const OBJECT_TYPE: u16 = 2;
const NOT_SELECTABLE: u32 = 1 << 25;
// `OBJECT_FIELD_TYPE` bit 3 = a CGUnit_C, the only kind whose slot `+0x58` is not the
// `xor eax,eax` base stub.
let unit = |pairs: &[(u16, u32)]| {
let mut all = vec![(OBJECT_TYPE, 0x8u32)];
all.extend_from_slice(pairs);
ObjectStore(ObjectFields::from_pairs(&all))
};
let (mut world, rx) = commit_world();
let sent = |rx: &crossbeam_channel::Receiver<ClientCommand>| rx.try_iter().count();
// A plain live mob commits.
let plain = unit(&[]);
assert!(go_with(&mut world, 0xA, Some(plain), false, Some(1), true).changed);
assert_eq!(sent(&rx), 1);
// The Baron: flagged, created by nobody. Refused outright — and 0xA is still the target.
let baron = unit(&[(FLAGS, NOT_SELECTABLE | 0x2)]);
let out = go_with(&mut world, 0xB, Some(baron), true, Some(1), true);
assert!(!out.changed && !out.swung, "the commit is a complete no-op");
assert_eq!(sent(&rx), 0, "nothing goes out — not even the stop");
assert_eq!(
world.resource::<Selection>().guid,
Some(0xA),
"the target already in hand survives the refused click"
);
// The `CREATEDBY` clause: the same flag on a unit **I** created still selects (my totem).
let mine = unit(&[(FLAGS, NOT_SELECTABLE), (CREATEDBY, 1), (CREATEDBY + 1, 0)]);
assert!(go_with(&mut world, 0xC, Some(mine), false, Some(1), true).changed);
assert_eq!(sent(&rx), 1);
// Somebody else's flagged creation is not mine to click.
let theirs = unit(&[(FLAGS, NOT_SELECTABLE), (CREATEDBY, 9), (CREATEDBY + 1, 0)]);
assert!(!go_with(&mut world, 0xD, Some(theirs), false, Some(1), true).changed);
assert_eq!(sent(&rx), 0);
// A non-unit object answers the base stub — a GameObject guid can never be the selection.
let go_obj = ObjectStore(ObjectFields::from_pairs(&[(OBJECT_TYPE, 0x20)]));
assert!(!go_with(&mut world, 0xE, Some(go_obj), false, Some(1), true).changed);
assert_eq!(sent(&rx), 0);
// And an unresolved object skips the vcall entirely (the out-of-range party member).
assert!(go_with(&mut world, 0xF, None, false, Some(1), true).changed);
assert_eq!(sent(&rx), 1);
}
fn cand(guid: u64, score: f32, on_screen: bool) -> Candidate {
Candidate {
entity: Entity::PLACEHOLDER,
+1 -1
View File
@@ -1035,7 +1035,7 @@ pub(crate) fn enrich_unit(
// under a pending name exactly as it does under a known one. It was gated on the record here
// on the premise that "before the query answers we have no name line either" — the premise
// decision 2002 corrected, and 2040 with it.
if rec.is_none_or(|r| r.type_flags & 0x10 == 0) {
if rec.is_none_or(|r| r.type_flags & crate::names::type_flags::NO_FACTION_TOOLTIP == 0) {
state.faction_name = (|| {
let catalog = factions?.catalog();
let faction_id = catalog.template(store.0.unit_faction_template()?)?.faction;
+12 -1
View File
@@ -562,7 +562,18 @@ fn drive_vplates(
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
// NOT_SELECTABLE never gets a plate (bit 25 — part of the byte gate).
// NOT_SELECTABLE never gets a plate — `0x60f600`'s gate 2 (`0x60f622 shr ecx,0x19` /
// `0x60f628 jne 0x60f740` → pool + hide + clear `[unit+0xe60]`), unconditional, and re-run
// every tick because `0x60f600`'s caller `0x607ef9` sits inside CGUnit's OnUpdate
// (`vtable+0x38` = `0x607ed0`). So the flag arriving on a plated unit takes its plate away
// on the next tick, which falls out of this per-frame gate for free.
//
// **This suppression is load-bearing, not cosmetic** (wow-re
// `object-layer/scratch/not-selectable-mouse-refusal.md`, decision 2060): a plate hover
// publishes the mouseover *directly* — `0x7cb850 OnEnter` → `0x7cb869 call 0x492890`, with
// none of the `IsSelectable` grading the world hover gets at `0x482982`. If a flagged unit
// ever kept its plate, hovering that plate would hand it a name tooltip the reference never
// shows. The plate gate is the only thing standing there.
if store.is_some_and(|s| s.0.unit_flags() & (1 << 25) != 0) {
continue;
}
+81
View File
@@ -433,6 +433,35 @@ impl ParticleEmitter {
self.alpha
}
/// The **cloud anchor** this emitter was spawned under ([`EmitterFrames::anchor`]) — the model
/// instance it belongs to. The identity an effect lane uses to find its own emitters when the
/// instance ends ([`Self::drain_on_owner_loss`]).
pub fn anchor(&self) -> Option<Entity> {
self.anchor
}
/// Switch this emitter to [`OwnerLoss::Drain`] — **the effect is ending, so its already-emitted
/// particles must finish rather than pop** (wow-re `ceffect-particle-drain.md` §4a).
///
/// The reference makes this distinction the same way, at the same moment. `0x6203e0` — the
/// teardown every ending `CEffect` reaches, whether by its completion callback or by the
/// same-slot replace — does **not** free the node: it pushes it onto the pending-destroy list
/// and hides it, which stops emission only. The particle draw path never reads a flag that
/// teardown clears (its own admission test is the live-particle count `emitter+0x64`,
/// `0x7b4b46`), so the survivors keep drawing and ageing out one at a time; `0x61f680` frees
/// the node on the first frame the aggregate `CM2Model+0x3d8` reads zero — **the free
/// condition and the draw condition are the same word**.
///
/// It is deliberately NOT the spawn-time policy for an attached instance, because the *other*
/// way an effect's owner can vanish is the model dtor — a gear change, a display swap, a unit
/// streaming out — and there the reference frees the emitters synchronously and the pool must
/// go with the body (decisions 0826/0833: draining that case stranded ghost clouds in the air
/// where the character had been). Both look identical from inside the sim, so the ending side
/// says so explicitly.
pub fn drain_on_owner_loss(&mut self) {
self.on_owner_loss = OwnerLoss::Drain;
}
/// The cloud's live world anchor — the census probe's fallback distance subject for an emitter
/// with no [`EmitterFade`] (entity-owned: creatures, GameObjects, spell kits). A faded
/// emitter's distance is measured to its OWNER's sphere instead, because that sphere is what
@@ -1036,6 +1065,58 @@ pub(crate) mod tests {
assert_eq!(seeded_slot(model_emitter(0), EmitClock::Pinned), Some(0));
}
/// **The ending side asks for the drain; the spawn-time policy does not give it** (wow-re
/// `ceffect-particle-drain.md` §4a). An attached instance is spawned `Free` so that a model
/// dtor — a gear change, a display swap, a unit streaming out — takes its pool with the body
/// (0826/0833: draining that case stranded ghost clouds in the air). The *other* way an
/// effect's owner vanishes is the effect simply ending, and there the reference hides the node
/// and lets the particles age out. Both look identical from inside the sim, so the ending side
/// flips the policy explicitly — and this is the flip.
#[test]
fn an_ending_instance_switches_its_emitters_from_free_to_drain() {
use bevy::ecs::system::RunSystemOnce;
let root = Entity::from_raw_u32(7).unwrap();
let mut app = App::new();
let e = app
.world_mut()
.run_system_once(move |mut c: Commands| {
spawn_emitter(
&mut c,
&model_emitter(0),
Transform::IDENTITY,
EmitterFrames {
anchor: Some(root),
on_owner_loss: OwnerLoss::Free,
..default()
},
EmitClock::Pinned,
)
})
.unwrap()
.expect("the emitter spawns");
assert_eq!(
app.world().get::<ParticleEmitter>(e).unwrap().anchor(),
Some(root),
"the instance root is the identity its lane finds this emitter by",
);
assert_eq!(
app.world().get::<ParticleEmitter>(e).unwrap().on_owner_loss,
OwnerLoss::Free,
"spawned Free — a torn-down model must not strand its cloud",
);
app.world_mut()
.get_mut::<ParticleEmitter>(e)
.unwrap()
.drain_on_owner_loss();
assert_eq!(
app.world().get::<ParticleEmitter>(e).unwrap().on_owner_loss,
OwnerLoss::Drain,
"the ending instance's particles finish instead of popping",
);
}
/// Camera at the origin looking down −Z (Bevy's convention), and an owner sphere `depth` yd
/// straight ahead — the geometry every draw-set test below uses.
fn gate(radius: f32, depth: f32, farclip: f32) -> bool {