/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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user