diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index ea7dd862..3623ebbc 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -96,6 +96,13 @@ pub(super) struct TargetHoverState<'w, 's> { /// The corpse reaction gate's inputs (`0x6067d0`). factions: Option>, self_store: Query<'w, 's, &'static ObjectStore, With>, + /// SuperWoW's `Clickthrough()` (`super::ClickthroughCorpses`): set, a dead unit holding no + /// loot for us is not a candidate at all, so the click lands on whatever is behind it. + /// + /// `Option`, like `factions` above: a harness that drives this pick directly builds the world + /// it needs and nothing more, and an absent resource means the feature is off — which is also + /// its default. + clickthrough: Option>, } impl TargetHoverState<'_, '_> { @@ -105,7 +112,17 @@ impl TargetHoverState<'_, '_> { /// whose store has not streamed is refused then. fn is_candidate(&self, kind: EntityKind, store: Option<&ObjectStore>) -> bool { match kind { - EntityKind::Unit | EntityKind::Player => true, + EntityKind::Unit | EntityKind::Player => { + // SuperWoW's `Clickthrough`: a body with nothing in it for us is looked through. + // The test is `CanLootUnit`'s own (dead, and `UNIT_DYNFLAG_LOOTABLE`), so + // "lootless" means one thing to the verb, to this pick and to the loot sparkle — + // and because that bit is per-viewer on the server, a corpse somebody else may + // loot is lootless *to us*, which is exactly the pile this feature is for. + // + // A LIVE unit is never skipped, whatever its flags: the feature is about corpses, + // and skipping a living mob would make it unclickable. + !clickthrough_skips(self.clickthrough.as_deref().is_some_and(|c| c.0), store) + } EntityKind::Corpse => { let Some(word) = self.corpse_pick.0 else { return true; @@ -124,6 +141,15 @@ impl TargetHoverState<'_, '_> { } } +/// Whether SuperWoW's `Clickthrough` looks through this body: the flag is set, and the unit is +/// dead and holds no loot for us. `CanLootUnit`'s own test, negated — so "lootless" means one +/// thing to that verb, to this pick and to the loot sparkle, and because the bit is per-viewer on +/// the server a corpse somebody else may loot is lootless *to us*, which is the pile the feature +/// is for. +fn clickthrough_skips(on: bool, store: Option<&ObjectStore>) -> bool { + on && store.is_some_and(|s| s.0.unit_is_dead() && !s.0.unit_lootable()) +} + /// The posed pick's geometry under one model root: its skinned parts. Ours are dropped while /// hidden: first person hides them ([`crate::player::apply_self_model_fade`] at alpha 0), where the /// reference's camera fade clears its player-visible flag `[0xc4d74c]` (`0x482c41`) and @@ -1081,4 +1107,47 @@ mod tests { "a body with no geometry of its own still offers what it holds" ); } + + /// SuperWoW's `Clickthrough`: a dead body with no loot for us is looked through, and nothing + /// else is — not a living mob whatever its flags, and nothing at all while the flag is clear. + #[test] + fn clickthrough_looks_through_an_empty_body_and_nothing_else() { + /// The three fields the test reads. `unit_is_dead` is `max > 0 && health == 0`, so the + /// maximum is load-bearing: without it a corpse reads as a unit whose health has not + /// streamed, which is why this fixture carries all three. + const F_HEALTH: u16 = 22; + const F_MAX_HEALTH: u16 = 28; + const F_DYNAMIC_FLAGS: u16 = 143; + let store = |health: u32, flags: u32| { + ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[ + (F_HEALTH, health), + (F_MAX_HEALTH, 100), + (F_DYNAMIC_FLAGS, flags), + ])) + }; + let dead_empty = store(0, 0); + let dead_lootable = store(0, 0x1); + let alive = store(100, 0); + + assert!( + clickthrough_skips(true, Some(&dead_empty)), + "the body this feature exists for: dead, and nothing in it for us" + ); + assert!( + !clickthrough_skips(true, Some(&dead_lootable)), + "a body we CAN loot stays clickable — looking through it would break looting" + ); + assert!( + !clickthrough_skips(true, Some(&alive)), + "a living unit is never skipped, whatever its flags: that would make mobs unclickable" + ); + assert!( + !clickthrough_skips(false, Some(&dead_empty)), + "and nothing is skipped while the flag is clear, which is the default" + ); + assert!( + !clickthrough_skips(true, None), + "a unit whose descriptor has not streamed is not known to be empty" + ); + } } diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index fd1dbb2c..a7af93ee 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -249,9 +249,40 @@ pub(crate) fn on_cvar( /// Targeting: click-to-select + the ground selection ring. pub(crate) struct TargetPlugin; +/// SuperWoW's `Clickthrough()`: whether the world pick looks **through** a dead unit that holds +/// no loot for us, so a click lands on whatever is under it. SuperAPI's own description is +/// "Allows you to click through corpses to loot corpses underneath them" — the pile after an AoE +/// pull, where the one body you want is behind four empty ones. +/// +/// Mirrored out of the VM once a frame by [`read_clickthrough`] rather than read there, because +/// the pick is a `SystemParam` with no reach into the script. A one-frame lag on a preference a +/// player sets once is immaterial, and the alternative is a VM handle in the pick. +#[derive(Resource, Default)] +pub(crate) struct ClickthroughCorpses(pub(crate) bool); + +/// Mirror `Clickthrough()`'s flag into [`ClickthroughCorpses`]. `.after(UiInput)`, with the other +/// VM drains, so a value Lua set this frame is here the same frame. +fn read_clickthrough( + script: Option>, + mut through: ResMut, +) { + if let Some(script) = script { + let want = script.clickthrough_corpses(); + if through.0 != want { + through.0 = want; + } + } +} + impl Plugin for TargetPlugin { fn build(&self, app: &mut App) { app.add_observer(on_cvar); + app.init_resource::().add_systems( + Update, + read_clickthrough + .after(crate::ui_script::UiInput) + .in_set(crate::char_select::InWorldGated), + ); app.init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-ui/src/script/compat/superwow.rs b/crates/benilla-ui/src/script/compat/superwow.rs index 9df26e06..e204f192 100644 --- a/crates/benilla-ui/src/script/compat/superwow.rs +++ b/crates/benilla-ui/src/script/compat/superwow.rs @@ -169,10 +169,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // Clickthrough([0|1]): click-through on lootless corpses. Stored and reported faithfully so an - // options panel round-trips, but NOTHING READS IT YET — the world-click corpse path is not - // wired to it (`Model::clickthrough_corpses`, `docs/ADDON_COMPAT.md`). Answering honestly - // beats raising: an addon that sets it keeps working, and the gap is one place, written down. + // Clickthrough([0|1]): click-through on lootless corpses — the pile after an AoE pull, where + // the one body you want is behind four empty ones. **Live**: the world pick skips a dead unit + // with no loot for us while this is set (`benilla-app`'s `target::ClickthroughCorpses`, read + // once a frame beside the other VM drains). The test it applies is `CanLootUnit`'s own, so + // "lootless" means the same thing to both verbs and to the loot sparkle. g.set( "Clickthrough", lua.create_function(|lua, on: Option| { @@ -660,6 +661,12 @@ impl crate::script::UiScript { self.model_mut().mouseover_override = None; } + /// `Clickthrough()`'s flag, for the world pick (`benilla-app`'s `target::ClickthroughCorpses`). + #[must_use] + pub fn clickthrough_corpses(&self) -> bool { + self.model_mut().clickthrough_corpses + } + /// `CombatLogAdd` lines since the last drain, each with its `addToRawLog` flag: the combat /// log takes every line, SuperWoW's raw log the flagged ones. pub fn take_combat_log_adds(&mut self) -> Vec<(String, bool)> { diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 9ab248ac..ef80f6ae 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -34,7 +34,8 @@ nothing behind it. | `GetWeaponEnchantID` | **done** | the one thing `GetWeaponEnchantInfo` cannot say: the reference reads that very dword as its gate (`0x4c97f8`, nonzero, no DBC lookup) and then reports only that there *was* an enchant, so on the stock surface nothing can tell Windfury from Rockbiter or a shaman's imbue from a rogue's poison. The id was already in hand app-side and discarded — `ui_char.rs`'s `weapon_enchant` used it as a `?` gate — so this was one field on the snapshot. Two returns, main hand then off hand, which is the order its six-value sibling already answers in; the shape is a reading, since upstream publishes no usage string for it and nothing in the corpus calls it. A test asserts the two functions cannot disagree about *which* hands are enchanted | | `GetWeaponEnchantInfo([unit])`'s unit argument | todo, blocked | the extension that takes a **unit** still needs per-unit weapon-enchant tracking; today only `"player"`'s is fed at all | | `GetContainerItemInfo` charges (negative stack count) | todo, blocked | the raw signed field exists (`ObjectFields::item_spell_charges`, benilla-protocol), but nothing in `benilla-formats`' item template says *which* of an item's five spell-trigger slots (if any) is a consumable charge rather than an equip/proc effect — exposing it without that would be a guess, not a port | -| New functions (`SpellInfo`, `TrackUnit`/`UntrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `UnitNameplate`, `CanLootUnit`, Import/Export/AppendText, map/world coord conversions, `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed`, mail/quest-link getters) | todo, blocked | each needs new app-side state, not a binding tweak: `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed` need a self-movement snapshot (`move_flags() & SWIMMING`, `unit_mount_display_id()`, `area.rs`'s `indoor` bool, and a `MoveSpeeds` component — none currently reach a `feed_*` system that can push them, and `ui_unit.rs`'s `feed_units` is already at Bevy's 16-param ceiling); **`SpellInfo` is now done** — `compat/superwow.rs`, over `script/spellbook.rs`'s `spell_display`: the player's book first, then the host's `Spell.dbc` probe ([`SpellProbe`], installed by `ui_script::lifecycle::install_spell_probe` on every VM build), so it reaches every shipped spell as the real mod does. The range pair stays nil — `SpellRange.dbc` is not on the probe; the rest (`TrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `CanLootUnit`, `UnitNameplate`, file I/O, map/world coords, quest links) are each plausible but unstarted. **`CombatLogAdd` is done** and has its own row below. **The two mail link getters are now done**, built for ClassicAPI's own `Mail` namespace (`compat/classic_api/merchant_frame.rs`, see its row below) — SuperWoW specifies the same `GetSendMailItemLink`/`GetInboxItemLink`, so that half of this row is closed | +| New functions (`SpellInfo`, `TrackUnit`/`UntrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `UnitNameplate`, `CanLootUnit`, `CombatLogAdd`, Import/Export/AppendText, map/world coord conversions, `IsSwimming`/`IsMounted`/`IsIndoors`/`GetSpeed`, mail and quest-link getters) | **superseded: every one of them is built** | this row used to read "todo, blocked — each needs new app-side state, not a binding tweak", and three of its four stated blockers turned out to be wrong. The self-movement snapshot it asked for **already existed** (`SelfMovement`, built for ClassicAPI's `State` namespace), and the three posture getters are SuperWoW's own spelling — `IsIndoors`, capital-I, not the lowercase `isIndoors` this row guessed at — so they were live before the row was written. `CanLootUnit` needed no feed at all: `UnitState` already carried `dead` and raw `dynamic_flags`. `UnitNameplate` was one line over a lookup ClassicAPI's `C_NamePlate` had already built. Only `GetSpeed` really did need a new field, and `net::current_speed` already computed the number. The row is kept rather than deleted because that pattern — a "needs a feed" verdict answered by data the tree already had — is the expensive mistake this file exists to record. Each name's own row, with what it is measured from, is in the DLL-manifest section below | +| `Clickthrough([0|1])` | **done** | the world pick skips a dead unit holding no loot for us while this is set, so a click lands on whatever is behind it — SuperAPI's own description is "click through corpses to loot corpses underneath them", the pile after an AoE pull. The test is `CanLootUnit`'s own, negated (`hover.rs`'s `clickthrough_skips`), so "lootless" means one thing to that verb, to this pick and to the loot sparkle; and because the bit is per-viewer on the server, a corpse somebody else may loot is lootless *to us*, which is exactly the pile the feature is for. A **living** unit is never skipped whatever its flags — that would make mobs unclickable. The flag is mirrored out of the VM once a frame (`target::ClickthroughCorpses`) rather than read in the pick, which is a `SystemParam` with no reach into the script; a one-frame lag on a preference set once is immaterial | | `UnitPosition(unitid)`, `CursorPosition()` | done | `UnitState.position` (`script/unit/mod.rs`), fed by `Transform` from `ui_unit.rs`'s `feed_units` (player/target/targettarget/npc) and `ui_tooltip.rs`'s `drive_mouseover_tooltip` (mouseover); functions in `compat/superwow.rs`. Party/raid/pet aren't wired (their own `snapshot()` call sites in `ui_party/feed.rs`/`ui_pet/unit.rs` pass `None` — each needs its own `Entity`→`Transform` lookup threaded through, not attempted this pass). Reading `Transform` broadly past the usual one-frame-stale tolerance raised `game_plugins.rs`'s `UPDATE_ACTIONABLE_CEILING` by 30 (cited there) — `Transform` is written by enough movement/animation/physics systems that `With` alone doesn't shrink the ambiguity count | | `UNIT_CASTEVENT` | done | `crates/benilla-app/src/superwow_events.rs`: a `CastEventFeed` queue the cast handlers push into, drained once per tick by one VM-holding system that fires the event — the pattern `ui_mirror.rs` already uses, and the only way across, since `spell/net.rs`'s `Lifecycle`/`Scene` bundles deliberately hold no `UiScript` (`game_plugins.rs`'s `every_vm_holder_in_update_declares_its_side_of_the_tick` gates that). Edges wired: `START` from `SMSG_SPELL_START` with its wire cast time as `arg5` (an instant raises none — its only edge is `CAST`, which is what the wiki's own START/CAST split means), `CAST` from `SMSG_SPELL_GO`, `FAIL` from `SMSG_SPELL_FAILED_OTHER`, `CHANNEL` from `MSG_CHANNEL_START` with its duration. GUIDs go out in the same 16-hex-digit spelling every other `compat/` surface uses, so `arg1`/`arg2` compare directly against a `UnitExists`/`UnitGUID` return. Not wired: `MAINHAND`/`OFFHAND` — benilla's melee-swing path raises no cast edge at all, so there is nothing to forward. `MSG_CHANNEL_START` names no caster (1.12 sends it for the local player's own channel only), so `CHANNEL`'s `arg1` is self and its `arg2` is 0 | | `RAW_COMBATLOG` | **done** | `ui_chat/frames.rs`'s `route` — the one funnel every combat line already passes on its way to a window and to `WoWCombatLog.txt`. Two arguments, as its one consumer in grom's corpus destructures them: the printed sentence, and the same sentence with each unit name spelled as its GUID (`PendingCombat::guid_fills`, composed a second time through the same `compose_line`, so the two can only ever differ in the name slots). Fired **before** the `CHAT_MSG_*`, which is what "raw" means upstream — it hooks the combat-log parse, ahead of the client's own formatting. Gated on `has_listener` — the gate nampower's combat-log family already uses — **or on SuperWoW's raw log being open**, since that file is the same `arg2` form written to disk; the composition is skipped under the same one test (`frames::wants_raw_form`), because a raid's log is tens of lines a second. A variant whose template says "you" keeps saying it, because that word is in the *template* and not in a fill — and its consumer depends on exactly that, reading the word and then asking `UnitExists("player")` for its own guid. **Why this row came first:** of SuperWoW's whole surface, it and `CombatLogAdd` are the only two names grom's addons call that this client lacked (`scripts/dll-surface.py --wanted`), and `SuperCleveRoidMacros/Utility.lua:10206` funnels **five** subsystems through this single event — immunity and resist learning, hidden-CC ("afflicted by") detection, debuff-duration learning off the GUID half, and the reactive-proc window that lights **Overpower** and **Revenge**. Its own comment there is why the last of those can come from nowhere else: "SPELL_GO only provides hit/miss COUNTS, not miss TYPES, so it cannot distinguish dodge (Overpower proc) from parry/block/resist". This does **not** by itself fix the Overpower complaint: that needs the dodged-*ability* sentence to be emitted at all, which is its own open item (`benilla-overpower-aura-spelldodged`) |