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