superwow: Clickthrough is live — the last function on the DLL's manifest
It had been stored and read by nothing, which is "a knob with two positions and one picture". Now the world pick looks through a dead unit that holds no loot for us, which is what SuperAPI's own description asks for: "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. The test is `CanLootUnit`'s own, negated (`clickthrough_skips`), so "lootless" means one thing to that verb, to this pick and to the loot sparkle; and because `UNIT_DYNFLAG_LOOTABLE` 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 — and a unit whose descriptor has not streamed is not known to be empty. The flag is mirrored out of the VM once a frame 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. The resource is `Option` in the pick, like `factions` beside it: a harness that drives this pick builds the world it needs and nothing more, and absent means off, which is also the default. The stale aggregate row this closes is rewritten rather than deleted, because what it got wrong is the lesson: it called six functions "blocked — each needs new app-side state", and three of those blockers were answered by data the tree already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -96,6 +96,13 @@ pub(super) struct TargetHoverState<'w, 's> {
|
||||
/// The corpse reaction gate's inputs (`0x6067d0`).
|
||||
factions: Option<Res<'w, super::Factions>>,
|
||||
self_store: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
|
||||
/// 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<Res<'w, super::ClickthroughCorpses>>,
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
mut through: ResMut<ClickthroughCorpses>,
|
||||
) {
|
||||
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::<ClickthroughCorpses>().add_systems(
|
||||
Update,
|
||||
read_clickthrough
|
||||
.after(crate::ui_script::UiInput)
|
||||
.in_set(crate::char_select::InWorldGated),
|
||||
);
|
||||
app.init_resource::<Selection>()
|
||||
.init_resource::<ClickConfig>()
|
||||
.init_resource::<AssistAttack>()
|
||||
|
||||
@@ -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<i64>| {
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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<Guid>` 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`) |
|
||||
|
||||
Reference in New Issue
Block a user