the Lightwell stands up, and stealth openers start your swing

- the Lightwell has its shaft: a billboard card kept only the x of its bone's scale, so a bone animated non-uniformly (the well's 8.8 × 8.8 × 37.6 light column) collapsed into a squat blob drowning the bowl — 32 models and 49 bones in the corpus were losing their stretch the same way, mage Teleport's six bones at ×11 among them; `bbscan` grew the column that finds them
- Backstab, Garrote, Ambush, Cheap Shot, Shred, Ravage, Pounce and one Judgement start your auto-attack on the spell's hit, as the reference does — the "no spell carries that bit" the deferral rested on was a proof by absence over ten rows; the census now covers the shipped file's 36
- Quiver's range rung reads right: the interact-distance map held players only, so a mob "target" answered a constant at every distance; it holds every unit now, with the inspect refusal riding the entry
- the craft and tradeskill reagent slot is the reference's QuestItemTemplate (147×41, the name plate, the count on the icon) — its base template lives in the Blizzard addons, not FrameXML, which is why the first cut was hand-rolled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:47:48 +07:00
co-authored by Claude Fable 5
parent 343e46fdde
commit eaabe372f7
20 changed files with 1175 additions and 248 deletions
+23 -12
View File
@@ -583,26 +583,37 @@
</Button>
<!--
One reagent slot (ref CraftItemTemplate inherits QuestItemTemplate, l.29-53 — TradeSkillFrame's
own BenillaTradeSkillReagentTemplate shape, byte-identical layout): a 28×28 icon, the reagent
name to its right, "have /need" below the name.
One reagent slot. The ref's `CraftItemTemplate` (l.29-53) inherits `QuestItemTemplate` and
overrides **only scripts** — same as TradeSkillFrame's `TradeSkillItemTemplate`, so the two
windows' slots really are the byte-identical layout this comment always claimed; what they
share is QuestItemTemplate's shape, which neither window had until B250 (decision 1595).
A 39×39 icon, the `UI-QuestItemNameFrame` plate riding its right edge, the name centred ON the
plate, and the "have /need" count on the icon's own BOTTOMRIGHT. This file keeps its OWN copy
(StackSplit.xml's "not a cross-file inherits" note), kept in sync by hand.
-->
<Button name="BenillaCraftReagentTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="140" y="32"/></Size>
<Size><AbsDimension x="147" y="41"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon">
<Size><AbsDimension x="28" y="28"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="39" y="39"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
</Texture>
<!-- Declared AFTER the icon (same parent+level ⇒ z-tiebreak): the plate's 128×64
texture starts 10px INSIDE the icon's right edge, but its art only begins 11
texels in, so the visible plate opens 1px clear of the icon. -->
<Texture name="$parentNameFrame" file="Interface\QuestFrame\UI-QuestItemNameFrame">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentIcon" relativePoint="RIGHT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="$parentName" inherits="GameFontHighlight" justifyH="LEFT">
<Size><AbsDimension x="90" y="36"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentNameFrame"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
<Layer level="ARTWORK">
<FontString name="$parentName" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP">
<Size><AbsDimension x="104" y="14"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentIcon" relativePoint="TOPRIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentCount" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentName" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<FontString name="$parentCount" inherits="NumberFontNormal" justifyH="RIGHT">
<Anchors><Anchor point="BOTTOMRIGHT" relativeTo="$parentIcon"><Offset><AbsDimension x="-4" y="1"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
@@ -860,28 +860,45 @@
</Button>
<!--
One reagent slot (ref TradeSkillItemTemplate inherits QuestItemTemplate, l.11-35 — the base
vanilla QuestItemTemplate isn't in the session's ref extraction, so its icon/name/count shape is
our own content, sized to the 2-column ~290px-wide grid the ref's own anchor chain implies):
a 28×28 icon, the reagent name to its right, "have /need" below the name. Gray-out + the
SetTradeSkillItem(sel, index) hover are the ref's own SetSelection reagent loop + OnEnter.
One reagent slot. The ref's `TradeSkillItemTemplate` (l.11-35) inherits `QuestItemTemplate` and
overrides **only scripts** — so the slot's whole visual IS QuestItemTemplate: a 39×39 icon, the
`UI-QuestItemNameFrame` plate riding its right edge, the name centred ON the plate, and the
"have /need" count on the icon's own BOTTOMRIGHT. Kept in sync by hand with QuestFrame.xml's
`QuestItemTemplate` and CraftFrame.xml's own copy (per-window duplication, StackSplit.xml's
"not a cross-file inherits" note).
This shape REPLACES a hand-rolled 28×28-icon / name-above / count-below slot that was written
when the session that built this window could not find the base QuestItemTemplate (bug B250,
decision 1595): TradeSkillFrame/CraftFrame are load-on-demand Blizzard ADDONS, so the ref lives
at `Interface\AddOns\Blizzard_TradeSkillUI\`, not in `Interface\FrameXML\`.
Gray-out + the SetTradeSkillItem(sel, index) hover are the ref's own SetSelection reagent loop
+ OnEnter.
-->
<Button name="BenillaTradeSkillReagentTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="140" y="32"/></Size>
<Size><AbsDimension x="147" y="41"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon">
<Size><AbsDimension x="28" y="28"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="39" y="39"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
</Texture>
<!-- Declared AFTER the icon (same parent+level ⇒ z-tiebreak, this file's own note):
the plate's 128×64 texture starts 10px INSIDE the icon's right edge, but its art
only begins 11 texels in, so the visible plate opens 1px clear of the icon — the
small gap the reference renders. -->
<Texture name="$parentNameFrame" file="Interface\QuestFrame\UI-QuestItemNameFrame">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentIcon" relativePoint="RIGHT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="$parentName" inherits="GameFontHighlight" justifyH="LEFT">
<Size><AbsDimension x="90" y="36"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentNameFrame"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
<Layer level="ARTWORK">
<FontString name="$parentName" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP">
<Size><AbsDimension x="104" y="14"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentIcon" relativePoint="TOPRIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentCount" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentName" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<FontString name="$parentCount" inherits="NumberFontNormal" justifyH="RIGHT">
<Anchors><Anchor point="BOTTOMRIGHT" relativeTo="$parentIcon"><Offset><AbsDimension x="-4" y="1"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
+10
View File
@@ -309,6 +309,11 @@ pub(super) fn apply_net_updates(
// The GO one-shot Custom play (`SMSG_GAMEOBJECT_CUSTOM_ANIM` — the bobber's bite
// splash, decision 1086), the step-8 sibling of the `GoLidOpen` writer above.
MessageWriter<crate::go_anim::GoCustomAnim>,
// Are *we* already swinging? The ref's `[player+0xc48]`, mirrored by the
// server-echoed [`crate::creature_anim::Engaged`] — read by the GO handler's deferred
// auto-attack start (`0x6e83e7`, decision 1593). Filter-only, so it conflicts with
// nothing else in this drain.
Query<(), (With<crate::creature_anim::Engaged>, With<SelfPlayer>)>,
),
),
// The aura feed's duration side-table + the clock to stamp arrivals (decisions 0255/0257): the
@@ -1290,6 +1295,11 @@ pub(super) fn apply_net_updates(
&net_commands,
&mut pet_bar,
),
(
&mut ui_actions.12,
&mut audio.15 .2,
!audio.15 .4.is_empty(),
),
play_seq.next(),
),
SessionEvent::SpellChainTargets {
+247 -3
View File
@@ -349,6 +349,13 @@ pub(super) fn spell_go(
&crate::net::NetCommands,
&mut crate::ui_pet::PetBar,
),
// The GO-deferred melee auto-attack start's write set (`0x6e83c0`, the arm below), plus the
// attack lock it gates on: our server-echoed `Engaged`, the ref's `[player+0xc48]`.
attack_ctx: (
&mut crate::ui_action::AutoRepeatActive,
&mut MessageWriter<crate::creature_anim::SheathRequest>,
bool,
),
seq: u64,
) {
debug!(
@@ -365,6 +372,7 @@ pub(super) fn spell_go(
go_lid.write(crate::go_anim::GoLidOpen { go_guid, spell_id });
}
let (cooldowns, spells, items, net_commands, pet_bar) = cooldown_ctx;
let (auto_repeat, sheath, engaged) = attack_ctx;
let now = Instant::now();
let display = spells.and_then(|s| s.catalog.get(spell_id));
// **A chest's loot-target arm** (wow-re `loot-anim-leg.md` §6, byte-verified §5 trio; decision
@@ -423,6 +431,44 @@ pub(super) fn spell_go(
// ring) opens exactly here, like the ref's inflight finish on the matching GO.
queued_melee.clear_if(spell_id);
// **The GO-deferred melee auto-attack start** (`HandleSpellGo 0x6e7a70` @ `0x6e83c0`,
// wow-re `combat-feel-law.md` §A3; bytes re-read for decision 1593). This is the exact
// complement of the send-time tail in [`crate::ui_action::cast_send`]: a spell carrying
// `AttributesEx2 & 0x100000` has its optimistic start *suppressed* there and armed here
// instead, so the swing begins only once the server confirms the strike landed. That is
// the whole 5875 stealth-opener class — Backstab, Garrote, Ambush, Cheap Shot, Shred,
// Ravage, Pounce — plus Judgement, none of which started an auto-attack in benilla at all
// before this (the deferred path was left unbuilt on a ten-row proof by absence; the real
// file carries the bit on 36 rows, censused in `benilla-formats`' `catalog_tests`).
//
// **The target** is the reference's, verbatim (`0x6e83e9`–`0x6e83fe`): `hits[0]` when the
// GO carries a hit list, else the null pair — which `0x612df0` resolves to the current
// selection and, failing that, acquires as the nearest hostile. We take the packet's own
// target guid as that fallback and stop there: the acquire-nearest leg is `target::scan`'s
// and reaching it from here would target something the player never named. A hostile
// single-target strike always fills the hit list, so the fallback is the quiet case.
//
// **The gate** is `[player+0xc48] == 0` (`0x6e83e7`) — already swinging, nothing happens,
// which is also what keeps the auto-repeat cancel in `start_attack_local`'s tail from
// firing on every strike of a fight.
if display.is_some_and(|d| d.initiates_auto_attack_at_go()) && !engaged {
if let (Some(&me), Some(guid)) =
(index.0.get(&caster), hits.first().copied().or(target))
{
debug!("net: spell go {spell_id} — deferred auto-attack start at {guid:#x}");
crate::creature_anim::start_attack_local(
me,
guid,
engaged,
false,
auto_repeat,
sheath,
commands,
net_commands,
);
}
}
// Our own launch starts the cast's cooldown locally, at the GO — byte-VERIFIED (the
// 2026-07-10 wow-re follow-up, `wave-handlers.md` ADDENDUM): `HandleSpellGo 0x6e7a70`'s
// self-insert tail forks on the packet guid pair — itemCaster == caster ⇒ the NO-ITEM
@@ -948,6 +994,7 @@ mod tests {
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
@@ -996,7 +1043,8 @@ mod tests {
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>| {
mut items: ResMut<crate::items::Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
10,
@@ -1029,6 +1077,11 @@ mod tests {
&net_commands,
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut sheath,
false,
),
1,
);
},
@@ -1108,6 +1161,7 @@ mod tests {
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
@@ -1146,7 +1200,8 @@ mod tests {
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>| {
mut items: ResMut<crate::items::Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
caster,
@@ -1179,6 +1234,11 @@ mod tests {
&net_commands,
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut sheath,
false,
),
1,
);
},
@@ -1233,6 +1293,7 @@ mod tests {
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
@@ -1282,7 +1343,8 @@ mod tests {
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>| {
mut items: ResMut<crate::items::Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
10,
@@ -1315,6 +1377,11 @@ mod tests {
&net_commands,
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut sheath,
false,
),
1,
);
},
@@ -1476,4 +1543,181 @@ mod tests {
removed_spell(14788, &mut actions);
assert!(!actions.dirty, "a spell we never knew is not a repaint");
}
/// **The GO-deferred auto-attack start** (`HandleSpellGo` @ `0x6e83c0`, decision 1593) — the
/// half of `combat-feel-law.md` §A3 benilla shipped without, because ten hand-picked warrior
/// rows were read as a census of `AttributesEx2 & 0x100000`. The real file carries the bit on
/// 36, so the class that never started an auto-attack here is every stealth opener and
/// positional strike: Backstab, Garrote, Ambush, Cheap Shot, Shred, Ravage, Pounce, Judgement.
///
/// Four things, and each is a way this arm could be subtly wrong:
/// - a bit20 spell's own GO sends `CMSG_ATTACKSWING` **at the GO's first hit target**
/// (`0x6e83e9`: `hits[0]`, not the packet's target field, and not our selection);
/// - a spell without the bit sends nothing — **bug B280's own control**: the hunter's instant
/// shots carry Ex2 bit **17** (`DO_NOT_RESET_COMBAT_TIMERS`), not bit 20, so casting Serpent
/// Sting starts no attack of either kind, which is what 0994 §4 recorded;
/// - already swinging sends nothing (`0x6e83e7`'s `[player+0xc48]` gate) — which is also what
/// keeps the start's own auto-repeat cancel off every strike of a fight;
/// - somebody else's Backstab going off sends nothing (the handler's self gate).
#[test]
fn a_go_deferred_spell_swings_at_its_first_hit_and_the_hunter_shots_do_not() {
use crate::combat_text::CombatTextSpawn;
use crate::creature_anim::{Casting, Engaged};
use crate::go_anim::GoLidOpen;
use crate::net::{ClientCommand, Guid, SelfPlayer};
use bevy::ecs::system::RunSystemOnce;
const BACKSTAB: u32 = 53;
const SERPENT_STING: u32 = 1978;
let spell = |name: &str, ex2: u32| benilla_formats::SpellDisplay {
name: name.into(),
attributes_ex2: ex2,
..Default::default()
};
let make_spells = || crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(
[
// The real 5875 words for the two: Backstab Ex2 0x100000, Serpent Sting
// Ex2 0x20000 (bit 17, one bit below — the whole distinction).
(BACKSTAB, spell("Backstab", 0x0010_0000)),
(SERPENT_STING, spell("Serpent Sting", 0x0002_0000)),
]
.into_iter()
.collect(),
),
forms: Default::default(),
ranges: Default::default(),
cast_times: Default::default(),
durations: Default::default(),
radii: Default::default(),
};
// `caster` casts `spell_id`, landing on 20; `engaged` is our mirror of `[+0xc48]`.
// Returns every command the seam put on the wire.
let fire = |caster: u64, spell_id: u32, engaged: bool| -> Vec<ClientCommand> {
let mut app = App::new();
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
.world_mut()
.spawn((Guid(10), SelfPlayer, ObjectStore::default()))
.id();
if engaged {
app.world_mut().entity_mut(self_e).insert(Engaged);
}
let other_e = app
.world_mut()
.spawn((Guid(11), ObjectStore::default()))
.id();
{
let mut index = app.world_mut().resource_mut::<GuidIndex>();
index.0.insert(10, self_e);
index.0.insert(11, other_e);
}
app.world_mut().resource_mut::<SelfGuid>().0 = Some(10);
let (tx, rx) = crossbeam_channel::unbounded();
let spells = make_spells();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
mut go_targets: MessageWriter<SpellGoTargets>,
self_guid: Res<SelfGuid>,
stores: Query<&mut ObjectStore>,
mut cast_bar: ResMut<CastBarFeed>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut text: MessageWriter<CombatTextSpawn>,
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
caster,
spell_id,
0,
vec![20],
vec![],
// The packet's own target field is deliberately a DIFFERENT guid: the
// arm must take `hits[0]`, and this is what catches it reading here.
Some(99),
None,
None,
None,
None,
&mut commands,
&index,
&casting,
&mut cast_events,
&mut go_targets,
&self_guid,
&stores,
&mut cast_bar,
&mut pending,
&mut queued_melee,
&mut text,
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
&mut cooldowns,
Some(&spells),
&mut items,
&net_commands,
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut sheath,
engaged,
),
1,
);
},
)
.unwrap();
rx.try_iter().collect()
};
let swings = |cmds: &[ClientCommand]| {
cmds.iter()
.filter_map(|c| match c {
ClientCommand::AttackSwing { guid } => Some(*guid),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(
swings(&fire(10, BACKSTAB, false)),
vec![20],
"a bit20 spell's own GO starts the swing at its first hit target"
);
assert!(
swings(&fire(10, SERPENT_STING, false)).is_empty(),
"Serpent Sting carries Ex2 bit 17, not bit 20 — it starts nothing (B280 / 0994 §4)"
);
assert!(
swings(&fire(10, BACKSTAB, true)).is_empty(),
"already swinging: `0x6e83e7`'s attack-lock gate refuses"
);
assert!(
swings(&fire(11, BACKSTAB, false)).is_empty(),
"somebody else's Backstab is not our attack-start"
);
}
}
+6 -11
View File
@@ -610,13 +610,13 @@ pub(super) fn follow_requests(
pub(crate) struct SelectCommit<'w, 's> {
pub(super) selection: ResMut<'w, Selection>,
seam: crate::creature_anim::AttackSeam<'w, 's>,
// `Entity` first because a caller can need our own body as a SELECTION target, not only as the
// comparison the classification runs against (`TargetUnit("player")`, the player frame's click).
// Our own body, read only for what the classification needs: the guid to compare against, the
// store `can_attack` reads, and whether we are mid-swing. It used to carry the `Entity` too,
// for `TargetUnit("player")`; that resolves through `crate::ui_unit::UnitTokens` now.
me: Query<
'w,
's,
(
Entity,
&'static Guid,
Option<&'static ObjectStore>,
Has<crate::creature_anim::Engaged>,
@@ -629,11 +629,6 @@ pub(crate) struct SelectCommit<'w, 's> {
}
impl SelectCommit<'_, '_> {
/// Our own body's entity + guid, for a caller that can be asked to select it.
pub(super) fn me(&self) -> Option<(Entity, u64)> {
self.me.single().ok().map(|(e, g, _, _)| (e, g.0))
}
pub(super) fn commit(&mut self, entity: Entity, guid: u64) {
let me = self.me.single().ok();
// The new-target classification `scan::commit` expects from its callers (it does not
@@ -642,15 +637,15 @@ impl SelectCommit<'_, '_> {
self.stores.get(entity).ok(),
self.factions.as_deref(),
&self.reputations,
me.and_then(|(_, _, store, _)| store),
me.and_then(|(_, store, _)| store),
);
scan::commit(
&mut self.selection,
&mut self.seam,
entity,
guid,
me.is_some_and(|(_, _, _, engaged)| engaged),
me.map(|(_, g, _, _)| g.0),
me.is_some_and(|(_, _, engaged)| engaged),
me.map(|(g, _, _)| g.0),
attackable,
);
}
+7 -46
View File
@@ -734,9 +734,10 @@ pub(super) fn clear_target_requests(
#[allow(clippy::too_many_arguments)]
pub(super) fn target_unit_requests(
script: Option<NonSendMut<UiScript>>,
group: Res<crate::ui_party::GroupState>,
index: Res<crate::net::GuidIndex>,
pet: Res<crate::ui_pet::PetBar>,
// The one unit-token resolver (`crate::ui_unit::UnitTokens`) — the arms this drain used to
// spell out inline. It is shared with the reach feed precisely so `TargetUnit("target")` and
// `CheckInteractDistance("target", …)` can never mean two different units (B304).
tokens: crate::ui_unit::UnitTokens,
// The one SetSelection tail, shared with `/target` and `/assist` (decision 1583). It carries
// the classification too, which is why this drain no longer states one: hand-stating it here
// is exactly how a `false` that the binary refutes got written down.
@@ -749,50 +750,10 @@ pub(super) fn target_unit_requests(
if requests.is_empty() {
return;
}
let me = commit.me();
let self_guid = me.map(|(_, g)| g);
for token in requests {
let resolved = match token.as_str() {
"player" => me,
"target" => commit.selection.target.zip(commit.selection.guid),
// The ToT frame's left click (decision 1576) — the target's own target, one hop off
// `UNIT_FIELD_TARGET`, resolved exactly as the `"targettarget"` snapshot and `/assist`
// resolve it. An unstreamed guid no-ops like every other arm here.
"targettarget" => commit
.selection
.target
.and_then(|e| commit.stores.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
.and_then(|g| index.0.get(&g).map(|e| (*e, g))),
// The pet resolves off the bar's cached guid, the same word `"pet"`'s snapshot and
// `UNIT_PET` read (`crate::ui_pet::feed_pet_unit`). An unstreamed pet no-ops, exactly
// as an out-of-range party member does.
"pet" => (pet.spells.pet_guid != 0)
.then(|| index.0.get(&pet.spells.pet_guid))
.flatten()
.map(|&e| (e, pet.spells.pet_guid)),
// `raidN` before the `partyN` arm for the same reason the reference's own resolver
// orders `partypet` ahead of `party`: a prefix test that runs first wins. These are
// the RaidFrame rows' left-click (decision 1549); like a party slot, an out-of-range
// member no-ops until the guid-only selection lands.
t if t.starts_with("raid") => t
.strip_prefix("raid")
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| (1..=40).contains(n))
.and_then(|n| {
crate::ui_party::raid_row_guids(&group, self_guid)
.get(n - 1)
.copied()
})
.and_then(|g| index.0.get(&g).map(|e| (*e, g))),
t => t
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| (1..=4).contains(n))
.and_then(|n| group.party_slots().nth(n - 1).map(|m| m.guid))
.and_then(|g| index.0.get(&g).map(|e| (*e, g))),
};
// Resolved before the commit borrows the selection mutably. An unstreamed unit — an
// out-of-range party member, a despawned pet — resolves to nothing and no-ops.
let resolved = tokens.resolve(&token, &commit.selection);
if let Some((entity, guid)) = resolved {
commit.commit(entity, guid);
}
+6 -80
View File
@@ -1,6 +1,9 @@
//! The app-side **inspect feed** (decision 0631) — the bridge that turns another player's PUBLIC
//! descriptor into the slot views the inspect window's Lua reads, and the one place the
//! `CanInspect` range gate is computed.
//! descriptor into the slot views the inspect window's Lua reads.
//!
//! The `CanInspect`/`CheckInteractDistance` distance map used to be fed from here too, back when it
//! held popup tokens only and only for players. It is unit-general now (B304) and lives with the
//! other unit-token feeds — `crate::ui_unit::feed_unit_reach`.
//!
//! This is [`crate::ui_char`]'s pattern turned onto a *foreign* unit, and the difference between
//! the two is the whole reason this module exists rather than a flag on that one:
@@ -16,13 +19,8 @@
//! rendering their gear). That is exactly why the reference's inspect paper doll shows no stack
//! counts and no durability: the client has none either.
//!
//! Four jobs, each frame, before the VM ticks:
//! Three jobs, each frame, before the VM ticks:
//!
//! - **Feed the reach map.** For every popup token that resolves to a live, inspectable player, its
//! squared distance from us — the input to both verified range predicates (`CanInspect` and
//! `CheckInteractDistance`), which the VM cannot compute because it holds no positions. Computed
//! for party tokens as well as `"target"`, which is why it can't ride `UnitState` (only
//! player/target/mouseover get one).
//! - **Drain the intents.** `NotifyInspect(unit)` → resolve the token to a player guid → send
//! `CMSG_INSPECT` and latch the token as the inspect target. `ClearInspectPlayer()` → drop it.
//! The window does not wait for `SMSG_INSPECT` (it carries only the echoed guid, and the ref
@@ -51,27 +49,6 @@ use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
use crate::portrait::InspectBooth;
use crate::ui_script::UiInput;
/// The unit tokens a UnitPopup row can name for another player — the popup menus' own set
/// (`UnitPopupMenus["PLAYER"]` is driven by the target frame, `["PARTY"]` by the four party
/// frames). The reach map below is computed for exactly these each frame.
const REACH_TOKENS: [&str; 5] = ["target", "party1", "party2", "party3", "party4"];
/// The squared distance between two world positions, in the binary's own accumulation shape: `f32`
/// inputs widened to `f64`, summed `(dz² + dx²) + dy²` (wow-re's transcription of
/// `0x48a26f..0x48a27d`, the kernel `caninspect_dist2` and `check_interact_dist2` share).
///
/// Our axes are Bevy's rather than the client's WoW triple. d² is invariant under that rotation, so
/// the only conceivable divergence from the binary is a last-ulp one — which can only change the
/// verdict for a unit standing *exactly* on a threshold. The thresholds and comparison operators,
/// which are what actually decide each gate, are transcribed exactly at the two bindings
/// (`benilla_ui::script::inspect`).
fn dist_sq(q: Vec3, p: Vec3) -> f64 {
let dx = f64::from(q.x) - f64::from(p.x);
let dy = f64::from(q.y) - f64::from(p.y);
let dz = f64::from(q.z) - f64::from(p.z);
(dz * dz + dx * dx) + dy * dy
}
/// Which unit the inspect window is bound to — the ref's `InspectFrame.unit`, mirrored app-side so
/// the feed knows whether to resolve anything. The **token** is the identity (not the guid): the
/// reference re-reads the token on every retarget, so `"target"` follows the selection.
@@ -197,12 +174,6 @@ fn feed_inspect(
stores: Query<&ObjectStore>,
selection: Res<crate::target::Selection>,
group: Res<crate::ui_party::GroupState>,
// The range gate's inputs (the same Transform distance `ui_session`'s service-range check uses)
// plus the faction pair `can_attack` needs.
self_q: Query<(&Transform, &ObjectStore), With<crate::net::SelfPlayer>>,
transforms: Query<&Transform>,
factions: Option<Res<crate::target::Factions>>,
reputations: Res<crate::net::Reputations>,
) {
let Some(mut script) = script else {
return;
@@ -211,51 +182,6 @@ fn feed_inspect(
// memo re-pushes the view into the VM that replaced the one which last saw it.
let memo = feed.vm.get(&script);
// The reach map: for every popup token that resolves to a **live unit object**, its squared
// distance from us plus whether it passes the two non-distance refusals vmangos makes for an
// inspect — the target is a player (`sObjectMgr.GetPlayer`) and is not attackable
// (`IsValidAttackTarget`, `MiscHandler.cpp:945-956`). That the *client* checks those two is
// INFERRED (the 348-byte `0x48a1b0`'s non-math part isn't in the RE record), but a wrong guess
// can only cost a request the server would drop.
//
// **Membership is the object lookup and nothing else** (report B316). An absent token is one
// the object manager holds no unit for, and the bindings answer `nil` there — the reference's
// own null-object arm. So the attackable refusal rides IN the entry as `inspectable` rather
// than keeping the token out of the map: dropping it would make `CheckInteractDistance` — a
// pure distance test in the binary — gray Follow and Duel on an enemy player the reference
// leaves live.
let self_pair = self_q.iter().next();
let mut reach = std::collections::HashMap::new();
if let Some((self_tf, self_store)) = self_pair {
for token in REACH_TOKENS {
let Some(guid) = crate::ui_unit::player_token_guid(token, &selection, &group) else {
continue;
};
// The object-manager miss: a party member outside the local area has a GUID (the
// roster wire carries it) and no object, which is exactly the case B316 reported.
let Some(&entity) = index.0.get(&guid) else {
continue;
};
let store = stores.get(entity).ok();
let inspectable = !crate::target::can_attack(
store,
factions.as_deref(),
&reputations,
Some(self_store),
);
if let Ok(tf) = transforms.get(entity) {
reach.insert(
token.to_string(),
benilla_ui::script::UnitReach {
dist_sq: dist_sq(tf.translation, self_tf.translation),
inspectable,
},
);
}
}
}
script.set_inspect_reach(reach);
// NotifyInspect(unit) → CMSG_INSPECT + latch the token. Resolving here (not in Lua) is what
// lets the window bind to a token while the wire speaks guids.
for token in script.take_inspect_notifies() {
+32 -12
View File
@@ -52,7 +52,7 @@ pub(super) struct FedParty {
ready_check: u32,
}
const PARTY_TOKENS: [&str; 4] = ["party1", "party2", "party3", "party4"];
pub(crate) const PARTY_TOKENS: [&str; 4] = ["party1", "party2", "party3", "party4"];
/// **The saved-instance edge** — a new LIST *or* a new ANSWER (1561).
///
@@ -70,7 +70,7 @@ fn saved_instances_moved(saved: &[SavedInstanceInfo], answers: u32, fed: &FedPar
/// and a table of forty `&'static str` costs nothing where forty `String`s per roster change
/// would (decision 1549). `MAX_RAID_MEMBERS` is the reference's own 40.
#[rustfmt::skip]
const RAID_TOKENS: [&str; 40] = [
pub(crate) const RAID_TOKENS: [&str; 40] = [
"raid1", "raid2", "raid3", "raid4", "raid5", "raid6", "raid7", "raid8", "raid9", "raid10",
"raid11", "raid12", "raid13", "raid14", "raid15", "raid16", "raid17", "raid18", "raid19",
"raid20", "raid21", "raid22", "raid23", "raid24", "raid25", "raid26", "raid27", "raid28",
@@ -481,22 +481,42 @@ pub(super) struct RaidSelf {
///
/// Empty outside a raid, which is what makes every raid-index verb a no-op in a plain party.
pub(crate) fn raid_row_guids(group: &GroupState, self_guid: Option<u64>) -> Vec<u64> {
if group.group_type != GROUPTYPE_RAID {
return Vec::new();
}
self_guid
.into_iter()
.chain(group.members.iter().map(|m| m.guid))
.collect()
raid_rows(group, self_guid).collect()
}
/// **The raid row ORDER, and the one place it is decided** — us first, then the roster in wire
/// order — as an iterator, so a caller that wants one row does not build all forty.
///
/// [`raid_row_guids`] collects it for the feed (which pushes every row anyway) and
/// [`raid_row_guid`] indexes it for the resolvers, which ask per token: `crate::ui_unit`'s reach
/// feed asks forty times a frame, and forty `Vec`s a frame for one `u64` each is the kind of cost
/// that only shows up in a raid, i.e. exactly where it hurts.
fn raid_rows(group: &GroupState, self_guid: Option<u64>) -> impl Iterator<Item = u64> + '_ {
let in_raid = group.group_type == GROUPTYPE_RAID;
self_guid.filter(|_| in_raid).into_iter().chain(
group
.members
.iter()
.filter(move |_| in_raid)
.map(|m| m.guid),
)
}
/// The guid at a 1-based raid row, or `None` for a row that is not there — the allocation-free
/// [`raid_row_guids`]`[index - 1]`.
pub(crate) fn raid_row_guid(
group: &GroupState,
self_guid: Option<u64>,
index: usize,
) -> Option<u64> {
raid_rows(group, self_guid).nth(index.checked_sub(1)?)
}
/// A raid row index (1-based, the Lua scale) → the guid it names, or `None` for a row that is not
/// there. Every raid-management verb goes through this, so "index 0", "index 500" and "not in a
/// raid" all collapse to the same quiet no-op the reference's own bindings produce.
fn raid_guid_at(group: &GroupState, self_guid: Option<u64>, index: u32) -> Option<u64> {
let rows = raid_row_guids(group, self_guid);
rows.get(usize::try_from(index).ok()?.checked_sub(1)?)
.copied()
raid_row_guid(group, self_guid, usize::try_from(index).ok()?)
}
/// A guid → the character name the wire wants for the by-name group opcodes. Ours comes from the
+1 -1
View File
@@ -34,7 +34,7 @@ use bevy::prelude::*;
use crate::ui_script::UiInput;
mod feed;
pub(crate) use feed::{raid_row_guids, synthetic_raid, synthetic_roster};
pub(crate) use feed::{raid_row_guid, synthetic_raid, synthetic_roster, PARTY_TOKENS, RAID_TOKENS};
pub(crate) struct UiPartyPlugin;
@@ -393,7 +393,7 @@ fn shown_inspect_honor_page() -> UiScript {
s.set_unit("player", Some(alliance_player()));
// 4 yards away (d² = 16), inside the verified `CanInspect` 100.0 — the window refuses to open
// otherwise, and a silently-refused open would read as a broken pane.
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s.set_unit_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s.set_inspect_honor(Some(inspect_reply()));
s.run(r#"InspectUnit("target")"#).unwrap();
s.run(r#"ToggleInspect("BenillaInspectHonorFrame")"#)
@@ -137,7 +137,7 @@ fn armed() -> UiScript {
s.set_unit("target", Some(target_unit()));
s.set_inspect(Some(inspect_view("target")));
// 4 yards away (d² = 16) — comfortably inside the verified 100.0.
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s.set_unit_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s
}
@@ -176,7 +176,7 @@ fn shipped_inspect_frame_loads_clean() {
fn inspect_unit_refuses_out_of_range() {
let mut s = armed();
// 11 yards (d² = 121) — past the verified 100.0 threshold.
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(121.0))]));
s.set_unit_reach(HashMap::from([("target".to_string(), reach(121.0))]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
@@ -191,7 +191,7 @@ fn inspect_unit_refuses_out_of_range() {
);
// Step inside the threshold: the same call now opens and requests.
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(99.9))]));
s.set_unit_reach(HashMap::from([("target".to_string(), reach(99.9))]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(
s.eval::<bool>("return BenillaInspectFrame:IsVisible()")
@@ -218,7 +218,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
(100.0, true, false),
(100.1, false, false),
] {
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(d2))]));
s.set_unit_reach(HashMap::from([("target".to_string(), reach(d2))]));
assert_eq!(
s.eval::<bool>(r#"return CanInspect("target") ~= nil"#)
.unwrap(),
@@ -233,7 +233,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
);
}
// Type 4 is the 30-yard row (900.0) — the table is indexed, not hardcoded to one distance.
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(899.0))]));
s.set_unit_reach(HashMap::from([("target".to_string(), reach(899.0))]));
assert!(s
.eval::<bool>(r#"return CheckInteractDistance("target", 4) ~= nil"#)
.unwrap());
@@ -259,7 +259,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
);
// The `type` argument's three degenerate arms, each the binary's own answer.
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(1.0))]));
s.set_unit_reach(HashMap::from([("target".to_string(), reach(1.0))]));
for bad in ["0", "5", "-1", "0.5"] {
assert!(
s.eval::<bool>(&format!(
@@ -281,6 +281,20 @@ fn range_predicates_transcribe_the_verified_thresholds() {
.is_err(),
"no distIndex is a script error"
);
// The token is CASE-FOLDED, like every compare in the resolver both predicates reach their
// unit through (`_strnicmp`, 1247) — the same fold `UnitName("Target")` already gets. An
// addon that capitalises must not be told the unit is out of range.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("TARGET", 1) ~= nil"#)
.unwrap(),
"an upper-case token names the same unit"
);
assert!(
s.eval::<bool>(r#"return CanInspect("Target") ~= nil"#)
.unwrap(),
"…for both predicates"
);
}
/// **The doll shows THEIR gear, not ours.** Both sources are fed, with different items in the same
+307
View File
@@ -181,6 +181,7 @@ impl Plugin for UiUnitPlugin {
Update,
(
feed_units,
feed_unit_reach,
melee_unit_combat,
fire_unit_combat,
fire_combat_text,
@@ -503,6 +504,185 @@ pub(crate) fn player_token_guid(
}
}
/// **The one unit-token resolver** — token → the live object it names, or `None`.
///
/// The reference has exactly one of these (`0x515970`, nine `_strnicmp` compares, then the object
/// manager), and every binding that takes a unit reaches its unit through it. We had three: this
/// one (inlined in `TargetUnit`'s drain), [`player_token_guid`]'s players-only popup map, and the
/// per-token legs [`feed_units`] walks. They disagreed, and the disagreement was report **B304** —
/// the range map was built from the players-only one, so a creature `"target"` never entered it and
/// `CheckInteractDistance("target", 4)` answered a constant for every mob at every distance.
///
/// So: one resolver, and a caller that wants a *typemask* applies it to the resolved unit rather
/// than narrowing the token (the same order 1564 §2 settled for the reach map's own entries, and
/// the one `target::by_name`'s follow drain already keeps for the start gate).
///
/// [`Selection`] is a parameter rather than a field because [`crate::target::SelectCommit`] holds
/// it as `ResMut`, and one system cannot take both.
///
/// **Not resolved:** `partypetN`/`raidpetN` (no feed holds those units at all — a token nothing
/// pushes a `UnitState` for would answer a distance for a unit `UnitExists` denies) and `npc`
/// (the interaction target, which we do not keep). Both are *recognised* by the grammar
/// ([`benilla_ui::script`]'s `token_recognised`), so they stay a quiet nil, not a raise.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct UnitTokens<'w, 's> {
/// `Option` for [`feed_units`]' reason: a UI-only harness runs these feeds with no net stack,
/// and a bare `Res` turns that into a system-validation panic rather than a token that names
/// nobody. Same for the two below, which belong to the pet and targeting plugins.
index: Option<Res<'w, crate::net::GuidIndex>>,
pet: Option<Res<'w, crate::ui_pet::PetBar>>,
hovered: Option<Res<'w, crate::target::Hovered>>,
group: Res<'w, crate::ui_party::GroupState>,
pub(crate) stores: Query<'w, 's, &'static ObjectStore>,
me: Query<'w, 's, (Entity, &'static Guid), With<SelfPlayer>>,
}
impl UnitTokens<'_, '_> {
/// Our own body's entity + guid.
fn me(&self) -> Option<(Entity, u64)> {
self.me.iter().next().map(|(e, g)| (e, g.0))
}
/// The guid → entity map, when the net stack is present.
fn held(&self, guid: u64) -> Option<(Entity, u64)> {
Some((*self.index.as_ref()?.0.get(&guid)?, guid))
}
/// Resolve `token` to the live object it names. Case-folded (`_strnicmp`, 1247), and the
/// prefix order is the reference's: `raid` before `party` for the same reason its own resolver
/// puts `partypet` before `party` — the first matching compare wins.
pub(crate) fn resolve(&self, token: &str, selection: &Selection) -> Option<(Entity, u64)> {
let token = token.to_ascii_lowercase();
match token.as_str() {
"player" => self.me(),
"target" => selection.target.zip(selection.guid),
// The target's own target, one hop off `UNIT_FIELD_TARGET` — the read `/assist` and
// the `"targettarget"` snapshot both run (decision 1576).
"targettarget" => selection
.target
.and_then(|e| self.stores.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
.and_then(|g| self.held(g)),
// The hovered unit — the same `Hovered` pick `ui_tooltip` pushes `"mouseover"` from,
// so `UnitExists("mouseover")` and this can never disagree.
"mouseover" => {
let h = self.hovered.as_ref()?;
h.target.zip(h.guid)
}
// Off the bar's cached guid, the word `"pet"`'s snapshot and `UNIT_PET` read.
"pet" => {
let guid = self.pet.as_ref()?.spells.pet_guid;
(guid != 0).then(|| self.held(guid)).flatten()
}
t if t.starts_with("raid") => t
.strip_prefix("raid")
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| (1..=40).contains(n))
.and_then(|n| {
crate::ui_party::raid_row_guid(&self.group, self.me().map(|(_, g)| g), n)
})
.and_then(|g| self.held(g)),
t => t
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| (1..=4).contains(n))
.and_then(|n| self.group.party_slots().nth(n - 1).map(|m| m.guid))
.and_then(|g| self.held(g)),
}
}
}
/// The tokens [`feed_unit_reach`] offers the map each frame — every token [`UnitTokens`] can
/// resolve, reusing the party feed's own two tables so this set can never name a row that feed
/// does not push a `UnitState` for. `"player"` is in it because the reference answers it too
/// (d² = 0, in range of everything); the popup rows that started this map need only the first few.
fn reach_tokens() -> impl Iterator<Item = &'static str> {
["player", "target", "targettarget", "mouseover", "pet"]
.into_iter()
.chain(crate::ui_party::PARTY_TOKENS)
.chain(crate::ui_party::RAID_TOKENS)
}
/// The squared distance between two world positions, in the binary's own accumulation shape: `f32`
/// inputs widened to `f64`, summed `(dz² + dx²) + dy²` (wow-re's transcription of
/// `0x48a26f..0x48a27d`, the kernel `caninspect_dist2` and `check_interact_dist2` share).
///
/// Our axes are Bevy's rather than the client's WoW triple. d² is invariant under that rotation, so
/// the only conceivable divergence from the binary is a last-ulp one — which can only change the
/// verdict for a unit standing *exactly* on a threshold. The thresholds and comparison operators,
/// which are what actually decide each gate, are transcribed exactly at the two bindings
/// (`benilla_ui::script::inspect`).
fn dist_sq(q: Vec3, p: Vec3) -> f64 {
let dx = f64::from(q.x) - f64::from(p.x);
let dy = f64::from(q.y) - f64::from(p.y);
let dz = f64::from(q.z) - f64::from(p.z);
(dz * dz + dx * dx) + dy * dy
}
/// Feed the **unit reach map** — for every token that resolves to a live unit object, its squared
/// distance from us, plus whether that unit passes inspect's own two non-distance refusals.
///
/// **Membership is the object lookup and nothing else** (1564 §2). An absent token is one the
/// object manager holds no unit for, and both bindings answer `nil` there — the reference's own
/// null-object arm. Every typemask therefore rides IN the entry:
///
/// - *attackable* — vmangos's inspect refusal (`IsValidAttackTarget`, `MiscHandler.cpp:945-956`).
/// Keeping the token out of the map instead would make `CheckInteractDistance` — a pure distance
/// test in the binary — gray Follow and Duel on an enemy player the reference leaves live (B316).
/// - *is a player* — vmangos's other one (`sObjectMgr.GetPlayer`). This is the leg that was applied
/// at the token and was report **B304**: a creature `"target"` never entered the map, so the
/// 30-yard rung Quiver's Dead Zone band reads answered a constant for every mob — "in range" at
/// the published tag, "out of range" after 1564 flipped the absent default. Both are the same
/// defect: the map was never asked about mobs at all.
///
/// That the *client* checks those two is INFERRED (the 348-byte `0x48a1b0`'s non-math part isn't in
/// the RE record), but a wrong guess can only cost a request the server would drop.
///
/// Ungated, unlike every other feed here (1439): the numbers change whenever anything moves, so a
/// change gate would fire every frame anyway and cost a comparison for nothing. Nothing keys an
/// event off this map, so a re-push is invisible.
fn feed_unit_reach(
script: Option<NonSendMut<UiScript>>,
tokens: UnitTokens,
selection: Res<Selection>,
self_q: Query<(&Transform, &ObjectStore), With<SelfPlayer>>,
transforms: Query<&Transform>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
) {
let Some(mut script) = script else {
return;
};
let mut reach = HashMap::new();
if let Some((self_tf, self_store)) = self_q.iter().next() {
for token in reach_tokens() {
let Some((entity, guid)) = tokens.resolve(token, &selection) else {
continue;
};
let Ok(tf) = transforms.get(entity) else {
continue;
};
let store = tokens.stores.get(entity).ok();
let inspectable = benilla_protocol::guid::is_player(guid)
&& !crate::target::can_attack(
store,
factions.as_deref(),
&reputations,
Some(self_store),
);
reach.insert(
token.to_string(),
benilla_ui::script::UnitReach {
dist_sq: dist_sq(tf.translation, self_tf.translation),
inspectable,
},
);
}
}
script.set_unit_reach(reach);
}
/// Build a unit snapshot from a streamed object's descriptor (decision 0061's `ObjectFields`) plus
/// its cache-resolved name and its `UnitReaction` value (`1..8`, or `0` for tokens whose reaction we
/// don't resolve — everything but `"target"`; see [`feed_units`]).
@@ -1379,6 +1559,133 @@ fn combo_edge(last: Option<(u8, u64)>, now: (u8, u64)) -> Option<bool> {
mod tests {
use super::*;
/// **Report B304 — Quiver's range indicator read Dead Zone on a far target.**
///
/// Its Dead Zone rung is `IsActionInRange(AutoShot) ~= 1` **and**
/// `CheckInteractDistance("target", 4)` truthy (`Quiver.bundle.lua:3136-3150`), i.e. "cannot
/// shoot, but inside 30 yards". The reach map only ever held *players*, so a creature
/// `"target"` never entered it and the 30-yard rung answered a **constant** for every mob at
/// every distance: "in range" at the published tag (the old permissive default), and — after
/// 1564 flipped that default for B316 — "out of range" for a mob standing next to you. Two
/// different wrong answers from one defect: the map was never asked about creatures at all.
///
/// So this drives the feed with a real creature target at three distances and reads the rung
/// itself. The player-only leg it used to apply at the token now rides in the entry, which the
/// `CanInspect` assertions pin: a boar three yards away is in interact range and is still not
/// inspectable.
#[test]
fn a_creature_target_enters_the_reach_map_at_its_real_distance() {
use benilla_protocol::messages::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
/// `UNIT_FIELD_FLAGS` bit 1 (NON_ATTACKABLE) — one of `can_attack`'s five disqualifiers,
/// so a unit carrying it is `inspectable` as far as the entry is concerned.
const NON_ATTACKABLE: u32 = 1 << 1;
const FIELD_UNIT_FLAGS: u16 = 46;
const ME: u64 = 0x0000_0000_0000_0001;
const BOAR: u64 = 0xF130_0000_0000_0002;
const FRIEND: u64 = 0x0000_0000_0000_0003;
/// Seat one target `yards` away and answer `expr` against it.
fn ask(guid: u64, flags: u32, yards: f32, expr: &str) -> bool {
let mut app = App::new();
app.init_resource::<crate::ui_party::GroupState>()
.init_resource::<Reputations>();
app.insert_non_send_resource(UiScript::new().unwrap());
let me = app
.world_mut()
.spawn((
SelfPlayer,
Guid(ME),
ObjectStore(ObjectFields::default()),
Transform::from_xyz(0.0, 0.0, 0.0),
))
.id();
let target = app
.world_mut()
.spawn((
Guid(guid),
ObjectStore(ObjectFields::from_pairs(&[(FIELD_UNIT_FLAGS, flags)])),
// Along one axis, so d² is exactly yards² and the thresholds are readable.
Transform::from_xyz(yards, 0.0, 0.0),
))
.id();
app.insert_resource(crate::net::GuidIndex(
[(ME, me), (guid, target)].into_iter().collect(),
));
app.insert_resource(Selection {
target: Some(target),
guid: Some(guid),
});
app.world_mut().run_system_once(feed_unit_reach).unwrap();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<bool>(expr)
.unwrap()
}
// The report itself: a mob well past 30 yards is OUT of the 30-yard row, so Quiver's
// "inside 30 but cannot shoot" conjunction is false and the bar reads Out of Range.
assert!(
ask(
BOAR,
0,
40.0,
r#"return CheckInteractDistance("target", 4) == nil"#
),
"a creature 40 yards away is out of the 30-yard row"
);
// …and the same rung still says YES inside the band, which is the half a token that is
// simply absent can never do — before this, no mob at any distance could light it.
assert!(
ask(
BOAR,
0,
15.0,
r#"return CheckInteractDistance("target", 4) ~= nil"#
),
"a creature 15 yards away is inside the 30-yard row"
);
assert!(
ask(
BOAR,
0,
15.0,
r#"return CheckInteractDistance("target", 1) == nil"#
),
"…and outside the 10-yard one: the table is indexed, not a constant"
);
// The typemask moved into the entry rather than vanishing: a creature in interact range is
// still not something `CanInspect` says yes to.
assert!(
ask(
BOAR,
0,
3.0,
r#"return CheckInteractDistance("target", 1) ~= nil"#
),
"a creature 3 yards away is in interact range"
);
assert!(
ask(BOAR, 0, 3.0, r#"return CanInspect("target") == nil"#),
"…and is still not inspectable — the players-only leg rides in the entry"
);
// The control: a non-attackable PLAYER at the same spot is inspectable, so the assertion
// above is about the guid type and not about the map having gone dark.
assert!(
ask(
FRIEND,
NON_ATTACKABLE,
3.0,
r#"return CanInspect("target") ~= nil"#
),
"a non-attackable player 3 yards away is inspectable"
);
}
/// The combo feed pushes on either half moving but speaks only for the count — the client's
/// watch is on that byte alone (decision 0879).
#[test]
@@ -224,3 +224,140 @@ fn craft_collapse_tab_loads_with_text_and_stays_hidden_for_a_flat_list() {
"zero headers → the tab hides (ref l.269-282's own scan)"
);
}
/// **The reagent slot IS `QuestItemTemplate`** — B250's pin, in both windows.
///
/// The ref's `TradeSkillItemTemplate` (Blizzard_TradeSkillUI.xml l.11-35) and `CraftItemTemplate`
/// (Blizzard_CraftUI.xml l.29-53) each inherit `QuestItemTemplate` and override **only scripts**, so
/// the slot's whole visual is that template's: 147×41, a 39×39 icon, the `UI-QuestItemNameFrame`
/// plate on the icon's right edge, the name centred ON the plate, and the count on the icon's own
/// BOTTOMRIGHT.
///
/// Every assertion here is one the shape that shipped before B250 would fail — a 140×32 slot with a
/// 28×28 icon, no plate at all, the name top-anchored right of the icon and the count *below* the
/// name. It is written that way on purpose (decision 1107's "so the sibling law would fail it"): the
/// numbers alone would let a future session drift the slot back toward a hand-rolled shape with no
/// test going red.
#[test]
fn reagent_slots_carry_the_questitemtemplate_shape_in_both_windows() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
// Both windows open (a slot's anchors resolve against a laid-out parent), and the slots this
// test measures are shown — the ref's own SetSelection shows one per reagent.
s.set_trade_skill(Some(state()));
s.fire_event("TRADE_SKILL_SHOW", vec![]);
s.fire_event("CRAFT_SHOW", vec![]);
for w in ["BenillaTradeSkillReagent", "BenillaCraftReagent"] {
for i in 1..=3 {
s.run(&format!("{w}{i}:Show()")).unwrap();
}
}
for w in ["BenillaTradeSkillReagent", "BenillaCraftReagent"] {
let num = |expr: &str| {
s.eval::<f64>(&format!("return {expr}"))
.unwrap_or_else(|e| panic!("{expr}: {e}"))
};
// The row box: 147×41, the ref's QuestItemTemplate <Size>.
assert_eq!(
(
num(&format!("{w}1:GetWidth()")),
num(&format!("{w}1:GetHeight()"))
),
(147.0, 41.0),
"{w}1 row box"
);
// The 2-column grid: column 2 opens exactly one row-width right (Reagent2 anchors
// LEFT→Reagent1's RIGHT with a zero offset, so the pitch IS the row width) …
assert_eq!(
num(&format!("{w}2:GetLeft()")) - num(&format!("{w}1:GetLeft()")),
147.0,
"{w} column pitch"
);
// … and row 2 drops one row height plus the ref's own 2px gutter.
assert_eq!(
num(&format!("{w}3:GetTop()")) - num(&format!("{w}1:GetTop()")),
-43.0,
"{w} row step"
);
// The icon: 39×39 flush in the row's TOPLEFT corner.
assert_eq!(
(
num(&format!("{w}1Icon:GetWidth()")),
num(&format!("{w}1Icon:GetHeight()"))
),
(39.0, 39.0),
"{w}1 icon"
);
assert_eq!(
num(&format!("{w}1Icon:GetLeft()")),
num(&format!("{w}1:GetLeft()")),
"{w}1 icon flush left"
);
assert_eq!(
num(&format!("{w}1Icon:GetTop()")),
num(&format!("{w}1:GetTop()")),
"{w}1 icon flush top"
);
// The name plate — the piece that was missing entirely. Its 128×64 texture starts 10px
// inside the icon's right edge and is centred on the icon's own middle.
assert_eq!(
s.eval::<String>(&format!("return {w}1NameFrame:GetTexture()"))
.unwrap(),
"Interface\\QuestFrame\\UI-QuestItemNameFrame",
"{w}1 name plate art"
);
assert_eq!(
(
num(&format!("{w}1NameFrame:GetWidth()")),
num(&format!("{w}1NameFrame:GetHeight()"))
),
(128.0, 64.0),
"{w}1 plate size"
);
assert_eq!(
num(&format!("{w}1NameFrame:GetLeft()")) - num(&format!("{w}1Icon:GetRight()")),
-10.0,
"{w}1 plate rides the icon's right edge"
);
// The name sits ON the plate (+15 from its left), vertically centred — not above-right of
// the icon, where the pre-B250 shape put it.
assert_eq!(
num(&format!("{w}1Name:GetLeft()")) - num(&format!("{w}1NameFrame:GetLeft()")),
15.0,
"{w}1 name inset"
);
let (nc, pc) = (
num(&format!("({w}1Name:GetTop() + {w}1Name:GetBottom()) / 2")),
num(&format!(
"({w}1NameFrame:GetTop() + {w}1NameFrame:GetBottom()) / 2"
)),
);
assert!(
(nc - pc).abs() < 0.01,
"{w}1 name is centred on the plate ({nc} vs {pc})"
);
// The count rides the ICON's bottom-right corner (-4, +1) — not a line below the name.
assert_eq!(
num(&format!("{w}1Count:GetRight()")) - num(&format!("{w}1Icon:GetRight()")),
-4.0,
"{w}1 count x"
);
assert_eq!(
num(&format!("{w}1Count:GetBottom()")) - num(&format!("{w}1Icon:GetBottom()")),
1.0,
"{w}1 count y"
);
assert!(
num(&format!("{w}1Count:GetBottom()")) >= num(&format!("{w}1Icon:GetBottom()")),
"{w}1 count sits ON the icon, not below the row"
);
}
}
@@ -115,6 +115,26 @@ fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option<f32> {
(len > 1e-9).then(|| n[0] / len)
}
/// The widest max/min axis spread a scale track ever holds — `1.0` for a uniform track (the
/// overwhelming majority: a glow card that only pulses in size), `> 1` for one that stretches its
/// bone along an axis. A key with an axis at or below zero contributes `1.0`: a zero axis is the
/// reference's own "hide this" (the eyelid blink's retracted lid), not a stretch, and dividing by
/// it would report infinity.
fn scale_spread<T>(keys: &[(T, [f32; 3])]) -> f32 {
keys.iter()
.map(|(_, s)| {
let (lo, hi) = s
.iter()
.fold((f32::MAX, 0.0f32), |(lo, hi), &v| (lo.min(v), hi.max(v)));
if lo > 1e-4 {
hi / lo
} else {
1.0
}
})
.fold(1.0f32, f32::max)
}
/// Sweep every `.m2` (under `prefix`, if given) and classify its billboard usage — see the
/// `Bbscan` command doc. Output per model: the authored arms and how many vertices ride each
/// DIRECTLY (primary bone is the billboard bone — the card path) vs INHERITED (primary bone
@@ -123,6 +143,16 @@ fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option<f32> {
/// emitter on (or under) a billboard bone has a camera-dependent origin, because the reference
/// folds the record position through the *replaced* palette matrix
/// (wow-re `part-anchoring-live-bone.md` §1 row 3 · `m2emitspine::particle_bone_xform`).
///
/// The `NONUNIF[…]` column is the third population: billboard bones whose SCALE is animated
/// **non-uniformly** (in any sequence band, or on a global-sequence loop), listed as
/// `bone:arm×ratio` where the ratio is the widest max/min axis spread the bone ever holds. The
/// billboard law preserves the bone's scale under the substituted camera basis (`T·R_cam·S`), so
/// such a bone stretches its card along one model axis — the Lightwell's lock-Z shaft
/// (`World\Goober\G_HolyLightWell.m2` bone 0, `×4.26`) is a 0.12 yd card pulled into a 4.5 yd
/// column of light. Any consumer that reduces the scale to one scalar renders these squat and
/// blown-out instead (bug B169), and a `d` in the direct column is where that lands on a **card**,
/// whose transform is rebuilt from the joint rather than skinned from it.
pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let names = super::m2_names(chain, prefix)?;
let arm = |k: benilla_formats::BillboardKind| match k {
@@ -140,6 +170,10 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let mut fx_total: HashMap<String, u32> = HashMap::new();
// …and the seam population (see the classification below).
let (mut seam_models, mut seam_bones) = (0u32, 0u32);
// …and the non-uniform-scale population: billboard bones that stretch their card along one
// model axis, split by whether geometry rides them DIRECTLY (the card path, where a scalar
// scale loses the stretch outright) or only by inheritance (the palette path).
let (mut nonunif_models, mut nonunif_bones, mut nonunif_direct) = (0u32, 0u32, 0u32);
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
@@ -225,6 +259,53 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
v.join(" ")
};
let bones: String = kinds.iter().flatten().map(|&k| arm(k)).collect();
// NON-UNIFORM billboard scale (see the `NONUNIF` column in the doc above).
let seqs = benilla_formats::parse_m2_animations(&bytes);
let gseq = benilla_formats::parse_m2_global_sequence_bones(&bytes);
let mut nonunif: BTreeMap<usize, f32> = BTreeMap::new();
for bk in seqs.iter().flat_map(|a| &a.bones) {
let r = scale_spread(&bk.scale);
if r > 1.01 {
let e = nonunif.entry(bk.bone as usize).or_insert(1.0);
*e = e.max(r);
}
}
for gb in &gseq {
let Some(ch) = &gb.scale else { continue };
let r = scale_spread(&ch.keys);
if r > 1.01 {
let e = nonunif.entry(gb.bone as usize).or_insert(1.0);
*e = e.max(r);
}
}
// Only the BILLBOARD bones matter — an ordinary bone's non-uniform scale rides the joint
// palette like any other transform and nothing reduces it.
nonunif.retain(|&b, _| kinds.get(b).copied().flatten().is_some());
let nonunif_col = if nonunif.is_empty() {
String::new()
} else {
nonunif_models += 1;
nonunif_bones += nonunif.len() as u32;
// Does geometry ride this bone DIRECTLY (primary bone == it, and separable)? That is
// the card path — where the stretch has to survive a transform rebuild.
let direct_bone = |b: usize| {
!seam.contains(&(b as u16))
&& m.vertices.iter().any(|v| v.bone_indices[0] as usize == b)
};
nonunif_direct += nonunif.keys().filter(|&&b| direct_bone(b)).count() as u32;
format!(
" NONUNIF[{}]",
nonunif
.iter()
.map(|(&b, r)| format!(
"{b}:{}{}\u{00d7}{r:.2}",
kinds.get(b).copied().flatten().map_or("?", arm),
if direct_bone(b) { "d" } else { "i" }
))
.collect::<Vec<_>>()
.join(",")
)
};
let seam_col = if seam.is_empty() {
String::new()
} else {
@@ -239,7 +320,7 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
)
};
println!(
"{bones:>4} direct[{}] inherited[{}] fx[{fx_counts}]{seam_col} {name}",
"{bones:>4} direct[{}] inherited[{}] fx[{fx_counts}]{seam_col}{nonunif_col} {name}",
fmt_counts(&direct),
fmt_counts(&inherited)
);
@@ -281,7 +362,7 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
v.join(" ")
};
eprintln!(
"{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]; {fx_models} with EFFECTS on a billboard chain [{fx_tot}]; {seam_models} with SEAM billboard bones ({seam_bones} total) — geometry welded to the model, which a rigid card tears",
"{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]; {fx_models} with EFFECTS on a billboard chain [{fx_tot}]; {seam_models} with SEAM billboard bones ({seam_bones} total) — geometry welded to the model, which a rigid card tears; {nonunif_models} with NON-UNIFORM billboard scale ({nonunif_bones} bones, {nonunif_direct} of them ridden DIRECTLY = a card whose stretch a scalar scale would lose)",
tot(&direct_models),
tot(&inherited_models)
);
@@ -711,29 +711,55 @@ fn real_spell_catalog_reads_tooltip_columns() {
);
}
/// The combat-initiation classes on the real build-5875 `Spell.dbc` — the two accessor masks
/// The combat-initiation classes on the real build-5875 `Spell.dbc` — the three accessor masks
/// the cast seam's queue/attack-start logic keys on ([`SpellDisplay::on_next_swing`] `0x404`,
/// [`SpellDisplay::initiates_auto_attack`] adding `AttributesEx & 0x200`), pinned against the
/// vmangos `spell_template` rows read at decision time (2026-07-14). A column slip or a mask
/// slip fails loudly. Skips without client data.
/// [`SpellDisplay::initiates_auto_attack`] adding `AttributesEx & 0x200`, and its GO-deferred
/// complement [`SpellDisplay::initiates_auto_attack_at_go`] on `AttributesEx2 & 0x100000`),
/// pinned against the vmangos `spell_template` rows read at decision time (2026-07-14). A column
/// slip or a mask slip fails loudly. Skips without client data.
///
/// **The bit20 column is why this test was rewritten** (decision 1593). It used to assert
/// `attributes_ex2 & 0x100000 == 0` *for each of the ten spells listed here* and read that as
/// "no spell carries the bit, so the deferred GO-time start is unbuilt". Ten warrior/mage rows
/// are not a census: the real file carries it on 36. The census below is the check that claim
/// needed, and it is now the thing that would catch the bit going dormant for real.
#[test]
fn real_spell_catalog_classifies_combat_initiation() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
// (spell, on_next_swing, initiates_auto_attack)
for (id, name, next_swing, initiates) in [
(78u32, "Heroic Strike", true, true), // Attributes 0x50014
(845, "Cleave", true, true), // Attributes 0x50014, Ex 0x200
(2973, "Raptor Strike", true, true), // Attributes 0x50404
(772, "Rend", false, true), // Ex 0x8000200
(7386, "Sunder Armor", false, true), // Ex 0x8000200
(1464, "Slam", false, true), // Ex 0x8000200
(100, "Charge", false, false), // Ex 0x400 — neither bit
(6673, "Battle Shout", false, false), // Ex 0x0
(6603, "Attack", false, false), // the auto-attack pseudo-spell itself
(133, "Fireball", false, false), // an ordinary cast
// (spell, on_next_swing, initiates_auto_attack, initiates_auto_attack_at_go)
for (id, name, next_swing, initiates, at_go) in [
(78u32, "Heroic Strike", true, true, false), // Attributes 0x50014
(845, "Cleave", true, true, false), // Attributes 0x50014, Ex 0x200
(2973, "Raptor Strike", true, true, false), // Attributes 0x50404
(772, "Rend", false, true, false), // Ex 0x8000200
(7386, "Sunder Armor", false, true, false), // Ex 0x8000200
(1464, "Slam", false, true, false), // Ex 0x8000200
(100, "Charge", false, false, false), // Ex 0x400 — neither bit, and bit20 CLEAR
(6673, "Battle Shout", false, false, false), // Ex 0x0
(6603, "Attack", false, false, false), // the auto-attack pseudo-spell itself
(133, "Fireball", false, false, false), // an ordinary cast
// The GO-deferred class: bit20 SET, so the send-time tail is suppressed and the start
// waits for `SMSG_SPELL_GO` (`0x6e83c0`). Every stealth opener and positional strike.
(53, "Backstab", false, false, true), // Attributes 0x50010, Ex 0x8000200, Ex2 0x100000
(703, "Garrote", false, false, true),
(8676, "Ambush", false, false, true),
(1833, "Cheap Shot", false, false, true),
(5221, "Shred", false, false, true),
(6785, "Ravage", false, false, true),
(9005, "Pounce", false, false, true),
(20271, "Judgement", false, false, true), // Ex 0x0 — bit20 is its ONLY initiation bit
// The hunter's instant shots (bug B280): `Attributes 0x00010002` (ranged slot),
// `AttributesEx 0`, `AttributesEx2 0x00020000` — bit 17, vmangos
// `SPELL_ATTR_EX2_DO_NOT_RESET_COMBAT_TIMERS`, the shot-weaving bit, and NOT bit 20.
// So no client attack-start of either kind fires for them; casting one does not begin
// Auto Shot, which is what 0994 §4 recorded and what this pins in data.
(1978, "Serpent Sting", false, false, false),
(3044, "Arcane Shot", false, false, false),
(2643, "Multi-Shot", false, false, false),
(75, "Auto Shot", false, false, false), // the auto-repeat itself: Ex2 0x20, not 0x100000
] {
let d = cat
.get(id)
@@ -747,23 +773,58 @@ fn real_spell_catalog_classifies_combat_initiation() {
assert_eq!(
d.initiates_auto_attack(),
initiates,
"{name} ({id}) initiates_auto_attack (Attributes {:#x}, Ex {:#x})",
"{name} ({id}) initiates_auto_attack (Attributes {:#x}, Ex {:#x}, Ex2 {:#x})",
d.attributes,
d.attributes_ex
);
// The §5's one DBC-owed bit (wow-re `combat-feel-law.md` @ c445713b): `AttributesEx2 &
// 0x100000` (INITIATE_COMBAT_POST_CAST) defers a spell's attack-start to SMSG_SPELL_GO —
// a client path benilla leaves unbuilt because no spell here carries the bit. This pins
// that from the real client DBC: in particular Charge (100) is bit20-CLEAR, so vanilla
// Charge starts no auto-attack through ANY client channel.
assert_eq!(
d.attributes_ex2 & 0x0010_0000,
0,
"{name} ({id}) must not carry INITIATE_COMBAT_POST_CAST (Ex2 {:#x}) — the deferred \
GO-time attack-start is unbuilt",
d.attributes_ex,
d.attributes_ex2
);
assert_eq!(
d.initiates_auto_attack_at_go(),
at_go,
"{name} ({id}) initiates_auto_attack_at_go (Ex2 {:#x})",
d.attributes_ex2
);
// The two halves are mutually exclusive by construction — bit20 suppresses the send-time
// tail — so no spell may ever start the attack twice.
assert!(
!(d.initiates_auto_attack() && d.initiates_auto_attack_at_go()),
"{name} ({id}) may not start the auto-attack at BOTH the send and the GO"
);
}
// The census the old ten-row assertion stood in for. 36 rows carry bit20 in the shipped file,
// and every one of them is a stealth opener, a positional strike or Judgement — the class
// whose attack-start has to wait for the server to resolve the strike.
let deferred: Vec<(u32, &str)> = cat
.iter()
.filter(|(_, d)| d.initiates_auto_attack_at_go())
.map(|(id, d)| (id, d.name.as_str()))
.collect();
assert_eq!(
deferred.len(),
36,
"5875 ships 36 INITIATE_COMBAT_POST_CAST rows, got {}: {:?}",
deferred.len(),
deferred
);
let mut names: Vec<&str> = deferred.iter().map(|&(_, n)| n).collect();
names.sort_unstable();
names.dedup();
assert_eq!(
names,
[
"Ambush",
"Backstab",
"Cheap Shot",
"Garrote",
"Judgement",
"Pounce",
"Ravage",
"Shred",
"Test Stab R50",
],
"the deferred-start class is the openers, the positional strikes and Judgement"
);
}
/// The crafting columns (decision 0437) on the real build-5875 `Spell.dbc`: `EffectItemType`
@@ -627,6 +627,35 @@ impl SpellDisplay {
&& self.attributes_ex2 & ATTR_EX2_INITIATE_COMBAT_POST_CAST == 0
}
/// Whether *this spell's own `SMSG_SPELL_GO`* turns on the melee auto-attack — the **deferred**
/// half of the same law, [`Self::initiates_auto_attack`]'s exact complement.
/// `HandleSpellGo 0x6e7a70` @ `0x6e83c0` (re-read at the bytes for 1593):
///
/// ```text
/// 6e83c0 call 0x6e5230(rec); test al,al; jne 6e83da ; AttrEx2 bit20 SET -> start
/// 6e83cb call 0x6e5200(rec); test al,al; je 6e8407 ; else the triad must hold
/// 6e83d4 test byte[rec+0x54],0x8; je 6e8407 ; ...AND rec+0x54 bit3
/// 6e83da lea ecx,[esi+0xc48]; call 0x47bf60; or eax,edx; jne 6e8407 ; NOT already attacking
/// 6e83e9 hitCount>0 ? guid = hits[0] : (0,0) ; [ebp-0x20] count, [ebp-0x1c] array
/// 6e8402 call 0x6131a0(ecx=caster, guid) ; START MELEE AUTO-ATTACK
/// ```
///
/// **Only the bit20 leg is modelled**, deliberately. The `rec+0x54 bit3` leg can only add
/// spells that already pass `0x6e5200` with bit20 CLEAR — and those started their attack at
/// the *send* (`initiates_auto_attack`), so by their GO the reference's `[+0xc48]` is set and
/// `0x6e83e7` refuses. It is unreachable-in-effect, and `rec+0x54`'s column is still only
/// INFERRED in wow-re (`combat-feel-law.md` §A3 owes it to the DBC), so building it would be
/// guessing at no gain. Modelling it would also be actively *wrong* here: our engaged mirror
/// is the server-echoed `Engaged`, not a local lock, so a leg that the reference silences
/// with `[+0xc48]` would fire a second `CMSG_ATTACKSWING` on our side.
///
/// The 36 spells that reach this and nothing else are the stealth openers and positional
/// strikes — Backstab, Garrote, Ambush, Cheap Shot, Shred, Ravage, Pounce — plus Judgement:
/// exactly the class whose attack must wait for the server to say the strike landed.
pub fn initiates_auto_attack_at_go(&self) -> bool {
self.attributes_ex2 & ATTR_EX2_INITIATE_COMBAT_POST_CAST != 0
}
/// Hidden from the player's buff bar — the cache-builder's **display filter**
/// (`PlayerAuras_Update 0x4e4170`'s append pass, sites `0x4e42b6`–`0x4e42c8`). The `Attributes`
/// clause is a **byte-width** read — `mov cl,[SpellRec+0x18]; test cl,cl; js` — so it tests
+10 -3
View File
@@ -454,9 +454,16 @@ const ATTR_EX_CHANNELED: u32 = 0x44;
/// `AttributesEx2` bit `0x100000` — vmangos `SPELL_ATTR_EX2_INITIATE_COMBAT_POST_CAST` ("Client
/// will send CMSG_ATTACK_SWING after SMSG_SPELL_GO"). The §5-verified send-tail predicate
/// EXCLUDES it (`[ebp-2] = 0x6e5200 && Ex2-bit20 CLEAR`): a bit20 spell defers its attack-start
/// to the `SMSG_SPELL_GO` handler (`0x6e83c0`) instead of starting at send. No spell benilla
/// meets carries it (asserted against the real 5875 `Spell.dbc` in `catalog_tests.rs`), so the
/// deferred path stays unbuilt — this bit only *suppresses* the send-time start.
/// to the `SMSG_SPELL_GO` handler (`0x6e83c0`) instead of starting at send. Both halves are
/// built — [`SpellDisplay::initiates_auto_attack`] is the send-time one, and
/// [`SpellDisplay::initiates_auto_attack_at_go`] the deferred one.
///
/// This doc used to say "no spell benilla meets carries it, so the deferred path stays unbuilt".
/// That was a proof by absence over the ten warrior/rogue spells `catalog_tests.rs` happened to
/// list. The real 5875 `Spell.dbc` carries the bit on **36 rows** — every rank of Backstab,
/// Garrote, Ambush, Cheap Shot, Shred, Ravage and Pounce, plus Judgement 20271 — i.e. every
/// stealth opener and positional strike in the game, whose attack must wait for the server to
/// resolve it. The census is the test beside this now (decision 1593, bug B280's sweep).
const ATTR_EX2_INITIATE_COMBAT_POST_CAST: u32 = 0x0010_0000;
/// `Attributes` bit 25 (`0x0200_0000`) — `SPELL_ATTR_COOLDOWN_ON_EVENT` (vmangos; "disabled while
+47 -15
View File
@@ -24,7 +24,10 @@
//! family is already unit-keyed; `super::char_stats::player_inv_slot` routes `"player"` to the
//! self feed and the inspected token here.
//! - **Range:** the two verified d² predicates, `CanInspect` and `CheckInteractDistance`, over one
//! app-fed per-token distance map ([`UiScript::set_inspect_reach`]) — the VM holds no positions.
//! app-fed per-token distance map ([`UiScript::set_unit_reach`]) — the VM holds no positions. The
//! map itself is unit-general (every held unit, creature included) and is fed by
//! `benilla::ui_unit`; only `CanInspect`'s players-only leg is inspect's, and it rides in the
//! entry.
//!
//! The view is keyed by **unit token**, not guid — the ref stores `InspectFrame.unit` as a token
//! and re-reads it on `PLAYER_TARGET_CHANGED`, so an inspect window follows a re-target exactly as
@@ -52,8 +55,8 @@ pub const INTERACT_DIST_SQ: [f64; 4] = [100.0, 123.45678, 100.0, 900.0];
/// as `INSPECT_DISTANCE` (`ObjectDefines.h:26`), so client and server agree exactly.
pub const CAN_INSPECT_DIST_SQ: f64 = 100.0;
/// What the app resolved about one popup token's unit this frame — the input both range predicates
/// read ([`UiScript::set_inspect_reach`]).
/// What the app resolved about one unit token's unit this frame — the input both range predicates
/// read ([`UiScript::set_unit_reach`]).
///
/// An entry exists **iff the token resolved to a live unit object**, which is the distinction the
/// reference's own bindings turn on: `0x515940` maps the token to a GUID and then asks the object
@@ -68,6 +71,11 @@ pub struct UnitReach {
/// (`sObjectMgr.GetPlayer` and `!IsValidAttackTarget`, `MiscHandler.cpp:945-956`) — consulted
/// by `CanInspect` alone. `CheckInteractDistance` is a pure distance test in the binary and
/// must not read this, or following an enemy player would gray a row the reference leaves live.
///
/// `sObjectMgr.GetPlayer` is the whole is-it-a-player leg, and it lives **here** rather than in
/// the map's membership: the map is fed for every held unit, creature included (B304), so a
/// boar 3 yards away is `inspectable: false` while still answering `CheckInteractDistance`
/// truthfully.
pub inspectable: bool,
}
@@ -86,15 +94,25 @@ pub struct InspectView {
}
impl super::UiScript {
/// Push the per-token **inspect reach** map: unit token → squared distance from the player to
/// that unit, for every popup token the app could resolve to a live player this frame.
/// Push the per-token **unit reach** map: unit token → squared distance from the player to
/// that unit, for **every** token the app resolved to a live unit object this frame. Nothing
/// to do with `UNIT_FIELD_COMBATREACH` — this is where each token's unit *is*, not how far it
/// swings.
///
/// One app-fed number serves both range predicates, because the binary uses the same d² for
/// both: `CanInspect` compares it against `100.0`, and `CheckInteractDistance(unit, type)`
/// against [`INTERACT_DIST_SQ`]`[type-1]`. It lives here rather than on
/// [`super::UnitState`] because the popup rows need it for **party** tokens too, and those have
/// no `UnitState` (only `"player"`/`"target"`/`"mouseover"` are fed) — keying it off the unit
/// snapshot would have left the party menu's rows permanently dead.
/// against [`INTERACT_DIST_SQ`]`[type-1]`.
///
/// **The map is unit-general, not a players-only or popup-only one.** It was both, and being
/// both was report B304: `CheckInteractDistance` is a pure distance test over any unit token in
/// the binary, so a creature `"target"` that never entered the map made Quiver's 30-yard rung
/// answer a constant for every mob at every distance. The one typemask that *is* real —
/// inspect's — rides in the entry as [`UnitReach::inspectable`], never at the token.
///
/// It lives here rather than on [`super::UnitState`] because a distance changes **every
/// frame**: the unit snapshots are pushed on diff (1439) and fire `UNIT_*` transitions when
/// they move, so folding a moving number into them would re-push and re-fire every token every
/// frame. This map is re-pushed wholesale instead, and nothing keys an event off it.
///
/// A token absent from the map is a token the object manager holds **no unit for**, and both
/// predicates answer `nil` there — the reference's own null-object arm (wow-re
@@ -102,8 +120,11 @@ impl super::UiScript {
/// `lua_pushnil`). It used to read as *in range*, on the reasoning that missing data must not
/// gray a row; the binary says the opposite, and that default was report B316 — every distance
/// row lit up for exactly the party member who was too far away to have an object at all.
pub fn set_inspect_reach(&mut self, reach: HashMap<String, UnitReach>) {
self.model_mut().inspect_reach = reach;
///
/// **Keys are lowercase** — the resolver folds case (`_strnicmp`, 1247), so the lookup does
/// too, exactly as `Model::unit` does for the snapshots.
pub fn set_unit_reach(&mut self, reach: HashMap<String, UnitReach>) {
self.model_mut().unit_reach = reach;
}
/// Push (or clear, with `None`) the inspected unit's resolved equipment view. The app calls
@@ -135,10 +156,19 @@ impl super::UiScript {
/// What the app resolved for `token`, or `None` when it resolved to no live unit at all — the
/// reference's NULL-object arm, which both predicates answer `nil` to.
///
/// Case-folded on the way in, the same shape `Model::unit` uses for the snapshot map: both
/// predicates reach the unit through the one resolver `0x515970`, whose nine compares are
/// `_strnicmp` (1247), so `CheckInteractDistance("Target", 4)` is the same question as
/// `CheckInteractDistance("target", 4)`.
fn reach(lua: &Lua, token: &Option<String>) -> Option<UnitReach> {
let token = token.as_deref()?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.inspect_reach.get(token).copied()
if token.bytes().any(|b| b.is_ascii_uppercase()) {
model.unit_reach.get(&token.to_ascii_lowercase()).copied()
} else {
model.unit_reach.get(token).copied()
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
@@ -150,9 +180,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `threshold = DAT_00b4d918 = 100.0` (10 yards, the same number vmangos enforces as
// `INSPECT_DISTANCE`). The operator is theirs too: `test ah,0x41; jne` skips the out-of-range
// action on `C0|C3` — Less, Equal, *or unordered* — so the three in-range arms below are that
// mask spelled out, and a NaN distance reads as in RANGE, not out. Only the app-fed distance is
// consulted here; the is-a-player and not-attackable legs are folded in app-side, since a token
// that fails them is never entered in the reach map at all. Decision 0631.
// mask spelled out, and a NaN distance reads as in RANGE, not out. The is-a-player and
// not-attackable legs are resolved app-side and ride IN the entry
// ([`UnitReach::inspectable`]) — not by keeping the token out of the map, which is the map's
// own law (a creature `"target"` is in it, at its real distance, and is not inspectable).
// Decision 0631.
g.set(
"CanInspect",
lua.create_function(|lua, unit: Option<String>| {
+7 -6
View File
@@ -1157,11 +1157,12 @@ pub(crate) struct Model {
pub(crate) dressup_intents: Vec<super::dressup::DressUpIntent>,
/// The dressing-room pane's bake yaw, the fourth of those scalars.
pub(crate) dressup_yaw: f32,
/// Unit token → what the app resolved about it this frame, for every popup token that named a
/// **live unit object** — the input to both verified range predicates (`CanInspect`,
/// `CheckInteractDistance`). An absent token is one the object manager holds nothing for, and
/// both answer `nil` there (see [`UiScript::set_inspect_reach`] and [`super::UnitReach`]).
pub(crate) inspect_reach: HashMap<String, super::UnitReach>,
/// Unit token (lowercase) → what the app resolved about it this frame, for **every** token
/// that named a **live unit object**, creature as readily as player — the input to both
/// verified range predicates (`CanInspect`, `CheckInteractDistance`). An absent token is one
/// the object manager holds nothing for, and both answer `nil` there (see
/// [`UiScript::set_unit_reach`] and [`super::UnitReach`]).
pub(crate) unit_reach: HashMap<String, super::UnitReach>,
/// The skills-pane snapshot the app pushes ([`skills::SkillsState`], decision 0437 phase 4) and
/// the synthesized display tree built from it ([`skills::UiScript::set_skills`]) — the skills
@@ -1615,7 +1616,7 @@ impl Model {
pet_paperdoll_yaw: 0.0,
dressup_intents: Vec::new(),
dressup_yaw: 0.0,
inspect_reach: HashMap::new(),
unit_reach: HashMap::new(),
chat_input: Vec::new(),
skills: skills::SkillsState::default(),
skills_groups: Vec::new(),
+81 -7
View File
@@ -29,7 +29,7 @@ use bevy::prelude::*;
use crate::view::WorldCamera;
/// A spawned billboard card: where its pivot sits in the world, the uniform placement scale, how
/// A spawned billboard card: where its pivot sits in the world, its per-axis scale, how
/// it tracks the camera (the bone-flag arm), and its optional global-sequence scale pulse. The
/// per-frame system rewrites the entity transform from these — including `Visibility` (the
/// hidden-owner mirror), so a card requires it rather than trusting every spawn site's `Mesh3d`
@@ -45,7 +45,16 @@ use crate::view::WorldCamera;
#[require(Transform, Visibility, MeshTag)]
pub struct BillboardCard {
world_pivot: Vec3,
scale: f32,
/// The card's scale in its OWN (pre-billboard) frame — the joint's/placement's per-axis scale,
/// applied before the camera basis, exactly as [`billboard_joint_palette`] applies it to a
/// billboard JOINT (`Transform { rotation: camera_basis, scale, .. }` = `T·R_cam·S`). It is a
/// `Vec3` and not a scalar because real content animates a billboard bone **non-uniformly**:
/// the Lightwell's shaft (`World\Goober\G_HolyLightWell.m2`, bone 0, lock-Z) holds
/// `(8.808, 8.808, 37.560)` through its whole Stand loop, a 0.12 yd card stretched into a
/// 4.5 yd column of light. Collapsing that to `scale.x` and splatting it — which every lane
/// did until 2026-08-25 — rendered the shaft 1.06 yd tall instead: a squat, blown-out card
/// sitting on the well's own bowl and drowning it (bug B169, "Lightwell renders very buggy").
scale: Vec3,
kind: BillboardKind,
/// The billboard bone's looping scale animation (the lamppost glow "breathe"), sampled each frame
/// and multiplied into [`Self::scale`]. `None` for a static card (no global-sequence scale track).
@@ -91,7 +100,7 @@ impl BillboardCard {
let world_pivot = placement.transform_point(info.pivot);
Self {
world_pivot,
scale: placement.scale.x,
scale: placement.scale,
kind: info.kind,
scale_anim: info.scale_anim.clone(),
arm_neg_ms: 0,
@@ -147,7 +156,7 @@ impl BillboardCard {
pub fn frame_following(kind: BillboardKind, pivot: Vec3, owner: Entity) -> Self {
Self {
world_pivot: Vec3::ZERO, // re-seated from the owner before the first facing write
scale: 1.0,
scale: Vec3::ONE,
kind,
scale_anim: None,
arm_neg_ms: 0,
@@ -196,7 +205,7 @@ impl BillboardCard {
/// placement is fixed at spawn).
pub fn re_place(&mut self, placement: Transform, local_pivot: Vec3) {
self.world_pivot = placement.transform_point(local_pivot);
self.scale = placement.scale.x;
self.scale = placement.scale;
self.placement_rot = placement.rotation;
}
}
@@ -580,7 +589,7 @@ pub(crate) fn face_billboards(
benilla_assets::trace::line(
"card",
&format!(
"card={entity} owner={owner} scale={:.3} a={:.2} pivot={:.2?}",
"card={entity} owner={owner} scale={:.3?} a={:.2} pivot={:.2?}",
card.scale,
tag.map_or(-1.0, |t| crate::mesh_tag::alpha_of(t.0)),
card.world_pivot
@@ -617,7 +626,7 @@ pub(crate) fn face_billboards(
let placed = Transform {
translation: card.world_pivot + bob,
rotation,
scale: Vec3::splat(card.scale) * pulse,
scale: card.scale * pulse,
};
// A parked camera over a still, track-less card recomputes bit-identical values, so the
// write only lands on real movement: unconditional, it marked every card
@@ -830,6 +839,71 @@ mod tests {
);
}
/// **A card keeps its bone's NON-UNIFORM scale** — bug B169, the Lightwell (2026-08-25).
///
/// `World\Goober\G_HolyLightWell.m2` bone 0 is a lock-Z billboard carrying the well's light
/// shaft (batch 0, `LIGHTWELLRAY.BLP`, 12 verts all weight-1 on that bone, so the 0028 split
/// makes it a card). Its scale is a **sequence** track, not a global-sequence one, so nothing
/// in `BillboardInfo::scale_anim` carries it: the value reaches the card only through the
/// joint, via `re_place`. Authored (WoW axes, `m2anim`): the Spawn clamp ramps
/// `(1,1,1) → (8.808, 8.808, 37.560)` in 0.5 s and the 23.3 s Stand loop holds it there,
/// pulsing Z between 35.170 and 37.560 — a 0.18 × 0.12 yd card stretched into a
/// **1.59 × 4.51 yd** column of light standing out of the bowl.
///
/// `re_place` used to keep `placement.scale.x` and splat it, so the shaft rendered
/// 1.59 × **1.06** yd: a squat additive card sitting exactly on the 1.09 yd bowl at the same
/// height, blowing it out. Every test above this one used a uniform `Vec3::splat`, which is
/// why the collapse survived — the numbers here are the asset's own.
#[test]
fn a_card_keeps_its_bones_non_uniform_scale() {
// The authored WoW-axis scale, permuted to Bevy axes exactly as the clip builder does
// (`benilla_assets`: `Vec3::new(s[1], s[2], s[0])` — Bevy Y is WoW Z, the shaft's long axis).
const WOW: [f32; 3] = [8.808, 8.808, 37.560];
let bevy_scale = Vec3::new(WOW[1], WOW[2], WOW[0]);
// The card's authored span (`m2batch`): 0.18 yd across, 0.12 yd tall, in the YZ plane.
const CARD_H_YD: f32 = 0.12;
let mut app = App::new();
app.init_resource::<Time>();
app.add_systems(Update, face_billboards);
app.world_mut().spawn((
crate::view::WorldCamera,
GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)),
));
let info = BillboardInfo {
bone: 0,
pivot: Vec3::ZERO,
kind: BillboardKind::LockZ,
scale_anim: None, // a SEQUENCE track — the joint is the only carrier
seq_translations: vec![],
};
// The joint as the rig composes it: the billboard palette preserves the full scale, so
// this is what `re_place` reads back.
let joint = app
.world_mut()
.spawn(GlobalTransform::from(Transform::from_scale(bevy_scale)))
.id();
let card = app
.world_mut()
.spawn((
BillboardCard::following_joint(&info, joint),
Transform::IDENTITY,
))
.id();
app.update();
let tf = *app.world().entity(card).get::<Transform>().unwrap();
assert_eq!(
tf.scale, bevy_scale,
"the joint's per-axis scale reaches the card whole — not `splat(scale.x)`"
);
let shaft_yd = tf.scale.y * CARD_H_YD;
assert!(
(shaft_yd - 4.507).abs() < 0.01,
"the shaft stands 4.51 yd out of the well, not the 1.06 yd the x-collapse rendered \
(got {shaft_yd:.3} yd)"
);
}
/// **The billboard FRAME an equipped item's emitter rides** (decision 0813), with the real
/// numbers of nazriel's B118 item (`LShoulder_Mail_PVPAlliance_C_01`: billboard bone 1 pivot
/// `(-0.012, 0.162, -0.060)`, sparkle emitter position `(-0.252, 0.178, -0.046)`, both raw WoW