Part of #41: the last of five `benilla-app` pieces. This one is everything the others did not cover: the addon harness, the settings registry, the glue screens (login, realm list, character select and create), key bindings, the minimap, nameplates, the loading screen, transports, the performance instruments, the smaller modules, the integration tests, and the UI asset files (`OptionsFrame.xml`, `benilla.toc` and the rest). Their comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 27,351 comment lines become 11,010. Fixes #33. Fixes #103. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - The weapon trail's emissive is 0 (`0x70ca30`): the trail is a type-5 render record, so its ambient-only light term is exact. - The camera-style dropdown writes "1", "2", "0" (`UIOptionsFrame.lua:525,536,547`); several register-site addresses in the settings table are corrected. - The graphics settings are held until `RestartGx`, as in 1.12; `Fonts.xml`'s early load no longer carries a false reason. - vmangos sends a player-name invalidation after a rename (`CharacterHandler.cpp:839`), and the cinematic camera's announce packet has an empty handler there (`MiscHandler.cpp:919`). - Stock FrameXML and GlueXML line citations are checked against the stock 1.12 files, and vmangos line citations against the pinned revision. - **Deviation labels.** "Deviation:" now marks only a choice with its recorded reason. Declining to copy a reference defect is stated plainly, and so is an unbuilt behaviour or an untraced one. - **Verification.** Every file strips to identical code, apart from test literals in `login/`, `death/` and `ui_script/death_tests.rs` that now use placeholder account and character names, and `cargo fmt --check` is clean. Six second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. A second commit corrects one comment in `benilla-ui` (`unit_is_grey`: the reference's exclusion is players only). A third lowers `ui_script_wall`'s ceiling from 45 to 40: five gameplay files named `UiScript` only in comments the rewrite removed, and the ratchet asks for the number to follow. Behaviour differences the rewrite surfaced are filed as their own issues (#339 to #353 among them). String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
234 lines
9.2 KiB
Rust
234 lines
9.2 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 latch a message; `0x148`
|
|
//! DEADTARGET, `0x149` CANT_ATTACK and `0x14e` `SMSG_CANCEL_COMBAT` (handler `0x5e7dd0`, whose
|
|
//! body is arm 4's byte for byte) stop the attack silently; `0x147` NOTSTANDING is never
|
|
//! registered, and vmangos never sends it.
|
|
//!
|
|
//! The server sends a refusal only on its edge (vmangos `Unit.cpp:406-472`), so the repeat the
|
|
//! player sees is the client's own latch and cooldown:
|
|
//!
|
|
//! ```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 only
|
|
//!
|
|
//! 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
|
|
//! ```
|
|
//!
|
|
//! So the first line shows on the next tick and repeats every 4 s while the latch stands. A
|
|
//! landed swing clears it: `SMSG_ATTACKERSTATEUPDATE`'s arm calls `0x5ea800` (at `0x6259b6`) when
|
|
//! the attacker is the active player and the victim resolves, and its first act is `0x5ecdb0(0)`.
|
|
//! A second refusal rewrites the deadline to now, defeating the cooldown, and the re-arm precedes
|
|
//! the print (`0x5ec9bc`, `0x5ec9c2`), so it happens even when the string is missing. Both edges
|
|
//! ride one message ([`SwingRefusalEdge`]) so they are consumed in packet order.
|
|
|
|
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`).
|
|
const REPEAT: Duration = Duration::from_millis(0xfa0);
|
|
|
|
/// One edge of the swing-refusal seam, off the net drain in packet order.
|
|
#[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).
|
|
CombatCancelled,
|
|
/// One of our swings landed on a victim we can see (`0x6259b6`). The write site applies both
|
|
/// gates: the attacker is the local player (`0x5fa6d0`) and the victim resolves as a unit
|
|
/// (`0x468460(8)`); benilla checks presence only, which is equivalent since the victim is
|
|
/// always a unit or player.
|
|
Landed,
|
|
}
|
|
|
|
/// The latched refusal: the reference's `[0xc4d758]` (the code) and `[0xc4c270]` (the absolute
|
|
/// time the next line is due). `None` is code 0, whose timestamp the tick never reads.
|
|
#[derive(Resource, Default)]
|
|
struct SwingRefusal(Option<(Latched, Duration)>);
|
|
|
|
/// The two refusals that latch; arm 4 writes no latch.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
enum Latched {
|
|
/// Code 1: `DisplayError(0xd8)`, `ERR_BADATTACKPOS`.
|
|
NotInRange,
|
|
/// Code 2: `DisplayError(0xd7)`, `ERR_BADATTACKFACING`.
|
|
BadFacing,
|
|
}
|
|
|
|
impl Latched {
|
|
/// The `GlobalStrings.lua` key the reference's error id resolves to.
|
|
const fn key(self) -> &'static str {
|
|
match self {
|
|
Self::NotInRange => "ERR_BADATTACKPOS",
|
|
Self::BadFacing => "ERR_BADATTACKFACING",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SwingRefusal {
|
|
/// `0x5ecdb0(code)`: latch the refusal, due now.
|
|
fn latch(&mut self, code: Latched, now: Duration) {
|
|
self.0 = Some((code, now));
|
|
}
|
|
|
|
/// `0x5ecdb0(0)`.
|
|
fn clear(&mut self) {
|
|
self.0 = None;
|
|
}
|
|
}
|
|
|
|
/// Consume this frame's edges in packet order: latch a refusal, clear on a landing, and stop the
|
|
/// attack for the silent arm.
|
|
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());
|
|
}
|
|
// `0x625ab8` and `0x5e7dd0`: no latch, no message, only the stop, whose own engaged
|
|
// guard (`0x5ecac0`) decides whether it 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`, gated in its order: an attack target set
|
|
/// ([`Engaged`] is `[+0xc48]`), the cooldown, then the code; it re-arms only when a line shows.
|
|
/// It runs once per rendered frame (vtable `0x80af78` slot 14, called only from `0x48160c` on the
|
|
/// world paint), as an `Update` system does.
|
|
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
|
|
}
|
|
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`, from `ClientInitializeGame`
|
|
/// (`0x401570`), which runs once per world entry.
|
|
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 queued
|
|
// key shows the same frame; chained so a refusal is latched before the tick.
|
|
(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 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.
|
|
#[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.
|
|
r.latch(Latched::BadFacing, secs(101));
|
|
assert_eq!(r.0, Some((Latched::BadFacing, secs(101))));
|
|
}
|
|
|
|
/// A landed swing clears it.
|
|
#[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);
|
|
}
|
|
|
|
/// The keys are the reference's error ids, `0xd8`/`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 error line, by the catalog's own `kind`.
|
|
assert_eq!(pos.kind, benilla_ui::messages::MsgKind::Error);
|
|
assert_eq!(facing.kind, benilla_ui::messages::MsgKind::Error);
|
|
}
|
|
}
|