diff --git a/README.md b/README.md index 3fe53783..fa220a7d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index d9cf00c8..89775267 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -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 `` 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 - /// `` 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 } } diff --git a/crates/benilla-ui/src/script/follow.rs b/crates/benilla-ui/src/script/follow.rs new file mode 100644 index 00000000..8d90692c --- /dev/null +++ b/crates/benilla-ui/src/script/follow.rs @@ -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 { + 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::().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)| { + // 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::().expect("model app_data"); + model + .follow_requests + .push(FollowRequest::ByName { name, exact }); + Ok(()) + })?, + )?; + + Ok(()) +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 68d5d1e9..aa876144 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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)?; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index e4f4fcff..3912025b 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, + /// 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, /// 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(), diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/law.rs b/crates/benilla-ui/src/script/tests/tooltip_item/law.rs index 6b780b0b..a3657048 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/law.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/law.rs @@ -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 `` (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 `` (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 = lines_of(&mut s).into_iter().map(|(t, _)| t).collect(); assert_eq!(texts, vec!["Small Barnacled Clam", ""]); + // 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 = 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()); } diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index acdf63d7..d981656f 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -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 `` — 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])), } }; diff --git a/crates/benilla-ui/src/script/tooltip_item/render.rs b/crates/benilla-ui/src/script/tooltip_item/render.rs index ce632d49..62aa06e3 100644 --- a/crates/benilla-ui/src/script/tooltip_item/render.rs +++ b/crates/benilla-ui/src/script/tooltip_item/render.rs @@ -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 `` 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 ``.** + /// 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)); diff --git a/crates/benilla/assets/ui/UnitPopup.xml b/crates/benilla/assets/ui/UnitPopup.xml index 51579108..465bfe6c 100644 --- a/crates/benilla/assets/ui/UnitPopup.xml +++ b/crates/benilla/assets/ui/UnitPopup.xml @@ -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 diff --git a/crates/benilla/assets/ui/ZoneText.xml b/crates/benilla/assets/ui/ZoneText.xml index 235ba703..d0b9ee79 100644 --- a/crates/benilla/assets/ui/ZoneText.xml +++ b/crates/benilla/assets/ui/ZoneText.xml @@ -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 ]]> + + + + + + + + + + + + + + + + + + + + AutoFollowStatus_OnLoad(); + + + AutoFollowStatus_OnEvent(event); + + + AutoFollowStatus_OnUpdate(arg1); + + + diff --git a/crates/benilla/src/items.rs b/crates/benilla/src/items.rs index 0ebadda0..6ff17d4b 100644 --- a/crates/benilla/src/items.rs +++ b/crates/benilla/src/items.rs @@ -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] diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index 8aa64952..0bbf8c10 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -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) diff --git a/crates/benilla/src/player/follow.rs b/crates/benilla/src/player/follow.rs index 4da641b2..e1583651 100644 --- a/crates/benilla/src/player/follow.rs +++ b/crates/benilla/src/player/follow.rs @@ -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, + /// 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, +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 ` 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>, + buttons: Res<'w, ButtonInput>, + /// 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