- a refused auto-attack is no longer silent, and the display is a latch, not a toast: six opcodes, four arms — `0x145 SMSG_ATTACKSWING_NOTINRANGE` and `0x146 SMSG_ATTACKSWING_BADFACING` latch a code through `0x5ecdb0`, which writes `[0xc4d758]` and `[0xc4c270]` together; `0x148 DEADTARGET` and `0x149 CANT_ATTACK` share arm `0x625ab8` verbatim (StopAttack, no message), so the client cannot tell them apart and `AttackSwingError` collapses them into one `DeadOrUnattackable`; `0x147 NOTSTANDING` is never registered by the reference and `SendAttackSwingNotStanding` has zero callers in vmangos, so a test pins that it stays an unknown opcode; and the fourth arm hides: `0x14e SMSG_CANCEL_COMBAT` is registered at `0x5e3303` by the SPELL TU, not the combat one, so the eight-opcode census every note quotes did not contain it, and its handler `0x5e7dd0` is arm 4's body instruction for instruction; vmangos sends each refusal on the edge alone, so the repeat the player sees is the client's own: the tick `0x5ec950` — `CGPlayer_C` vtable `0x80af78` slot 14, dispatched once per object per rendered frame on the `CWorldFrame::OnPaint` chain, so a Bevy `Update` system is the exact cadence — prints `ERR_BADATTACKPOS`/`ERR_BADATTACKFACING` (catalog rows `0xd8`/`0xd7`, both red) at once and again every 4000 ms while an attack target is set, and a landed swing clears it: `SMSG_ATTACKERSTATEUPDATE`'s arm calls `0x5ea800`, whose first act is `0x5ecdb0(0)`, gated on the attacker being the active player AND the victim resolving (both gates kept — wow-re's round had glossed the second away, and sending that back corrected the note); the latch is one `Option<(Latched, Duration)>` because `[0xc4c270]` has no meaning while `[0xc4d758]` is 0, a second refusal packet defeats the cooldown and the re-arm precedes the print (both tested), the clear also runs once per world entry (`0x5e2510`), and both edges ride ONE `SwingRefusalEdge` stream so a refusal and a landing in the same frame stay in packet order; `SMSG_FEIGN_DEATH_RESISTED` (`0x2b4`) arrives beside them and is built differently because its handler `0x6e9800` is two instructions — a bare `DisplayError(421)` = `ERR_FEIGN_DEATH_RESISTED`, the single word "Resisted" — so it takes the plain `UiErrorKeys` route with no latch; the `--attack` world probe now reports the refusals it tallies; and two remainders are named, not built: `0x5ea800`'s post-swing leash — `R = 4.0 * max(5.0, reach(victim) + reach(self) + 1.3333333730697632)`, floor 20.0, sending a bare `CMSG_ATTACKSTOP` past it — a new packet-sending behaviour left for the director's call, and `StopAttack 0x5ecac0`'s two one-shot tutorials (10 low-health, 11 low-mana), which benilla's copy has never fired (2037)
- the trade arc stops being silent: eighteen of the twenty-three `SMSG_TRADE_STATUS` arms print where benilla printed none, all through `UiErrorKeys` so the text is the player's own `GlobalStrings.lua` and the surface is the catalog record's `+0x04`; the four-row start was the trade block's own emit-site census, which answered "which arms raise a TRADE-keyed message" — eleven of the ids come from elsewhere in the 465-row table (`ERR_PLAYER_BUSY_S` `0x3e`, `ERR_PLAYER_DEAD` `0x7e`, `ERR_GENERIC_NO_TARGET` `0xb8`, `ERR_PLAYER_WRONG_FACTION` `0xff`, `ERR_IGNORING_YOU_S` `0x13d`, the stunned pair `0x191`/`0x192`, the logout pair `0x19d`/`0x19e`, `ERR_RESTRICTED_ACCOUNT` `0x1be`, and `ERR_TRADE_WRONG_REALM` `0x1ca`, whose text is the ONLY_CONJURED sentence), and the surfaces do not follow the English — a successful trade's two lines are the yellow ones, `ERR_PLAYER_BUSY_S`/`ERR_IGNORING_YOU_S` are chat lines, the refusals red; `TRADE_REQUEST_CANCEL` fires from the top of the CANCELED arm (`0x4bf832`, the dispatcher's one `SignalEvent`) where nothing had fired it; `ERR_INITIATE_TRADE_S` prints — it is client-emitted from inside the `CMSG_INITIATE_TRADE` sender, at once on a name-cache hit (`0x5d4042`) and from the query callback on a miss (`0x5d40ca`), so clicking Trade had produced no text in either direction; the three arms that name a player (0/5/14) read the network handler's guid cell `[0xc4bed0]`/`[0xc4bed4]` behind the outbound-initiate latch `[0xc4bec8]` — `InitiateTrade 0x5d3fb0` writes the target's guid and then the latch, the `BEGIN_TRADE` arm writes the initiator's and no latch — which is what keeps a client that just ignored a trade from printing "X is ignoring you." to itself, and behind `0x468460(guid, TYPEMASK_PLAYER)`, a live-object test the initiate line does not share, so `NamedLine::needs_live_object` drops a status line the moment its player stops being streamed while an initiate line waits; a network-cell clear is not a window close: `0x4bf4e0` has four call sites in the dispatcher — one opens, `{3, 8, 12}` close — and the other fourteen run the handler's tail `0x5d490a`, which drops the latch and the pending guid and leaves the window standing, so `close()` splits into `close_window()` and `clear_pending()`; `REJECTED` drops only the partner's accept, `BACK_TO_TRADE` reaches `0x4bf230(0)` and sends `CMSG_UNACCEPT_TRADE` on a real change, `ONLY_CONJURED` bounces the offending slot out of our own offer; `CLOSE_WINDOW`'s message is deliberately not built (`0x4bfd70` maps `result` over a 61-id range with a flagged self/other polarity inconsistency, and vmangos never sends status 12); and `ERR_TRADE_BLOCKED_S`'s transcribed English literal is deleted along with `ChatLog::pending_texts` (2042)
- both halves of B373's lighting closed: `MeshTag` bits 6..=13 mean two things — the exterior payload's ground-shade byte and the low eight bits of the interior payload's 13-bit probe slot — and the overlap cannot be removed (the four mode-independent fields cost 19 bits, leaving 13 for a payload region the two modes want 21 of), so the payload question becomes a witness the accessors demand: `mesh_tag::ExteriorPayload` is a zero-sized proof only `exterior_payload(on_bake_law, own_probe)` mints, when both probe-slot populations answer no, and `with_shade`/`shade_of` take it, so no expression in the crate reaches those bits without having asked; `describe` alone reads the raw masks and prints both readings (2041); and a transport's deck cargo is lit by its own doodad def, not by the boat (B373): a transport's WMO is linked into the same global `TSExplicitList<CMapObjDef>` at `0xca7d98` the ordinary scene walk iterates, so its MODDs are ordinary doodad defs; `[def+0xa4]` has exactly four writers image-wide — the ctor `0x6a7d73` (0.0), the doodad-set commit `0x695bb3` (1.0), the ADT MDDF commit `0x6b01bb` (1.0) and the MCSH refresh `0x698cb4` (0.5) — and none is the light node, whose 2.5-producer and ramp are reachable only through `[obj+0xe0]`, a path a doodad def never takes; the MCSH sample is one-shot (`0x698c50` unlinks each entry) and its null-tile legs return lit; so we had the wrong source, the wrong cadence and the wrong mechanism: `entity_shade::DoodadDefLit` marks every WMO-gameobject prop batch from inside the placed-model assembler — the engine asserts it, not the caller; the world-API wall failed the first cut, and a following card is reachable only there — both shade write sites skip it on ownership, separately from 2041's payload question, and exterior props take one `doodad_ground_shade` at their own footprint at spawn, frozen, which answers lit for a non-resident tile so a boat at sea needs no special case and the unverified "a boat is never MCSH-shadowed" goes; 749 prop parts under a shade root carry the marker on a live run — the same 749 B373 measured reading a foreign probe — and the ADT doodad path is deliberately untouched, since the D3D capture's measured 2.5 gain outranks the closure (2047)
- 2043's unit-predicate law is confirmed byte-exact by the round it dispatched — all 29 `Unit*` predicates push the constant double 1.0 or nil, exactly one value at every live `ret`, and a BFS from all 83 bindings in `0x850438` reaches 460 functions without hitting `lua_pushboolean` — and three of its supporting citations are corrected in the comments, no behaviour changing: `lua_pushboolean 0x6f39f0` EXISTS with seven call sites, one of them the registered FrameScript binding `IsPetAttackActive 0x4be0e0`, which answers a real true/false and never nil (ours already did, and its doc already named itself the exception), so 2043's "no boolean on its binding surface at all" is withdrawn for the scoped claim 1830 actually made; `IsEnabled` is `0x7800b0`, not `0x780270` (that is `SetButtonState`), and its number-1-or-0-never-nil fact stands; and `UnitReaction 0x5167e0` pushes `0x6061e0(u1, u2)` PLUS ONE, so 0 is unreachable and its nil means only that a token did not resolve — our `reaction == 0` → nil stays, named now as a feed gap rather than a mechanism the binary does not have (2048)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
326 lines
16 KiB
Rust
326 lines
16 KiB
Rust
//! **The melee swing refusal** — what the client does with the server's answers to a
|
|
//! `CMSG_ATTACKSWING` it will not honour.
|
|
//!
|
|
//! **Six opcodes, FOUR arms.** `0x145` NOTINRANGE and `0x146` BADFACING take one each; `0x148`
|
|
//! DEADTARGET and `0x149` CANT_ATTACK share arm 4 verbatim; `0x147` NOTSTANDING is never wired at
|
|
//! all; and `0x14e` `SMSG_CANCEL_COMBAT` — the forced cancel, registered by the *spell* TU
|
|
//! (`0x5e3308` → handler `0x5e7dd0`), which is why `0x625520`'s eight-opcode census never names it
|
|
//! — is arm 4's body **byte-identical**, down to the `__LINE__` it pushes. So the whole family is
|
|
//! two arms that latch a message and two that silently stop the attack.
|
|
//!
|
|
//! The server refuses a swing on the **edge** alone (vmangos `Unit::AttackerStateUpdate`'s
|
|
//! `swingError` switch, `Objects/Unit.cpp:405-475`: each arm sends only when
|
|
//! `GetLastSwingErrorMsg() != <result>`), so a player standing out of range gets **one** packet,
|
|
//! not one per swing timer. The repeat the player actually sees is the client's own, and it is a
|
|
//! **latch plus a cooldown**, never a per-packet toast:
|
|
//!
|
|
//! ```text
|
|
//! 0x625a8a (0x145 NOTINRANGE) ecx = 1 ; call 0x5ecdb0 ; latch code 1
|
|
//! 0x625aa1 (0x146 BADFACING) ecx = 2 ; call 0x5ecdb0 ; latch code 2
|
|
//! 0x625ab8 (0x148 DEADTARGET / 0x149 CANT_ATTACK) ; NO message — StopAttack, nothing else
|
|
//!
|
|
//! 0x5ecdb0 [0xc4d758] = code ; [0xc4c270] = OsGetAsyncTimeMs() ; the code + "show at"
|
|
//!
|
|
//! 0x5ec950 the local player's tick:
|
|
//! this is the active player (0x468550) else out
|
|
//! [this+0xc48] != 0 (0x47bf60) else out ; an attack target is set
|
|
//! now - [0xc4c270] >= 0 (5ec999 js) else out
|
|
//! [0xc4d758] == 1 -> DisplayError(0xd8) | == 2 -> DisplayError(0xd7) | else out
|
|
//! [0xc4c270] = now + 0xfa0 ; re-arm, 4000 ms
|
|
//! ```
|
|
//!
|
|
//! Three consequences, all of them visible, none of them guessable from the packet alone:
|
|
//!
|
|
//! 1. **The first line is immediate** — the latch writes `now`, and the tick's test is
|
|
//! `now - stored >= 0`, so the very next tick shows it.
|
|
//! 2. **It repeats every 4 s while the refusal stands**, because nothing about the *packet*
|
|
//! drives the display; the latch does, and it survives until something clears it.
|
|
//! 3. **A dead or unattackable target says nothing at all.** `0x148` and `0x149` share arm 4
|
|
//! verbatim — the client cannot even tell them apart — and that arm raises no message on any
|
|
//! surface: it resolves the active player and calls StopAttack
|
|
//! ([`crate::creature_anim::stop_attack_local`]) on it. Which is right: the target is dead or
|
|
//! friendly, the attack ends, and the reason is already on screen from elsewhere.
|
|
//!
|
|
//! **What clears the latch is a landed swing.** `SMSG_ATTACKERSTATEUPDATE`'s arm calls `0x5ea800`
|
|
//! (at `0x6259b6`) when the attacker **is the active player** (`0x5fa6d0`, a guid compare against
|
|
//! `0x468550`) and the **victim resolves** as a streamed unit — and `0x5ea800`'s first act, ahead
|
|
//! of everything else it does, is `0x5ecdb0(0)`. So the moment a swing connects the stale "too far
|
|
//! away" stops repeating; while none does, it keeps saying so. `0x5e2510`, a module init, clears
|
|
//! it the same way.
|
|
//!
|
|
//! Both edges ride ONE message ([`SwingRefusalEdge`]) written by two arms of the same net drain,
|
|
//! so they are consumed in **packet order** — the reference processes them in one synchronous
|
|
//! dispatch, and a refusal and a landing that arrive in the same frame must not be reordered.
|
|
//!
|
|
//! Two consequences of the latch being written **unconditionally** — both reproduced here, both
|
|
//! easy to lose in a refactor:
|
|
//!
|
|
//! - a **second** refusal packet resets the deadline to `now`, so it **defeats the 4 s cooldown**
|
|
//! and re-shows on the next tick; a NOTINRANGE→BADFACING flip shows at once for the same reason;
|
|
//! - the **re-arm precedes the print** (`0x5ec9bc` then `0x5ec9c2`), so the 4 s advance happens
|
|
//! even when the line is then swallowed by a missing GlobalString.
|
|
//!
|
|
//! `SMSG_ATTACKSWING_NOTSTANDING` (`0x147`) is in neither this module nor
|
|
//! [`benilla_protocol::messages::opcode`]: the reference never registers it, and vmangos never
|
|
//! sends it (`Player::SendAttackSwingNotStanding` has zero callers).
|
|
//!
|
|
//! Whole seam §5-verified in wow-re `object-layer/scratch/attackswing-refusal-law.md` (decision
|
|
//! 2037), which corrected three prior glosses of these same addresses.
|
|
|
|
use std::time::Duration;
|
|
|
|
use benilla_protocol::messages::AttackSwingError;
|
|
use bevy::prelude::*;
|
|
|
|
use crate::creature_anim::{AttackSeam, Engaged};
|
|
use crate::net::SelfPlayer;
|
|
use crate::ui_action::{UiError, UiErrorKeys};
|
|
|
|
/// The reference's re-arm interval — `0x5ec9b6 add ecx,0xfa0`.
|
|
const REPEAT: Duration = Duration::from_millis(0xfa0);
|
|
|
|
/// One edge of the swing-refusal seam, off the net drain in **packet order** — see the module
|
|
/// header for why they share a message rather than taking one each.
|
|
#[derive(Message, Clone, Copy, Debug)]
|
|
pub(crate) enum SwingRefusalEdge {
|
|
/// The server refused our swing (`0x145`/`0x146`/`0x148`/`0x149`).
|
|
Refused(AttackSwingError),
|
|
/// `SMSG_CANCEL_COMBAT` (`0x14e`) — the server forced our attack to stop
|
|
/// (`Unit::CombatStop`, `Unit::StopAttackFaction`, `Unit::InterruptAttacksOnMe`, a resisted
|
|
/// feign death). Its own message rather than a fifth `AttackSwingError`, because it is a
|
|
/// different opcode family; that its ARM is the same one is the finding, and it is expressed
|
|
/// where the arms are dispatched.
|
|
CombatCancelled,
|
|
/// One of OUR swings landed on a victim we can see — arm 5's `0x5ea800` call at `0x6259b6`,
|
|
/// whose first act is the latch clear. **Both** of the reference's gates are applied at the
|
|
/// write site, which is the only place holding the guids: GATE A, the attacker is the local
|
|
/// player (`0x6259a8 test al,al; je`, `0x5fa6d0`), and GATE B, the victim resolves
|
|
/// (`0x6259b1 test eax,eax; je`). The §5's first pass glossed B away ("any
|
|
/// ATTACKERSTATEUPDATE with the local player as attacker"); the disassembly it quoted did not,
|
|
/// and a re-derivation confirmed the reading and corrected the note (wow-re `fc385895`, which
|
|
/// also proved the leg is entered by fall-through alone — 0 branches into `[0x6259ac,
|
|
/// 0x6259b7]`, 1 rel32 caller of `0x5ea800`, 0 address-takes).
|
|
///
|
|
/// GATE B is exactly `0x468460(ecx = 8)`, i.e. **"resolves, as a UNIT"** — its two zero
|
|
/// returns are "guid absent from the object index" and "resolved but the type mask misses the
|
|
/// bit". benilla's index lookup covers the first and not the second, and the gap is
|
|
/// unreachable: this packet's victim is a unit or a player server-side, and a player's mask
|
|
/// carries the UNIT bit too. Stated rather than tightened, because the sibling uses of the
|
|
/// same lookup two lines away (the full-block synthesis, the impact fallback) ask the index
|
|
/// the same question, and one arm asking it differently would read as a distinction.
|
|
Landed,
|
|
}
|
|
|
|
/// The latched refusal — the reference's `[0xc4d758]` (which code) and `[0xc4c270]` (when the next
|
|
/// line is due).
|
|
///
|
|
/// **One `Option` over the pair**, not two fields, because the reference's timestamp has no meaning
|
|
/// while its code is `0`: `0x5ecdb0` writes both in the same breath, and the tick reads the second
|
|
/// only after the first has passed its `cmp eax,1`/`cmp eax,2`. `None` IS code `0` — the value both
|
|
/// clear sites push, and the value the tick falls through on. The `Duration` is an absolute time,
|
|
/// not a countdown, exactly as the reference stores it.
|
|
///
|
|
/// The §5 warns a re-implementation modelling this as `(Option<Code>, Instant)` not to assume the
|
|
/// timestamp is zero when the code is — the reference's clear writes `now`, not `0`. Pairing them
|
|
/// inside the `Option` is how that warning is answered rather than obeyed: there is no timestamp
|
|
/// to be wrong about while the code is absent, and every transition into `Some` writes a fresh
|
|
/// one.
|
|
#[derive(Resource, Default)]
|
|
struct SwingRefusal(Option<(Latched, Duration)>);
|
|
|
|
/// The two refusals that latch. [`AttackSwingError::DeadOrUnattackable`] is deliberately absent:
|
|
/// arm 4 writes no latch, so there is no third code to hold.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
enum Latched {
|
|
/// Code `1` → `DisplayError(0xd8)` = `ERR_BADATTACKPOS`, "You are too far away!".
|
|
NotInRange,
|
|
/// Code `2` → `DisplayError(0xd7)` = `ERR_BADATTACKFACING`, "You are facing the wrong way!".
|
|
BadFacing,
|
|
}
|
|
|
|
impl Latched {
|
|
/// The `GlobalStrings.lua` key the reference's error id resolves to — catalog rows `0xd8`/
|
|
/// `0xd7` ([`benilla_ui::messages`]) — so the text and its localization come out of the
|
|
/// player's own file like every other line.
|
|
const fn key(self) -> &'static str {
|
|
match self {
|
|
Self::NotInRange => "ERR_BADATTACKPOS",
|
|
Self::BadFacing => "ERR_BADATTACKFACING",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SwingRefusal {
|
|
/// `0x5ecdb0(ecx = code)` — latch the refusal and make the next line due **now**.
|
|
fn latch(&mut self, code: Latched, now: Duration) {
|
|
self.0 = Some((code, now));
|
|
}
|
|
|
|
/// `0x5ecdb0(ecx = 0)` — the clear both of the reference's zero sites push.
|
|
fn clear(&mut self) {
|
|
self.0 = None;
|
|
}
|
|
}
|
|
|
|
/// Consume this frame's edges in packet order: latch a refusal, clear on a landing, and run the
|
|
/// shared `0x148`/`0x149` arm's one and only act.
|
|
///
|
|
/// The StopAttack lives here rather than in the net drain because [`AttackSeam`] is the whole
|
|
/// write set `0x5ecac0` needs and the drain is already at Bevy's SystemParam ceiling — and
|
|
/// because putting it beside the latch keeps arm 4's "stop, and say nothing" legible as one fact.
|
|
fn apply_swing_refusals(
|
|
mut edges: MessageReader<SwingRefusalEdge>,
|
|
time: Res<Time>,
|
|
mut refusal: ResMut<SwingRefusal>,
|
|
mut seam: AttackSeam,
|
|
engaged: Query<(), (With<SelfPlayer>, With<Engaged>)>,
|
|
) {
|
|
for edge in edges.read() {
|
|
match edge {
|
|
SwingRefusalEdge::Refused(AttackSwingError::NotInRange) => {
|
|
debug!("swing refused: out of range");
|
|
refusal.latch(Latched::NotInRange, time.elapsed());
|
|
}
|
|
SwingRefusalEdge::Refused(AttackSwingError::BadFacing) => {
|
|
debug!("swing refused: bad facing");
|
|
refusal.latch(Latched::BadFacing, time.elapsed());
|
|
}
|
|
// The two SILENT arms — arm 4 (`0x625ab8`, for `0x148`/`0x149`) and
|
|
// `SMSG_CANCEL_COMBAT`'s own `0x5e7dd0`, whose bodies are byte-identical. No latch, no
|
|
// message on any surface; only the stop, and `0x5ecac0`'s own engaged guard decides
|
|
// whether even that sends anything.
|
|
SwingRefusalEdge::Refused(AttackSwingError::DeadOrUnattackable)
|
|
| SwingRefusalEdge::CombatCancelled => {
|
|
debug!("swing stopped by the server — silently ({edge:?})");
|
|
seam.stop(!engaged.is_empty());
|
|
}
|
|
SwingRefusalEdge::Landed => refusal.clear(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The reference's per-player tick `0x5ec950` — the whole display side of this seam.
|
|
///
|
|
/// Every gate is the reference's, in its order: the active player, an attack target set
|
|
/// ([`Engaged`] is benilla's `[+0xc48]`, see [`crate::creature_anim::stop_attack_local`]), the
|
|
/// cooldown, then the code. The re-arm happens **only when a line is actually shown** — the
|
|
/// reference reaches `0x5ec9b6` from the two code arms alone and its `else` jumps past it.
|
|
///
|
|
/// **Once per rendered frame is the exact cadence, not merely a fast-enough one.** `0x5ec950` is
|
|
/// `CGPlayer_C` vtable `0x80af78` **slot 14** (`+0x38`), and the only dispatcher of that slot
|
|
/// image-wide is `0x48160c` inside `0x4815d0`, on the `CWorldFrame::OnPaint` chain
|
|
/// (`0x483460` → `0x48361d` → `0x681070` → `0x683dd0` → `0x481540`). Culling does not skip it —
|
|
/// it only changes the pass — and neither does a first-person camera. A Bevy `Update` system is
|
|
/// that, and the 4000 ms gate makes the match exact rather than approximate.
|
|
fn show_swing_refusal(
|
|
time: Res<Time>,
|
|
mut refusal: ResMut<SwingRefusal>,
|
|
engaged: Query<(), (With<SelfPlayer>, With<Engaged>)>,
|
|
mut errors: ResMut<UiErrorKeys>,
|
|
) {
|
|
let Some((code, due)) = refusal.0 else {
|
|
return;
|
|
};
|
|
if engaged.is_empty() {
|
|
return; // `[+0xc48] == 0` — no attack target, so nothing to complain about
|
|
}
|
|
let now = time.elapsed();
|
|
if now < due {
|
|
return;
|
|
}
|
|
refusal.0 = Some((code, now + REPEAT));
|
|
errors.0.push(UiError::key(code.key()));
|
|
}
|
|
|
|
/// **Entering** the world drops the latch — the reference's `0x5e2510`, reached from
|
|
/// `ClientInitializeGame 0x401570` (`0x401648` → `0x5e253a`), which the §5 pinned to **once per
|
|
/// world entry, not once per process**. So a fresh world cannot inherit the last one's refusal.
|
|
///
|
|
/// Nothing else clears it, and never the passage of time: what actually silences a repeating
|
|
/// refusal in play is a landed swing, or the attack target going away (the tick's second gate).
|
|
fn on_world_enter(mut refusal: ResMut<SwingRefusal>) {
|
|
refusal.clear();
|
|
}
|
|
|
|
pub(crate) struct SwingRefusalPlugin;
|
|
|
|
impl Plugin for SwingRefusalPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<SwingRefusal>()
|
|
.add_message::<SwingRefusalEdge>()
|
|
.add_systems(
|
|
Update,
|
|
// After the drain that writes the edges, and before the unit feeds, so a key
|
|
// queued this frame is resolved and shown by `ui_action::feed_actions` on the same
|
|
// frame rather than the next. Chained, so a refusal that arrives this frame is
|
|
// latched before the tick reads the latch.
|
|
(apply_swing_refusals, show_swing_refusal)
|
|
.chain()
|
|
.after(benilla_world::schedule::WorldStage::Net)
|
|
.before(crate::ui_unit::UnitFeed),
|
|
)
|
|
.add_systems(
|
|
OnEnter(crate::char_select::ClientState::InWorld),
|
|
on_world_enter,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn secs(s: u64) -> Duration {
|
|
Duration::from_secs(s)
|
|
}
|
|
|
|
/// The latch makes the line due immediately; the re-arm is exactly 4 s later.
|
|
#[test]
|
|
fn the_first_line_is_immediate_and_the_repeat_is_four_seconds() {
|
|
let mut r = SwingRefusal::default();
|
|
r.latch(Latched::NotInRange, secs(100));
|
|
assert_eq!(
|
|
r.0,
|
|
Some((Latched::NotInRange, secs(100))),
|
|
"due now, not in 4 s"
|
|
);
|
|
assert_eq!(secs(100) + REPEAT, Duration::from_millis(104_000));
|
|
}
|
|
|
|
/// A **second** refusal defeats the 4 s cooldown: `0x5ecdb0` writes the deadline
|
|
/// unconditionally, so the next tick shows again at once — and a NOTINRANGE→BADFACING flip
|
|
/// shows immediately rather than 4 s later.
|
|
#[test]
|
|
fn a_second_refusal_packet_defeats_the_cooldown() {
|
|
let mut r = SwingRefusal::default();
|
|
r.latch(Latched::NotInRange, secs(100));
|
|
// …the tick shows the line and re-arms for 104 s…
|
|
r.0 = Some((Latched::NotInRange, secs(100) + REPEAT));
|
|
// …and a second packet one second later puts it due NOW, not at 104 s.
|
|
r.latch(Latched::BadFacing, secs(101));
|
|
assert_eq!(r.0, Some((Latched::BadFacing, secs(101))));
|
|
}
|
|
|
|
/// A landed swing clears it — the reason the line stops instead of repeating forever.
|
|
#[test]
|
|
fn a_landed_swing_clears_the_latch() {
|
|
let mut r = SwingRefusal::default();
|
|
r.latch(Latched::BadFacing, secs(5));
|
|
r.clear();
|
|
assert_eq!(r.0, None);
|
|
}
|
|
|
|
/// Both keys are catalog rows, and they are the two the reference's error ids name — the
|
|
/// `push 0xd8`/`push 0xd7` at `0x5ec9a5`/`0x5ec9b1`.
|
|
#[test]
|
|
fn the_two_keys_are_the_reference_error_ids() {
|
|
let pos = benilla_ui::messages::by_key(Latched::NotInRange.key()).expect("0xd8 is a row");
|
|
let facing = benilla_ui::messages::by_key(Latched::BadFacing.key()).expect("0xd7 is a row");
|
|
assert_eq!(pos.id, 0xd8);
|
|
assert_eq!(facing.id, 0xd7);
|
|
// Both go to the red line, not to chat — the catalog's own `kind`, not a choice here.
|
|
assert_eq!(pos.kind, benilla_ui::messages::MsgKind::Error);
|
|
assert_eq!(facing.kind, benilla_ui::messages::MsgKind::Error);
|
|
}
|
|
}
|