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:
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {} \
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user