casting a hunter shot starts Auto Shot, and the ping wears its real art
- Serpent Sting starts Auto Shot in play: the chain built earlier the same day never fired because the in-flight record was armed only for the cast classes it refuses on, and every hunter shot is a ranged attack — the reference records every commit and discriminates at the gate; two tests now run the real ladder and reproduce the shipped symptom - the minimap ping is MinimapPing.mdx, measured: three additive quads — a 12 px spinner on the model's global clock, a static core, and a ring expanding 3→32 px on an 833 ms loop — the interim art was ~3× too big and missing the ring the eye actually reads - a mouse release goes to the frame that took the press and nothing else — the reference's up handler reads the capture alone and never the hover frame, so a press over open space delivers no release to whatever the cursor wandered onto (corrects the morning's fallback) - `WOW_MOVE_TRACE_TAGS=use` traces a right-click end to end (the cursor decision, the use packet, the spell-go answer), and the first headless hover capture parks the cursor on a chat tab so the reveal can be measured against the bare scene Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1640,10 +1640,16 @@
|
||||
|
||||
So it rides a frame the dock cannot hide. Mounting it on BOTH windows instead would work
|
||||
only while exactly one of them is ever visible — an invariant the dock happens to hold
|
||||
today and that a second visible window would turn into a double-speed fade. The reference
|
||||
offers no shape to copy here: 1.12.1 ships `FCF_OnUpdate` DEFINED and never referenced
|
||||
(0 hits across all of FrameXML), because vanilla's tabs do not hover-fade at all — the
|
||||
reveal is benilla's (0288), so its mounting point is ours to get right.
|
||||
today and that a second visible window would turn into a double-speed fade.
|
||||
|
||||
**A frame the dock cannot hide is the reference's own answer**, and this comment used to
|
||||
claim the opposite: that 1.12.1 ships `FCF_OnUpdate` defined and never referenced, so
|
||||
vanilla's tabs never hover-fade and the reveal was benilla's own (0288). That is wrong.
|
||||
`UIParent.xml` l.19 calls `FCF_OnUpdate(arg1)` from **UIParent's** `<OnUpdate>`, in the
|
||||
same list as `UIFrameFadeUpdate`/`UIFrameFlashUpdate` — never from a chat frame, for the
|
||||
reason above. A 1x1 frame parented to UIParent is that mounting point re-expressed; the
|
||||
reasoning was right and only its premise was invented. The hover reveal, the 0.2 s
|
||||
stationary delay and the 1.0/0.5 selected/unselected tab alphas are all the reference's.
|
||||
|
||||
`ChatFrame_OnUpdate` stays on the windows, where it belongs: it reads `this` and blinks
|
||||
that frame's own bottom button. -->
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
1. THE GLOBALSTRINGS ARE NAMED, NEVER COPIED. Older windows here carry an
|
||||
`X = X or "<the enUS text>"` fallback so a chainless harness still paints words; this
|
||||
page ships no such copy for its own labels, because three of them are the
|
||||
`NEWBIE_TOOLTIP_*` paragraphs and shipping Blizzard's prose is exactly what decision
|
||||
1234 forbids. The section labels reach the player's own strings through the loader's
|
||||
`NEWBIE_TOOLTIP_*` paragraphs and shipping Blizzard's prose was what decision 1234
|
||||
forbade (retired by 1602; the lookup stays because it is the better design). The section labels reach the player's own strings through the loader's
|
||||
`text=` resolution (a `text=` attribute is a GlobalStrings LOOKUP, `loader::resolve_text`);
|
||||
the row labels and tooltip bodies reach them as plain globals. A harness with no
|
||||
install behind it therefore paints a key where a label goes and raises if you hover a
|
||||
|
||||
@@ -24,10 +24,14 @@
|
||||
flaky, and no corpus addon touches any of that surface (measured: 0 of 218 for MiniMapPing /
|
||||
Minimap_SetPing / MINIMAP_PING / PingLocation / GetPingPosition).
|
||||
|
||||
OUT (no wire / later arc slices, per 0203): meeting stone and battlefield buttons;
|
||||
the ping MODEL (the reference's MiniMapPing is a <Model> on MinimapPing.mdx — ours is a flat
|
||||
stand-in at an INTERIM size, `minimap::ping::PING_PX`); the rotate-minimap CVar. The day/night indicator now lives in
|
||||
its own GameTime.xml (the reference's own file split), loaded right after this file. The
|
||||
OUT (no wire / later arc slices, per 0203): meeting stone and battlefield buttons. The ping
|
||||
MODEL is not out any more and never needed to be: the reference's MiniMapPing is a <Model> on
|
||||
MinimapPing.mdx, and 1600 re-expresses its three drawn quads as flat animated sprites at the
|
||||
model's own byte-measured sizes and tracks. (The "rotate-minimap CVar" this list used to name
|
||||
does not EXIST in 5875 — a 214-site CVar::Register census found no such knob, and the address
|
||||
it was pinned on, 0xceaa60, is the inside-a-WMO flag. Nothing to omit.) The day/night indicator
|
||||
now lives in its own GameTime.xml (the reference's own file split), loaded right after this
|
||||
file. The
|
||||
reference's minimapPlayerModel/.mdx arrow renders app-side from its .blp for now (0203 flags
|
||||
the simplification). The reference's <HitRectInsets> on the cluster is dropped (it only
|
||||
enlarges the mouse-blocking hull; the loader models the tag now, so restore it if it ever
|
||||
|
||||
@@ -1218,6 +1218,67 @@ pub(super) fn seed_ui_fixture(
|
||||
warn!("capture: ui-chatedit seed failed: {e}");
|
||||
}
|
||||
}
|
||||
UiFixture::ChatTabHover => {
|
||||
let Some(script) = script.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Lines so the window has content, the Combat Log selected, then the cursor parked
|
||||
// on the GENERAL tab — the unselected-tab hover the director reported.
|
||||
//
|
||||
// A capture CAN hover: the app only pumps the OS cursor into the VM on a non-synthetic
|
||||
// run (`ui_script::input`'s `.filter(|_| !ui_hidden && !synthetic)`), so a fed
|
||||
// `mouse_move` is what the whole run then sees. That is the hover instrument decision
|
||||
// 0254 wrote down as missing ("No capture scenario exercises a hover state, so nothing
|
||||
// in the harness covers the very texture this decision is about") — and 0254's own
|
||||
// named residual is exactly what this scenario turned out to show.
|
||||
for (text, r, g, b) in [
|
||||
("[One] says: hello northshire", 1.0, 1.0, 1.0),
|
||||
("[One] says: a second line to stack", 1.0, 1.0, 1.0),
|
||||
] {
|
||||
script.add_chat_message("ChatFrame1", text, r, g, b);
|
||||
}
|
||||
// `$WOW_TABHOVER` picks which of the five dock states to shoot. It is an A/B rig,
|
||||
// not a setting: the four alternates are what let a change to the composite be read
|
||||
// as a difference rather than judged from one frame. Default "1" is the reported
|
||||
// case, so the golden is stable without the variable.
|
||||
// 1 (default) Combat Log selected, hovering GENERAL — the report
|
||||
// 3 General selected, hovering COMBAT LOG — its mirror
|
||||
// 2 Combat Log selected, hovering COMBAT LOG — hover on the SELECTED tab
|
||||
// 0 revealed, hovering neither tab — the no-glow control
|
||||
// 9 cursor off the dock — bare scene, the baseline
|
||||
// a tab quad is measured against
|
||||
let mode = std::env::var("WOW_TABHOVER").unwrap_or_else(|_| "1".into());
|
||||
let select = if mode == "3" { 1 } else { 2 };
|
||||
if let Err(e) = script.run(&format!("BenillaFCF_TabClick({select})")) {
|
||||
warn!("capture: ui-chat-tabhover select failed: {e}");
|
||||
}
|
||||
script.resolve();
|
||||
let expr = match mode.as_str() {
|
||||
"2" | "3" => "return ChatFrame2Tab:GetCenter()",
|
||||
"0" => {
|
||||
"return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \
|
||||
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2"
|
||||
}
|
||||
// 9: park far away — the dock conceals itself, so the tab band is bare scene.
|
||||
"9" => "return 2000, 2000",
|
||||
_ => "return ChatFrame1Tab:GetCenter()",
|
||||
};
|
||||
let centre: Result<(f32, f32), _> = script.eval(expr);
|
||||
match centre {
|
||||
Ok((x, y)) => {
|
||||
script.mouse_move(x, y);
|
||||
// Settle the reveal ramp without waiting on the app's own frames.
|
||||
for _ in 0..40 {
|
||||
if let Err(e) = script.run("FCF_OnUpdate(0.05)") {
|
||||
warn!("capture: ui-chat-tabhover pump failed: {e}");
|
||||
break;
|
||||
}
|
||||
script.resolve();
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("capture: ui-chat-tabhover centre failed: {e}"),
|
||||
}
|
||||
}
|
||||
UiFixture::NameWater => {
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
// The synthetic self player at the eye (the reaction lookup reads its store, and the
|
||||
|
||||
@@ -106,6 +106,11 @@ pub(super) enum UiFixture {
|
||||
/// capture checks the header (left-flush, Say-white), the typed text past the live insets,
|
||||
/// and the three-piece input border.
|
||||
ChatEdit,
|
||||
/// The chat dock **hovered**: revealed, the Combat Log selected, and the cursor parked on the
|
||||
/// unselected GENERAL tab — the first capture scenario that exercises a hover state at all,
|
||||
/// which decision 0254 recorded as the gap that left its own lane uncovered. `$WOW_TABHOVER`
|
||||
/// swings it through the four alternate dock states (see `fixtures.rs`).
|
||||
ChatTabHover,
|
||||
/// The social pane (`FriendsFrame`), opened through the live toggle — the instrument for B264
|
||||
/// (decision 1298). Its `BenillaFriendsDropDown` host is declared with no anchors, exactly as
|
||||
/// the reference's own `FriendsDropDown` is; while an unpositioned owner stood in a zero rect
|
||||
@@ -986,6 +991,17 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::ChatEdit),
|
||||
},
|
||||
// The hovered chat dock — the tab plate + its ADD highlight over the world, which is the
|
||||
// pair the UI-over-world composite space decides. Run with
|
||||
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-chat-tabhover`.
|
||||
Scenario {
|
||||
name: "ui-chat-tabhover",
|
||||
map: MAP_AZEROTH,
|
||||
eye: GROUND_EYE,
|
||||
look: GROUND_LOOK,
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::ChatTabHover),
|
||||
},
|
||||
// The social pane, for the stray-dropdown regression (B264, decision 1298). Run with
|
||||
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-social`.
|
||||
Scenario {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! `UI-Character-ScrollBar` track, and the four-button bottom row in the reference's own order
|
||||
//! (Disable All · Enable All ··· Okay · Cancel). Geometry constants below are the authored
|
||||
//! numbers, resolved top-down into the 640×512 plate. Behaviour is **re-expressed natively** —
|
||||
//! transcribing GlueXML is forbidden, not discouraged (the contract's FrameXML rule; 1234, 1260).
|
||||
//! written under 1234's rule, which 1602 has since retired; the native form stays as a design
|
||||
//! choice, not a law.
|
||||
//!
|
||||
//! ## What the reference does, and what we do
|
||||
//!
|
||||
|
||||
@@ -274,11 +274,10 @@ struct MinimapAssets {
|
||||
/// (`Rotating-MinimapArrow.mdx`) the reference re-animates per blip source. See
|
||||
/// [`blips::RimArrow`] for the sequence→layer table and why there are four of them.
|
||||
rim_arrows: blips::RimArrowArt,
|
||||
/// The ping marker's art, in draw order (decision 1596). The reference's `MiniMapPing` is a
|
||||
/// `<Model>` frame — `Interface\MiniMap\Ping\MinimapPing.mdx`, XML `scale="0.4"`, 50×50 —
|
||||
/// so this is the flat stand-in for it: the model's own `ping2`/`ping5` textures stacked, the
|
||||
/// same pair the paused first version shipped. Empty = no art, and the ping draws nothing.
|
||||
ping: Vec<Handle<Image>>,
|
||||
/// The ping marker's three drawn layers — the flat re-expression of `MinimapPing.mdx`'s
|
||||
/// coincident additive quads, at the model's own byte-measured sizes and animation
|
||||
/// ([`ping::PingArt`], decisions 1596/1599).
|
||||
ping: ping::PingArt,
|
||||
/// The unit-blip atlas (`Interface\Minimap\ObjectIcons`, five 32-px dot cells) — the
|
||||
/// quest-giver dots.
|
||||
object_icons: Option<Handle<Image>>,
|
||||
@@ -324,15 +323,28 @@ fn setup_minimap(
|
||||
}
|
||||
let object_icons =
|
||||
assets.sprite_texture("Interface\\Minimap\\ObjectIcons", &mut images);
|
||||
let ping: Vec<_> = [
|
||||
"Interface\\Minimap\\Ping\\ping2",
|
||||
"Interface\\Minimap\\Ping\\ping5",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|p| assets.sprite_texture(p, &mut images))
|
||||
.collect();
|
||||
if ping.is_empty() {
|
||||
warn!("minimap: no Ping art — a minimap ping will place but draw nothing");
|
||||
// `ping6` is deliberately absent: the model carries it on two quads whose weight
|
||||
// track is a single key of 0, so the client culls those batches before it reads their
|
||||
// blend mode. Loading it would be loading art that never draws.
|
||||
let ping = ping::PingArt {
|
||||
ping5: assets.sprite_texture("Interface\\Minimap\\Ping\\ping5", &mut images),
|
||||
ping2: assets.sprite_texture("Interface\\Minimap\\Ping\\ping2", &mut images),
|
||||
ping4: assets.sprite_texture("Interface\\Minimap\\Ping\\ping4", &mut images),
|
||||
};
|
||||
// Per layer, not just "none of them": the three do different jobs, and a silently
|
||||
// absent `ping4` would cost the ring — the one thing the eye actually reads — while
|
||||
// the marker still looked plausible (1203's rule, applied to art).
|
||||
for (name, present) in [
|
||||
("ping5", ping.ping5.is_some()),
|
||||
("ping2", ping.ping2.is_some()),
|
||||
("ping4", ping.ping4.is_some()),
|
||||
] {
|
||||
if !present {
|
||||
warn!(
|
||||
"minimap: Interface\\Minimap\\Ping\\{name} missing — the ping marker \
|
||||
will draw without that layer"
|
||||
);
|
||||
}
|
||||
}
|
||||
if mask.is_none() {
|
||||
warn!("minimap: MinimapMask.blp missing — the map will draw square");
|
||||
|
||||
@@ -53,28 +53,117 @@ const PING_HOLD: f32 = 5.0;
|
||||
/// The tail after the hold — the reference's `MINIMAPPING_FADE_TIMER`.
|
||||
const PING_FADE: f32 = 0.5;
|
||||
|
||||
/// **The reference's ping does not fade; it pops.** `Minimap_OnUpdate` writes
|
||||
/// `SetAlpha(255 * (t / MINIMAPPING_FADE_TIMER))` — a 0..255 alpha handed to an API that clamps to
|
||||
/// 0..1, so the marker stays fully opaque until `t` falls below `0.5/255` ≈ 2 ms and then vanishes
|
||||
/// in one frame. That is what the real client looks like, so it is what we draw. It is a
|
||||
/// *behaviour* of theirs, not a quirk of ours to hide: flip this to `false` for a real linear fade
|
||||
/// and nothing else changes.
|
||||
/// **The reference's ping does not fade; it pops** — now byte-confirmed, not inferred from the Lua
|
||||
/// (wow-re `minimap-ping-law.md`). `Minimap_OnUpdate` writes `SetAlpha(255 * (t / 0.5))`, and
|
||||
/// `Frame:SetAlpha` (`0x774e90`) **clamps to [0,1]** before scaling to a 0–255 byte — so the
|
||||
/// nominal half-second ramp is at full alpha for 498 of its 500 ms and the whole visible fall is
|
||||
/// ~12 % of one frame at 60 fps. The real client holds bright for ~5 s and vanishes.
|
||||
///
|
||||
/// Reproduced as the clamp rather than as a constant 1.0, so the mechanism stays visible and the
|
||||
/// `false` branch is a real alternative (a smooth fade, which wow-re notes will visibly diverge).
|
||||
const PING_ALPHA_IS_A_POP: bool = true;
|
||||
|
||||
/// The marker's on-screen size, as a fraction of the widget side on the client's byte-pinned
|
||||
/// 140.8-px minimap basis — the same basis every other blip constant is frozen against
|
||||
/// ([`super::blips::BLIP_BASIS_PX`]).
|
||||
/// **The marker, byte-measured** (wow-re `system/ui/scratch/minimap-ping-law.md` §10, VERIFIED).
|
||||
///
|
||||
/// **INTERIM — an eyeball, not a measurement.** What the reference declares is verified (ref
|
||||
/// `Minimap.xml` l.396): `MiniMapPing` is a `<Model>` frame on
|
||||
/// `Interface\MiniMap\Ping\MinimapPing.mdx`, XML `scale="0.4"`, viewport 50×50. What that
|
||||
/// *renders* at is not: the on-screen size is the model's own quad extent times the frame's
|
||||
/// px-per-model-unit, the way [`super::blips::ARROW_QUAD_PX`] is 0.0500 · 768. Nobody has measured
|
||||
/// this model's quad, so 40 px is the paused first version's eyeball carried forward unchanged —
|
||||
/// deliberately, so the pin can be judged without the art also moving. Out for RE with the model's
|
||||
/// animation; the rim arrows' precedent says a model like this is a plain textured quad, in which
|
||||
/// case the flat stand-in becomes geometry-exact at the model's own size.
|
||||
const PING_PX: f32 = 40.0;
|
||||
/// `MiniMapPing` is a `<Model>` on `Interface\MiniMap\Ping\MinimapPing.mdx` at XML `scale="0.4"`,
|
||||
/// which puts **1 model unit at 512 px** on the client's stock basis. The model is *five coincident
|
||||
/// full-UV quads* sharing one centre, every one of them `blendMode = 4` — **additive**
|
||||
/// `SRC_ALPHA/ONE` — with `flags = 0x0011` (unlit, no depth write). Three of them draw:
|
||||
///
|
||||
/// | texture | model units | px | behaviour |
|
||||
/// |---|---|---|---|
|
||||
/// | `ping5` | `0.069 × 0.350634` | **12.39** | spins CW, one turn per 4833 ms, alpha 1 |
|
||||
/// | `ping2` | `0.025` | **12.80** | fully static — its bone has no tracks at all |
|
||||
/// | `ping4` | `0.00625 × (1→10)` | **3.20 → 32.00** | the expanding, fading ring |
|
||||
///
|
||||
/// `ping6` (two of the five quads) is **culled, not invisible**: its weight track is a single key
|
||||
/// of 0, so the batch is skipped before `blendMode` is read. It is not emitted here.
|
||||
///
|
||||
/// Sizes are px on the frozen 140.8-px minimap basis, like every other blip constant
|
||||
/// ([`super::blips::BLIP_BASIS_PX`]) — the model's px-per-unit and the widget's size both scale
|
||||
/// with the same screen basis, so their ratio is the constant.
|
||||
///
|
||||
/// The paused first version drew one flat 40 px stack: **~3× too big, and missing the ring** —
|
||||
/// which is the motion the eye actually reads.
|
||||
const PING5_PX: f32 = 0.069 * 0.350_634 * PX_PER_MODEL_UNIT;
|
||||
const PING2_PX: f32 = 0.025 * PX_PER_MODEL_UNIT;
|
||||
const PING4_PX: f32 = 0.006_25 * PX_PER_MODEL_UNIT;
|
||||
|
||||
/// `scale="0.4"` on a `<Model>` frame ⇒ `(5/3) · 0.4 · 768` px per model unit at the reference's
|
||||
/// screen basis (aspect- and resolution-independent — the terms cancel).
|
||||
const PX_PER_MODEL_UNIT: f32 = 512.0;
|
||||
|
||||
/// The **sequence** clock: sequence 1 "Stand", looped. `ping4`'s ring rides this.
|
||||
const PING_SEQ_MS: f32 = 833.0;
|
||||
/// The **global** clock (`globalSequence = 0`), which never consults the sequence window at all.
|
||||
/// `ping5`'s spin rides this — the two periods are coprime, so they re-phase only after ~67 min.
|
||||
const PING_SPIN_MS: f32 = 4833.0;
|
||||
|
||||
/// `ping4`'s alpha: linear `0 → 1` over the first 400 ms of the loop, then `1 → 0` over the
|
||||
/// remaining 433 ms. Peak alpha lands at 400 ms, when the ring is `1 + 9·400/833 = 5.32×`.
|
||||
const PING4_ALPHA_PEAK_MS: f32 = 400.0;
|
||||
|
||||
/// `ping5`'s spin, as the model's own 21 keys — `(ms, degrees clockwise on screen)`, unwrapped and
|
||||
/// monotonic (wow-re §10.3). The rate is **not** uniform: ~67.5 °/s over the first half-turn,
|
||||
/// ~83.1 °/s over the second, with a 108 °/s burst across 225°→270° that an average hides.
|
||||
///
|
||||
/// Kept as the table rather than collapsed to a constant rate for exactly that reason. wow-re's
|
||||
/// three transcription warnings do not bite here — they are about interpolating the model's
|
||||
/// *quaternions* (a shortest-path slerp collapses the revolution to no motion, and a `w ≥ 0`
|
||||
/// canonicalisation reverses it at 180°). Interpolating the **angle** linearly, as below, is the
|
||||
/// sanctioned form: it deviates by at most 0.014°.
|
||||
const PING5_SPIN: [(f32, f32); 21] = [
|
||||
(0.0, 0.0),
|
||||
(333.0, 22.5),
|
||||
(667.0, 45.0),
|
||||
(889.0, 60.0),
|
||||
(1111.0, 75.0),
|
||||
(1333.0, 90.0),
|
||||
(1666.0, 112.5),
|
||||
(2000.0, 135.0),
|
||||
(2222.0, 150.0),
|
||||
(2444.0, 165.0),
|
||||
(2667.0, 180.0),
|
||||
(2847.0, 195.0),
|
||||
(3027.0, 210.0),
|
||||
(3208.0, 225.0),
|
||||
(3416.0, 247.5),
|
||||
(3625.0, 270.0),
|
||||
(3805.0, 285.0),
|
||||
(3986.0, 300.0),
|
||||
(4167.0, 315.0),
|
||||
(4500.0, 337.5),
|
||||
(4833.0, 360.0),
|
||||
];
|
||||
|
||||
/// The spin angle at `ms` into the global clock, in **radians clockwise on screen** — which is our
|
||||
/// quad `rotation`'s own sense (the player arrow negates a WoW facing for the same reason).
|
||||
fn spin_radians(ms: f32) -> f32 {
|
||||
let t = ms.rem_euclid(PING_SPIN_MS);
|
||||
let deg = PING5_SPIN
|
||||
.windows(2)
|
||||
.find(|w| t <= w[1].0)
|
||||
.map_or(0.0, |w| {
|
||||
let (t0, a0) = w[0];
|
||||
let (t1, a1) = w[1];
|
||||
let u = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
|
||||
a0 + (a1 - a0) * u
|
||||
});
|
||||
deg.to_radians()
|
||||
}
|
||||
|
||||
/// The **art**, one handle per drawn layer. Named rather than a list: the three do different
|
||||
/// things, and a `Vec` that silently changed order would change the animation.
|
||||
#[derive(Default)]
|
||||
pub(super) struct PingArt {
|
||||
/// The spinner (draw order 0 in the model — though with additive and no depth write, order
|
||||
/// cannot change the image).
|
||||
pub(super) ping5: Option<Handle<Image>>,
|
||||
/// The static centre.
|
||||
pub(super) ping2: Option<Handle<Image>>,
|
||||
/// The expanding ring.
|
||||
pub(super) ping4: Option<Handle<Image>>,
|
||||
}
|
||||
|
||||
/// The live ping. One at a time — the reference keeps no list either.
|
||||
struct LivePing {
|
||||
@@ -97,6 +186,13 @@ struct LivePing {
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MinimapPing {
|
||||
live: Option<LivePing>,
|
||||
/// The model's own clock, in **milliseconds, accumulated only while a ping is shown** — which
|
||||
/// is the reference's behaviour rather than a simplification of it. `SetSequence(0)` runs once
|
||||
/// in the ref's `OnLoad` and stores an *anchor*; the sampler free-runs from it, and a Model
|
||||
/// frame's private clock advances only while the frame is shown. The ref then only `Show()`s
|
||||
/// per ping. So **ping N resumes where ping N−1 left off**, and no two consecutive pings look
|
||||
/// alike (wow-re §10, VERIFIED). One accumulator reproduces that; a per-ping reset would not.
|
||||
shown_ms: f32,
|
||||
/// A ping seated since the last [`drive_minimap_ping`] — it still owes the world an outbound
|
||||
/// `MSG_MINIMAP_PING` (if it is ours) and a `MINIMAP_PING` event (either way).
|
||||
fresh: bool,
|
||||
@@ -171,7 +267,7 @@ pub(super) fn emit_ping(
|
||||
ping: &mut MinimapPing,
|
||||
click: Option<(f32, f32)>,
|
||||
map: u32,
|
||||
art: &[Handle<Image>],
|
||||
art: &PingArt,
|
||||
quads: &mut UiQuads,
|
||||
) {
|
||||
if let Some(world) = click.and_then(|c| click_to_world(ctx, c, ctx.seam)) {
|
||||
@@ -191,19 +287,46 @@ pub(super) fn emit_ping(
|
||||
if d >= ctx.radius_yd {
|
||||
return;
|
||||
}
|
||||
let rect = Rect::from_center_size(
|
||||
ctx.center + ctx.offset([px, py, 0.0]),
|
||||
Vec2::splat(ctx.side * (PING_PX / super::blips::BLIP_BASIS_PX)),
|
||||
);
|
||||
for layer in art {
|
||||
let at = ctx.center + ctx.offset([px, py, 0.0]);
|
||||
// px on the frozen 140.8 basis → this widget's px, the blip layer's own scalar.
|
||||
let k = ctx.side / super::blips::BLIP_BASIS_PX;
|
||||
let a = alpha * ctx.alpha;
|
||||
let mut layer = |art: &Option<Handle<Image>>, side_px: f32, alpha: f32, rotation: f32| {
|
||||
let Some(texture) = art.clone() else { return };
|
||||
if alpha <= 0.0 {
|
||||
return;
|
||||
}
|
||||
quads.overlays.push(UiQuad {
|
||||
rect,
|
||||
rect: Rect::from_center_size(at, Vec2::splat(side_px * k)),
|
||||
z_key: ctx.z,
|
||||
texture: Some(layer.clone()),
|
||||
color: [1.0, 1.0, 1.0, alpha * ctx.alpha],
|
||||
texture: Some(texture),
|
||||
color: [1.0, 1.0, 1.0, alpha],
|
||||
// Every one of the model's quads is `blendMode = 4`, SRC_ALPHA/ONE.
|
||||
additive: true,
|
||||
rotation,
|
||||
..default()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The spinner, on the 4833 ms global clock.
|
||||
layer(&art.ping5, PING5_PX, a, spin_radians(ping.shown_ms));
|
||||
// The static centre.
|
||||
layer(&art.ping2, PING2_PX, a, 0.0);
|
||||
// The ring, on the 833 ms sequence loop: geometry scale `1 + 9·t/833` about its own centre
|
||||
// (the model carries NO texture-transform chunk, so this is a real size change, not a UV one),
|
||||
// under a two-leg alpha that peaks 400 ms in.
|
||||
let t = ping.shown_ms.rem_euclid(PING_SEQ_MS);
|
||||
let ring_alpha = if t <= PING4_ALPHA_PEAK_MS {
|
||||
t / PING4_ALPHA_PEAK_MS
|
||||
} else {
|
||||
(PING_SEQ_MS - t) / (PING_SEQ_MS - PING4_ALPHA_PEAK_MS)
|
||||
};
|
||||
layer(
|
||||
&art.ping4,
|
||||
PING4_PX * (1.0 + 9.0 * t / PING_SEQ_MS),
|
||||
a * ring_alpha,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Age the ping, announce a fresh one, and expire it — everything that is *not* geometry.
|
||||
@@ -229,8 +352,19 @@ pub(super) fn drive_minimap_ping(
|
||||
// whenever a VM next appears.
|
||||
if let Some(live) = ping.live.as_mut() {
|
||||
live.age += time.delta_secs();
|
||||
// The model clock runs only while a ping is up — see `shown_ms`. (The reference stops it
|
||||
// on `Hide()`, which also covers the off-disc case; we keep it running there, a difference
|
||||
// of a few tenths of a phase on a marker nobody can see.)
|
||||
ping.shown_ms += time.delta_secs() * 1000.0;
|
||||
}
|
||||
// A map change drops it: the point it marks is not on this map.
|
||||
// A map change drops it — a DELIBERATE divergence, now that the law is known. wow-re
|
||||
// `minimap-ping-law.md` §3 is categorical: **nothing** engine-side ever clears the reference's
|
||||
// ping (six instructions touch those cells; the only zeroing is a CRT static initializer before
|
||||
// `WinMain`), so a real 1.12 client that teleports mid-ping keeps drawing a marker at a world
|
||||
// point that is now somewhere else entirely. That is a consequence of storing the ping in a
|
||||
// never-cleared global, not a behaviour anyone chose, and the 5 s timer hides it in practice.
|
||||
// We drop it instead: same observable in every case a player can produce, minus the stale
|
||||
// marker after a worldport.
|
||||
if let (Some(live), Some(map)) = (ping.live.as_ref(), map.as_ref()) {
|
||||
if live.map != map.0 {
|
||||
ping.live = None;
|
||||
@@ -276,9 +410,11 @@ pub(super) fn drive_minimap_ping(
|
||||
let Some(live) = ping.live.as_ref() else {
|
||||
return;
|
||||
};
|
||||
// Ours goes on the wire — raw world floats; the server relays them to the group and does
|
||||
// nothing at all when we are solo (which is why the marker was drawn locally, not awaited).
|
||||
if live.sender == 0 {
|
||||
// Ours goes on the wire — raw world floats — but only when there is a group to relay them to.
|
||||
// The reference gates its send the same way (`PingLocation` `0x4eeca0` sends only when
|
||||
// grouped, VERIFIED) while still pinging locally, which is why a solo ping works at all and
|
||||
// why the marker is drawn at click time rather than awaited off the wire.
|
||||
if live.sender == 0 && group.in_group {
|
||||
let _ = commands.0.send(ClientCommand::MinimapPing {
|
||||
x: live.world.0,
|
||||
y: live.world.1,
|
||||
@@ -431,33 +567,54 @@ mod tests {
|
||||
assert_eq!(ping.alpha(), None, "over");
|
||||
}
|
||||
|
||||
fn art() -> PingArt {
|
||||
PingArt {
|
||||
ping5: Some(Handle::default()),
|
||||
ping2: Some(Handle::default()),
|
||||
ping4: Some(Handle::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// **It draws, and where.** The pin's whole claim is that the marker's rect comes out of
|
||||
/// [`BlipCtx::offset`] like every other blip's — so this drives the real emitter and checks
|
||||
/// the rect, one quad per art layer, rather than trusting the caller.
|
||||
/// the rect, rather than trusting the caller. It also pins the two layers' byte-measured
|
||||
/// sizes, and the fact that the ring contributes **nothing** at phase 0 (its alpha track
|
||||
/// starts at zero, so a "three layers ⇒ three quads" assertion would be wrong).
|
||||
#[test]
|
||||
fn the_emitter_puts_a_quad_per_layer_at_the_pinned_point() {
|
||||
fn the_emitter_puts_the_measured_layers_at_the_pinned_point() {
|
||||
let c = ctx();
|
||||
let mut ping = MinimapPing::default();
|
||||
let mut quads = UiQuads::default();
|
||||
let art = vec![Handle::<Image>::default(), Handle::<Image>::default()];
|
||||
let art = art();
|
||||
|
||||
// No ping: nothing drawn.
|
||||
emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
|
||||
assert!(quads.overlays.is_empty());
|
||||
|
||||
// A click 30 UI units up (north) at seam 1 seats a ping 30/px_per_yd yards north...
|
||||
// A click 30 UI units up (north) at seam 1 seats a ping 30/px_per_yd yards north.
|
||||
emit_ping(&c, &mut ping, Some((0.0, 30.0)), 0, &art, &mut quads);
|
||||
assert_eq!(quads.overlays.len(), 2, "one quad per layer");
|
||||
assert_eq!(
|
||||
quads.overlays.len(),
|
||||
2,
|
||||
"the ring is transparent at phase 0"
|
||||
);
|
||||
let want = c.center + c.offset([30.0 / c.px_per_yd, 0.0, 0.0]);
|
||||
let k = c.side / super::super::blips::BLIP_BASIS_PX;
|
||||
for q in &quads.overlays {
|
||||
let mid = (q.rect.min + q.rect.max) * 0.5;
|
||||
assert!((mid - want).length() < 1e-3, "{mid:?} vs {want:?}");
|
||||
let side = c.side * (PING_PX / super::super::blips::BLIP_BASIS_PX);
|
||||
assert!((q.rect.width() - side).abs() < 1e-3);
|
||||
assert!(q.additive, "every quad of the model is SRC_ALPHA/ONE");
|
||||
}
|
||||
// ping5 (12.39 px) draws first, ping2 (12.80 px) second — the model's own order.
|
||||
assert!((quads.overlays[0].rect.width() - PING5_PX * k).abs() < 1e-3);
|
||||
assert!((quads.overlays[1].rect.width() - PING2_PX * k).abs() < 1e-3);
|
||||
assert!(
|
||||
(PING5_PX - 12.39).abs() < 0.01 && (PING2_PX - 12.80).abs() < 0.01,
|
||||
"the byte-measured sizes: {PING5_PX} / {PING2_PX}"
|
||||
);
|
||||
|
||||
// ...and once it is out of range it stops drawing WITHOUT dying: walk 200 yd away, then
|
||||
// back. (The reference's Lua hides the marker off-disc; it does not clear the ping.)
|
||||
// Out of range it stops drawing WITHOUT dying: walk 200 yd away, then back. (The
|
||||
// reference's Lua hides the marker off-disc; it does not clear the ping.)
|
||||
quads.overlays.clear();
|
||||
let mut far = ctx();
|
||||
far.wx = -200.0;
|
||||
@@ -467,6 +624,92 @@ mod tests {
|
||||
assert_eq!(quads.overlays.len(), 2, "back in range: visible again");
|
||||
}
|
||||
|
||||
/// **The ring is the motion the eye reads** (wow-re §10.4): a geometry scale `1 + 9·t/833`
|
||||
/// from 3.2 px to 32 px across the sequence loop, under an alpha that rises over 400 ms and
|
||||
/// falls over the remaining 433 — peaking when the ring is 5.32× its base. The paused version
|
||||
/// had no ring at all.
|
||||
#[test]
|
||||
fn the_ring_expands_and_peaks_at_four_hundred_milliseconds() {
|
||||
let c = ctx();
|
||||
let k = c.side / super::super::blips::BLIP_BASIS_PX;
|
||||
let art = art();
|
||||
let ring_at = |ms: f32| {
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((0.0, 0.0), 0, 0);
|
||||
ping.shown_ms = ms;
|
||||
let mut quads = UiQuads::default();
|
||||
emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
|
||||
// The ring is the third layer whenever it is visible at all.
|
||||
quads
|
||||
.overlays
|
||||
.get(2)
|
||||
.map(|q| (q.rect.width() / k, q.color[3]))
|
||||
};
|
||||
assert_eq!(ring_at(0.0), None, "alpha 0 at the loop start: not emitted");
|
||||
let (w, a) = ring_at(PING4_ALPHA_PEAK_MS).expect("visible at the peak");
|
||||
assert!((a - 1.0).abs() < 1e-3, "peak alpha: {a}");
|
||||
assert!(
|
||||
(w / PING4_PX - 5.32).abs() < 0.01,
|
||||
"5.32x its base at the peak: {}",
|
||||
w / PING4_PX
|
||||
);
|
||||
// It keeps growing past the alpha peak, all the way to 10x, while fading out.
|
||||
let (w_late, a_late) = ring_at(PING_SEQ_MS - 1.0).expect("still visible near the end");
|
||||
assert!(w_late > w, "still expanding: {w_late} vs {w}");
|
||||
assert!(a_late < 0.02, "nearly gone: {a_late}");
|
||||
assert!(
|
||||
(w_late / PING4_PX - 10.0).abs() < 0.02,
|
||||
"10x at the loop end: {}",
|
||||
w_late / PING4_PX
|
||||
);
|
||||
assert!(
|
||||
(PING4_PX - 3.2).abs() < 0.01 && (PING4_PX * 10.0 - 32.0).abs() < 0.05,
|
||||
"3.2 → 32.0 px"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The spin is not uniform**, and reproducing it as a constant rate would be wrong by up to
|
||||
/// 15° (wow-re §10.3's 108 °/s burst across 225°→270°). The table's own keys, and the
|
||||
/// direction, are what this pins: one clockwise turn per 4833 ms, monotonic, never reversing.
|
||||
#[test]
|
||||
fn the_spin_follows_the_models_own_non_uniform_keys() {
|
||||
for (ms, deg) in PING5_SPIN {
|
||||
let got = spin_radians(ms).to_degrees();
|
||||
// The last key is a full turn, which wraps to 0 — the same rotation.
|
||||
let want = if ms >= PING_SPIN_MS { 0.0 } else { deg };
|
||||
assert!((got - want).abs() < 0.01, "at {ms} ms: {got} vs {want}");
|
||||
}
|
||||
// Monotonic clockwise across the whole revolution (positive rotation = CW on screen, the
|
||||
// sense the player arrow's `-facing` already establishes).
|
||||
let mut prev = -1.0;
|
||||
for i in 0..480 {
|
||||
let t = i as f32 * (PING_SPIN_MS / 480.0);
|
||||
let deg = spin_radians(t).to_degrees();
|
||||
assert!(deg >= prev - 1e-3, "never reverses: {deg} after {prev}");
|
||||
prev = deg;
|
||||
}
|
||||
// The burst the half-turn average hides: 225°→270° takes 417 ms, not the ~667 a uniform
|
||||
// rate would give.
|
||||
let quarter = spin_radians(3208.0).to_degrees();
|
||||
let burst_end = spin_radians(3625.0).to_degrees();
|
||||
assert!((quarter - 225.0).abs() < 0.01 && (burst_end - 270.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// The model clock is **not** reset per ping: the reference's Model frame free-runs and only
|
||||
/// ticks while shown, so consecutive pings resume mid-animation. Seating a second ping must
|
||||
/// leave the phase alone.
|
||||
#[test]
|
||||
fn a_second_ping_resumes_the_animation_rather_than_restarting_it() {
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((0.0, 0.0), 0, 0);
|
||||
ping.shown_ms = 1234.0;
|
||||
ping.seat((10.0, 10.0), 0, 0);
|
||||
assert!(
|
||||
(ping.shown_ms - 1234.0).abs() < f32::EPSILON,
|
||||
"the clock is the model's, not the ping's"
|
||||
);
|
||||
}
|
||||
|
||||
/// A degenerate frame (the widget has not drawn yet) drops the click rather than seating a
|
||||
/// ping at a garbage point — and, unlike the first version, that is the *only* case in which
|
||||
/// a click is dropped for want of a scale.
|
||||
|
||||
@@ -119,7 +119,7 @@ pub(super) fn cast_result(
|
||||
// BEFORE either arm touches the guard, because both the failure arm's clear below and the
|
||||
// chain's own clear are the reference's ONE `0x6e741a call 0x6e4940(0x1c)` — the in-flight slot
|
||||
// is finished on either outcome, and only then is column 38 read.
|
||||
let in_flight = pending.current(Instant::now()) == Some(spell_id);
|
||||
let in_flight = pending.committed(Instant::now()) == Some(spell_id);
|
||||
if !success {
|
||||
// The cast-fail cooldown edges (the client's `HandleCastFailed 0x6e1a00`, wow-re
|
||||
// `wave-cooldown.md` + the 2026-07-10 §5): a plain interactive-cast failure clears ONLY
|
||||
@@ -406,6 +406,23 @@ pub(super) fn spell_go(
|
||||
hits.len(),
|
||||
misses.len()
|
||||
);
|
||||
// **The interact chain's third link** (tag `use`, the same one `target::click` writes): the cast
|
||||
// the server answered a `CMSG_GAMEOBJ_USE` with. `caster_indexed=false` is the load-bearing
|
||||
// case — every impact, sound and effect model below hangs off `index.0.get(&caster)`, so a
|
||||
// caster we never streamed drops the whole visual silently. A GameObject IS the caster for a
|
||||
// SPELLCASTER-type object (vmangos leaves `spellCaster = this` for type 22), which is why this
|
||||
// reads the index rather than assuming a unit.
|
||||
if benilla_assets::trace::enabled_for("use") {
|
||||
benilla_assets::trace::line(
|
||||
"use",
|
||||
&format!(
|
||||
"SPELL_GO spell={spell_id} caster={caster:#x} caster_indexed={} hits={} misses={}",
|
||||
index.0.contains_key(&caster),
|
||||
hits.len(),
|
||||
misses.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
// A cast that names a GameObject (an open-lock cast on a chest / locked door) hands off to the GO
|
||||
// animation driver, which gates on the open-lock effect and opens the lid on the cast going off
|
||||
// (decision 0250). Independent of the caster being streamed to us — an observed open still animates.
|
||||
@@ -1846,7 +1863,10 @@ mod tests {
|
||||
if let Some(id) = in_flight {
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(id, Instant::now());
|
||||
// `guards: false` — a hunter shot is `Attributes & 0x2` ranged, so it is
|
||||
// recorded as committed and does NOT occupy the refusal. Arming it the other
|
||||
// way would hide the very regression this test exists for (1601).
|
||||
.arm(id, Instant::now(), false);
|
||||
}
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let cat = spells();
|
||||
|
||||
@@ -196,6 +196,28 @@ pub(super) fn act_on_right_click(
|
||||
// no packet). The lock arm runs first in `0x5f3130`, and it does here too: a `Refuse` toasts
|
||||
// even when we are also out of range.
|
||||
if go_is_nearest(&hovered, &hovered_object) {
|
||||
// **The interact chain's first link** (tag `use`). "I clicked it and nothing happened" spans
|
||||
// three systems — this decision, the `CMSG_GAMEOBJ_USE` it sends, and the `SMSG_SPELL_GO`
|
||||
// the server answers with — and until this line existed only the *taken* branches said
|
||||
// anything, at `debug!`, which the hardcoded log filter keeps off. Both silent refusals are
|
||||
// traced here: a `Point` cursor (the reference's highlightable no-op) and `unable` (the
|
||||
// range gray, which suppresses the send with no toast). Pairs with the `fx` kit lines, so
|
||||
// ONE run says which link is dead instead of one round-trip per link.
|
||||
if benilla_assets::trace::enabled_for("use") {
|
||||
let ty = hovered_object
|
||||
.target
|
||||
.and_then(|e| stores.get(e).ok())
|
||||
.map_or(-1, |(s, _)| s.0.gameobject_type_id());
|
||||
benilla_assets::trace::line(
|
||||
"use",
|
||||
&format!(
|
||||
"right-click go guid={:?} type={ty} cursor={:?} unable={}",
|
||||
hovered_object.guid.map(|g| format!("{g:#x}")),
|
||||
cursor.kind,
|
||||
cursor.unable
|
||||
),
|
||||
);
|
||||
}
|
||||
if cursor.kind != cursor_mode::CursorKind::Point {
|
||||
if let Some(guid) = hovered_object.guid {
|
||||
let go = hovered_object
|
||||
@@ -261,6 +283,12 @@ pub(super) fn act_on_right_click(
|
||||
GoAction::OpenLock(_) | GoAction::OpenByKey { .. } if cursor.unable => {}
|
||||
GoAction::Use => {
|
||||
debug!("right-click gameobject use: {guid:#x}");
|
||||
if benilla_assets::trace::enabled_for("use") {
|
||||
benilla_assets::trace::line(
|
||||
"use",
|
||||
&format!("SEND CMSG_GAMEOBJ_USE guid={guid:#x}"),
|
||||
);
|
||||
}
|
||||
let _ = seam.net.0.send(ClientCommand::GameObjUse { guid });
|
||||
}
|
||||
GoAction::OpenLock(spell_id) => {
|
||||
|
||||
@@ -172,7 +172,15 @@ impl CastLadder<'_, '_> {
|
||||
if commit.is_item() {
|
||||
self.pending.arm_item(spell_id, now);
|
||||
} else {
|
||||
self.pending.arm(spell_id, now);
|
||||
// `normal_cast`'s own predicate — a targeted-cursor commit is always an ordinary cast
|
||||
// in practice (a ranged shot never raises the cursor), but computing it keeps this
|
||||
// arm from drifting from the ladder's if the classes ever meet.
|
||||
let guards = self
|
||||
.spells
|
||||
.as_ref()
|
||||
.and_then(|s| s.catalog.get(spell_id))
|
||||
.is_none_or(|d| !d.ranged_attack() && !d.on_next_swing());
|
||||
self.pending.arm(spell_id, now, guards);
|
||||
}
|
||||
if let Some(d) = self.spells.as_ref().and_then(|s| s.catalog.get(spell_id)) {
|
||||
self.cooldowns.start_gcd(spell_id, d, now);
|
||||
@@ -632,16 +640,21 @@ fn send_spell_cast(
|
||||
},
|
||||
},
|
||||
});
|
||||
if normal_cast {
|
||||
// One inflight id, every cast source (`0xceca88`). The item arm's provisional is shorter
|
||||
// because `CMSG_USE_ITEM` has legs vmangos answers with `SMSG_INVENTORY_CHANGE_FAILURE`
|
||||
// and no cast result at all — [`crate::ui_cast::PendingCast`]'s own doc, decision 0908.
|
||||
if commit.is_item() {
|
||||
pending.arm_item(spell_id, now);
|
||||
} else {
|
||||
pending.arm(spell_id, now);
|
||||
}
|
||||
} else if on_next_swing {
|
||||
// **One inflight id, every cast source** (`0xceca88`, written at `0x6e5026` for every commit).
|
||||
// The item arm's provisional is shorter because `CMSG_USE_ITEM` has legs vmangos answers with
|
||||
// `SMSG_INVENTORY_CHANGE_FAILURE` and no cast result at all —
|
||||
// [`crate::ui_cast::PendingCast`]'s own doc, decision 0908.
|
||||
//
|
||||
// **Every** class is recorded; only `normal_cast` and the item arm *guard* (1601). This used
|
||||
// to be one thing: the record was armed only for the classes that refuse on it, so a ranged
|
||||
// shot — every hunter shot is `Attributes & 0x2` — left no trace of having been sent, and the
|
||||
// `modalNextSpell` chain's "is this reply mine?" test (`0x6e7408`) could never answer yes.
|
||||
if commit.is_item() {
|
||||
pending.arm_item(spell_id, now);
|
||||
} else {
|
||||
pending.arm(spell_id, now, normal_cast);
|
||||
}
|
||||
if on_next_swing {
|
||||
queued_melee.arm(spell_id);
|
||||
}
|
||||
// TryCast's post-send tail (`6e51b5`) — byte-verified whole by the 2026-07-14 wow-re §5
|
||||
@@ -773,6 +786,7 @@ mod tests {
|
||||
|
||||
const AUTO_SHOT: u32 = 75;
|
||||
const RAPTOR_STRIKE: u32 = 2973;
|
||||
const SERPENT_STING: u32 = 1978;
|
||||
const MOB: u64 = 0x0000_0000_0000_00f1;
|
||||
|
||||
/// The two combat-initiation classes exactly as the real 5875 rows classify them (the data
|
||||
@@ -794,15 +808,102 @@ mod tests {
|
||||
attributes: 0x404,
|
||||
..Default::default()
|
||||
};
|
||||
// The third class the seam has to keep apart: an instant hunter shot. Ranged slot
|
||||
// (`Attributes & 0x2`) like Auto Shot, but NOT auto-repeat — and it names Auto Shot in
|
||||
// `modalNextSpell` (1597).
|
||||
let serpent_sting = benilla_formats::SpellDisplay {
|
||||
targets: 0x2,
|
||||
attributes: 0x0001_0002,
|
||||
attributes_ex2: 0x0002_0000,
|
||||
modal_next_spell: AUTO_SHOT,
|
||||
..Default::default()
|
||||
};
|
||||
Spells {
|
||||
catalog: benilla_formats::SpellCatalog::from_displays(HashMap::from([
|
||||
(AUTO_SHOT, auto_shot),
|
||||
(RAPTOR_STRIKE, raptor_strike),
|
||||
(SERPENT_STING, serpent_sting),
|
||||
])),
|
||||
..Spells::empty_for_tests()
|
||||
}
|
||||
}
|
||||
|
||||
/// **A committed cast is recorded as committed — whatever its class** (`0xceca88` is written
|
||||
/// at `0x6e5026` for every commit; decision 1601).
|
||||
///
|
||||
/// This is the test whose absence shipped B280 broken **twice**. 1597 built the
|
||||
/// `modalNextSpell` chain and unit-tested it by arming the in-flight record **by hand** — so
|
||||
/// it passed while the real send path never armed it for a hunter shot at all
|
||||
/// (`normal_cast = !ranged_attack && !on_next_swing`, and every hunter shot is
|
||||
/// `Attributes & 0x2`). The chain's "is this reply mine?" test could not answer yes in play,
|
||||
/// and the fix looked green and did nothing. So this goes through [`send`], the real ladder.
|
||||
///
|
||||
/// The control is the other half: recorded is not the same as *guarding*. A ranged shot must
|
||||
/// not start refusing the next press with `0x61`, which is what arming the old field for it
|
||||
/// would have done.
|
||||
#[test]
|
||||
fn a_committed_ranged_shot_is_recorded_but_does_not_guard() {
|
||||
let (mut world, _rx) = combat_world(false);
|
||||
send_at(&mut world, SERPENT_STING, MOB);
|
||||
let now = Instant::now();
|
||||
let pending = world.resource::<crate::ui_cast::PendingCast>();
|
||||
assert_eq!(
|
||||
pending.committed(now),
|
||||
Some(SERPENT_STING),
|
||||
"the shot we just sent is the committed cast — this is what `0x6e7408` reads"
|
||||
);
|
||||
assert!(
|
||||
!pending.in_flight(now),
|
||||
"...and it does not occupy the refusal: a shot must not block the next press"
|
||||
);
|
||||
assert_eq!(
|
||||
pending.current(now),
|
||||
None,
|
||||
"...nor light the in-flight ring the ordinary casts drive"
|
||||
);
|
||||
|
||||
// An ordinary cast still does both, unchanged.
|
||||
let (mut world, _rx) = combat_world(false);
|
||||
send_at(&mut world, RAPTOR_STRIKE, MOB);
|
||||
let pending = world.resource::<crate::ui_cast::PendingCast>();
|
||||
assert_eq!(
|
||||
pending.committed(Instant::now()),
|
||||
Some(RAPTOR_STRIKE),
|
||||
"an on-next-swing strike is committed too"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The chained Auto Shot survives the ladder** — the wall after the one 1601 knocked down.
|
||||
/// Recording the sting as committed is only half: the chain then hands 75 back through
|
||||
/// [`CastLadder::send`], which re-runs every rung. If any of them refused it — the
|
||||
/// auto-repeat toggle, the in-flight guard the sting just armed, the GCD the sting just
|
||||
/// started — the chain would still end in silence, and silence is what the bug looked like.
|
||||
///
|
||||
/// So: send the sting, then send what its `modalNextSpell` names, and require the wire to
|
||||
/// carry it. The sting's own GCD is running by then (it is armed at the commit above), which
|
||||
/// is exactly the state the real chain fires in — Auto Shot's `StartRecoveryCategory` is 0
|
||||
/// against the GCD node's 133, so the getter's GCD leg cannot match it.
|
||||
#[test]
|
||||
fn the_chained_auto_shot_is_not_refused_by_the_sting_it_followed() {
|
||||
let (mut world, rx) = combat_world(false);
|
||||
send_at(&mut world, SERPENT_STING, MOB);
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == SERPENT_STING),
|
||||
"the sting goes out first"
|
||||
);
|
||||
// What `cast_result` would queue, handed to the one send path the drain uses.
|
||||
send_at(&mut world, AUTO_SHOT, MOB);
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == AUTO_SHOT),
|
||||
"and the chained Auto Shot goes out behind it — no rung eats it"
|
||||
);
|
||||
assert_eq!(
|
||||
world.resource::<AutoRepeatActive>().0,
|
||||
Some(AUTO_SHOT),
|
||||
"...and the commit arms the repeat, which is the whole observable"
|
||||
);
|
||||
}
|
||||
|
||||
/// [`world`] plus the catalog and the self player the commit tail needs — `engaged` is our
|
||||
/// server-echoed mirror of the ref's attack lock `[+0xc48]`.
|
||||
fn combat_world(engaged: bool) -> (World, Receiver<ClientCommand>) {
|
||||
|
||||
@@ -95,12 +95,45 @@ pub(crate) struct PendingCast(Option<PendingCastState>);
|
||||
struct PendingCastState {
|
||||
spell_id: u32,
|
||||
deadline: Instant,
|
||||
/// Does this record **guard** — i.e. does it refuse the next press, light the in-flight ring,
|
||||
/// and gate the by-name walk? True for the classes benilla lets occupy the guard: ordinary
|
||||
/// casts and item uses.
|
||||
///
|
||||
/// The reference has no such flag, because it has no such split: `[0xceca88]` is written for
|
||||
/// **every** committed cast (`0x6e5026 call 0x6e4ad0` inside TryCast) and the *gate* does the
|
||||
/// discriminating instead, on the inflight rec's `Attributes & 0x404` at `6e4d97`. Ours grew
|
||||
/// up the other way round — the guard is armed only for the classes it refuses on, so a
|
||||
/// ranged shot was never recorded as committed **at all**.
|
||||
///
|
||||
/// That gap is what shipped B280 broken twice: the `modalNextSpell` chain
|
||||
/// (`crate::net::apply::spells::cast_result`) has to answer the reference's `0x6e7408 cmp
|
||||
/// ecx,[0xceca88]` — "is this reply for the cast I just sent?" — and every hunter shot is
|
||||
/// `Attributes & 0x2` ranged, so the answer was always no and the chain never fired in play.
|
||||
/// The flag closes it without touching the three consumers that were right: the record is now
|
||||
/// armed for every commit ([`PendingCast::committed`] reads it), and `guards` keeps
|
||||
/// [`PendingCast::in_flight`] / [`PendingCast::current`] answering exactly what they did.
|
||||
guards: bool,
|
||||
}
|
||||
|
||||
impl PendingCast {
|
||||
/// Whether a cast we sent is still outstanding (unresolved and inside its safety deadline).
|
||||
/// Whether a **guarding** cast we sent is still outstanding (unresolved and inside its safety
|
||||
/// deadline) — the refusal's own question. A ranged shot is committed but does not guard.
|
||||
pub(crate) fn in_flight(&self, now: Instant) -> bool {
|
||||
self.0.as_ref().is_some_and(|p| now < p.deadline)
|
||||
self.0
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.guards && now < p.deadline)
|
||||
}
|
||||
|
||||
/// **The reference's `[0xceca88]` proper** — the id of the cast we last committed and have not
|
||||
/// yet had a resolution for, whatever its class. This is what `HandleCastResult 0x6e7330`
|
||||
/// tests at `0x6e7408` before it reads `modalNextSpell`, and the only read that must see a
|
||||
/// ranged shot. Everything else about the record is unchanged; see [`PendingCastState::guards`]
|
||||
/// for why the two questions are separate fields here and one global there.
|
||||
pub(crate) fn committed(&self, now: Instant) -> Option<u32> {
|
||||
self.0
|
||||
.as_ref()
|
||||
.filter(|p| now < p.deadline)
|
||||
.map(|p| p.spell_id)
|
||||
}
|
||||
|
||||
/// The outstanding cast's spell id, if one is in flight — the app-side mirror of the client's
|
||||
@@ -109,15 +142,18 @@ impl PendingCast {
|
||||
pub(crate) fn current(&self, now: Instant) -> Option<u32> {
|
||||
self.0
|
||||
.as_ref()
|
||||
.filter(|p| now < p.deadline)
|
||||
.filter(|p| p.guards && now < p.deadline)
|
||||
.map(|p| p.spell_id)
|
||||
}
|
||||
|
||||
/// Arm the guard on a fresh send (the optimistic `0xceca88` write at the client's cast-send).
|
||||
pub(crate) fn arm(&mut self, spell_id: u32, now: Instant) {
|
||||
/// Arm on a fresh send — the optimistic `0xceca88` write at the client's cast-send. `guards`
|
||||
/// says whether this class also occupies the refusal ([`PendingCastState::guards`]): ordinary
|
||||
/// casts do, a ranged shot is recorded and does not.
|
||||
pub(crate) fn arm(&mut self, spell_id: u32, now: Instant, guards: bool) {
|
||||
self.0 = Some(PendingCastState {
|
||||
spell_id,
|
||||
deadline: now + SEND_PROVISIONAL,
|
||||
guards,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,6 +169,7 @@ impl PendingCast {
|
||||
self.0 = Some(PendingCastState {
|
||||
spell_id,
|
||||
deadline: now + ITEM_SEND_PROVISIONAL,
|
||||
guards: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -720,7 +757,7 @@ mod tests {
|
||||
fn arming_closes_the_guard_and_a_matching_resolution_opens_it() {
|
||||
let t0 = Instant::now();
|
||||
let mut g = PendingCast::default();
|
||||
g.arm(FIREBALL, t0);
|
||||
g.arm(FIREBALL, t0, true);
|
||||
assert!(
|
||||
g.in_flight(t0),
|
||||
"a cast is in flight — a second one is refused"
|
||||
@@ -736,7 +773,7 @@ mod tests {
|
||||
fn a_different_spells_resolution_leaves_the_guard_closed() {
|
||||
let t0 = Instant::now();
|
||||
let mut g = PendingCast::default();
|
||||
g.arm(FIREBALL, t0);
|
||||
g.arm(FIREBALL, t0, true);
|
||||
g.clear_if(FIREBALL + 1); // a triggered proc's GO for some other spell mid-cast
|
||||
assert!(
|
||||
g.in_flight(t0),
|
||||
@@ -750,7 +787,7 @@ mod tests {
|
||||
// model): the guard cannot wedge casting shut forever.
|
||||
let t0 = Instant::now();
|
||||
let mut g = PendingCast::default();
|
||||
g.arm(FIREBALL, t0);
|
||||
g.arm(FIREBALL, t0, true);
|
||||
assert!(g.in_flight(t0 + SEND_PROVISIONAL - Duration::from_secs(1)));
|
||||
assert!(!g.in_flight(t0 + SEND_PROVISIONAL + Duration::from_secs(1)));
|
||||
}
|
||||
@@ -759,7 +796,7 @@ mod tests {
|
||||
fn a_pushback_extends_the_guard_so_it_holds_past_the_stretched_cast() {
|
||||
let t0 = Instant::now();
|
||||
let mut g = PendingCast::default();
|
||||
g.arm(FIREBALL, t0);
|
||||
g.arm(FIREBALL, t0, true);
|
||||
g.refine(1_000, t0); // a 1 s cast: deadline t0 + 1s + 2s slack = t0 + 3s
|
||||
g.delay(500, t0); // a hit at t0 pushes it +0.5s ⇒ t0 + 3.5s
|
||||
assert!(
|
||||
@@ -772,7 +809,7 @@ mod tests {
|
||||
fn spell_start_tightens_the_deadline_to_the_real_cast_time() {
|
||||
let t0 = Instant::now();
|
||||
let mut g = PendingCast::default();
|
||||
g.arm(FIREBALL, t0); // provisional 5s window
|
||||
g.arm(FIREBALL, t0, true); // provisional 5s window
|
||||
g.refine(1_500, t0); // SMSG_SPELL_START: a 1.5s cast (+ 2s pushback slack ⇒ 3.5s)
|
||||
let at_four = t0 + Duration::from_secs(4);
|
||||
assert!(
|
||||
@@ -884,7 +921,7 @@ mod tests {
|
||||
)]));
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(FIREBALL, Instant::now());
|
||||
.arm(FIREBALL, Instant::now(), true);
|
||||
mark_casting(&mut app, FIREBALL);
|
||||
|
||||
run(&mut app, true);
|
||||
@@ -939,7 +976,7 @@ mod tests {
|
||||
)]));
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(FIREBALL, Instant::now());
|
||||
.arm(FIREBALL, Instant::now(), true);
|
||||
|
||||
run(&mut app, true);
|
||||
|
||||
@@ -961,7 +998,7 @@ mod tests {
|
||||
let (mut app, rx) = harness(HashMap::new());
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(FIREBALL, Instant::now());
|
||||
.arm(FIREBALL, Instant::now(), true);
|
||||
|
||||
run(&mut app, true);
|
||||
|
||||
@@ -1016,7 +1053,7 @@ mod tests {
|
||||
)]));
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(FIREBALL, Instant::now());
|
||||
.arm(FIREBALL, Instant::now(), true);
|
||||
|
||||
run(&mut app, false);
|
||||
|
||||
@@ -1074,7 +1111,7 @@ mod tests {
|
||||
.0 = Some(AUTO_SHOT);
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(FIREBALL, Instant::now());
|
||||
.arm(FIREBALL, Instant::now(), true);
|
||||
mark_casting(&mut app, FIREBALL);
|
||||
|
||||
// Press 1: truthy mirror (auto-repeat ∪ cast) → trigger queued → the drain stops
|
||||
@@ -1234,7 +1271,7 @@ mod tests {
|
||||
.arm(HEROIC_STRIKE);
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(FIREBALL, Instant::now());
|
||||
.arm(FIREBALL, Instant::now(), true);
|
||||
mark_casting(&mut app, FIREBALL);
|
||||
|
||||
app.world_mut().run_system_once(feed_cast_bar).unwrap();
|
||||
|
||||
@@ -546,24 +546,22 @@ impl UiScript {
|
||||
// ── `OnMouseUp` goes to the frame that took the PRESS, not to whatever is under the
|
||||
// cursor now — the mouse CAPTURE (`root+0x80`).
|
||||
//
|
||||
// The client's mouse-DOWN handler `0x7662c0` "resolves target = root+0x80 (existing
|
||||
// capture) else root+0x7c (hover frame) … then title-region drag or **capture**+OnMouseDown"
|
||||
// (wow-re ledger `0x7662c0`, VERIFIED), so a press takes the pointer.
|
||||
// The client's mouse-DOWN handler `0x7662c0` takes the pointer: "resolves target =
|
||||
// root+0x80 (existing capture) else root+0x7c (hover frame) … then title-region drag or
|
||||
// **capture**+OnMouseDown" (wow-re ledger `0x7662c0`).
|
||||
//
|
||||
// ⚠ **The release half is INFERRED, not carved.** That the capture is what the *UP*
|
||||
// handler dispatches through is an extrapolation from the DOWN handler: the sibling
|
||||
// `0x766420` (category `0xe`) carries only a bulk wave-classify label in wow-re's ledger,
|
||||
// not a derivation. The inference is strong — a capture nothing consults on release would
|
||||
// be pointless, and something has to clear `root+0x80` — but it is an inference, and this
|
||||
// is a whole-UI change (every press-on-A-release-on-B in the client now delivers to A). A
|
||||
// §5 is dispatched for `0x766420`'s target resolution (decision 1594); when it lands this
|
||||
// comment says VERIFIED or this code changes.
|
||||
// The **release** half is `0x766420`, and it is VERIFIED — wow-re
|
||||
// `system/ui/scratch/mouseup-dispatch-law.md`, a §5 dispatched from this repo for exactly
|
||||
// this line after it first landed on an extrapolation (decision 1599). It reads the capture
|
||||
// and **nothing else**: `[mgr+0x80]` is snapshotted into `ebx` at entry (`0x76642b`),
|
||||
// before anything, and `[mgr+0x7c]` — the hover frame — is **never read** anywhere in
|
||||
// `[0x766420, 0x7664f0)`. One dispatch, `0x7664a4 call [eax+0x6c]`.
|
||||
//
|
||||
// We already track exactly the capture
|
||||
// — [`Model::mouse_down_on`], the press target per button, which `OnClick`'s same-frame
|
||||
// rule reads — and firing `OnMouseUp` at the *hit* frame instead was the one place it went
|
||||
// unused. This function's own doc has always said "on the captured frame"; the code did
|
||||
// not.
|
||||
// So a press on A released over B fires `OnMouseUp` on **A**, and B gets nothing on the
|
||||
// release edge. We already track exactly that capture — [`Model::mouse_down_on`], the press
|
||||
// target per button, which `OnClick`'s same-frame rule reads — and firing `OnMouseUp` at
|
||||
// the *hit* frame was the one place it went unused. This function's doc always said "on the
|
||||
// captured frame"; the code did not.
|
||||
//
|
||||
// It is not a nicety: a chat window's resize grip is a 16×16 button that the drag pulls
|
||||
// out from under the cursor the moment the size clamps at `SetMinResize`/`SetMaxResize`
|
||||
@@ -572,10 +570,20 @@ impl UiScript {
|
||||
// rest of the session — the stuck-drag failure `cursor::abandon_drag` exists to prevent on
|
||||
// the other path.
|
||||
//
|
||||
// A press that captured NOTHING (over the world, or swallowed by a title region) falls
|
||||
// back to the hit frame, which is `root+0x7c`'s arm of the same resolve.
|
||||
// **A press that captured NOTHING fires nothing**, and this is the half the first version
|
||||
// of this code got wrong: it fell back to the frame under the cursor, reading the DOWN
|
||||
// handler's `+0x80`-else-`+0x7c` precedence as if the UP handler shared it. It does not —
|
||||
// `0x766498 test ebx,ebx / je 0x7664cd` returns with no virtual call at all. The fallback
|
||||
// was a divergence that let a press over open space deliver a release to whatever the
|
||||
// cursor had wandered onto.
|
||||
//
|
||||
// (Two things the same §5 settled that are invisible from here: the capture is cleared at
|
||||
// `0x7664bb`, **after** the dispatch — so a C++ consumer inside `OnMouseUp` still sees it,
|
||||
// though no Lua binding reads it, `GetMouseFocus 0x48df40` being `+0x7c`; and the clear is
|
||||
// gated on `evt+0x18 == 0`, the button mask *after* the event, so a **chorded** release
|
||||
// fires `OnMouseUp` and keeps the capture for the button still held.)
|
||||
let script = if down { "OnMouseDown" } else { "OnMouseUp" };
|
||||
if let Some(id) = if down { hit_id } else { captured_id.or(hit_id) } {
|
||||
if let Some(id) = if down { hit_id } else { captured_id } {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, id, script, vec![btn.clone()]) {
|
||||
self.push_error(e);
|
||||
}
|
||||
|
||||
@@ -718,3 +718,100 @@ fn the_repick_re_hovers_without_a_mouse_move() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// The mouse-UP dispatch law (decision 1599) — byte-verified, wow-re `mouseup-dispatch-law.md`
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Two side-by-side frames, each recording every `OnMouseDown`/`OnMouseUp` it gets.
|
||||
fn two_frames() -> crate::script::UiScript {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
local function mk(name, x)
|
||||
local f = CreateFrame("Frame", name)
|
||||
f:SetPoint("BOTTOMLEFT", x, 200); f:SetSize(200, 200); f:EnableMouse(true)
|
||||
f:SetScript("OnMouseDown", function(self) table.insert(log, self:GetName()..":down") end)
|
||||
f:SetScript("OnMouseUp", function(self) table.insert(log, self:GetName()..":up") end)
|
||||
return f
|
||||
end
|
||||
mk("A", 100)
|
||||
mk("B", 400)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
fn log_of(s: &mut crate::script::UiScript) -> String {
|
||||
s.eval::<String>("return table.concat(log, ' ')").unwrap()
|
||||
}
|
||||
|
||||
/// **The release goes to the frame that took the press, wherever the cursor has got to.** The
|
||||
/// client's `0x766420` snapshots `[mgr+0x80]` — the capture — at entry (`0x76642b`) and dispatches
|
||||
/// through it alone; `[mgr+0x7c]`, the hover frame, is never read in the whole function. So B, which
|
||||
/// the cursor is over at release, gets nothing on that edge.
|
||||
///
|
||||
/// The concrete thing this buys: a chat window's 16×16 resize grip is pulled out from under the
|
||||
/// cursor the instant the size clamps at a resize bound, and its `OnMouseUp → FCF_StopResize` has
|
||||
/// to run anyway or the sizing drag is held for the rest of the session.
|
||||
#[test]
|
||||
fn the_release_goes_to_the_frame_that_took_the_press() {
|
||||
let mut s = two_frames();
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", true); // press on A
|
||||
s.mouse_move(500.0, 300.0); // ... cursor wanders onto B
|
||||
s.mouse_button(500.0, 300.0, "LeftButton", false); // ... and releases there
|
||||
assert_eq!(
|
||||
log_of(&mut s),
|
||||
"A:down A:up",
|
||||
"the capture takes the release; B gets nothing on that edge"
|
||||
);
|
||||
}
|
||||
|
||||
/// **A press that captured nothing fires nothing on release** — and this is the half that is easy
|
||||
/// to get wrong, because the DOWN handler *does* have a hover fallback (`root+0x80` else
|
||||
/// `root+0x7c`) and the UP handler does **not** share it: `0x766498 test ebx,ebx / je 0x7664cd`
|
||||
/// returns with no virtual call at all.
|
||||
///
|
||||
/// This shipped briefly as `captured.or(hit)` — a press over open space then delivered its release
|
||||
/// to whatever the cursor had wandered onto, which is a frame that never saw the press.
|
||||
#[test]
|
||||
fn a_press_that_captured_nothing_delivers_no_release() {
|
||||
let mut s = two_frames();
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true); // press over open space
|
||||
s.mouse_move(500.0, 300.0); // ... cursor onto B
|
||||
s.mouse_button(500.0, 300.0, "LeftButton", false);
|
||||
assert_eq!(
|
||||
log_of(&mut s),
|
||||
"",
|
||||
"no capture, no dispatch — the UP handler has no hover arm"
|
||||
);
|
||||
}
|
||||
|
||||
/// The ordinary case still behaves: press and release on the same frame is down-then-up on it.
|
||||
#[test]
|
||||
fn a_press_and_release_on_one_frame_is_unchanged() {
|
||||
let mut s = two_frames();
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", true);
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", false);
|
||||
assert_eq!(log_of(&mut s), "A:down A:up");
|
||||
}
|
||||
|
||||
/// The capture is **per button**, so a right-button release cannot be answered by a left-button
|
||||
/// press's capture — `Model::mouse_down_on` is keyed by button name, which is what makes this fall
|
||||
/// out rather than needing its own arm.
|
||||
#[test]
|
||||
fn the_capture_is_per_button() {
|
||||
let mut s = two_frames();
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", true); // A holds the LEFT button
|
||||
s.mouse_move(500.0, 300.0);
|
||||
s.mouse_button(500.0, 300.0, "RightButton", false); // a RIGHT release, never pressed
|
||||
assert_eq!(
|
||||
log_of(&mut s),
|
||||
"A:down",
|
||||
"the right button captured nothing, so its release dispatches nothing"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,7 +296,10 @@ impl CooldownState {
|
||||
/// `0x8116e8` = `{150,120,90,60,40,25}` yd). `GetZoom`/`SetZoom` read/write whichever is active, and
|
||||
/// each persists across transitions — so zooming in indoors does not disturb the outdoor zoom, and
|
||||
/// vice versa. (An earlier reading held the second index to be a rotate-minimap CVar and the interior
|
||||
/// scale to be a constant; both were wrong — superseded in wow-re.) `set_zoom` clamps to 5.
|
||||
/// scale to be a constant; both were wrong — superseded in wow-re. Stronger since the ping RE
|
||||
/// (`minimap-ping-law.md`): there is **no rotate-minimap CVar in 5875 at all** — a 214-site
|
||||
/// `CVar::Register` census plus a whole-image string scan found no such knob, so nothing on this
|
||||
/// path ever needs a rotation term.) `set_zoom` clamps to 5.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MinimapState {
|
||||
/// The **outdoor** zoom index, `0..MINIMAP_ZOOM_LEVELS` (0 = widest, 5 = tightest). `SetZoom`
|
||||
|
||||
Reference in New Issue
Block a user