/follow works end to end, and a parried swing rings out

- follow a player and the screen names who you're following, the right-click menu grows its Follow row, and your own movement is what cancels it — creatures are refused like the real client
- a locked lockbox click asks the server instead of refusing silently, a quest-starter chest offers its quest before opening, and an item on cooldown hides its green "open" line
- a parried swing plays its clang like the real client keys it
- the README's What works / Not built yet caught up on a week of shipping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-03 17:51:08 +07:00
co-authored by Claude Fable 5
parent 94de741cb1
commit f68c0cd958
20 changed files with 1467 additions and 332 deletions
+19 -11
View File
@@ -19,26 +19,34 @@ crates, and no bundled game assets: benilla reads its data at runtime from your
- **Models & animation** — GPU-skinned M2s with the full animation controller (cross-fades,
one-shots, fidgets), a near feature-complete particle system, ribbons, and animated gameobjects
(doors, chests, mailboxes, lifts).
- **Characters** — customization end to end, the armor-region texture composite, held weapons and
sheathing, and mounts.
- **Characters** — customization end to end, the armor-region texture composite, held weapons with
sheathing and enchant glows, shapeshift forms, stealth's fade, and mounts.
- **Movement & travel** — a WoW-feel controller (run/strafe/jump/swim/step-up), networked movement
in both directions, a follow-camera with collision, ridden boats and zeppelins, and taxi flights.
in both directions, the server-granted modes (slow fall, levitate, water walking, roots and
stuns), a follow-camera with collision, ridden boats and zeppelins, and taxi flights.
- **Networking** — SRP6 auth through world-session crypto, the object-descriptor mirror into the
ECS, and live wire coverage for movement, chat, spells and auras, party, mail, trade, vendors,
bank, trainers, quests, loot with group rolls, and death.
ECS, and live wire coverage for movement, chat, spells and auras, party, duels, inspect, the
social lists, mail, trade, vendors, bank, trainers, professions, quests, loot with group rolls,
item use and locks, and death.
- **UI** — a from-scratch FrameXML + Lua UI engine driving the built-in interface: login and
character screens, the full HUD, the classic windows (bags, spellbook, talents, quests, mail,
merchant, trade, bank, world map, …), chat, and tooltips.
character screens, the full HUD (cast bars, breath and fatigue timers, combo points), the classic
windows (bags and keyring, spellbook, talents, quests, professions, social, inspect, mail,
merchant, trade, bank, world map, …), chat with its slash commands, nameplates, floating combat
text, chat bubbles, and tooltips.
- **Combat** — melee with the faithful swing law, ranged and Auto Shot, casting with GCD, cooldowns
and the cancel ladder, and the spell-visual pipeline (kits, missiles, impacts, ground rings).
and the cancel ladder, combo points, ground-targeted casting, crowd control that really holds you
(roots, stuns, the Ice Block freeze), and the spell-visual pipeline (kits, missiles, impacts,
ground rings).
- **Audio** — music, ambience, and SFX under the client's own selection and crossfade rules, with
interior/underwater transitions and zone reverb.
## Not built yet
Guilds, the auction house, hunter/warlock pets, duels and inspect, the social panel
(friends/who), PvP systems, the macros/options/key-binding screens, and third-party addon
loading — the Lua/FrameXML engine currently runs only the built-in UI.
Guilds (past the name over a head), the auction house, hunter/warlock pet control (the pet bar),
battlegrounds, honor and the zone-control PvP overlays (the PvP flag itself shows), the fishing
minigame, camera-possession spells (Mind Vision, Sentry Totem), the try-on preview (dressing
room), the macros/options/key-binding screens, and third-party addon loading — the Lua/FrameXML
engine currently runs only the built-in UI.
## Running it
+41 -15
View File
@@ -192,25 +192,51 @@ impl ItemInfo {
self.use_spell.is_some() || self.inventory_type != 0
}
/// Does a right-click **open** this concrete instance rather than use it? `instance_flags` is
/// the item object's `ITEM_FIELD_FLAGS` (0 for a template-only view, which can never be
/// openable — you can't right-click a hyperlink).
/// Does the tooltip put the green `<Right Click to Open>` line on this instance?
/// `instance_flags` is the item object's `ITEM_FIELD_FLAGS`; a template-only view (a
/// hyperlink, a merchant row) passes 0 and, being object-less, gets no line at all — the
/// reference's own gate 1 at `0x52e2e0`.
///
/// The one predicate behind both halves of the affordance: the tooltip's green
/// `<Right Click to Open>` line and the `CMSG_OPEN_ITEM` fork. It is the reference's own
/// tooltip condition (wow-re `tooltip-content-law.md` §1-OPENABLE, byte-read at
/// `0x52e2f8`–`0x52e321`): the template LOOTABLE bit [`ITEM_FLAG_LOOTABLE`] behind its lock
/// sub-gate — a [`ItemInfo::lock_id`] item only opens once the instance carries
/// VERIFIED wow-re `right-click-open.md` §1.4 (`0x52e2f8`–`0x52e321`), re-derived by a §5 pair
/// 2026-08-02: the template LOOTABLE bit [`ITEM_FLAG_LOOTABLE`] behind its **lock sub-gate** —
/// a [`ItemInfo::lock_id`] item earns the line only once the instance carries
/// [`ITEM_DYNFLAG_UNLOCKED`] — **or** a wrapped gift ([`ITEM_FLAG_WRAPPER`] on the template
/// plus [`ITEM_DYNFLAG_WRAPPED`] on the instance). vmangos's `HandleOpenItemOpcode` gates on
/// exactly the same pair, so the two ends agree: what the tooltip promises is what the server
/// will honour, and a still-locked junkbox neither advertises the line nor sends the packet.
pub fn openable(&self, instance_flags: u32) -> bool {
/// plus [`ITEM_DYNFLAG_WRAPPED`] on the instance).
///
/// **This is deliberately NOT the send condition** ([`Self::opens_loot`]). The line is a
/// promise, and the reference only makes it when opening will actually work; the *click*
/// tests the bare template bit and lets the server refuse a still-locked box with its own
/// error line. Decision 0896 — an earlier reading of ours collapsed the two, which would have
/// silently swallowed the click on every locked junkbox.
pub fn shows_open_line(&self, instance_flags: u32) -> bool {
let lootable = self.flags & ITEM_FLAG_LOOTABLE != 0
&& (self.lock_id == 0 || instance_flags & ITEM_DYNFLAG_UNLOCKED != 0);
let gift =
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED != 0;
lootable || gift
lootable || self.unwraps_gift(instance_flags)
}
/// Does a right-click on this instance send `CMSG_OPEN_ITEM` to **unwrap a gift**? The first
/// arm of the reference's use dispatcher (`0x5d8d00` #2, `0x5d8d92`/`0x5d8d9d` → the `0x5edd60`
/// emitter): template [`ITEM_FLAG_WRAPPER`] **and** instance [`ITEM_DYNFLAG_WRAPPED`].
///
/// It is a separate predicate from [`Self::opens_loot`] because it sits at a different point
/// in the dispatcher's order — *before* the quest-starter and readable arms, where the loot
/// arm sits after them. A wrapper template whose instance is no longer wrapped takes the
/// begin-wrap cursor path instead (local, no packet; not built yet).
pub fn unwraps_gift(&self, instance_flags: u32) -> bool {
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED != 0
}
/// Does a right-click on this template send `CMSG_OPEN_ITEM` to **loot it open**? The
/// reference's arm #8 (`0x5d8f7c: test al,4` → the `0x5edc80` emitter): a **bare** template
/// [`ITEM_FLAG_LOOTABLE`] test.
///
/// VERIFIED wow-re `right-click-open.md` §3, positively *and* negatively — no `LockID`
/// (`[rec+0x1ac]`) operand exists anywhere on the send path, checked against a positive
/// control. So a **still-locked junkbox does send the packet**, and the server answers
/// `EQUIP_ERR_ITEM_LOCKED`, which is where the player's "Item is locked" line comes from.
/// Refusing locally instead would eat the click in silence.
pub fn opens_loot(&self) -> bool {
self.flags & ITEM_FLAG_LOOTABLE != 0
}
}
+91
View File
@@ -0,0 +1,91 @@
//! The auto-follow **Era API surface** — two globals, no state.
//!
//! [`super::duel`]'s shape exactly: everything the UI *reads* about a follow arrives as an event
//! argument (`AUTOFOLLOW_BEGIN`'s name), so there is no snapshot to push — only the outbound half.
//! Each call queues a [`FollowRequest`] the app drains ([`super::UiScript::take_follow_requests`])
//! and turns into its own follow start, keeping the engine free of ECS reach (decision 0068 §3).
//!
//! The two are the reference's own follow bindings, and between them they cover every shipped call
//! site in the 1.12 FrameXML:
//!
//! | binding | called by | subject |
//! |---|---|---|
//! | `FollowUnit(unit)` | `SlashCmdList["FOLLOW"]` with no argument | a unit token |
//! | `FollowByName(name, exactMatch)` | `SlashCmdList["FOLLOW"]` with one; `UnitPopup_OnClick`'s FOLLOW row | a name |
//!
//! `FollowByName` is `0x489ec0`, one more caller of the shared by-name resolver `0x493aa0` —
//! typemask `0x10` (players only) and filter mode 2 (`CanAssist` + alive), with **the second Lua
//! argument steering exact-only matching** (decision 0886's table). That argument is what makes the
//! popup row different from the slash command: `UnitPopup.lua` passes `FollowByName(name, 1)`, so
//! clicking *Follow* on a menu that already names the unit exactly must not prefix-match its way
//! onto somebody else standing nearby, while `/follow rag` still may.
//!
//! benilla parses slash lines in Rust rather than in Lua (decision 0881), so the chat half of that
//! table enters the app's follow funnel directly; these globals are the *other* half — the shipped
//! UI's, and any addon's.
use mlua::Lua;
use super::Model;
/// Outbound follow intents queued by the Era API calls, drained by the app
/// ([`super::UiScript::take_follow_requests`]). Plain data — [`super::duel::DuelRequest`]'s twin.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FollowRequest {
/// `FollowUnit(unit)` — follow whoever a unit token points at. The app resolves the token;
/// `"target"` is the bare `/follow`, and takes the selection **whatever it is** (the reference
/// applies no typemask on this path — decision 0890).
ByUnit(String),
/// `FollowByName(name, exactMatch)` — follow a player found by name. `exact` is the second Lua
/// argument: set, it skips the resolver's longest-common-prefix tier and admits only a
/// whole-string (case-insensitive) hit.
ByName { name: String, exact: bool },
}
impl super::UiScript {
/// Drain the follow intents queued since the last call.
pub fn take_follow_requests(&mut self) -> Vec<FollowRequest> {
std::mem::take(&mut self.model_mut().follow_requests)
}
}
/// Register the follow globals (the same style/place [`super::duel`] registers its own).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// FollowUnit(unit) — `SlashCmdList["FOLLOW"]`'s no-argument arm, `FollowUnit("target")`.
g.set(
"FollowUnit",
lua.create_function(|lua, unit: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.follow_requests.push(FollowRequest::ByUnit(unit));
Ok(())
})?,
)?;
// FollowByName(name, exactMatch) — the unit popup's Follow row passes `1`; the slash command
// passes nothing, which is the prefix-matching case (`nil` is false here, as in the ref, where
// the argument reaches `0x493aa0` as a plain zero).
g.set(
"FollowByName",
lua.create_function(|lua, (name, exact): (String, Option<mlua::Value>)| {
// Absent is the slash command's case; the popup row passes `1`. A numeric zero counts
// as false as well — no shipped caller passes one, and treating it that way is the
// reading both plausible arg-2 fetches on the C side (`lua_toboolean` vs a number
// compare) agree on for every value that can actually arrive here.
let exact = match exact {
None | Some(mlua::Value::Nil) | Some(mlua::Value::Boolean(false)) => false,
Some(mlua::Value::Integer(n)) => n != 0,
Some(mlua::Value::Number(n)) => n != 0.0,
Some(_) => true,
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.follow_requests
.push(FollowRequest::ByName { name, exact });
Ok(())
})?,
)?;
Ok(())
}
+3
View File
@@ -53,6 +53,7 @@ pub(crate) use editbox::adopt_text_region;
mod duel;
mod event;
mod extract;
mod follow;
mod gossip;
mod inspect;
mod item_stats;
@@ -111,6 +112,7 @@ pub use craft::{CraftReagent, CraftRecipe, CraftState};
pub use cursor::{CursorAction, CursorItem, CursorPayload, CursorSpell, WorldPick, EQUIPMENT_BAG};
pub use death::{DeathAction, DeathUiState};
pub use duel::DuelRequest;
pub use follow::FollowRequest;
pub use gossip::{GossipMenu, GossipOptionView, GossipQuestRow};
pub use inspect::InspectView;
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
@@ -257,6 +259,7 @@ impl UiScript {
party::install(&lua)?;
social::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
session::install(&lua)?;
pvp::install(&lua)?;
death::install(&lua)?;
+9 -3
View File
@@ -4,9 +4,9 @@ use crate::layout::{LayoutInput, LayoutSolver, Rect};
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, gossip, inspect, item_text,
loot, loot_roll, mail, merchant, party, quest, quest_log, session, skills, slider, social,
spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, FontObject,
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect,
item_text, loot, loot_roll, mail, merchant, party, quest, quest_log, session, skills, slider,
social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, FontObject,
ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
};
@@ -180,6 +180,11 @@ pub(crate) struct Model {
/// [`super::UiScript::take_duel_requests`] drain — the outbound seam ([`duel`]). There is no
/// duel *snapshot* beside it: everything the UI reads arrives as event arguments.
pub(crate) duel_requests: Vec<duel::DuelRequest>,
/// Follow intents (`FollowUnit`/`FollowByName`) queued since the app's last
/// [`super::UiScript::take_follow_requests`] drain — the outbound seam ([`follow`]). The duel
/// queue's twin, snapshot-less for the same reason: the only thing the UI reads back is the
/// `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` pair the app fires, which carries its own argument.
pub(crate) follow_requests: Vec<follow::FollowRequest>,
/// Session-exit intents (`Logout`/`Quit`/`CancelLogout`/`ForceQuit`) queued since the app's
/// last [`super::UiScript::take_session_requests`] drain — the outbound seam ([`session`]).
/// Snapshot-less like the duel queue above: what the UI reads back (the camp/quit countdown)
@@ -707,6 +712,7 @@ impl Model {
social_requests: Vec::new(),
tell_requests: Vec::new(),
duel_requests: Vec::new(),
follow_requests: Vec::new(),
session_requests: Vec::new(),
pvp_toggles: 0,
sound_queue: Vec::new(),
@@ -426,8 +426,10 @@ fn required_level_one_is_hidden() {
/// then READABLE off the instance text id (its template's PageText is 0 — the
/// director-reported gap). An unresolved creator (name query in flight) emits no line. LOCKED
/// yields to the instance UNLOCKED bit — which is also what un-gates ITEM_OPENABLE: a bag hover
/// on an openable instance DOES show the green `<Right Click to Open>` (director-observed on a
/// clam; see `ItemInstance::openable_source` for why this overrides §1-OPENABLE's p6 reading).
/// on an openable instance shows the green `<Right Click to Open>` (director-observed on a clam;
/// wow-re `right-click-open.md` §1 re-derived the `p6` leg selector that had it suppressed).
/// A **running cooldown** takes SetBagItem's other leg and suppresses the line — the two are
/// structurally exclusive on this binding (decision 0896).
#[test]
fn instance_tail_creator_and_readable() {
let mut s = script();
@@ -474,11 +476,21 @@ fn instance_tail_creator_and_readable() {
slots.insert(3, slot(2589, false, Some("Geoffrey"), 0)); // crafted, still locked
slots.insert(4, slot(2589, false, None, 0x4)); // unlocked → LOCKED gone, open line on
slots.insert(5, slot(7973, false, None, 0)); // the clam: lockless, openable outright
// The same clam with a RUNNING cooldown — `SetBagItem`'s p6=1 leg, which skips the openable
// tree entirely (the ITEM_COOLDOWN_TIME line takes its place in the reference; that line has
// no feed here yet, so this slot renders bare).
slots.insert(
6,
ContainerSlot {
cooldown: Some((1_000, 30_000, true)),
..slot(7973, false, None, 0)
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 5,
num_slots: 6,
slots,
}),
);
@@ -526,5 +538,18 @@ fn instance_tail_creator_and_readable() {
.unwrap();
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
assert_eq!(texts, vec!["Small Barnacled Clam", "<Right Click to Open>"]);
// The same clam mid-cooldown takes SetBagItem's OTHER leg (p6=1) — the openable tree is
// skipped wholesale, so the green line is gone (the reference prints ITEM_COOLDOWN_TIME in
// its place; unfed here). `hasCooldown`, the binding's own return, is the same boolean.
let has_cd: bool = s
.eval(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); return TT:SetBagItem(0, 6)"#)
.unwrap();
assert!(has_cd, "the cooldown leg is what the return value reports");
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
assert_eq!(
texts,
vec!["Small Barnacled Clam"],
"a running cooldown suppresses ITEM_OPENABLE — the two lines are exclusive here"
);
assert!(s.take_errors().is_empty());
}
@@ -309,10 +309,13 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
creator: s.creator.clone(),
has_text: false,
flags: s.flags,
// Unobservable either way: nothing openable is equippable, so the
// doll hover has no case to show. Left `false` (wow-re
// §1-OPENABLE's p6 reading) rather than flipped on the bag's
// evidence — see `ItemInstance::openable_source`.
// `SetInventoryItem 0x532ee0` also has p6=0 legs (`0x533106`,
// `0x5332ad`) — the "this binding can never emit OPENABLE" claim
// is dead here too (wow-re `right-click-open.md` §1.2). Which leg
// each takes is not pinned, and the case is unobservable anyway:
// nothing openable is equippable, so the doll hover has no clam to
// show. Left `false` deliberately — inventing a selector we have
// not read would be the §4 trade, and there is nothing to gain.
openable_source: false,
},
)
@@ -367,24 +370,33 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
.and_then(|c| c.slots.get(&slot))
.filter(|s| s.item_id != 0)
{
Some(s) => (
s.item_id,
s.count.max(1),
s.cooldown.is_some_and(|(rem, _, en)| en && rem > 0),
s.link.clone(),
s.quality.unwrap_or(1),
render::ItemInstance {
durability: s.durability,
creator: s.creator.clone(),
has_text: s.readable,
flags: s.flags,
// The bag hover DOES emit `<Right Click to Open>` — director-observed
// (a clam in a bag), against wow-re §1-OPENABLE's p6 reading. See
// `ItemInstance::openable_source`: that gate is under re-derivation,
// and the observation is the ground truth we build on.
openable_source: true,
},
),
// The reference's own leg selector, byte-read: `SetBagItem 0x534620` asks the
// item-cooldown query `0x6e2ed0` and takes the p6=1 (instance-block) leg iff
// **all three** of enable/start/duration are non-zero — a genuinely running
// cooldown. That one boolean is both the Lua `hasCooldown` return and the
// openable gate's inverse, so they are computed once, here (decision 0896).
Some(s) => {
let has_cd = s
.cooldown
.is_some_and(|(start, dur, en)| en && start > 0 && dur > 0);
(
s.item_id,
s.count.max(1),
has_cd,
s.link.clone(),
s.quality.unwrap_or(1),
render::ItemInstance {
durability: s.durability,
creator: s.creator.clone(),
has_text: s.readable,
flags: s.flags,
// p6 == 0 ⇔ no running cooldown. A clam shows the green line; the
// same clam mid-cooldown would show ITEM_COOLDOWN_TIME instead
// (that line has no feed here yet — a separate, pre-existing gap).
openable_source: !has_cd,
},
)
}
None => return Ok(MultiValue::from_vec(vec![Value::Nil])),
}
};
@@ -29,24 +29,25 @@ pub(super) struct ItemInstance {
/// Instance `ITEM_FIELD_FLAGS` — the openable lock sub-gate reads UNLOCKED `0x4`
/// (`0x52e30c`) and the wrapped-gift arm WRAPPED `0x8` (`0x52e31d`).
pub flags: u32,
/// May this source emit the ITEM_OPENABLE line at all?
/// May this source emit the ITEM_OPENABLE line? = the reference's `p6 == 0` — "this tooltip
/// carries **no** caller-supplied instance block" (`[this+0x440]`, tested at `0x52e2e8`: when
/// it is set, the builder evaluates only READABLE and skips the openable tree entirely).
///
/// wow-re §1-OPENABLE (byte-census 2026-07-20) reads a `[this+0x440]` gate at `0x52e2e8` as
/// bypassing the whole openable tree for every `p6=1` binding, and concludes that
/// `SetBagItem` — the binding your own bags hover through — can NEVER emit it. **That
/// conclusion is contradicted by ground truth:** the director's screenshot shows a Small
/// Barnacled Clam hovered IN A BAG with the green `<Right Click to Open>` line, and vmangos's
/// own `ITEM_FLAG_LOOTABLE` comment names that exact client behaviour ("It or lockid set
/// enable for client show 'Right click to open'"). A line that could only ever appear on the
/// send-mail / auction-sell / buyback tooltips would also be behaviourally absurd. The note is
/// additionally self-inconsistent there (§1-CREATOR puts the GIFTCREATOR read at `0x52e32a`,
/// inside the region §1-OPENABLE calls the openable emit + jump), so the transcription — not
/// the observation — is the suspect. A wow-re §5 re-derivation of `[0x52e2e0, 0x52e376)` is in
/// flight; until it lands, the bag hover follows the director's eyes.
/// **This is not a per-binding constant.** wow-re's earlier §1-OPENABLE said `SetBagItem`
/// passes `p6=1` and so could *never* show the line — which is why our bag hover had no green
/// line at all, and which the director's screenshot of a clam falsified. The re-derivation
/// (wow-re `right-click-open.md` §1, §5 pair 2026-08-02) found the cause: the old p6 table was
/// enumerated per *binding* from the instance-block **writers**, so it only ever saw the p6=1
/// leg. A per-**call-site** census of all 31 `0x52b650` sites finds five callers with two or
/// more legs, and `SetBagItem 0x534620` is one: `0x534900` p6=1 and `0x53493e` p6=0.
///
/// The flag is kept rather than deleted because the *template-vs-instance* distinction it also
/// carries is settled (gate 1, `0x52e2e0`: no object ⇒ no line — you can't right-click a
/// hyperlink), and because the p6 split may still be real for the sources we cannot observe.
/// What selects the leg is the item-cooldown query `0x6e2ed0` at `0x53483a` — p6=1 iff
/// **enable, start and duration are all non-zero**, i.e. iff the item has a *running
/// cooldown*. That same block writes `this+0x408 = start + duration − now`, whose sole
/// consumer is the ITEM_COOLDOWN_TIME line and the builder's `hasCooldown` return. So on the
/// bag binding the two lines are structurally exclusive: **an item on cooldown shows
/// "Cooldown remaining", the same item off cooldown shows `<Right Click to Open>`.**
/// Decision 0896.
pub openable_source: bool,
}
@@ -449,12 +450,18 @@ pub(super) fn render_view(
}
}
}
// ITEM_OPENABLE / ITEM_READABLE — ONE line, openable wins (the if/else at
// `0x52e2f2..0x52e35d`, wow-re §1-OPENABLE): openable = a p6=0 source AND the template
// loot flag `0x4` behind its lock sub-gate (LockID set → only once the instance carries
// UNLOCKED `0x4`), or a wrapped gift (template WRAPPER `0x200` + instance WRAPPED `0x8`);
// readable = template PageText (the CGItem vtable `+0x74` getter `0x5d9e10`) OR the
// instance letter text. Both green.
// ITEM_OPENABLE / ITEM_READABLE — ONE line, openable wins outright (the `jmp 0x52e35d` past
// the READABLE test; `0x52e2f2..0x52e35d`, wow-re `right-click-open.md` §1.4, re-verified
// unchanged by the 2026-08-02 §5 pair): openable = a p6=0 source ([`ItemInstance::
// openable_source`]) AND the template loot flag `0x4` behind its lock sub-gate (LockID set →
// only once the instance carries UNLOCKED `0x4`), or a wrapped gift (template WRAPPER `0x200`
// + instance WRAPPED `0x8`); readable = template PageText (the CGItem vtable `+0x74` getter
// `0x5d9e10`) OR the instance letter text. Both green.
//
// Note the lock sub-gate is the LINE's, not the CLICK's: the send arm tests the bare template
// bit, so a locked junkbox stays silent here while its right-click still goes out and draws
// the server's "Item is locked" (decision 0896, `ItemInfo::opens_loot`). The click order is
// also the inverse of this one — READABLE wins there.
let openable = inst.openable_source
&& ((v.flags & 0x4 != 0 && (v.lock_id == 0 || inst.flags & 0x4 != 0))
|| (v.flags & 0x200 != 0 && inst.flags & 0x8 != 0));
+15 -6
View File
@@ -16,10 +16,13 @@
exactly zero times — so the row was a deviation that made this menu read wrong against the
reference. `/pvp` is the toggle the client has.
- DEFERRED ENTRIES: rows whose backing system doesn't exist yet are HIDDEN outright (the
BENILLA_UNITPOPUP_DEFERRED set in HideButtons) rather than shown-dead: FOLLOW (its CMSG
family is its own slice), TARGET (the friends list), GUILD_PROMOTE/GUILD_LEAVE (the guild
arc), RESET_INSTANCES (instances). TRADE came out with decision 0592 P1, DUEL with 0633,
INSPECT with 0631. Everything a menu SHOWS works. The PET menu and the RAID_* rank rows stay
BENILLA_UNITPOPUP_DEFERRED set in HideButtons) rather than shown-dead: TARGET (the friends
list), GUILD_PROMOTE/GUILD_LEAVE (the guild arc), RESET_INSTANCES (instances). TRADE came out
with decision 0592 P1, DUEL with 0633, INSPECT with 0631, FOLLOW with 0890's follow-up — and
that last entry's stated reason ("its CMSG family is its own slice") was simply WRONG: 0890
verified auto-follow sends nothing at all, it is pure client-side movement, so the row was
waiting on a wire slice that does not exist. It was the motion law it really needed.
Everything a menu SHOWS works. The PET menu and the RAID_* rank rows stay
in the tables but are unreachable (no "pet" token, no raid roster) — their OnClick branches
are trimmed with the rest.
- Lua 5.1: the ref's 5.0 `for i, v in t do` iteration is ipairs/pairs here; strsub → the
@@ -131,7 +134,7 @@
-- The DEFERRED set (header): entries hidden until their backing arc lands.
BENILLA_UNITPOPUP_DEFERRED = {
FOLLOW = 1, TARGET = 1,
TARGET = 1,
GUILD_PROMOTE = 1, GUILD_LEAVE = 1, RESET_INSTANCES = 1,
};
@@ -567,8 +570,14 @@
-- Open the inspect window (decision 0631) — the ref UnitPopup_OnClick's INSPECT arm
-- verbatim: InspectUnit(unit), which gates on CanInspect and sends CMSG_INSPECT.
InspectUnit(unit);
elseif ( button == "FOLLOW" ) then
-- Begin auto-following — the ref UnitPopup_OnClick's FOLLOW arm verbatim, by NAME
-- and EXACT. That `1` is the resolver's exact-only flag, and it matters: this menu
-- already knows the unit's name to the letter, so unlike `/follow rag` it must not
-- prefix-match its way onto a bystander. Client-side movement only, no wire.
FollowByName(name, 1);
end
-- DEFERRED branches trimmed with their rows (header): TARGET/FOLLOW,
-- DEFERRED branches trimmed with their rows (header): TARGET,
-- GUILD_*, RESET_INSTANCES, PET_*, RAID_LEADER/PROMOTE/DEMOTE/REMOVE.
PlaySound("UChatScrollButton");
end
+101 -5
View File
@@ -7,11 +7,13 @@ by the FadingFrame kit when the app fires the ZONE_CHANGED family off the player
(`area.rs`).
Quoted from ref-ZoneText.xml as the live 5875 patch chain ships it (patch.MPQ overrides the
interface.MPQ copy — decision 0246), reduced by ONE frame: AutoFollowStatus (the /follow status
text, the only thing ref-ZoneText.lua actually contains) stays behind until follow support
exists. The FadingFrame kit (ref-FadingFrame.lua, its own TOC line right before ZoneText.xml)
is inlined verbatim below instead: ZoneTextFrame/SubZoneTextFrame are its only consumers in the
whole 1.12 FrameXML (grep-verified over the extraction), so it rides with them.
interface.MPQ copy — decision 0246) — now all THREE of its frames, the third being
AutoFollowStatus (the /follow status text, and the only thing ref-ZoneText.lua actually
contains), which waited here until follow support existed and no longer does. The FadingFrame
kit (ref-FadingFrame.lua, its own TOC line right before ZoneText.xml) is inlined verbatim below
too: ZoneTextFrame/SubZoneTextFrame are its only consumers in the whole 1.12 FrameXML
(grep-verified over the extraction), so it rides with them. AutoFollowStatus is NOT one of
them — it rolls its own fade, which is why the kit stays a two-frame affair.
The subtle law worth naming (it looks like a bug and is not): SubZoneTextString — a child of
SubZoneTextFrame — anchors to regions of the *other* frame (ZoneTextString / PVPInfoTextString),
@@ -28,6 +30,15 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
CONTESTED_TERRITORY = CONTESTED_TERRITORY or "Contested Territory";
FACTION_CONTROLLED_TERRITORY = FACTION_CONTROLLED_TERRITORY or "%s Territory"; -- Faction name
FREE_FOR_ALL_TERRITORY = FREE_FOR_ALL_TERRITORY or "PvP Area";
AUTOFOLLOWSTART = AUTOFOLLOWSTART or "Following %s.";
AUTOFOLLOWSTOP = AUTOFOLLOWSTOP or "You stop following %s.";
-- The ref's localization identity (UIParent.lua), so the AutoFollowStatus body below can
-- stay verbatim. Guarded like everything else here rather than leaning on UnitPopup.xml
-- happening to load first — a one-line helper is no place to acquire a load-order edge.
if ( not TEXT ) then
TEXT = function(s) return s end
end
-- ── ref-FadingFrame.lua, verbatim ──────────────────────────────────────────────────────
function FadingFrame_SetFadeInTime(this, time)
@@ -114,6 +125,49 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
SubZoneTextString:SetPoint("TOP", "ZoneTextString", "BOTTOM", 0, 0);
end
end
-- ── ref-ZoneText.lua, verbatim ─────────────────────────────────────────────────────────
-- The auto-follow status line. Note it does NOT use the FadingFrame kit above: its own
-- OnUpdate rolls a plain linear alpha fade with no fade-in and no hold, started only on
-- AUTOFOLLOW_END. BEGIN is a full reset (fadeTime cleared, alpha restored, shown), which
-- is why a follow that switches subject needs no END first.
AUTOFOLLOW_STATUS_FADETIME = 4.0;
function AutoFollowStatus_OnLoad()
this:RegisterEvent("AUTOFOLLOW_BEGIN");
this:RegisterEvent("AUTOFOLLOW_END");
this:RegisterEvent("PLAYER_ENTERING_WORLD");
end
function AutoFollowStatus_OnEvent(event)
if ( event == "AUTOFOLLOW_BEGIN" ) then
this.unit = arg1;
this.fadeTime = nil;
this:SetAlpha(1.0);
AutoFollowStatusText:SetText(format(TEXT(AUTOFOLLOWSTART),this.unit));
this:Show();
end
if ( event == "AUTOFOLLOW_END" ) then
this.fadeTime = AUTOFOLLOW_STATUS_FADETIME;
AutoFollowStatusText:SetText(format(TEXT(AUTOFOLLOWSTOP),this.unit));
this:Show();
end
if ( event == "PLAYER_ENTERING_WORLD" ) then
this:Hide();
end
end
function AutoFollowStatus_OnUpdate(elapsed)
if( this.fadeTime ) then
if( elapsed >= this.fadeTime ) then
this:Hide();
else
this.fadeTime = this.fadeTime - elapsed;
local alpha = this.fadeTime / AUTOFOLLOW_STATUS_FADETIME;
this:SetAlpha(alpha);
end
end
end
]]></Script>
<!-- These frames are shown when the current zone or subzone changes.
(ref carries toplevel="true" on both; dropped here — SetToplevel is a v1 object-model
@@ -238,4 +292,46 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
</OnUpdate>
</Scripts>
</Frame>
<!-- The auto-follow status line, quoted from ref-ZoneText.xml. The only thing ref-ZoneText.lua
actually contains, and the reason that .lua exists at all; it waited here until follow
support did (decisions 0890, 0893). `setAllPoints` over UIParent, so the line sits dead
centre of the screen. BACKGROUND strata: it is under everything, which is why a full-screen
panel hides it.
ONE DEVIATION, forced: the ref anchors the string CENTER to **WorldFrame**, and benilla has
no WorldFrame — it is the reference's 3D-scene layer frame, which our renderer does not
model as a frame at all. This is the ONLY `relativeTo="WorldFrame"` in the whole 1.12
FrameXML (grep-verified over the extraction), so it buys no convention; left verbatim it
merely logged an unresolved-anchor WARN every boot and fell back to the owner. UIParent is
the right substitute and not an approximation: both are full-screen in the reference, so
their CENTERs coincide, and AutoFollowStatus is setAllPoints over UIParent anyway. -->
<Frame name="AutoFollowStatus" parent="UIParent" setAllPoints="true" frameStrata="BACKGROUND" hidden="true">
<Layers>
<Layer level="ARTWORK">
<FontString name="AutoFollowStatusText" inherits="GameFontNormal">
<Anchors>
<Anchor point="CENTER" relativeTo="UIParent">
<Offset>
<AbsDimension x="0" y="0"/>
</Offset>
</Anchor>
</Anchors>
<FontHeight>
<AbsValue val="20"/>
</FontHeight>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>
AutoFollowStatus_OnLoad();
</OnLoad>
<OnEvent>
AutoFollowStatus_OnEvent(event);
</OnEvent>
<OnUpdate>
AutoFollowStatus_OnUpdate(arg1);
</OnUpdate>
</Scripts>
</Frame>
</Ui>
+33 -19
View File
@@ -294,13 +294,13 @@ mod tests {
assert!(items.take_fresh().is_empty(), "a drain empties the queue");
}
/// The one predicate both halves of the right-click-to-open affordance ask
/// (`ItemInfo::openable`): the tooltip's green line and the `CMSG_OPEN_ITEM` fork. Its whole
/// subtlety is the two sub-gates — a `LockID` template stays shut until the INSTANCE carries
/// UNLOCKED, and a wrapper template needs the instance's WRAPPED bit — so a template-only
/// view (instance flags 0) can never be openable.
/// The right-click-to-open predicates — and the fact that the **line and the click are not the
/// same test** (decision 0896). The tooltip's `shows_open_line` carries the lock sub-gate (a
/// `LockID` template earns the line only once the INSTANCE says UNLOCKED); the click's
/// `opens_loot` is the BARE template bit, so a still-locked junkbox sends anyway and the
/// server supplies the refusal. Getting that backwards eats the click in silence.
#[test]
fn openable_is_the_lootable_bit_behind_its_lock_and_gift_sub_gates() {
fn open_line_carries_the_lock_gate_the_open_send_does_not() {
let plain = |flags: u32, lock_id: u32| {
let mut t = info("Small Barnacled Clam");
t.flags = flags;
@@ -308,19 +308,33 @@ mod tests {
t
};
// The clam: LOOTABLE, no lock — openable outright, instance flags irrelevant.
assert!(plain(ITEM_FLAG_LOOTABLE, 0).openable(0));
// An ordinary item is never openable, however its instance is flagged.
assert!(!plain(0, 0).openable(ITEM_DYNFLAG_UNLOCKED | ITEM_DYNFLAG_WRAPPED));
// A junkbox: LOOTABLE but locked — shut until the instance says UNLOCKED.
assert!(!plain(ITEM_FLAG_LOOTABLE, 7).openable(0));
assert!(plain(ITEM_FLAG_LOOTABLE, 7).openable(ITEM_DYNFLAG_UNLOCKED));
// Gift wrap: the WRAPPER template opens only while the instance is still WRAPPED.
assert!(!plain(ITEM_FLAG_WRAPPER, 0).openable(0));
assert!(plain(ITEM_FLAG_WRAPPER, 0).openable(ITEM_DYNFLAG_WRAPPED));
// The wrapped arm ignores the lock gate entirely — it is the sibling `||`, not a nested
// case: a wrapped gift of a locked box opens as the gift.
assert!(plain(ITEM_FLAG_WRAPPER | ITEM_FLAG_LOOTABLE, 7).openable(ITEM_DYNFLAG_WRAPPED));
// The clam: LOOTABLE, no lock — line and send agree, instance flags irrelevant.
assert!(plain(ITEM_FLAG_LOOTABLE, 0).shows_open_line(0));
assert!(plain(ITEM_FLAG_LOOTABLE, 0).opens_loot());
// An ordinary item does neither, however its instance is flagged.
assert!(!plain(0, 0).shows_open_line(ITEM_DYNFLAG_UNLOCKED | ITEM_DYNFLAG_WRAPPED));
assert!(!plain(0, 0).opens_loot());
// A junkbox: LOOTABLE but locked. **The two predicates part company here** — no line
// until the instance says UNLOCKED, but the click goes out either way.
assert!(!plain(ITEM_FLAG_LOOTABLE, 7).shows_open_line(0));
assert!(plain(ITEM_FLAG_LOOTABLE, 7).shows_open_line(ITEM_DYNFLAG_UNLOCKED));
assert!(
plain(ITEM_FLAG_LOOTABLE, 7).opens_loot(),
"the send ignores LockID entirely — the server owns the refusal"
);
// Gift wrap: the WRAPPER template unwraps only while the instance is still WRAPPED, and
// that arm is its own dispatcher position, not a nested case of the loot arm.
assert!(!plain(ITEM_FLAG_WRAPPER, 0).unwraps_gift(0));
assert!(plain(ITEM_FLAG_WRAPPER, 0).unwraps_gift(ITEM_DYNFLAG_WRAPPED));
assert!(
!plain(ITEM_FLAG_WRAPPER, 0).opens_loot(),
"WRAPPER is not LOOTABLE"
);
assert!(plain(ITEM_FLAG_WRAPPER, 0).shows_open_line(ITEM_DYNFLAG_WRAPPED));
// A wrapped gift of a locked box: the gift arm ignores the lock gate on both sides.
let gift_box = plain(ITEM_FLAG_WRAPPER | ITEM_FLAG_LOOTABLE, 7);
assert!(gift_box.unwraps_gift(ITEM_DYNFLAG_WRAPPED));
assert!(gift_box.shows_open_line(ITEM_DYNFLAG_WRAPPED));
}
#[test]
+5
View File
@@ -108,6 +108,7 @@ mod ui_char;
mod ui_chat;
mod ui_craft;
mod ui_duel;
mod ui_follow;
mod ui_gamma;
mod ui_gossip;
mod ui_hide;
@@ -193,6 +194,7 @@ use ui_char::UiCharPlugin;
use ui_chat::UiChatPlugin;
use ui_craft::UiCraftPlugin;
use ui_duel::UiDuelPlugin;
use ui_follow::UiFollowPlugin;
use ui_gossip::UiGossipPlugin;
use ui_item_text::UiItemTextPlugin;
use ui_items::UiItemsPlugin;
@@ -613,6 +615,9 @@ fn main() -> AppExit {
// Duels (decision 0633): the wire session, the client-side countdown tick, the four Era
// events, and the accept/cancel/challenge intents.
.add_plugins(UiDuelPlugin)
// Auto-follow's UI seam: the popup's Follow row + `FollowUnit`/`FollowByName` inbound, and
// the AUTOFOLLOW_BEGIN/END pair that drives the centre-screen status line outbound.
.add_plugins(UiFollowPlugin)
// Leaving (decision 0674): the game menu's Logout/Exit Game — the request, the server's
// 20-second answer narrated as the CAMP/QUIT countdown, and the process exit.
.add_plugins(UiLogoutPlugin)
+217 -45
View File
@@ -1,17 +1,16 @@
//! `/follow` — the auto-follow movement mode (decision 0890).
//! `/follow` — the auto-follow movement mode (decisions 0890, 0893).
//!
//! The finding that shapes this module: **follow synthesizes keyboard input.** It owns no
//! translation of its own. Every state change in the reference funnels through the movement
//! singleton's setter (`0x60e790` sets the move-forward bit `0x100000`, `0x60e7f0` clears it) — the
//! same setter the MoveForward keybinding drives, one of ~40 stubs in the keybinding command table
//! at `0x513de0`-`0x514273`. So follow is "hold W for me, and steer", and the right implementation
//! reuses the controller wholesale rather than growing a second mover (wow-re
//! `object-layer/scratch/targeting-by-name.md` PART D, §5-cross-checked).
//! reuses the controller wholesale rather than growing a second mover.
//!
//! It also sends **nothing on the wire** — corroborated against vmangos, which has no follow opcode
//! in 1.12.1. The server only ever sees the ordinary movement stream our synthesized input produces.
//!
//! ## The law
//! ## The motion law
//!
//! - **Facing is steered, never snapped** (`0x6103d0`/`0x6108xx`): turn toward the followee at
//! [`TURN_RATE`] = π rad/s (180°/s), clamped to `min(remaining, rate × elapsed)`, and stop turning
@@ -24,19 +23,62 @@
//! whatever a held forward key yields for *you*, which is why follow falls behind a faster runner.
//! - **A hysteresis band, not a stop distance** — see [`should_move`]. Arrive is inclusive, resume is
//! one-directional. The gap between the two is why follow visibly starts and stops rather than
//! juddering on a single threshold.
//! juddering on a single threshold. Arrival does **not** end the follow.
//!
//! ## The one INFERRED rule
//! ## The cancel set — what actually makes this a *mode*
//!
//! The reference cancels follow when the player turns ~180° away (band 160°-220°), but wow-re could
//! not trace *what that angle is measured against* — it is explicitly flagged unresolved. Measuring
//! it against the live bearing alone cannot be right: starting a follow while facing away from the
//! followee would then cancel on the first tick, before the steer had a chance to turn us.
//! **Every player-initiated movement START cancels the follow, on the key-DOWN edge.** A key
//! *release* never does, and neither does the follow's own synthesized input.
//!
//! So this module latches [`FollowState::aligned`] the first time the facing comes inside the band
//! and only cancels *after* that. Observable behaviour: starting a follow facing any direction works
//! and turns you around; deliberately turning around once you are following drops it. That matches
//! what the band is plainly *for*, and is marked INFERRED until the reference angle is pinned.
//! The mechanism sits two layers below the keybinding stubs, which is why reading the handlers
//! answers "nothing cancels follow" and is wrong about the behaviour — the same failure mode
//! wow-re's RF-0079 hit on autorun. Every movement START emitter calls **`0x60e990`**, which
//! cancels at `0x60e9b5` unless the re-entrancy bracket `ds:0xc4da48` **bit 0** is set — and
//! follow's own four emitters (`0x60e790`, `0x60e7f0`, `0x60e8a0`, `0x60e940`) set that bracket
//! around their calls. A real key press never sets it. **That asymmetry is the entire mechanism.**
//!
//! We need no bracket of our own, because our synthesized input is [`Player::follow_forward`] — a
//! flag the controller reads, never a key event — so it cannot reach the cancel at all.
//!
//! | action | benilla key | verdict |
//! |---|---|---|
//! | MoveForward / MoveBackward | `W` / `S` | **cancels** (key-DOWN) |
//! | TurnLeft / TurnRight | `A` / `D` | **cancels** (key-DOWN) |
//! | StrafeLeft / StrafeRight | `Q` / `E` | **cancels** (key-DOWN) |
//! | ToggleAutoRun | `MouseButton::Forward` | **cancels**, on the ON edge only |
//! | both-mouse-buttons run | `L`+`R` | **cancels**, on the both-held transition |
//! | mouse-look turning | `R` held | **cancels** while held, however far the mouse moved |
//! | **Jump** | `Space` | **survives** — the one emitter passing `0x60e990(0,0)` (`0x60dea8`) |
//! | Sit / stand | — | **survives** (a `CMSG_STANDSTATECHANGE`, no guard call) |
//! | walk/run toggle | — | **survives** |
//! | any key release | — | **survives** |
//!
//! Plus the non-input cancels: **losing the mover** (death, stun, a taxi hand-off — the reference's
//! second, rarer site at `0x5146d6`), the followee becoming unresolvable or **dying**, and the
//! degenerate-bearing guard below.
//!
//! ## What is deliberately NOT here
//!
//! **The ~180° turn-away cancel is drunk-only, and is therefore absent.** 0890 shipped it as an
//! INFERRED rule with a latch invented to make the 160°-220° band self-consistent. The band is
//! real (`0x80c604` = 2.7925267 rad, `0x80c600` = 3.8397243 rad, both strict) — but its whole
//! evaluation is gated at `0x610a3d` on an argument that is the **inebriation fraction**,
//! `min([[unit+0xe68]+0x1d], 100) × 0.01`. That byte is proven to be drunkenness by two independent
//! consumers of the identical expression: the `DRUNK_MESSAGE_SELF%d` formatter at `0x5e2ac3`, and
//! `0x60001a` comparing the same fraction against 0.5 to pick an animation state. **On a sober
//! character the band never executes**, so a client that does not model inebriation must not
//! implement it — and benilla does not. (Its real arming trigger is turn-*convergence*, not "inside
//! the band", with a stochastic disarm at `0x61092e`; recorded for whoever adds drunkenness.)
//!
//! Also refuted by census, each a real "surely this cancels follow?" candidate: taking damage,
//! entering combat, mounting, beginning a cast, and the followee simply going out of range
//! (`0x6107e5` explicitly excludes follow's mode at `0x610749`/`0x610752`).
//!
//! ## The start gate lives elsewhere
//!
//! Who you may begin following — a living, assistable **player**, and only while you are alive,
//! unstunned and not casting — is [`crate::target::by_name`]'s, because that is where the subject
//! is resolved. The refusals it raises are real error lines, not silence.
use std::f32::consts::PI;
@@ -44,6 +86,7 @@ use bevy::prelude::*;
use crate::net::GuidIndex;
use super::camera::{CameraControl, LookButton};
use super::state::{MoveSpeed, Player};
/// Follow's facing-turn rate, rad/s — `0xc4d93c`, seeded `0x40490fdb` = π at `0x6111f9`. Its own
@@ -65,22 +108,26 @@ const STOP_DISTANCE: f32 = 3.0;
/// closes below 3.0 / 4.5 yd however slowly you are moving.
const RESUME_FACTOR: f32 = 1.5;
/// How far off the bearing the facing must sit for a deliberate turn to drop the follow. The
/// reference's band is 160°-220°; wrapped into `[0, π]` that is "at least 160° off".
const CANCEL_ANGLE: f32 = 160.0 * PI / 180.0;
/// `cos(1°)`. The degenerate-bearing guard at `0x610d32` ends the follow once the followee sits
/// within one degree of straight up or down (`|dy| / dist > cos 1°`) — at which point the
/// horizontal bearing a beeline needs is numerically meaningless. Held in `0xc4d9d0`, seeded at
/// `0x610c8d` from `.rdata 0x80c5f8`.
const VERTICAL_ALIGN_COS: f32 = 0.999_847_7;
/// Who we are following, and the hysteresis latch — the reference's followed-guid pair `0xc4d980`
/// (armed `0x6111c9`, cleared `0x60fc1e`) plus the state its band implies.
/// (armed `0x6111c9`, cleared `0x60fc1e`; **exactly two writers each**, earned by an
/// opcode-agnostic raw-byte scan for the little-endian addresses, not a literal-displacement grep).
#[derive(Resource, Default)]
pub(crate) struct FollowState {
/// The followee's guid, or `None` when not following.
pub(crate) guid: Option<u64>,
/// The followee's name as it read at the moment the follow began — what
/// `AUTOFOLLOW_BEGIN`'s argument carries, and therefore what the status text says. Latched
/// rather than re-read so the line can't change under a rename or a cache eviction mid-follow.
pub(crate) name: String,
/// Whether the synthesized forward input is currently held. The band's latch: which threshold
/// applies depends on which side we are already on.
moving: bool,
/// Whether the facing has come inside [`CANCEL_ANGLE`] at least once since the follow began —
/// the guard on the INFERRED turn-away cancel (see the module header).
aligned: bool,
/// `WOW_FOLLOW_TRACE` bookkeeping: when the last trace line went out, and where we were.
/// `None` until a tick has seeded it.
traced_at: f64,
@@ -89,10 +136,10 @@ pub(crate) struct FollowState {
impl FollowState {
/// Begin following `guid`, from a clean band.
pub(crate) fn start(&mut self, guid: u64) {
pub(crate) fn start(&mut self, guid: u64, name: String) {
self.guid = Some(guid);
self.name = name;
self.moving = false;
self.aligned = false;
self.traced_at = 0.0;
self.traced_pos = None;
}
@@ -100,17 +147,26 @@ impl FollowState {
/// Stop following. Returns whether we actually were.
pub(crate) fn stop(&mut self) -> bool {
self.moving = false;
self.aligned = false;
self.guid.take().is_some()
}
}
/// `/follow [name]` — start following. `None` is the bare form (`FollowUnit("target")`), whose
/// subject is the current selection; a name resolves **players only** and additionally through the
/// reference's filter mode 2 (`CanAssist` + alive), which is [`crate::target`]'s to apply.
/// Start following — the app-side funnel every entry point converges on, whether it came from the
/// chat parser (decision 0881 parses slash lines in Rust) or from the Era API globals the shipped
/// UI calls ([`benilla_ui::script::FollowRequest`], via [`crate::ui_follow`]).
///
/// The two variants are the reference's own two bindings. They differ in how the subject is *found*
/// — a name resolves players only and through the by-name resolver's filter mode 2, a token takes
/// whatever it points at — but **not** in what is allowed: the start gate downstream applies to
/// both alike, which is why `/follow` on a creature refuses either way.
#[derive(bevy::ecs::message::Message, Clone, Debug)]
pub(crate) struct FollowRequest {
pub(crate) name: Option<String>,
pub(crate) enum FollowRequest {
/// `FollowUnit(unit)` — the bare `/follow` is `FollowUnit("target")`.
Unit(String),
/// `FollowByName(name, exactMatch)`. `exact` is the second Lua argument: the unit popup's
/// Follow row passes `1` (it already knows the exact name and must not prefix-match onto a
/// bystander), `/follow <name>` passes nothing.
Name { name: String, exact: bool },
}
/// The distance at which follow **arrives** and lets go of the key (`0x610ad2`-`0x610b1b`):
@@ -136,6 +192,25 @@ fn should_move(was_moving: bool, distance: f32, speed: f32) -> bool {
}
}
/// Does this frame's input **destroy** the follow? See the module header's table for the census
/// and for what is deliberately absent.
///
/// `move_start` is the key-DOWN **edge** of any movement start (W/S/A/D/Q/E), never held state —
/// the reference cancels in the START emitters and its STOP siblings carry no guard call at all,
/// which is why letting go of a key never resumes a follow you just broke. `autorun_engaged` is
/// likewise the ON edge only (the OFF edge routes to the unguarded `0x60dc90`). `mouse_look` is a
/// *level*, not an edge: the camera's facing commit re-enters the guard every frame the look is
/// held, regardless of how far the mouse actually moved.
fn follow_cancelled(
move_start: bool,
autorun_engaged: bool,
both_engaged: bool,
mouse_look: bool,
lost_mover: bool,
) -> bool {
move_start || autorun_engaged || both_engaged || mouse_look || lost_mover
}
/// Wrap an angle into `(-π, π]`.
fn wrap_pi(a: f32) -> f32 {
let t = std::f32::consts::TAU;
@@ -153,6 +228,13 @@ fn bearing_to(delta: Vec3) -> f32 {
(-delta.x).atan2(-delta.z)
}
/// Is the followee within one degree of straight up or down? `0x610d32`'s guard — at that point the
/// horizontal bearing is numerically meaningless and the follow ends rather than spinning.
fn vertically_degenerate(delta: Vec3) -> bool {
let dist = delta.length();
dist > f32::EPSILON && delta.y.abs() / dist > VERTICAL_ALIGN_COS
}
/// Turn `face` toward `bearing` by at most this tick's budget, or leave it alone inside the
/// deadzone. The reference's `min(remaining, rate × elapsed)` clamp.
fn steer(face: f32, bearing: f32, dt: f32) -> f32 {
@@ -164,13 +246,47 @@ fn steer(face: f32, bearing: f32, dt: f32) -> f32 {
face + remaining.clamp(-budget, budget)
}
/// Drive the follow: re-resolve the followee, steer the facing, and decide whether the synthesized
/// forward input is held this tick. Runs immediately **before** `control`, which reads
/// [`Player::follow_forward`] as one more term of its forward axis — the reference's shape exactly,
/// where follow pushes the same move-forward bit the W key does.
/// Everything the cancel set reads. Bundled so [`steer_follow`]'s own parameter list stays about
/// the motion.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct FollowInput<'w> {
keys: Res<'w, ButtonInput<KeyCode>>,
buttons: Res<'w, ButtonInput<MouseButton>>,
/// The camera rig, for the mouse-look level. Read one frame behind `control`'s own update,
/// which is deliberate and harmless: the cancel then lands on the frame *after* the look
/// begins, versus the reference's same-frame commit.
rig: Res<'w, CameraControl>,
/// While a focused UI EditBox owns the keyboard, keyboard reads see "no keys held" — so typing
/// "we should go" in chat must not break your follow. `control`'s own gate, applied here for
/// the same reason.
capture: Res<'w, crate::ui_script::UiKeyboardCapture>,
}
impl FollowInput<'_> {
/// The key-DOWN edge of any movement start. A/D are turn keys normally and strafe keys under
/// mouse-look; either way they are a movement start and either way they cancel, so the
/// distinction the controller draws does not matter here.
fn move_start(&self) -> bool {
!self.capture.0
&& self.keys.any_just_pressed([
KeyCode::KeyW,
KeyCode::KeyS,
KeyCode::KeyA,
KeyCode::KeyD,
KeyCode::KeyQ,
KeyCode::KeyE,
])
}
}
/// Drive the follow: cancel if this frame's input says so, then re-resolve the followee, steer the
/// facing, and decide whether the synthesized forward input is held this tick. Runs immediately
/// **before** `control`, which reads [`Player::follow_forward`] as one more term of its forward
/// axis — the reference's shape exactly, where follow pushes the same move-forward bit W does.
///
/// The player's own turn input runs *after* this in the same frame and therefore wins; that is what
/// makes the turn-away cancel reachable at all.
/// The cancel runs before the steer for the reference's own reason: it lives in the movement START
/// emitters, which run ahead of the axis math (`0x5150a7` vs the emitter tail `0x5151a0`), so the
/// frame you break a follow on is a frame the follow never steers.
pub(super) fn steer_follow(
time: Res<Time>,
mut follow: ResMut<FollowState>,
@@ -178,8 +294,30 @@ pub(super) fn steer_follow(
speed: Res<MoveSpeed>,
index: Res<GuidIndex>,
transforms: Query<&Transform>,
input: FollowInput,
) {
player.follow_forward = false;
if follow.guid.is_none() {
return;
}
// ── The cancel set ── before anything else this frame (see the doc above).
let both_engaged = input.buttons.pressed(MouseButton::Left)
&& input.buttons.pressed(MouseButton::Right)
&& (input.buttons.just_pressed(MouseButton::Left)
|| input.buttons.just_pressed(MouseButton::Right));
if follow_cancelled(
input.move_start(),
// The ON edge only: `control` toggles `autorun` AFTER this runs, so the flag we read here
// is still the pre-toggle value, and "press while off" is exactly the engage.
input.buttons.just_pressed(MouseButton::Forward) && !player.autorun,
both_engaged,
input.rig.look == Some(LookButton::Right),
player.modes.rooted || player.server_riding(),
) {
info!("follow: cancelled by the player's own movement input");
follow.stop();
return;
}
let Some(guid) = follow.guid else { return };
// Re-resolved every tick, nothing cached — a followee that streams out ends the follow
// (`0x610e40` → `0x6106e7`).
@@ -194,22 +332,19 @@ pub(super) fn steer_follow(
return;
};
let delta = target - player.pos;
// The degenerate-bearing guard (`0x610d32`), checked on the FULL 3D delta before the bearing is
// taken from the flattened one.
if vertically_degenerate(delta) {
info!("follow: the followee is within 1° of straight overhead — follow ends");
follow.stop();
return;
}
let flat = Vec3::new(delta.x, 0.0, delta.z);
let distance = flat.length();
if distance < f32::EPSILON {
return;
}
let bearing = bearing_to(flat);
// The INFERRED turn-away cancel (module header): only armed once the facing has come inside the
// band, so beginning a follow while facing away turns us around instead of cancelling.
let off_bearing = wrap_pi(bearing - player.face_yaw).abs();
if off_bearing < CANCEL_ANGLE {
follow.aligned = true;
} else if follow.aligned {
info!("follow: turned away from the followee — follow ends");
follow.stop();
return;
}
player.face_yaw = steer(player.face_yaw, bearing, time.delta_secs());
let moving = should_move(follow.moving, distance, speed.value);
if moving != follow.moving {
@@ -339,4 +474,41 @@ mod tests {
"should wrap forward across ±π"
);
}
/// The cancel set's table, in its own terms. Each argument is one row of the module header, and
/// the two that matter most are the FALSE ones: jump and a plain key release are VERIFIED
/// survivors, not oversights — they reach the guard with `realStart = 0` or never reach it.
#[test]
fn any_movement_start_cancels_but_jump_and_releases_do_not() {
// Nothing happening: a follow just keeps following, however long it runs.
assert!(!follow_cancelled(false, false, false, false, false));
// Each start, alone, is enough.
assert!(follow_cancelled(true, false, false, false, false));
assert!(follow_cancelled(false, true, false, false, false));
assert!(follow_cancelled(false, false, true, false, false));
assert!(follow_cancelled(false, false, false, true, false));
// Losing the mover — death, stun, a taxi hand-off — is a LEVEL, not an edge.
assert!(follow_cancelled(false, false, false, false, true));
// Jump and key releases never become any of those arguments (see `FollowInput::move_start`,
// which reads `just_pressed` and does not list Space at all), so the all-false row above IS
// the jump case — asserted here so deleting Space's absence breaks a test.
assert!(
!follow_cancelled(false, false, false, false, false),
"a jump leaves every cancel input false"
);
}
/// The degenerate-bearing guard (`0x610d32`) — a followee straight overhead ends the follow,
/// one a degree off the vertical does not. The horizontal case must be nowhere near it.
#[test]
fn a_followee_straight_overhead_ends_the_follow() {
assert!(vertically_degenerate(Vec3::new(0.0, 30.0, 0.0)));
assert!(vertically_degenerate(Vec3::new(0.0, -30.0, 0.0)));
// 1° off vertical is outside the guard: tan(1°) ≈ 0.01746, so 30 yd up needs > 0.52 yd out.
assert!(!vertically_degenerate(Vec3::new(0.6, 30.0, 0.0)));
// The ordinary case — a followee on roughly your own level — is untouched.
assert!(!vertically_degenerate(Vec3::new(10.0, 2.0, 5.0)));
// A zero delta is not "degenerate": it is the arrived case, which the band owns.
assert!(!vertically_degenerate(Vec3::ZERO));
}
}
+157 -46
View File
@@ -20,21 +20,31 @@
//! tag many creature attack clips never author — ogre.m2 authors it in 1 of its ~14 attack
//! variations, so ogre hits were near-silent):
//!
//! - a **natural-weapon** swing (`$AHn` fired the dispatch) plays the attacker's
//! `CreatureSoundData.CustomAttack[n]` column INSTEAD of the generic weapon impact — the
//! `SWINGNOHITSOUND` latch (`0x6247d0` §f);
//! - otherwise: parry → the attacker row's parry slot (metal/wood by the victim's weapon),
//! block → the shield slot, a landed hit → the `WeaponImpactSounds` impact/crit slot for the
//! victim's material (`CreatureImpactType`);
//! - plus the victim's injury vocal (`Injury`/`InjuryCritical`/`InjuryCrushing`) on every
//! damaging, undefended hit.
//! It is **two blocks in the client's order**, not one pick (decision 0899):
//!
//! 1. `0x6247d0`'s own weapon-sound block, at the attacker — a **natural-weapon** swing
//! (`$AHn` fired the dispatch) plays the attacker's `CreatureSoundData.CustomAttack[n]`
//! column INSTEAD of the generic weapon impact (the `SWINGNOHITSOUND` latch, `0x6247d0` §f);
//! otherwise a landed hit plays the `WeaponImpactSounds` impact/crit slot for the victim's
//! material (`CreatureImpactType`).
//! 2. `0x624530`'s **victimState-keyed clang**, at the victim — parry (`0x6245bb` →
//! `0x623640(sel=0)`) takes the attacker row's parry slot (metal/wood by the victim's
//! weapon), block (`0x6245d8` → `sel=1`) the shield slot. This block is reached on **every**
//! impact tag: the `$AHn` digit block "has zero effect on the victim dispatch", so a beast's
//! bite and the parry clang both sound.
//!
//! Plus the victim's injury vocal (`Injury`/`InjuryCritical`/`InjuryCrushing`) on every
//! damaging, undefended hit.
//!
//! A `text_only` flush (supersede/attack-stop) drops its sounds — only the floating number
//! flushes (decision 0149's flush law, inherited from the shared dispatch).
//!
//! INTERIM readings (flagged for a wow-re pass): victims' armor lands on the flesh slot (the
//! chain/plate slots need the armor-material chain);
//! blocks assume a metal shield; the injury vocal plays on every damaging hit (the client may
//! blocks assume a metal shield; a defended outcome suppresses block 1's generic weapon impact
//! (the tail's latch test `0x624936` carries no victimState gate in the trace, so whether it
//! also plays under a clang is unpinned);
//! the injury vocal plays on every damaging hit (the client may
//! throttle); the deflect (`0x457f20`) and immune/absorb (`0x458610`) positioned stubs' kit ids
//! are unpinned, so those branches stay silent here; the natural-weapon column is gated on
//! contact like the weapon impact (whether the digit block also plays on a whiff is unpinned).
@@ -131,6 +141,44 @@ fn no_contact(swing: &SwingMessage) -> bool {
)
}
/// A defended outcome — parry or block. These take their sound from the victim dispatch's
/// victimState-keyed clang ([`defense_clang`]), never from the attacker's weapon-sound block.
fn defended(victim_state: u32) -> bool {
matches!(victim_state, VICTIM_PARRY | VICTIM_BLOCK)
}
/// `0x624530`'s clang: the attacker's weapon row × the victim's defense (`0x623640`). Parry
/// picks metal/wood by the victim's own weapon body; block takes the shield slot. Crit does not
/// tier it — the parry/shield columns carry the same kit in both tables.
fn defense_clang(
row: &benilla_formats::WeaponImpactRow,
victim_state: u32,
victim_wooden: bool,
) -> u32 {
let slot = match (victim_state, victim_wooden) {
(VICTIM_PARRY, true) => impact_slot::PARRY_WOOD,
(VICTIM_PARRY, false) => impact_slot::PARRY_METAL,
_ => impact_slot::SHIELD_METAL,
};
row.impact[slot]
}
/// `0x6247d0`'s generic weapon impact for a landed hit: the victim's `CreatureImpactType`
/// material slot off the attacker's weapon row, crit-tiered.
fn landed_impact(row: &benilla_formats::WeaponImpactRow, impact_type: u32, crit: bool) -> u32 {
let slot = match impact_type {
1 => impact_slot::STONE,
2 => impact_slot::WOOD,
3 => impact_slot::ETHEREAL,
_ => impact_slot::FLESH,
};
if crit {
row.crit[slot]
} else {
row.impact[slot]
}
}
#[allow(clippy::too_many_arguments)]
fn combat_sounds(
mut swings: MessageReader<SwingMessage>,
@@ -234,52 +282,54 @@ fn combat_sounds(
};
let crit = swing.hit_info & HITINFO_CRITICAL != 0;
if !no_contact(swing) {
// The attacker's weapon row (`0x625460(attacker, leftswing)`) — shared by both
// blocks below. `None` for a wand/thrown: no melee row, nothing to strike with.
let offhand = swing.hit_info & 0x4 != 0;
let (subclass, wooden) = swing_weapon(attacker.and_then(|(_, w, _)| w), offhand);
let row = impacts.0.get(subclass, !wooden);
let defended = defended(swing.victim_state);
// `0x6247d0`'s own weapon-sound block, BEFORE the victim dispatch: the `$AHn` digit
// column, else the generic `WeaponImpactSounds` impact behind the SWINGNOHITSOUND
// latch. A defended outcome takes its sound from the dispatch below instead
// (INTERIM, decision 0899: the tail's latch test `0x624936` carries no victimState
// gate in the trace, so whether the generic impact ALSO plays under a clang is
// unpinned — we suppress, which is what the game sounds like).
if let Some(n) = imp.natural {
// The `$AHn` digit column: the attacker's own natural-weapon sound replaces the
// generic weapon impact (the SWINGNOHITSOUND latch).
// The attacker's own natural-weapon sound replaces the generic weapon impact.
let vocal = attacker
.and_then(|(_, _, net)| net.display_id)
.and_then(|d| voices.0.for_display(d))
.and_then(|v| v.custom_attack.get(usize::from(n)).copied())
.unwrap_or(0);
play(&mut kits, &mut out, vocal, pos, "natural impact");
} else {
let offhand = swing.hit_info & 0x4 != 0;
let (subclass, wooden) = swing_weapon(attacker.and_then(|(_, w, _)| w), offhand);
if let Some(row) = impacts.0.get(subclass, !wooden) {
let kit = match swing.victim_state {
VICTIM_PARRY => {
// The parry family by the *victim's* weapon body (INTERIM heuristic).
let victim_wooden = victim
.map(|(_, w, _)| swing_weapon(w, false).1)
.unwrap_or(false);
row.impact[if victim_wooden {
impact_slot::PARRY_WOOD
} else {
impact_slot::PARRY_METAL
}]
}
VICTIM_BLOCK => row.impact[impact_slot::SHIELD_METAL],
_ => {
// A landed hit: the victim's material slot (players/rowless → flesh).
let material = victim
.and_then(|(_, _, net)| net.display_id)
.and_then(|d| voices.0.for_display(d))
.map(|v| v.impact_type)
.unwrap_or(0);
let slot = match material {
1 => impact_slot::STONE,
2 => impact_slot::WOOD,
3 => impact_slot::ETHEREAL,
_ => impact_slot::FLESH,
};
let table = if crit { &row.crit } else { &row.impact };
table[slot]
}
};
} else if !defended {
if let Some(row) = row {
// A landed hit: the victim's material slot (players/rowless → flesh).
let material = victim
.and_then(|(_, _, net)| net.display_id)
.and_then(|d| voices.0.for_display(d))
.map(|v| v.impact_type)
.unwrap_or(0);
let kit = landed_impact(row, material, crit);
play(&mut kits, &mut out, kit, pos, "impact");
}
// else: no melee row (wand/thrown) — nothing to strike with.
}
// `0x624530`'s victimState-keyed clang (`0x6245bb` parry → `0x623640(sel=0)`,
// `0x6245d8` block → `sel=1`), emitted at the VICTIM (`vtable+0x14` on `this`).
// Reached on EVERY impact tag — the `$AHn` digit block "has zero effect on the
// victim dispatch" (wow-re `melee-impact-timing.md` §f): a wolf's bite and the
// parry clang both sound. Decision 0899 — 0525 wrongly let the natural column
// swallow this, so every beast you parried was silent.
if let (true, Some(row)) = (defended, row) {
// The parry family by the *victim's* weapon body (INTERIM heuristic).
let victim_wooden = victim
.map(|(_, w, _)| swing_weapon(w, false).1)
.unwrap_or(false);
let kit = defense_clang(row, swing.victim_state, victim_wooden);
let at = victim.map(|(t, ..)| t.translation).unwrap_or(pos);
play(&mut kits, &mut out, kit, at, "defense clang");
}
}
@@ -315,3 +365,64 @@ pub(super) fn plugin(app: &mut App) {
app.add_systems(Startup, load_weapon_impacts.after(AssetSet::Open))
.add_systems(Update, combat_sounds.in_set(WorldStage::Present));
}
#[cfg(test)]
mod tests {
use super::*;
/// A row shaped like the real 5875 Sword1H-metal row (byte-verified ids): flesh 143/144,
/// parry-metal 1002, parry-wood 1001, shield-metal 3263.
fn sword1h_metal() -> benilla_formats::WeaponImpactRow {
let mut impact = [0u32; 10];
let mut crit = [0u32; 10];
impact[impact_slot::FLESH] = 143;
crit[impact_slot::FLESH] = 144;
impact[impact_slot::STONE] = 3206;
impact[impact_slot::SHIELD_METAL] = 3263;
crit[impact_slot::SHIELD_METAL] = 3263;
impact[impact_slot::PARRY_METAL] = 1002;
impact[impact_slot::PARRY_WOOD] = 1001;
benilla_formats::WeaponImpactRow { impact, crit }
}
/// The clang picks the parry family by the VICTIM's weapon body, the shield slot for a
/// block — and never crit-tiers (the data carries one kit in both tables).
#[test]
fn defense_clang_picks_parry_by_victim_body_and_shield_for_block() {
let row = sword1h_metal();
assert_eq!(defense_clang(&row, VICTIM_PARRY, false), 1002);
assert_eq!(defense_clang(&row, VICTIM_PARRY, true), 1001);
assert_eq!(defense_clang(&row, VICTIM_BLOCK, false), 3263);
assert_eq!(defense_clang(&row, VICTIM_BLOCK, true), 3263);
}
/// Parry and block are the defended pair — the outcomes whose sound comes from the victim
/// dispatch's clang instead of the attacker's weapon-sound block. A landed hit is not.
#[test]
fn only_parry_and_block_are_defended() {
assert!(defended(VICTIM_PARRY));
assert!(defended(VICTIM_BLOCK));
for other in [
0,
1,
VICTIM_DODGE,
4,
VICTIM_EVADE,
VICTIM_IMMUNE,
VICTIM_DEFLECT,
] {
assert!(!defended(other), "victimState {other} is not a defense");
}
}
/// The landed hit reads the victim's `CreatureImpactType` slot, crit-tiered; an unknown
/// material falls to flesh (players carry no creature row).
#[test]
fn landed_impact_reads_material_slot_crit_tiered() {
let row = sword1h_metal();
assert_eq!(landed_impact(&row, 0, false), 143);
assert_eq!(landed_impact(&row, 0, true), 144);
assert_eq!(landed_impact(&row, 1, false), 3206);
assert_eq!(landed_impact(&row, 99, false), 143, "unknown → flesh");
}
}
+237 -26
View File
@@ -147,9 +147,66 @@ fn common_prefix_len(query: &str, name: &str) -> usize {
.count()
}
/// The follow start gate's decision, as a pure function of the five things it reads — so the
/// **order** of the chain (which refusal you see when two apply at once) is pinned by test rather
/// than by whichever `if` happens to come first after an edit.
///
/// `0x60fed0`'s mode-3 chain, in its own order, with the message ids it pushes:
///
/// | check | site | failure |
/// |---|---|---|
/// | followee typemask `0x10` (PLAYER) | `0x60ff5f` | `0x128` `ERR_INVALID_FOLLOW_TARGET` |
/// | `CanAssist` the followee (`0x606ba0`) | `0x60ff6a` | `0x128`, the same line |
/// | we are alive | `0x60ff7c` | `0x7e` `ERR_PLAYER_DEAD` |
/// | we are not stunned (`UNIT_FIELD_FLAGS & 0x40000`) | `0x60ff95` | `0x191` `ERR_GENERIC_STUNNED` |
/// | we are not casting (`[[mover+0x110]+0x228] == 0`) | `0x60ffb9` | `0x134` `ERR_TOOBUSYTOFOLLOW` |
///
/// There is deliberately **no followee-alive check** and **no distance check**. A corpse can be
/// followed (it is the per-tick death test that ends the follow, not the gate), and the
/// max-distance machinery — `0x6110a0`, which raises `ERR_AUTOFOLLOW_TOO_FAR` at the binary's only
/// `0x126` call site — is **disabled by data**: follow's row in the per-mode table `0x860a58` has a
/// `0.0f` threshold, which `0x6110c8` short-circuits on. So the error string ships, the code ships,
/// and no distance is ever enforced at either end. One `.rdata` float from being a real limit.
fn follow_refusal_key(
followee_is_player: bool,
can_assist_followee: bool,
we_are_dead: bool,
we_are_stunned: bool,
we_are_casting: bool,
) -> Option<&'static str> {
if !followee_is_player || !can_assist_followee {
return Some("ERR_INVALID_FOLLOW_TARGET");
}
if we_are_dead {
return Some("ERR_PLAYER_DEAD");
}
if we_are_stunned {
return Some("ERR_GENERIC_STUNNED");
}
if we_are_casting {
return Some("ERR_TOOBUSYTOFOLLOW");
}
None
}
/// Whether the resolver's **second tier** is live — the shared resolver's exact-only parameter,
/// which every caller in the table above sources from the second Lua argument.
///
/// It is not decoration: `UnitPopup.lua`'s Follow row passes `FollowByName(name, 1)`, and a menu
/// that already names its unit exactly must not prefix-match its way onto a bystander who happens
/// to share a first letter. `/follow rag` still may, because the slash command passes nothing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Match {
/// Tier 1 then tier 2 — a whole-string hit, else the longest common prefix.
PrefixOk,
/// Tier 1 only. Tier 2 is skipped entirely, so a query that is not the whole name matches
/// nothing at all.
ExactOnly,
}
/// Score one candidate against the query, or `None` when it does not match at all. The reference's
/// running best length seeds at **1**, so a zero-length overlap is not a match.
fn rank(query: &str, name: &str, dist2: f32) -> Option<Rank> {
fn rank(query: &str, name: &str, dist2: f32, mode: Match) -> Option<Rank> {
if query.eq_ignore_ascii_case(name) {
return Some(Rank {
exact: true,
@@ -157,6 +214,9 @@ fn rank(query: &str, name: &str, dist2: f32) -> Option<Rank> {
dist2,
});
}
if mode == Match::ExactOnly {
return None;
}
let prefix = common_prefix_len(query, name);
(prefix >= 1).then_some(Rank {
exact: false,
@@ -238,13 +298,54 @@ impl ByNameScan<'_, '_> {
) >= 4
}
/// The **follow start gate** — `0x60fed0`'s mode-3 chain (reached via the remap
/// `0x610094[3] = 1` → `0x610084[1]` = `0x60ff59`), in the reference's own order. Returns the
/// GlobalStrings key of the *first* refusal, or `None` to let the follow begin.
///
/// This gate is why 0890's "the bare form follows whatever you have selected, creature or
/// player, with no such filter" was **true and yet wrong about the behaviour**. That sentence
/// was about the by-name *resolver's* filter, and it is correct: `FollowUnit` never goes
/// through `0x493aa0`. But the gate below sits downstream of both bindings, so a creature is
/// refused all the same — with "You can't follow that unit.", which is exactly what the real
/// client says. Being right about *a* mechanism is not being right about *the* cause.
///
/// Each refusal is a real error line through the one `CGGameUI::DisplayError` route
/// ([`crate::ui_action::UiErrorKeys`]) rather than silence, because that is what the reference
/// does — these five have message ids, and three of the four strings exist for no other caller.
fn follow_refusal(
&self,
followee: u64,
followee_store: Option<&ObjectStore>,
casting: bool,
) -> Option<&'static str> {
let self_store = self.self_q.single().ok().and_then(|(_, _, _, s)| s);
follow_refusal_key(
guid::is_player(followee),
super::ring_reaction(
self.factions.as_deref(),
&self.reputations,
followee_store,
self_store,
) >= 4,
self_store.is_some_and(|s| s.0.unit_is_dead()),
self_store.is_some_and(|s| s.0.unit_flags() & crate::player::UNIT_FLAG_STUNNED != 0),
casting,
)
}
/// Resolve a name to a unit, or `None`. See the module header for the full law.
///
/// Every call logs its verdict under the `by-name:` prefix — one line per typed command, so a
/// "`/target` won't take that mob" report is diagnosable from the log rather than re-guessed
/// (the `WOW_TAB_TRACE` instrument's shape, ungated because the cost is per-command, not
/// per-frame). The line names how many candidates were eligible, which won, and on what.
fn resolve(&self, query: &str, search: NameSearch, filter: Filter) -> Option<(Entity, u64)> {
fn resolve(
&self,
query: &str,
search: NameSearch,
filter: Filter,
mode: Match,
) -> Option<(Entity, u64, String)> {
let query = query.trim();
if query.is_empty() {
return None;
@@ -269,7 +370,7 @@ impl ByNameScan<'_, '_> {
};
considered += 1;
let dist2 = tf.translation.distance_squared(origin);
let Some(r) = rank(query, name, dist2) else {
let Some(r) = rank(query, name, dist2, mode) else {
continue;
};
if best.as_ref().is_none_or(|(_, _, b, _)| r.beats(*b)) {
@@ -286,10 +387,10 @@ impl ByNameScan<'_, '_> {
),
None => info!(
"by-name: \"{query}\" ({search:?}) -> NO MATCH over {considered} named candidates \
({nameless} unnamed); target left untouched"
({nameless} unnamed, {mode:?}); target left untouched"
),
}
best.map(|(e, g, _, _)| (e, g))
best.map(|(e, g, _, name)| (e, g, name))
}
}
@@ -307,9 +408,12 @@ pub(super) fn target_by_name_requests(
mut commit: SelectCommit,
) {
for request in requests.read() {
let Some((entity, guid)) =
scan_params.resolve(&request.name, NameSearch::AnyUnit, Filter::AcceptAll)
else {
let Some((entity, guid, _)) = scan_params.resolve(
&request.name,
NameSearch::AnyUnit,
Filter::AcceptAll,
Match::PrefixOk,
) else {
continue;
};
commit.commit(entity, guid);
@@ -340,8 +444,13 @@ pub(super) fn assist_requests(
// The basis: a named PLAYER, or — bare — whatever is currently selected.
let basis = match &request.name {
Some(name) => scan_params
.resolve(name, NameSearch::PlayerOnly, Filter::AcceptAll)
.map(|(e, _)| e),
.resolve(
name,
NameSearch::PlayerOnly,
Filter::AcceptAll,
Match::PrefixOk,
)
.map(|(e, _, _)| e),
None => commit.selection.target,
};
let Some(basis) = basis else {
@@ -377,28 +486,71 @@ pub(super) fn assist_requests(
/// Drain `/follow [name]`: resolve the subject and hand it to [`crate::player`], which owns the
/// motion (decision 0890). Nothing goes on the wire — follow is client-side movement only.
///
/// The two forms differ in more than the argument. A **name** goes through the resolver with the
/// reference's filter mode 2 — players only, and only ones that are **alive and assistable** — so
/// `/follow <an enemy>` finds nothing. The **bare** form is `FollowUnit("target")`, a unit-token
/// resolve that never touches the by-name path at all, so it follows whatever you have selected,
/// creature or player, with no such filter.
/// The two forms differ in how the subject is **found** — a name goes through the resolver with
/// the reference's filter mode 2 (players only, alive and assistable), a token takes whatever it
/// points at — but **not** in what is allowed. [`ByNameScan::follow_refusal`] sits downstream of
/// both, so `/follow` on a creature refuses either way, with the reference's own error line.
///
/// The followee's **name** is latched here rather than re-read later: it is what
/// `AUTOFOLLOW_BEGIN` carries into the status text ([`crate::ui_follow`]), and the resolver has
/// already produced it for the by-name half.
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
pub(super) fn follow_requests(
mut requests: MessageReader<crate::player::FollowRequest>,
scan_params: ByNameScan,
selection: Res<Selection>,
group: Res<crate::ui_party::GroupState>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
cast: Res<crate::ui_cast::PendingCast>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
mut follow: ResMut<crate::player::FollowState>,
) {
for request in requests.read() {
let resolved = match &request.name {
Some(name) => scan_params
.resolve(name, NameSearch::PlayerOnly, Filter::AssistableAlive)
.map(|(_, guid)| guid),
None => selection.guid,
let resolved = match request {
crate::player::FollowRequest::Name { name, exact } => scan_params
.resolve(
name,
NameSearch::PlayerOnly,
Filter::AssistableAlive,
if *exact {
Match::ExactOnly
} else {
Match::PrefixOk
},
)
.map(|(_, guid, name)| (guid, name)),
// `"target"` is deliberately NOT routed through `player_token_guid`: that helper
// applies the inspect/popup family's players-only typemask *at the token*, whereas
// follow's typemask is the start gate's, one layer down, and must produce the gate's
// error line rather than a silent nothing. The party tokens do go through it — they
// can only ever name a player anyway, and it knows the roster order.
crate::player::FollowRequest::Unit(token) => match token.as_str() {
"target" => selection.guid,
tok => crate::ui_unit::player_token_guid(tok, &selection, &group),
}
.map(|guid| {
let name = scan_params.names.peek(guid).unwrap_or_default().to_string();
(guid, name)
}),
};
// The start gate, applied to whichever way the subject was found.
if let Some((guid, _)) = &resolved {
let followee = index.0.get(guid).and_then(|e| stores.get(*e).ok());
if let Some(key) = scan_params.follow_refusal(
*guid,
followee,
cast.in_flight(std::time::Instant::now()),
) {
info!("follow: refused — {key}");
errors.0.push(crate::ui_action::UiError::key(key));
continue;
}
}
match resolved {
Some(guid) => {
info!("follow: now following guid {guid:#x}");
follow.start(guid);
Some((guid, name)) => {
info!("follow: now following \"{name}\" guid {guid:#x}");
follow.start(guid, name);
}
None => {
// The reference's `if GetSlashCmdTarget(msg)` guard and the resolver's own miss both
@@ -492,17 +644,76 @@ mod tests {
#[test]
fn a_match_needs_one_folded_character() {
// The reference seeds its best length at 1, so zero overlap is not a match at all.
assert!(rank("zzz", "Ragnaros", 1.0).is_none());
assert_eq!(rank("rag", "Ragnaros", 1.0).map(|r| r.prefix), Some(3));
assert!(rank("zzz", "Ragnaros", 1.0, Match::PrefixOk).is_none());
assert_eq!(
rank("rag", "Ragnaros", 1.0, Match::PrefixOk).map(|r| r.prefix),
Some(3)
);
}
#[test]
fn whole_string_match_is_case_insensitive_and_ranks_exact() {
let r = rank("kobold vermin", "Kobold Vermin", 9.0).expect("matches");
let r = rank("kobold vermin", "Kobold Vermin", 9.0, Match::PrefixOk).expect("matches");
assert!(r.exact, "tier 1 is case-insensitive whole-string");
// A multi-word creature name is the case that made the whole-argument trim load-bearing.
}
/// The exact-only flag is the unit popup's Follow row (`FollowByName(name, 1)`): tier 2 is
/// skipped outright, so the prefix that `/follow rag` rides cannot fire. Tier 1 is unaffected
/// and stays case-insensitive — "exact" is about the whole string, never about case.
/// The follow start gate's chain, in the reference's order. The order is the point: a dead
/// player who targets a creature sees "You can't follow that unit.", not "You are dead" — the
/// followee checks come first, and both of them raise the SAME message id (0x128).
#[test]
fn the_follow_gate_refuses_in_the_references_own_order() {
// The clean case.
assert_eq!(follow_refusal_key(true, true, false, false, false), None);
// A creature — the correction that matters most, and the reason `/follow` on a mob refuses
// however you got there (0890 read the by-name filter and missed this gate below it).
assert_eq!(
follow_refusal_key(false, true, false, false, false),
Some("ERR_INVALID_FOLLOW_TARGET")
);
// An enemy player fails CanAssist, and shares the creature's line.
assert_eq!(
follow_refusal_key(true, false, false, false, false),
Some("ERR_INVALID_FOLLOW_TARGET")
);
// Our own three, each shadowed by the followee checks above it.
assert_eq!(
follow_refusal_key(true, true, true, false, false),
Some("ERR_PLAYER_DEAD")
);
assert_eq!(
follow_refusal_key(true, true, false, true, false),
Some("ERR_GENERIC_STUNNED")
);
assert_eq!(
follow_refusal_key(true, true, false, false, true),
Some("ERR_TOOBUSYTOFOLLOW")
);
// Everything wrong at once: the followee's line wins outright.
assert_eq!(
follow_refusal_key(false, false, true, true, true),
Some("ERR_INVALID_FOLLOW_TARGET")
);
// Dead AND stunned: dead wins, because it is checked first.
assert_eq!(
follow_refusal_key(true, true, true, true, true),
Some("ERR_PLAYER_DEAD")
);
}
#[test]
fn exact_only_skips_the_prefix_tier_but_keeps_the_case_fold() {
assert!(rank("rag", "Ragnaros", 1.0, Match::ExactOnly).is_none());
assert!(
rank("RAGNAROS", "Ragnaros", 1.0, Match::ExactOnly)
.expect("whole-string still matches")
.exact
);
}
#[test]
fn exact_beats_any_prefix_and_nearest_breaks_the_tie() {
// Our deliberate deviation: the reference's enumeration-order early-out would let a nearer
+7 -2
View File
@@ -529,9 +529,14 @@ pub(super) fn drain_chat_input(
chat_out.assist.write(crate::target::AssistRequest { name });
}
// `/follow` (decision 0890) — the subject is resolved by `crate::target` and the motion
// is `crate::player`'s; chat only carries the ask. Bare follows the current selection.
// is `crate::player`'s; chat only carries the ask. The two arms are the ref handler's
// own two calls: bare is `FollowUnit("target")`, named is `FollowByName(name)` with no
// second argument, i.e. prefix matching live.
ParsedChat::Follow { name } => {
chat_out.follow.write(crate::player::FollowRequest { name });
chat_out.follow.write(match name {
Some(name) => crate::player::FollowRequest::Name { name, exact: false },
None => crate::player::FollowRequest::Unit("target".into()),
});
}
// The ref's handler is `SlashCmdList["PVP"] = function() TogglePVP() end` — one line
// over the same binding the popup row calls, so it enters the same intent queue
+119
View File
@@ -0,0 +1,119 @@
//! The app-side **follow bridge** — the seam between the auto-follow movement mode
//! ([`crate::player::follow`], decision 0890) and the UI that has to say so.
//!
//! Two directions, one small module:
//!
//! - **In.** Drain the Era API's follow intents ([`benilla_ui::script::FollowRequest`], queued by
//! `FollowUnit`/`FollowByName`) into the app's own [`crate::player::FollowRequest`] funnel, which
//! [`crate::target::by_name`] resolves. The unit popup's **Follow** row is the shipped caller;
//! the slash command reaches the same funnel directly, because benilla parses slash lines in Rust
//! (decision 0881).
//! - **Out.** Fire `AUTOFOLLOW_BEGIN` / `AUTOFOLLOW_END` on the follow state's transitions. That
//! pair is the *entire* interface the reference gives the UI for follow — there is no
//! `IsFollowing()` and no unit token for the followee, so a status display is only possible at
//! all because the events carry the name.
//!
//! ## What the events drive
//!
//! `AutoFollowStatus` (ZoneText.xml) — a `setAllPoints` frame over `UIParent` carrying one
//! centre-anchored 20px `GameFontNormal` string. On `AUTOFOLLOW_BEGIN` it latches `arg1`, sets alpha
//! to 1 and shows `AUTOFOLLOWSTART` ("Following %s."); on `AUTOFOLLOW_END` it swaps the text to
//! `AUTOFOLLOWSTOP` ("You stop following %s.") — reusing the *latched* name, which is why END needs
//! no argument of its own — and starts a linear 4-second alpha fade to hidden. It is not the
//! FadingFrame kit; `AutoFollowStatus_OnUpdate` rolls its own fade, with no fade-in and no hold.
//!
//! So the "You stop following X" line is the reason [`crate::player::FollowState`] latches the name
//! at start rather than re-reading it: by the time the follow ends, the followee may well be gone.
//!
//! ## Transitions
//!
//! Start → `BEGIN`. Stop → `END`. A follow that *switches* subject fires **`END` then `BEGIN`**:
//! the reference's arm routine calls the canceller first (`0x61113e`, inside `0x611130`), so
//! re-issuing `/follow` while already following really does emit both, in that order. On screen the
//! pair is indistinguishable from `BEGIN` alone — `AutoFollowStatus_OnEvent`'s BEGIN arm is a full
//! reset, clearing `fadeTime` and restoring alpha — but an addon listening for `AUTOFOLLOW_END`
//! would notice, so we emit what the reference emits.
//!
//! ## The three paths where the reference does NOT fire `END`
//!
//! Two writers set the follow mode cell `0xc4d888` back to `0xc` **directly** (`0x5fb646`,
//! `0x60396f`), bypassing the canceller entirely: no event fires, and `0xc4d980` is left holding a
//! stale guid. A third miss is the canceller's own early-out (`0x60fb70`), which skips the fire
//! *and* the state clear. The two direct writers' triggers are INFERRED to be zoning / world-leave
//! / logout.
//!
//! We do not reproduce this. It is a reference **bug** whose visible symptom is the status line
//! surviving a zone change with nobody to hide it — and our shape cannot reproduce it anyway, since
//! the line follows [`FollowState`] rather than a separately-cleared cell. Recorded because "the
//! status text is stuck after a zone" is a real report someone may one day file *against the real
//! client*, and the answer is "yes, it does that".
use bevy::prelude::*;
use benilla_ui::script::{FollowRequest as ScriptFollowRequest, ScriptValue, UiScript};
use crate::player::{FollowRequest, FollowState};
use crate::ui_script::UiInput;
/// What the VM has last been told, so the two events fire exactly on transitions and never once a
/// frame. Mirrors [`FollowState::guid`]; `None` means the VM believes no follow is running.
#[derive(Resource, Default)]
pub(crate) struct FollowFeed {
told: Option<u64>,
}
/// Push the follow state's transitions into the VM as the reference's two events.
fn feed_follow(
script: Option<NonSendMut<UiScript>>,
follow: Res<FollowState>,
mut feed: ResMut<FollowFeed>,
) {
let Some(mut script) = script else {
return;
};
match (follow.guid, feed.told) {
// Started, or switched subject. A switch emits END first, because the reference's arm
// routine calls the canceller before arming (see the module header).
(Some(guid), told) if told != Some(guid) => {
if told.is_some() {
script.fire_event("AUTOFOLLOW_END", vec![]);
}
script.fire_event(
"AUTOFOLLOW_BEGIN",
vec![ScriptValue::Str(follow.name.clone())],
);
feed.told = Some(guid);
}
// Stopped — by arrival at an unresolvable followee, by the turn-away cancel, or by any of
// the input cancels. END carries no argument: the frame reuses the name BEGIN latched.
(None, Some(_)) => {
script.fire_event("AUTOFOLLOW_END", vec![]);
feed.told = None;
}
_ => {}
}
}
/// Drain the Era API's follow intents into the app's own funnel.
fn drain_follow(script: Option<NonSendMut<UiScript>>, mut out: MessageWriter<FollowRequest>) {
let Some(mut script) = script else {
return;
};
for request in script.take_follow_requests() {
out.write(match request {
ScriptFollowRequest::ByUnit(unit) => FollowRequest::Unit(unit),
ScriptFollowRequest::ByName { name, exact } => FollowRequest::Name { name, exact },
});
}
}
pub(crate) struct UiFollowPlugin;
impl Plugin for UiFollowPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<FollowFeed>().add_systems(
Update,
(feed_follow.before(UiInput), drain_follow.after(UiInput)),
);
}
}
+154 -99
View File
@@ -209,126 +209,181 @@ pub(super) fn drain_container_uses(
}
continue;
}
// The openable fork: a clam, an unlocked lockbox, a wrapped gift. `CMSG_OPEN_ITEM`, not
// `CMSG_USE_ITEM` — the server's `HandleOpenItemOpcode` is the only handler that answers
// with `SendLoot(item guid, LOOT_CORPSE)`, i.e. a loot window over a thing in your bag;
// sending USE_ITEM instead casts the item's (absent) on-use spell and nothing happens,
// which is exactly the "no way to open clams" symptom. The predicate is
// `ItemInfo::openable` — literally the same call the tooltip's `<Right Click to Open>`
// line makes, so the promise and the packet cannot drift apart.
//
// It sits ABOVE the readable fork because the reference's own tooltip law makes the two
// mutually exclusive with **openable winning** (wow-re `tooltip-content-law.md`
// §1-OPENABLE, the `jmp` past the READABLE test): a wrapped gift that also carries letter
// text opens, it does not read. Below the merchant/bank intercepts, which outrank every
// ordinary click.
let openable = self_q
// Everything the reference's click law reads off the clicked slot, resolved once. `None` =
// an empty slot, or a template still in flight — the click then falls all the way through
// to a plain USE, whose refusal is at least visible (the template is all but always cached
// by click time; the bag needed it for the icon).
let clicked = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &items))
.and_then(|guid| {
let obj = items.object(guid)?;
let inst_flags = obj.item_flags().unwrap_or(0);
let item_text_id = obj.item_text_id().unwrap_or(0);
let entry = obj.object_entry()?;
items
.template(entry, guid, &commands)?
.openable(inst_flags)
.then_some(guid)
let t = items.template(entry, guid, &commands)?;
Some(Clicked {
guid,
entry,
item_text_id,
inventory_type: t.inventory_type,
display_info_id: t.display_info_id,
start_quest: t.start_quest,
// The wire's spell byte is a template BLOCK ordinal (decision 0666) — the
// template is right here, so send the real one rather than assuming 0.
spell_index: t.use_spell_index().unwrap_or(0),
unwraps_gift: t.unwraps_gift(inst_flags),
opens_loot: t.opens_loot(),
})
});
if let Some(guid) = openable {
debug!("ui_items: open item {guid:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})");
// The reference's equip-vs-use fork (`0x4fa3b9`/`0x4fa3bd`, wow-re `right-click-open.md`
// §2), with the ammo sub-fork `cursor-dragdrop-slots.md` pins: the auto-equip sender
// `0x5e1480` sends `CMSG_SET_AMMO` (the item entry) for an ammo-class item,
// `CMSG_AUTOEQUIP_ITEM` for any other equippable (inventoryType != 0 — weapons, armor,
// bags). display_id feeds the synthetic pickup→place auto-equip sound (this path never
// moves the cursor; a drag already gets that pair via the cursor-payload transitions).
//
// The arm carries the reference's own **quest guard** (`0x4fa3bd`–`0x4fa3cc`, decision
// 0664): it equips only when `StartQuest` (`[rec+0x1a8]`) is 0, so a quest-starter falls
// through *whatever* its inventoryType — the five equippable ones (Pendant of Myzrael,
// Arena Master, …) offer their quest on a right-click, they don't put themselves on.
//
// **Everything below this fork is `0x5d8d00`, the USE dispatcher, in ITS OWN order**
// (wow-re `right-click-open.md` §3) — an equippable item never reaches any of it.
if let Some(c) = clicked.filter(|c| c.start_quest == 0 && c.inventory_type != 0) {
if c.inventory_type == INVTYPE_AMMO {
// Ammo loads by entry (`CMSG_SET_AMMO`), NOT the equip swap wire — the stack stays
// in the bag and `PLAYER_AMMO_ID` references it (decision 0526). The server
// refuses a wrong/absent ranged weapon via `SMSG_INVENTORY_CHANGE_FAILURE`.
debug!(
"ui_items: set ammo entry {} (lua bag {bag} slot {slot})",
c.entry
);
let _ = commands.0.send(ClientCommand::SetAmmo { entry: c.entry });
} else {
debug!("ui_items: auto-equip (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = commands.0.send(ClientCommand::AutoEquipItem {
bag_index,
slot: wire_slot,
});
}
equip_sound.write(crate::sound::AutoEquipSound {
display_id: c.display_info_id,
});
continue;
}
// #2 — the wrapped gift (`0x5d8d92`/`0x5d8d9d` → emitter `0x5edd60`): `CMSG_OPEN_ITEM`
// unwraps it. FIRST in the dispatcher, ahead of the quest and readable arms, so a gift
// that also carries letter text unwraps rather than reads. (vmangos answers this one with
// an entry swap out of `character_gifts`, not a loot window.)
if let Some(c) = clicked.filter(|c| c.unwraps_gift) {
debug!(
"ui_items: unwrap gift {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
c.guid
);
let _ = commands.0.send(ClientCommand::OpenItem {
bag_index,
slot: wire_slot,
});
continue;
}
// The readable fork: an item INSTANCE carrying `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made
// permanent letter) opens the reader instead of a use — client-side, no permission
// packet (vmangos' `CMSG_READ_ITEM` handler gates on the *template*'s PageText, which is
// 0 for the Plain Letter; the text rides the ask-once `CMSG_ITEM_TEXT_QUERY` instead).
let read = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &items))
.and_then(|guid| {
items
.object(guid)
.and_then(|o| o.item_text_id())
.filter(|&t| t != 0)
.map(|t| (guid, t))
});
if let Some((guid, text_id)) = read {
debug!("ui_items: read item {guid:#x} (text {text_id}, lua bag {bag} slot {slot})");
item_text.open(guid, text_id);
// #3 — the quest-starter (`0x5d8dd2`, decision 0664): the item's own guid is the
// questgiver. Placed at the reference's position rather than at the tail, so a starter
// that is *also* readable or lootable offers its quest instead of reading/opening. Routed
// through the one shared use fork, which is what turns a non-zero StartQuest into the
// item-guid query.
if let Some(c) = clicked.filter(|c| c.start_quest != 0) {
debug!(
"ui_items: quest-starter {:#x} offers quest {}",
c.guid, c.start_quest
);
let _ = commands.0.send(super::item_use_command(
Some(c.guid),
c.start_quest,
bag_index,
wire_slot,
c.spell_index,
));
continue;
}
// The real client's equip-vs-use fork, with the ammo sub-fork wow-re
// `cursor-dragdrop-slots.md` pins: the auto-equip sender `0x5e1480` sends `CMSG_SET_AMMO`
// (the item entry) for an ammo-class item, `CMSG_AUTOEQUIP_ITEM` for any other equippable
// (inventoryType != 0 — weapons, armor, bags), and only a non-equippable is a USE (food,
// potions, hearthstone). The template is all but always cached by click time (the bag
// needed it for the icon); unresolved falls back to USE, whose refusal is at least visible.
// display_id feeds the synthetic pickup→place auto-equip sound (this path never moves the
// cursor; a drag already gets that pair via the cursor-payload transitions).
// #6 — readable: an item INSTANCE carrying `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made
// permanent letter) opens the reader — client-side, no permission packet (vmangos'
// `CMSG_READ_ITEM` handler gates on the *template*'s PageText, which is 0 for the Plain
// Letter; the text rides the ask-once `CMSG_ITEM_TEXT_QUERY` instead).
//
// The equip arm carries the reference's own **quest guard** (`0x4fa3bd`–`0x4fa3cc`,
// decision 0664): `Script::UseContainerItem` equips only when the cache record's
// `StartQuest` (`[rec+0x1a8]`) is 0, so a quest-starter goes to the use fork *whatever* its
// inventoryType — the five equippable ones (Pendant of Myzrael, Arena Master, …) offer their
// quest on a right-click, they don't put themselves on.
let resolved = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &items))
.and_then(|guid| {
let entry = items.object(guid)?.object_entry()?;
let t = items.template(entry, guid, &commands)?;
Some((
guid,
entry,
t.inventory_type,
t.display_info_id,
t.start_quest,
// The wire's spell byte is a template BLOCK ordinal (decision 0666) — the
// template is right here, so send the real one rather than assuming 0.
t.use_spell_index().unwrap_or(0),
))
});
let equippable = resolved.is_some_and(|(_, _, inv_type, _, q, _)| q == 0 && inv_type != 0);
match resolved {
// Ammo loads by entry (`CMSG_SET_AMMO`), NOT the equip swap wire — the stack stays in
// the bag and `PLAYER_AMMO_ID` references it (decision 0526). The server refuses a
// wrong/absent ranged weapon via `SMSG_INVENTORY_CHANGE_FAILURE`.
Some((_, entry, INVTYPE_AMMO, display_id, _, _)) if equippable => {
debug!("ui_items: set ammo entry {entry} (lua bag {bag} slot {slot})");
let _ = commands.0.send(ClientCommand::SetAmmo { entry });
equip_sound.write(crate::sound::AutoEquipSound { display_id });
}
Some((_, _, _, display_id, _, _)) if equippable => {
debug!("ui_items: auto-equip (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = commands.0.send(ClientCommand::AutoEquipItem {
bag_index,
slot: wire_slot,
});
equip_sound.write(crate::sound::AutoEquipSound { display_id });
}
_ => {
let (guid, start_quest, spell_index) =
resolved.map_or((None, 0, 0), |(guid, _, _, _, q, i)| (Some(guid), q, i));
debug!("ui_items: use item (lua bag {bag} → wire {bag_index}/{wire_slot}, starts quest {start_quest})");
let _ = commands.0.send(super::item_use_command(
guid,
start_quest,
bag_index,
wire_slot,
spell_index,
));
}
// The dispatcher's arm **#5** — the same local open off the *template*'s `PageText`
// (`0x5d8e4c`) — has no feed here yet (books/pages aren't built). When it lands it belongs
// immediately above this one, and still above the open arm: the send order is the INVERSE
// of the tooltip's, where OPENABLE wins over READABLE. A template that is both readable
// and lootable therefore *shows* `<Right Click to Open>` and *reads* on click — the
// reference's own inversion, byte-verified, not a slip of ours.
if let Some(c) = clicked.filter(|c| c.item_text_id != 0) {
debug!(
"ui_items: read item {:#x} (text {}, lua bag {bag} slot {slot})",
c.guid, c.item_text_id
);
item_text.open(c.guid, c.item_text_id);
continue;
}
// #8 — the open arm (`0x5d8f7c: test al,4` → emitter `0x5edc80`): a **bare** template
// LOOTABLE test. `CMSG_OPEN_ITEM`, not `CMSG_USE_ITEM` — the server's
// `HandleOpenItemOpcode` is the only handler that answers with
// `SendLoot(item guid, LOOT_CORPSE)`, i.e. a loot window over a thing in your bag; sending
// USE_ITEM instead casts the item's (absent) on-use spell and nothing happens, which was
// exactly the "no way to open clams" symptom.
//
// Deliberately looser than the tooltip line's predicate (`ItemInfo::shows_open_line`): the
// send consults **neither** LockID nor the instance UNLOCKED bit (VERIFIED both ways — no
// `[rec+0x1ac]` operand exists anywhere on the send path). So a still-locked junkbox DOES
// send, and the server's `EQUIP_ERR_ITEM_LOCKED` is where the player's "Item is locked"
// line comes from. Gating locally would eat the click in silence (decision 0896).
if let Some(c) = clicked.filter(|c| c.opens_loot) {
debug!(
"ui_items: open item {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
c.guid
);
let _ = commands.0.send(ClientCommand::OpenItem {
bag_index,
slot: wire_slot,
});
continue;
}
// The tail: a plain use (food, potions, hearthstone). The quest arm already fired above,
// so the shared fork's own quest leg is inert here by construction.
debug!("ui_items: use item (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = commands.0.send(super::item_use_command(
clicked.map(|c| c.guid),
0,
bag_index,
wire_slot,
clicked.map_or(0, |c| c.spell_index),
));
}
}
/// One clicked bag slot, resolved: the live instance's guid and the template scalars the
/// reference's fork chain tests, read once so the chain above is a plain ordered cascade rather
/// than five repeats of the same lookup. Copy-cheap on purpose — no borrow of [`Items`] outlives
/// the resolve.
#[derive(Clone, Copy)]
struct Clicked {
guid: u64,
/// `OBJECT_FIELD_ENTRY` — the ammo arm addresses by entry, not by slot (decision 0526).
entry: u32,
/// Instance `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made permanent letter); 0 = not a letter.
item_text_id: u32,
inventory_type: u32,
display_info_id: u32,
start_quest: u32,
spell_index: u8,
/// `ItemInfo::unwraps_gift` for this instance — dispatcher arm #2.
unwraps_gift: bool,
/// `ItemInfo::opens_loot` for this template — dispatcher arm #8.
opens_loot: bool,
}
/// Drain the pick/place/swap/split moves `PickupContainerItem`/`SplitContainerItem` queued and
/// send them on the wire (decision 0216 §6, whole-space since slice 2).
///
@@ -4,7 +4,7 @@
//! `SetItemRef` player branch + its minimal `FriendsFrame` stand-in (`ItemRef.xml`). Proven
//! serverless through the real hit/route paths.
use benilla_ui::script::{PartyRequest, UiScript, UnitState};
use benilla_ui::script::{FollowRequest, PartyRequest, UiScript, UnitState};
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
@@ -53,6 +53,9 @@ fn bake_strings(s: &UiScript) {
-- `Interface\FrameXML\GlobalStrings.lua:2327` off the 1.12.1 patch chain — which is what the
-- app itself runs at boot (`load_global_strings`); this stub only stands in for it here.
INSPECT = "Inspect"
-- The follow row's label (decision 0893), likewise the real
-- `GlobalStrings.lua:1981` value off the 1.12.1 patch chain.
FOLLOW = "Follow"
CANCEL = "Cancel"
RAID_TARGET_ICON = "Raid Target Icon"
"#,
@@ -176,15 +179,16 @@ fn solo_target_right_click_invites_a_player() {
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"a friendly player target opens the PLAYER menu solo"
);
// title (Ally) + Whisper + Inspect + Invite + Trade + Duel + Cancel — only FOLLOW is still
// DEFERRED, and the raid-target submenu needs a party. (Trade came out of the deferred set in
// decision 0592 P1, Inspect in 0631, Duel in 0633 — Inspect precedes Invite/Trade/Duel in the
// reference's own PLAYER menu order, which is why every row below it sits one later than it did
// before 0631.)
// title (Ally) + Whisper + Inspect + Invite + Trade + Follow + Duel + Cancel — nothing in the
// PLAYER menu is DEFERRED any more, and only the raid-target submenu (which needs a party) is
// absent. (Trade came out of the deferred set in decision 0592 P1, Inspect in 0631, Duel in
// 0633, Follow in 0893 — Inspect precedes Invite/Trade/Follow/Duel in the reference's own
// PLAYER menu order, which is why every row below it sits one later than it did before 0631,
// and Duel one later again than before 0893.)
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
7,
"title + Whisper + Inspect + Invite + Trade + Duel + Cancel"
8,
"title + Whisper + Inspect + Invite + Trade + Follow + Duel + Cancel"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button3:GetText()")
@@ -204,6 +208,11 @@ fn solo_target_right_click_invites_a_player() {
assert_eq!(
s.eval::<String>("return DropDownList1Button6:GetText()")
.unwrap(),
"Follow"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button7:GetText()")
.unwrap(),
"Duel"
);
@@ -286,6 +295,73 @@ fn solo_target_trade_click_queues_an_initiate() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Clicking the **Follow** row (un-deferred in decision 0893) through the real hit path reaches
/// `FollowByName("Ally", 1)` — and the `1` is the point of the test, not decoration. That second
/// argument is the resolver's exact-only flag: the menu already knows the unit's name to the
/// letter, so unlike `/follow rag` it must not prefix-match its way onto a bystander. A dispatch
/// that dropped the argument would still queue a follow and still look right on screen, which is
/// exactly the kind of break a row-label assertion cannot see.
#[test]
fn solo_target_follow_click_queues_an_exact_by_name_follow() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_strings(&s);
load_popup_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Me".into()),
is_player: true,
..UnitState::default()
}),
);
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Ally".into()),
health: 40,
max_health: 40,
is_player: true,
reaction: 5,
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.resolve();
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaTargetFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
s.resolve();
assert_eq!(
s.eval::<String>("return DropDownList1Button6:GetText()")
.unwrap(),
"Follow",
"Follow is the sixth row (title + Whisper + Inspect + Invite + Trade + Follow)"
);
let (fx, fy) = s
.eval::<(f64, f64)>("return DropDownList1Button6:GetCenter()")
.unwrap();
s.mouse_button(fx as f32, fy as f32, "LeftButton", true);
s.mouse_button(fx as f32, fy as f32, "LeftButton", false);
assert_eq!(
s.take_follow_requests(),
vec![FollowRequest::ByName {
name: "Ally".into(),
exact: true,
}],
"the popup follows by NAME and exactly — not by unit token, and not prefix-matched"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Clicking the **Inspect** row (un-deferred in decision 0631) through the real hit path reaches
/// `InspectUnit("target")` — the same "the row is there but the click does nothing" seam 0592 hit on
/// Trade, which is a dispatch break (`button == "INSPECT"` never matching) that a row-label test
@@ -325,3 +325,87 @@ fn subzone_seat_hangs_under_the_territory_line_on_new_area() {
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// `AutoFollowStatus` — the third frame in `ZoneText.xml`, and the only thing `ref-ZoneText.lua`
/// actually contains (decision 0893). The app fires `AUTOFOLLOW_BEGIN` with the followee's name and
/// `AUTOFOLLOW_END` with nothing; the frame does the rest.
///
/// The load-bearing assertion is that **END reuses the name BEGIN latched**. That is why the app
/// gets to fire END with no argument at all, and why [`crate::player::FollowState`] latches the
/// name at start rather than re-reading it — by the time a follow ends, the followee is often gone.
#[test]
fn the_autofollow_status_line_names_the_followee_and_fades_on_end() {
let mut s = harness();
assert!(
!visible(&s, "AutoFollowStatus"),
"hidden until a follow begins"
);
s.fire_event(
"AUTOFOLLOW_BEGIN",
vec![benilla_ui::script::ScriptValue::Str("Probeone".into())],
);
s.resolve();
assert!(visible(&s, "AutoFollowStatus"));
assert_eq!(text_of(&s, "AutoFollowStatusText"), "Following Probeone.");
let alpha: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
assert!((alpha - 1.0).abs() < 0.01, "full alpha while following");
// A follow that runs for a while must NOT fade — the fade is armed only by END, and there is
// no hold timer to expire (this is not the FadingFrame kit).
s.tick(10.0);
assert!(visible(&s, "AutoFollowStatus"), "no fade while following");
let alpha: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
assert!((alpha - 1.0).abs() < 0.01, "still opaque after 10 s");
// END carries no argument: the line reuses the latched name.
s.fire_event("AUTOFOLLOW_END", vec![]);
s.resolve();
assert_eq!(
text_of(&s, "AutoFollowStatusText"),
"You stop following Probeone.",
"END has no argument of its own — the name comes from what BEGIN latched"
);
// A linear 4 s fade, no hold: half gone at 2 s, hidden past 4.
s.tick(2.0);
let alpha: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
assert!((alpha - 0.5).abs() < 0.05, "mid-fade alpha, got {alpha}");
s.tick(2.5);
assert!(!visible(&s, "AutoFollowStatus"), "gone after the 4 s fade");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// A follow that switches subject fires `BEGIN` alone (see `crate::ui_follow`'s header). The
/// reference's own handler treats BEGIN as a full reset — it clears `fadeTime`, restores alpha and
/// re-shows — so a switch made *during* the end-fade must come back to a clean, opaque line rather
/// than inheriting the dying one's alpha.
#[test]
fn a_begin_during_the_end_fade_resets_the_line() {
let mut s = harness();
s.fire_event(
"AUTOFOLLOW_BEGIN",
vec![benilla_ui::script::ScriptValue::Str("Probeone".into())],
);
s.fire_event("AUTOFOLLOW_END", vec![]);
s.tick(3.0); // most of the way through the fade
let faded: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
assert!(faded < 0.4, "mid-fade, got {faded}");
s.fire_event(
"AUTOFOLLOW_BEGIN",
vec![benilla_ui::script::ScriptValue::Str("Probetwo".into())],
);
s.resolve();
assert_eq!(text_of(&s, "AutoFollowStatusText"), "Following Probetwo.");
let alpha: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
assert!(
(alpha - 1.0).abs() < 0.01,
"BEGIN restores alpha, got {alpha}"
);
s.tick(10.0);
assert!(
visible(&s, "AutoFollowStatus"),
"and clears the pending fade rather than letting it finish"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}