a melee spell's range is in yards, and item tooltips read their data

- a melee spell's tooltip says 5 yd range instead of Melee Range and grows with the reach of whatever you are auto-attacking; Heroic Strike and Bloodrage show no range line at all, as the reference does (2158)
- an item's resistances list Arcane first, and its type word comes from ItemSubClass.dbc, so every container names itself: 24 Slot Soul Bag, 8 Slot Quiver, 6 Slot Ammo Pouch (2080, 2158)
- weapon damage rounds the client's way, floor on the low bound and ceiling on the high, so Fang of the Mystics reads 38 - 86 not 39 - 86; ammo reads Adds 1.5 damage per second (2158)
- an arrow's slot line reads Projectile | Arrow and a bullet's Projectile | Bullet, the word the reference takes from ItemClass.dbc where we printed the type alone (2158)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:56:20 +07:00
co-authored by Claude Fable 5.1
parent 6af2bfcb73
commit 5f5d0023a7
20 changed files with 821 additions and 269 deletions
+8 -1
View File
@@ -194,8 +194,15 @@ impl Wielded {
/// The unit is engaged in melee auto-attack (`SMSG_ATTACKSTART` .. `ATTACKSTOP`, decision 0073):
/// standing still it plays the weapon-class Ready idle — the client's `0x5fd360` arm gates on the
/// auto-attack-target GUID being set, i.e. engagement, **not** sheath state.
///
/// **It carries that GUID**, because the reference's `[+0xc48]` is the target and not a flag, and
/// a second reader wants the unit and not just the fact: `0x6e3480`'s melee arm resolves
/// `[caster+0xc48]` and uses THAT unit's combat reach, which is what the spell tooltip's range
/// cell prints while you are auto-attacking (wow-re
/// `tooltip-damage-matrix-and-container-slots.md` §D4.2b). Every animation reader still asks only
/// `With`/`Has`, which is unchanged by the payload.
#[derive(Component)]
pub(crate) struct Engaged;
pub(crate) struct Engaged(pub(crate) u64);
/// The local player has fired an auto-repeat spell (Auto Shot / wand Shoot) — the client's
/// `[+0xd58] & 0x200`, whose **only writer binary-wide** is the local cast-send tail (`0x6e593b`,
@@ -203,7 +203,7 @@ fn stationary_cast_hold_stows_an_engaged_casters_weapon() {
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
Engaged,
Engaged(0),
Wielded {
main: Some((2, 0xa)), // class 2 subclass 10: a staff
off: None,
@@ -257,7 +257,7 @@ fn moving_cast_hold_keeps_its_stow_between_plays() {
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
Engaged,
Engaged(0),
Wielded {
main: Some((2, 0xa)),
off: None,
@@ -445,7 +445,7 @@ fn relaxed_base_arms_roll_variations_and_the_shuffle_drives_them() {
fn engaged_base_arms_keep_the_head_variation() {
let mut app = app();
let (unit, nodes) = spawn_fidgeter(&mut app);
app.world_mut().entity_mut(unit).insert(Engaged);
app.world_mut().entity_mut(unit).insert(Engaged(0));
app.update();
let player = app.world().entity(unit).get::<AnimationPlayer>().unwrap();
// Engaged with no weapon: the Ready pick resolves down to Stand — armed as the HEAD.
@@ -492,7 +492,7 @@ fn cast_hold_stows_even_when_the_model_lacks_the_spell_anims() {
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
Engaged,
Engaged(0),
Wielded {
main: Some((2, 0xa)),
off: None,
@@ -614,7 +614,7 @@ fn spell_flinch_picks_the_wound_by_engagement() {
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
Engaged,
Engaged(0),
))
.id();
let idle = app
@@ -3640,7 +3640,7 @@ fn a_disarmed_attacker_swings_and_stands_unarmed() {
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
Engaged,
Engaged(0),
Wielded {
main: Some((2, 0x7)), // 1H sword
off: Some((2, 0xf)), // dagger
+4 -1
View File
@@ -25,7 +25,10 @@ pub(super) fn attack_start(attacker: u64, victim: u64, commands: &mut Commands,
// Melee-start drops the `0x400` weapon-visual hold unconditionally (the client's
// `0x60fc50` sibling clear) — a shooter that closes to melee leaves the drawn idle.
// The LOCAL player's melee paths additionally run the full cancel funnel at send.
commands.entity(e).insert(Engaged).remove::<RangedHold>();
commands
.entity(e)
.insert(Engaged(victim))
.remove::<RangedHold>();
}
}
+1 -1
View File
@@ -1681,7 +1681,7 @@ mod tests {
.spawn((Guid(10), SelfPlayer, ObjectStore::default()))
.id();
if engaged {
app.world_mut().entity_mut(self_e).insert(Engaged);
app.world_mut().entity_mut(self_e).insert(Engaged(0));
}
let other_e = app
.world_mut()
@@ -974,7 +974,7 @@ mod tests {
world.insert_resource(combat_catalog());
let mut me = world.spawn(SelfPlayer);
if engaged {
me.insert(crate::creature_anim::Engaged);
me.insert(crate::creature_anim::Engaged(0));
}
(world, rx)
}
+3 -118
View File
@@ -41,14 +41,6 @@ use crate::target::Selection;
use super::{usable, AutoRepeatActive, PlayerActions, Spells};
/// `GetMinMaxRange 0x6e3480`'s byte constants (wow-re `wave-cooldown.md` + the decomp
/// `FUN_006e3480`, VERIFIED): the **melee-branch-only** reach pad (`0x80b058`; the ranged
/// branch pads by the bare reach sum), the melee floor, and the self-cast short-circuit's
/// flat max.
const MELEE_REACH_PAD: f32 = 1.3333;
const MELEE_RANGE_FLOOR: f32 = 5.0;
const SELF_CAST_MAX: f32 = 100.0;
/// The feed's memory: what was last pushed, and the edge detectors.
#[derive(Default)]
pub(super) struct StateMemory {
@@ -70,7 +62,7 @@ pub(super) const ERR_TOO_CLOSE: u8 = 0x76;
/// → `IsTargetInRange 0x6e47b0` BEFORE `ArmCast`/`SendCast` (`wave-cast.md`, byte-verified), so
/// an out-of-range or too-close press fails locally and the commit tail — the ranged sheath
/// snap `0x6e5930` included — never runs. This is why a too-close Throw/Auto Shot must NOT draw
/// the ranged weapon. Squared 3D distance against [`resolve_range`]'s {min, max}: beyond max² →
/// the ranged weapon. Squared 3D distance against [`benilla_formats::min_max_range`]'s {min, max}: beyond max² →
/// [`ERR_OUT_OF_RANGE`], inside a nonzero min² → [`ERR_TOO_CLOSE`]. Untestable inputs (no range
/// row, unknown distance) pass — the server still judges the cast.
pub(super) fn cast_range_refusal(
@@ -80,7 +72,7 @@ pub(super) fn cast_range_refusal(
target_reach: Option<f32>,
dist_sq: Option<f32>,
) -> Option<u8> {
let (min, max) = resolve_range(spell, row, self_reach, target_reach)?;
let (min, max) = benilla_formats::min_max_range(spell, row, self_reach, target_reach)?;
let d2 = dist_sq?;
if d2 > max * max {
return Some(ERR_OUT_OF_RANGE);
@@ -344,50 +336,6 @@ pub(super) fn cast_moving_refusal(
|| d.channel_interrupt_flags & AURA_INTERRUPT_MOVING_TURNING != 0)
}
/// The resolved {min, max} for one action against one target — the `GetMinMaxRange 0x6e3480`
/// law over our descriptor reaches.
///
/// Two decomp legs are deliberately UNMODELED (0426): the PvP max bonus (`6e3648` — +2.6667 yd
/// when both units carry the `[unit+0x118]+0x40 & 0x200d` flags and the pair is hostile; its
/// gate helpers `0x5fc350` are un-RE'd, so modeling it would be a guess) and the
/// `Attributes & 2` item-scaling leg (`6e36aa` — `max *= item range-mod %` off the resolved
/// item record; verified a data no-op 2026-07-16: vmangos `item_template.range_mod` is 100 on
/// all 513 player-obtainable ranged weapons, 0 only on nine NPC "Monster -" wands). The melee
/// no-target reach fallback also simplifies: the real client re-resolves the current-target
/// global (`0x47bf60(0x498)`) and failing that doubles the caster's own reach — we default the
/// missing side to 1.5.
fn resolve_range(
spell: &SpellDisplay,
range: Option<&SpellRange>,
self_reach: f32,
target_reach: Option<f32>,
) -> Option<(f32, f32)> {
// The self-cast short-circuit's attribute test (`SpellRec+0x18 & 0x404` at `0x6e34fb`) —
// the same on-next-swing mask the queue tracking reads, tested here by the range law.
if spell.on_next_swing() {
return Some((0.0, SELF_CAST_MAX));
}
let row = range?;
if row.is_melee() {
let reach_sum = self_reach + target_reach.unwrap_or(1.5) + MELEE_REACH_PAD;
return Some((0.0, reach_sum.max(MELEE_RANGE_FLOOR)));
}
if row.min == 0.0 && row.max == 0.0 {
return None; // the self row (id 1): no range to test
}
// The ranged branch (0x6e35ee) pads by the BARE reach sum — no 1.3333, that constant is
// melee-only — added to the max unconditionally but to the min ONLY when the row's min is
// already nonzero (the fcomp-vs-0.0 guard, decomp `if (*min != 0.0)`): a min-0 spell
// (Fireball, Shadow Bolt) must never grow a min range, or point-blank casts refuse
// TOO_CLOSE.
let Some(target_reach) = target_reach else {
return Some((row.min, row.max));
};
let pad = self_reach + target_reach;
let min = if row.min == 0.0 { 0.0 } else { row.min + pad };
Some((min, row.max + pad))
}
/// What a slot *is* once the MACRO indirection is applied — the reference's slot→spell resolver
/// `0x4e5a50` plus the leg of the usable compute `0x4e5050` that reads its zero (decision 1636).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -586,7 +534,7 @@ pub(super) fn feed_action_state(
}
// C4: the range verdict vs the current target; nil without one.
let row = spells.as_ref().and_then(|s| s.ranges.get(d.range_index));
let resolved = resolve_range(d, row, self_reach, target_reach);
let resolved = benilla_formats::min_max_range(d, row, self_reach, target_reach);
st.has_range = resolved
.is_some_and(|(min, max)| min.abs() > f32::EPSILON || max.abs() > f32::EPSILON);
st.in_range = match (resolved, dist_sq) {
@@ -732,69 +680,6 @@ mod tests {
}
}
/// The `GetMinMaxRange 0x6e3480` transcription: melee reach floor, the ranged reach pad on
/// both bounds, the self-cast short-circuit, and the rangeless self row.
#[test]
fn resolve_range_follows_the_byte_law() {
let melee = SpellRange {
min: 0.0,
max: 5.0,
flags: 1,
};
// Two naked-reach units (1.5 + 1.5 + 1.3333 = 4.333) floor at 5.0…
let d = spell_with_range(2, 0);
assert_eq!(
resolve_range(&d, Some(&melee), 1.5, Some(1.5)),
Some((0.0, MELEE_RANGE_FLOOR))
);
// …a big pair (4 + 4 + 1.3333) exceeds it.
let (_, max) = resolve_range(&d, Some(&melee), 4.0, Some(4.0)).unwrap();
assert!((max - 9.3333).abs() < 1e-3);
// Charge's 8–25 row pads both bounds by the BARE reach sum (no 1.3333 — melee-only)
// against a unit target.
let charge_row = SpellRange {
min: 8.0,
max: 25.0,
flags: 0,
};
let (min, max) = resolve_range(&d, Some(&charge_row), 1.5, Some(1.5)).unwrap();
assert!((min - (8.0 + 3.0)).abs() < 1e-3);
assert!((max - (25.0 + 3.0)).abs() < 1e-3);
// A min-0 row (Fireball's 0–35) pads the max only — the fcomp-vs-0.0 guard keeps the
// min at zero, so a point-blank cast never reads a min range.
let fireball_row = SpellRange {
min: 0.0,
max: 35.0,
flags: 0,
};
let (min, max) = resolve_range(&d, Some(&fireball_row), 1.5, Some(1.5)).unwrap();
assert_eq!(min, 0.0);
assert!((max - 38.0).abs() < 1e-3);
// No unit target: the row's raw bounds, unpadded.
assert_eq!(
resolve_range(&d, Some(&charge_row), 1.5, None),
Some((8.0, 25.0))
);
// The self-cast attribute short-circuits to a flat 100 without touching the row.
let selfish = spell_with_range(1, 0x400);
assert_eq!(
resolve_range(&selfish, None, 1.5, None),
Some((0.0, SELF_CAST_MAX))
);
// The self row (0, 0, no melee flag) resolves to no range at all.
let self_row = SpellRange {
min: 0.0,
max: 0.0,
flags: 0,
};
assert_eq!(resolve_range(&d, Some(&self_row), 1.5, None), None);
}
/// The pre-send refusal (`IsTargetInRange 0x6e47b0`'s two compares over the resolved
/// bounds): Auto Shot's {8, 35} row + the unit reach pad — a point-blank target refuses
/// TOO_CLOSE, a distant one OUT_OF_RANGE, the sweet spot passes; untestable inputs pass.
+4
View File
@@ -186,6 +186,10 @@ fn template_view(
item_sub_type: sub_classes
.and_then(|c| c.name(t.class, t.subclass))
.map(str::to_string),
// …and the tooltip's own spelling of the same row — DisplayName alone.
sub_class_display: sub_classes
.and_then(|c| c.display_name(t.class, t.subclass))
.map(str::to_string),
flags: t.flags,
bonding: t.bonding,
max_count: t.max_count,
@@ -709,13 +709,20 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
for line in [
"Vendor Blade",
"Main Hand",
"Sword",
"5 - 9 Damage",
"Speed 2.60",
"(2.7 damage per second)",
] {
assert!(has_text(&quads, line), "tooltip line {line:?} missing");
}
// The type cell ("Sword") is deliberately NOT here. This row renders through the stat-head
// FALLBACK — the shape a hover takes only while the full template view is still in flight —
// and the type word is `ItemSubClass.dbc`'s DisplayName, which only the app can resolve. The
// head carries `class`/`subclass` numbers and no name, so the cell stands empty rather than
// being composed from a table in the engine (the renderer's hand-typed copy of that DBC is
// gone — decision 2080's shape). A hover whose template HAS landed takes `view_of` and prints
// it; that path is pinned in `benilla-ui`'s own item-law tests.
assert!(!has_text(&quads, "Sword"), "the head carries no type word");
// Two "Vendor Blade" quads exist — the merchant row's own gold name and the tooltip's header
// line; the tooltip's is the quality-green one.
let name_colors: Vec<[f32; 4]> = quads
@@ -770,18 +777,13 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
);
s.resolve();
let quads = s.extract();
for line in [
"Chipped Buckler",
"Off Hand",
"Shield",
"85 Armor",
"1 Block",
] {
for line in ["Chipped Buckler", "Off Hand", "85 Armor", "1 Block"] {
assert!(
has_text(&quads, line),
"shield tooltip line {line:?} missing"
);
}
assert!(!has_text(&quads, "Shield"), "the head carries no type word");
assert!(!has_text(&quads, "Main Hand"), "row 1's tooltip cleared");
// Leave the window entirely: tooltip + highlight gone.
+69 -27
View File
@@ -50,6 +50,13 @@ struct ViewCtx<'a> {
home_area: Option<&'a str>,
form: u8,
store: Option<&'a ObjectStore>,
/// The caster's own `UNIT_FIELD_COMBATREACH`. 1.5 is the descriptor default, not a guess.
combat_reach: f32,
/// The reach of whoever the caster is currently auto-attacking, when it is auto-attacking —
/// the melee range arm's second term. The tooltip passes no target, but `0x6e3480`'s melee
/// arm resolves `[caster+0xc48]` itself, so this cell moves with the mob you are swinging at
/// and falls back to doubling the caster's own reach when nothing is engaged.
attack_target_reach: Option<f32>,
items: &'a mut Items,
commands: &'a NetCommands,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
@@ -72,8 +79,8 @@ fn keyed(get: &dyn Fn(&str) -> Option<String>, key: &str, args: &[Arg<'_>]) -> O
/// every string resolved here where the catalogs live: the cost cell (the RESOLVED
/// `power_cost` through the power-type key array, health fallback, `_PER_TIME` composite — law
/// §3.3, 1074; rage prints wire-cost ÷ 10), the range cell ("N yd range", "N-M yd range" when
/// the row's min is nonzero — the law's `"%d-%d"` fork; "Melee Range" for the melee family —
/// INTERIM text, the proper source is SpellRange.dbc's own display-name column), the cast cell
/// the resolved min is nonzero — the law's `"%d-%d"` fork; the melee family resolves through the
/// same key off the caster's combat reach, and the on-next-swing class shows no cell at all), the cast cell
/// (the full `52eb45` ladder: sec/min, the negative-base sentinel, "Next melee"/"Attack
/// speed"/"Channeled", and the mana-keyed Instant fork — law §3.4, 1074; None = the law's
/// passive gate, which omits the whole line), the cooldown cell
@@ -138,36 +145,46 @@ fn spell_tooltip_view(
Some(format!("{} {unit}", resolved_cost / div))
}
};
let range = spells.ranges.get(d.range_index).and_then(|r| {
if r.is_melee() {
// **INTERIM, and a known divergence — the reference has no melee wording at all.**
//
// The guess this line used to carry ("the display-name column of `SpellRange.dbc`")
// is REFUTED: a melee spell is simply one whose range row sets flags bit 0 — the one
// shipped such row is id 2 "Combat Range", 813 spells — and `0x6e3480` computes
// `max = casterReach + reach + 1.3333334` floored at `5.0`, which then renders
// through the very same `SPELL_RANGE` + `"%d"` path as every other range. On a player
// that is normally **"5 yd range"**, not a word.
//
// Not converted here because the faithful number needs the caster's own
// `UNIT_FIELD_COMBATREACH`, which this builder does not read yet — a data change, not
// a string one. Verified in wow-re `system/ui/scratch/tooltip-globalstring-key-resolves.md`
// §A3; named in decision 2080 rather than guessed at.
Some("Melee Range".to_string())
} else if r.max > 0.0 {
// The range cell (law §3.3 / wow-re `tooltip-globalstring-key-resolves.md` §A3, VERIFIED):
// two attribute gates, then `GetMinMaxRange 0x6e3480` with **`target = NULL`** (`0x52e9c2`),
// then a third gate on the resolved `max <= 0`.
//
// **There is no melee wording anywhere in the client** — `SPELL_RANGE_AREA` is not in
// `WoW.exe` at all, and the melee family is not a display name either: a melee spell is one
// whose `SpellRange` row sets flags bit 0 (the single shipped such row is id 2 "Combat
// Range", 813 spells), and the resolver hands back `max(reach + casterReach + 1.3333334,
// 5.0)` — which then prints through the SAME `SPELL_RANGE` + `"%d"` path as every other
// range, normally **"5 yd range"** (decision 2080 named this cell; it printed the invented
// "Melee Range" until it was converted).
//
// The tooltip passes no target, but that does NOT make both reaches the caster's: the melee
// arm resolves the caster's own `attack_target_guid` and uses that unit's reach, so the cell
// reads wider while you are auto-attacking something big.
let range = (!d.tooltip_omits_range_line())
.then(|| {
let (min, max) = benilla_formats::min_max_range(
d,
spells.ranges.get(d.range_index),
vctx.combat_reach,
vctx.attack_target_reach,
)?;
if max <= 0.0 {
return None;
}
// `SPELL_RANGE = "%s yd range"` — and its hole is a **string**, which is what makes
// the law's pair fork (`0x854fb4`'s `"%d-%d"`, Charge: 8-25) expressible at all: the
// number cell is composed first and handed over whole.
let yards = if r.min > 0.0 {
format!("{}-{}", r.min as i32, r.max as i32)
// number cell is composed by a nested `SStrPrintf` first and handed over whole. Both
// holes are `fistp` conversions — round-to-nearest, never truncation (the melee sum
// is the only value that is ever fractional).
let yd = |v: f32| f64::from(v).round_ties_even() as i64;
let yards = if min > 0.0 {
format!("{}-{}", yd(min), yd(max))
} else {
format!("{}", r.max as i32)
format!("{}", yd(max))
};
keyed(vctx.get, "SPELL_RANGE", &[Arg::S(&yards)])
} else {
None
}
});
})
.flatten();
// Law §3.4's own gate — wider than the spellbook's `passive`: a TRADE_SKILL or ATTACK
// Effect[0] omits the line too ([`SpellDisplay::tooltip_omits_cast_line`]). The arm order is
// the byte ladder `0x52eb45-0x52ec90` (1074): a positive time prints sec/min at the 60 s
@@ -391,6 +408,10 @@ struct SpellFeedMemory {
/// The player's block/dodge/parry/crit percentages, as raw bit patterns so the diff needs no
/// float comparison (law line 10's printed value — it moves with gear, buffs and talents).
avoidance: Option<[u32; 4]>,
/// The two reaches the melee range cell reads, as raw bit patterns — the caster's own
/// (which moves with scale and shapeshift) and its auto-attack target's (which changes with
/// the mob).
combat_reach: Option<(Option<u32>, Option<u32>)>,
/// Per reagent entry currently on show: `(owned count, item name resolved)` — law §3.8's
/// inline red plus the ask-once template landing.
reagents: std::collections::BTreeMap<u32, (u32, bool)>,
@@ -411,6 +432,9 @@ fn feed_spell_tooltips(
selection: Res<crate::target::Selection>,
stores: Query<&ObjectStore>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// Who the player is auto-attacking, if anyone — the melee range cell's second reach.
engaged_q: Query<&crate::creature_anim::Engaged, With<SelfPlayer>>,
guids: Res<crate::net::GuidIndex>,
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
mut items: ResMut<Items>,
@@ -527,6 +551,22 @@ fn feed_spell_tooltips(
memory.avoidance = avoidance;
wanted.extend(memory.pushed.drain());
}
// …and so does the range cell's melee arm, whose inputs are the caster's own reach and the
// reach of whatever it is auto-attacking (`0x6e3480` resolves the latter itself).
let attack_target_reach = engaged_q
.single()
.ok()
.and_then(|e| guids.0.get(&e.0))
.and_then(|&e| stores.get(e).ok())
.map(|s| s.0.unit_combat_reach());
let reaches = (
self_store.map(|s| s.0.unit_combat_reach().to_bits()),
attack_target_reach.map(f32::to_bits),
);
if memory.combat_reach != Some(reaches) {
memory.combat_reach = Some(reaches);
wanted.extend(memory.pushed.drain());
}
let watched: Vec<u32> = memory.reagents.keys().copied().collect();
let reagent_state: std::collections::BTreeMap<u32, (u32, bool)> = watched
.into_iter()
@@ -553,6 +593,8 @@ fn feed_spell_tooltips(
home_area: home_area.as_deref(),
form,
store: self_store,
combat_reach: self_store.map_or(1.5, |s| s.0.unit_combat_reach()),
attack_target_reach,
items: &mut items,
commands: &commands,
sub_classes: sub_classes.as_deref().map(|c| &c.0),
@@ -55,6 +55,19 @@ impl TestCtx {
self.ctx_for(form, sub_classes, None)
}
/// The same context with an auto-attack target engaged — the melee range arm's second
/// reach, which `0x6e3480` resolves out of `[caster+0xc48]` rather than taking as an
/// argument.
fn ctx_engaged<'a>(
&'a mut self,
store: Option<&'a ObjectStore>,
target_reach: f32,
) -> ViewCtx<'a> {
let mut ctx = self.ctx_for(0, None, store);
ctx.attack_target_reach = Some(target_reach);
ctx
}
fn ctx_for<'a>(
&'a mut self,
form: u8,
@@ -65,6 +78,10 @@ impl TestCtx {
home_area: None,
form,
store,
combat_reach: store.map_or(1.5, |s| s.0.unit_combat_reach()),
// The tests drive the melee arm through `combat_reach` alone; the engaged-target
// reach has its own case in `range_cell_on_real_data`.
attack_target_reach: None,
items: &mut self.items,
commands: &self.commands,
sub_classes,
@@ -257,6 +274,75 @@ fn cost_and_cast_cells_on_real_data() {
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
}
/// The RANGE cell's whole law on the REAL 5875 data (wow-re `tooltip-globalstring-key-resolves.md`
/// §A3): the melee family renders through `SPELL_RANGE` off the caster's own combat reach — the
/// invented "Melee Range" decision 2080 named is gone — the authored rows print their own numbers
/// through the same key, and the on-next-swing class and the self-only rows print no cell at all.
/// Skips without client data.
#[test]
fn range_cell_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
// A default-reach player: `UNIT_FIELD_COMBATREACH` (130) unset reads the descriptor's 1.5.
let store = empty_player();
// Sinister Strike (1752) sits on SpellRange row 2 "Combat Range" — the ONE shipped row with
// flags bit 0. 1.5 + 1.5 + 1.3333334 = 4.333 does not clear the 5.0 floor, so the cell reads
// the floor: the melee family's normal render is a NUMBER, not a word.
let d = spells.catalog.get(1752).expect("Sinister Strike 1752");
assert_eq!(d.range_index, 2, "the melee row");
assert!(spells.ranges.get(2).expect("row 2").is_melee());
let v = spell_tooltip_view(1752, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("5 yd range"));
// …and it MOVES with the caster's reach, which is the whole reason the cell needs a store:
// 4.0 + 4.0 + 1.3333334 = 9.333 clears the floor and `fistp` rounds it to 9.
let big = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
130u16,
4.0f32.to_bits(),
)]));
let v = spell_tooltip_view(1752, &spells, &mut t.ctx_for(0, None, Some(&big)))
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("9 yd range"));
// …and the second reach is the AUTO-ATTACK target's, not the caster's doubled: swinging at
// a 4.0-reach mob with a default 1.5 body reads 1.5 + 4.0 + 1.3333334 = 6.833 -> 7.
let v = spell_tooltip_view(1752, &spells, &mut t.ctx_engaged(Some(&store), 4.0))
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("7 yd range"));
// An authored single-number row: Fireball's 0–35.
let v = spell_tooltip_view(133, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Fireball view");
assert_eq!(v.range.as_deref(), Some("35 yd range"));
// An authored PAIR (the `"%d-%d"` nested fill): Charge's 8–25, unpadded — the tooltip's own
// `GetMinMaxRange` call passes `target = NULL`, so the reach never joins these two.
let v =
spell_tooltip_view(100, &spells, &mut t.ctx_for(0, None, Some(&big))).expect("Charge view");
assert_eq!(v.range.as_deref(), Some("8-25 yd range"));
// The two absences. Heroic Strike (78) carries the on-next-swing pair, which jumps the cell
// before the resolver runs — it does NOT read "Melee Range", and it does not read 5 yd
// either. Bloodrage (2687) sits on the self row, whose resolved max is 0.
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Heroic Strike view");
assert_eq!(v.range, None, "Attributes & 0x404 → no range cell");
let v = spell_tooltip_view(2687, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Bloodrage view");
assert_eq!(v.range, None, "the self row resolves max 0");
}
/// The three lines the 2026-07-25 reference captures pinned (decision 0620), each against the
/// REAL 5875 data. Skips without client data.
#[test]
+7 -7
View File
@@ -196,14 +196,14 @@ mod spells;
pub use spells::{
cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times,
load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii,
load_spell_ranges, substitute, CcExemption, FormRefusal, OpenLock, ShapeshiftForm,
SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay,
SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
load_spell_ranges, min_max_range, substitute, CcExemption, FormRefusal, OpenLock,
ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes,
SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
ATTR_ONLY_STEALTHED, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM,
SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL,
SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING,
SPELL_EFFECT_TRADE_SKILL,
ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE,
SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM,
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL,
SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
};
mod skill_lines;
pub use skill_lines::{
@@ -639,6 +639,18 @@ impl SpellDisplay {
)
}
/// The spell **tooltip's** range gate — the two attribute tests the builder runs BEFORE it
/// ever calls `GetMinMaxRange 0x6e3480` (`0x52e9a5`: `Attributes & 0x404`, the on-next-swing
/// pair; `0x52e9b2`: `AttributesEx3 & 0x40000000`), each jumping straight past the cell.
/// The third absence case is not an attribute — it is a resolved `max <= 0`, which is what
/// the 11 777 self-only rows produce and is by far the dominant one (wow-re
/// `tooltip-globalstring-key-resolves.md` §A3, VERIFIED).
///
/// So a Heroic Strike or a Backstab shows **no range cell at all** — not a melee wording.
pub fn tooltip_omits_range_line(&self) -> bool {
self.on_next_swing() || self.attributes_ex3 & 0x4000_0000 != 0
}
/// The cooldown getter's HEAD exclusion (`GetCooldownInfo 0x6e13e0` @ `6e1439`/`6e1442`,
/// wow-re `gcd-power-gate.md` §2, §5-verified): `Effect[0] ∈ {0x4e ATTACK, 0x2f TRADE_SKILL}`
/// returns "no cooldown" unconditionally — the reason the Attack and profession buttons never
+4 -1
View File
@@ -128,7 +128,10 @@ pub use forms::{load_shapeshift_forms, ShapeshiftForm};
mod immunity;
pub use immunity::{cc_exemption, grants_immunity, CcExemption};
pub use radius::{load_spell_radii, SpellRadius, SpellRadiusCatalog};
pub use ranges::{load_spell_ranges, SpellRange, SpellRangeCatalog};
pub use ranges::{
load_spell_ranges, min_max_range, SpellRange, SpellRangeCatalog, COMBAT_REACH_ADD,
MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE,
};
pub use tokens::{substitute, TokenContext};
use std::collections::HashMap;
+167
View File
@@ -30,6 +30,99 @@ impl SpellRange {
}
}
/// The pad the melee arm adds to the two reaches — `[0x80b058]`, byte-exact `1.3333334`
/// (`0x3faa_aaab`). The same constant `CanLootNow 0x5ec110` uses inline for the loot/skin reach.
pub const COMBAT_REACH_ADD: f32 = f32::from_bits(0x3faa_aaab);
/// The melee arm's FLOOR — `[0x80a1e8]`. A reach sum *below* it is replaced by it (the "cap 5.0"
/// gloss is inverted; byte-checked at `0x6e35bf`).
pub const MELEE_RANGE_FLOOR: f32 = 5.0;
/// The on-next-swing short-circuit's max — `[0x8118d4]`, `0x6e3504`.
pub const ON_NEXT_SWING_RANGE: f32 = 100.0;
/// `GetMinMaxRange 0x6e3480` — a spell's `{min, max}` cast range, the ONE home for the reach
/// arithmetic every consumer shares (the action bar's out-of-range red, the spell tooltip's range
/// cell). Transcribed from wow-re's diff-gated `crates/spell/src/cooldown.rs::spell_minmax_range`
/// (`PRIMITIVE:spell_minmax_range`), including its 53-bit intermediates: the binary keeps the reach
/// sums on the x87 stack and stores `f32` only at the end, so the additions are `f64` here.
///
/// - **on-next-swing** (`Attributes & 0x404`, `0x6e34fb`): `(0, 100)` — the self-cast
/// short-circuit, before the row is even read.
/// - **melee** (`SpellRange` flags bit 0): `min = 0`; `max = reach + caster_reach + 1.3333334`,
/// replaced by `5.0` when it does not exceed it.
/// - **ranged**: the authored row pair, padded by `caster_reach + reach` when a target resolves —
/// the max unconditionally, the min ONLY when the row's min is already nonzero (the
/// `fcomp`-vs-0.0 guard: a min-0 spell must never grow a min range, or point-blank casts refuse
/// TOO_CLOSE — decision 0426).
///
/// `reach` is the TARGET's when one resolves and the **caster's own** otherwise — the binary
/// doubles the caster's reach rather than assuming a default body (`target_reach.unwrap_or(
/// caster_reach)` in the transcription; benilla defaulted the missing side to a flat `1.5` until
/// this moved here, which diverged for every non-default reach). **A caller with no explicit
/// target still owes the auto-attack one**: the melee arm resolves `[caster+0xc48]`
/// (`attack_target_guid`) itself at `0x6e356a` and uses that unit's reach, which is why the spell
/// tooltip's melee cell moves while you are swinging at something big (wow-re
/// `tooltip-damage-matrix-and-container-slots.md` §D4.2b). Passing it as `target_reach` is how a
/// caller models that; only with neither does the caster's own reach stand in twice.
///
/// Two decomp legs are deliberately UNMODELED (0426), and **neither is target-gated the way an
/// earlier reading here claimed**: the PvP `max += 2.6667` bonus (`0x6e3648`, gated on both
/// units' `[unit+0x118]+0x40 & 0x200d` through the un-RE'd `0x5fc350`) is reachable from the
/// melee arm even with no target argument, because that arm sets the target register itself; and
/// the `Attributes & 2` leg (`0x6e36aa`) is not an "inspect" arm and touches the target nowhere —
/// it is `max *= RangedModRange(item template +0x118) · 0.01` for whatever sits in
/// EQUIPMENT_SLOT_RANGED, for a player caster.
///
/// **That second one is NOT the data no-op it was twice recorded as** (decision 2161). Scoped by
/// the client's own slot-17 mask (`0x809200` gives bit 17 to InventoryTypes 15/25/26/28 and no
/// others), `item_template.range_mod` is 100 on all 515 bows/guns/crossbows/wands/thrown — but
/// **0 on all 19 shipped RELICS**: every Druid idol, Paladin libram and Shaman totem
/// (InventoryType 28, ordinary player gear) carries a zero there, alongside nine NPC "Monster -"
/// wands and three oddments. The gate never tests the item's class, so a relic in the ranged slot
/// would collapse `max` to 0 and make the caller suppress the **whole** `SPELL_RANGE` cell.
///
/// It stays unmodelled anyway, and for the honest reason rather than the false one: whether a
/// stock client ever *builds* a bit-1 spell's range cell for a relic-wearing player is a question
/// about the builder's callers, not about this function — none of the 192 `Attributes & 2` rows
/// is a spell a relic class learns, so the only route is an addon's `SetHyperlink`. wow-re has
/// that scoped as a caller census; `ItemInfo::ranged_mod_range` is already on the wire here, so
/// modelling it is a small change the moment the census says it is reachable.
///
/// `None` = no range to test: no row, or the self row (id 1, `{0, 0}`).
pub fn min_max_range(
spell: &crate::spells::SpellDisplay,
row: Option<&SpellRange>,
caster_reach: f32,
target_reach: Option<f32>,
) -> Option<(f32, f32)> {
if spell.on_next_swing() {
return Some((0.0, ON_NEXT_SWING_RANGE));
}
let row = row?;
let reach = target_reach.unwrap_or(caster_reach);
if row.is_melee() {
let sum = f64::from(reach) + f64::from(caster_reach) + f64::from(COMBAT_REACH_ADD);
let max = if sum > f64::from(MELEE_RANGE_FLOOR) {
sum as f32
} else {
MELEE_RANGE_FLOOR
};
return Some((0.0, max));
}
if row.min == 0.0 && row.max == 0.0 {
return None;
}
let Some(target_reach) = target_reach else {
return Some((row.min, row.max));
};
let pad = f64::from(caster_reach) + f64::from(target_reach);
let min = if row.min == 0.0 {
0.0
} else {
(pad + f64::from(row.min)) as f32
};
Some((min, (f64::from(row.max) + pad) as f32))
}
/// `SpellRange.dbc`, by row id ([`SpellDisplay::range_index`]).
#[derive(Default)]
pub struct SpellRangeCatalog {
@@ -86,6 +179,80 @@ pub fn load_spell_ranges(chain: &mut Chain) -> Result<SpellRangeCatalog> {
mod tests {
use super::*;
/// The `GetMinMaxRange 0x6e3480` transcription: melee reach floor, the no-target reach
/// DOUBLING, the ranged reach pad on both bounds, the self-cast short-circuit, and the
/// rangeless self row.
#[test]
fn min_max_range_follows_the_byte_law() {
let spell = |range_index: u32, attributes: u32| crate::spells::SpellDisplay {
range_index,
attributes,
..Default::default()
};
let melee = SpellRange {
min: 0.0,
max: 5.0,
flags: 1,
};
// Two naked-reach units (1.5 + 1.5 + 1.3333334 = 4.333) floor at 5.0…
let d = spell(2, 0);
assert_eq!(
min_max_range(&d, Some(&melee), 1.5, Some(1.5)),
Some((0.0, MELEE_RANGE_FLOOR))
);
// …a big pair (4 + 4 + 1.3333334) exceeds it.
let (_, max) = min_max_range(&d, Some(&melee), 4.0, Some(4.0)).unwrap();
assert!((max - 9.3333).abs() < 1e-3);
// With NO target the caster's own reach stands in for the missing side — the binary
// doubles it, so a 4.0-reach caster reads the same 9.333 alone as it does against an
// equal-reach target. (A flat 1.5 fallback would have said 6.833.)
let (_, max) = min_max_range(&d, Some(&melee), 4.0, None).unwrap();
assert!((max - 9.3333).abs() < 1e-3);
// Charge's 8–25 row pads both bounds by the BARE reach sum (no 1.3333 — melee-only)
// against a unit target.
let charge_row = SpellRange {
min: 8.0,
max: 25.0,
flags: 0,
};
let (min, max) = min_max_range(&d, Some(&charge_row), 1.5, Some(1.5)).unwrap();
assert!((min - (8.0 + 3.0)).abs() < 1e-3);
assert!((max - (25.0 + 3.0)).abs() < 1e-3);
// A min-0 row (Fireball's 0–35) pads the max only — the fcomp-vs-0.0 guard keeps the
// min at zero, so a point-blank cast never reads a min range.
let fireball_row = SpellRange {
min: 0.0,
max: 35.0,
flags: 0,
};
let (min, max) = min_max_range(&d, Some(&fireball_row), 1.5, Some(1.5)).unwrap();
assert_eq!(min, 0.0);
assert!((max - 38.0).abs() < 1e-3);
// No unit target: the row's raw bounds, unpadded — the shape the spell tooltip's own
// call (`target = NULL` at `0x52e9c2`) always takes.
assert_eq!(
min_max_range(&d, Some(&charge_row), 1.5, None),
Some((8.0, 25.0))
);
// The self-cast attribute short-circuits to a flat 100 without touching the row.
assert_eq!(
min_max_range(&spell(1, 0x400), None, 1.5, None),
Some((0.0, ON_NEXT_SWING_RANGE))
);
// The self row (0, 0, no melee flag) resolves to no range at all.
let self_row = SpellRange {
min: 0.0,
max: 0.0,
flags: 0,
};
assert_eq!(min_max_range(&d, Some(&self_row), 1.5, None), None);
}
/// `SpellRange.dbc` on the real data — the byte law's own probe rows (`GetMinMaxRange
/// 0x6e3480`): row 2 is the melee family (flags bit 0), 114 = Auto Shot's 8–35, 95 =
/// Charge's 8–25. Skips without client data.
@@ -48,6 +48,16 @@ pub struct ItemTemplateView {
/// **This is a different spelling from the tooltip's type cell**, which reads `+0x28`
/// (DisplayName) only — a one-handed sword is "One-Handed Swords" here and "Sword" there.
pub item_sub_type: Option<String>,
/// What `(class, subclass)` is called on the TOOLTIP's own type cell — `ItemSubClass.dbc`'s
/// **DisplayName** alone (`row + 4*locale + 0x28`, column 10: the read the builder makes at
/// `0x52c0xx` off the `0xc0db90` row cache), which is the SINGULAR spelling: "Sword", not
/// [`Self::item_sub_type`]'s "One-Handed Swords". App-resolved, because the engine reads no
/// DBCs — and it is the same string the bag line's `CONTAINER_SLOTS` second hole takes.
///
/// This used to be a hand-typed `(class, subclass) → &str` table in the renderer, which is
/// the shape decision 2080 caught one file over: a stale copy of data the catalog already
/// held, missing every container family outright.
pub sub_class_display: Option<String>,
/// The alternate subclass whose proficiency also permits use (ItemSubClass.dbc
/// prerequisite/postrequisite, app-resolved with the builder's sentinel walk: prerequisite
/// wins, postrequisite only when prerequisite is −1). A weapon missing its own mask bit but
@@ -40,8 +40,10 @@ fn item_line_law_and_red_requirements() {
"[ITEM_BIND_ON_EQUIP]",
"[INVTYPE_2HWEAPON]",
// School 0 is physical: the reference names no school word for it either.
"68 - 103 Damage",
"+ 2 - 4 [SCHOOL5] Damage",
// The first emitted slot takes the plain key, a later one the `PLUS_` twin, and a
// school'd slot the `_WITH_SCHOOL` arm — the word sits INSIDE the template.
"[DMG 68 - 103]",
"[+DMGS 2 - 4 [SCHOOL5]]",
"[DPS 25.3]",
// Display order, NOT wire order: the fixture feeds (Stamina, Strength) but the
// 0x808e88 table prints Strength first (STR,AGI,STA,INT,SPI,HP,MANA).
@@ -213,6 +215,34 @@ fn verified_families_signable_locked_resists_known() {
"#,
)
.unwrap();
// …and the five that DO print come out in the builder's own order, which is not the field
// order: `0x52c8ad` runs `edi` 1..5 reading school `(edi == 1) ? 6 : edi`, so **Arcane leads**
// and Holy is displaced rather than skipped (decision 2080 named this; we printed plain field
// order, Fire first and Arcane last).
s.set_item_template(
5520,
ItemTemplateView {
name: "Prismatic Band".into(),
quality: 1,
resistances: [9, 1, 2, 3, 4, 5],
..Default::default()
},
);
s.run(r#"TT:SetOwner(Slot5, "ANCHOR_RIGHT"); TT:BenillaSetItemById(5520)"#)
.unwrap();
let lines = lines_of(&mut s);
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
&texts[1..],
[
"[RESIST_SINGLE +5 [SCHOOL6]]",
"[RESIST_SINGLE +1 [SCHOOL2]]",
"[RESIST_SINGLE +2 [SCHOOL3]]",
"[RESIST_SINGLE +3 [SCHOOL4]]",
"[RESIST_SINGLE +4 [SCHOOL5]]",
],
"Arcane, Fire, Nature, Frost, Shadow — and the +9 Holy never prints"
);
assert!(s.take_errors().is_empty());
}
@@ -321,6 +351,7 @@ fn proficiency_and_reputation_reds() {
name: "Left-Hand Blade".into(),
class: 2,
subclass: 15,
sub_class_display: Some("Dagger".into()),
inventory_type: 22,
..Default::default()
},
@@ -812,3 +843,194 @@ fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() {
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_QUEST]");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The damage block's five-arm template matrix** (wow-re
/// `tooltip-damage-matrix-and-container-slots.md` §D1, VERIFIED; decision 2080 named this cell and
/// 2158 converted it). Each arm here names the leg it takes: the school predicate is the slot's
/// school NUMBER, the ammo predicate is `ItemClass == 6` alone, the single predicate compares the
/// two ROUNDED bounds, and the first/`PLUS_` flag is per-item — a skipped slot does not consume it.
/// The rounding is `floor(min)` / `ceil(max)`, not a round-half pair.
#[test]
fn damage_matrix_arms_and_the_first_flag() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let show = |s: &mut UiScript, id: u32, v: ItemTemplateView| -> Vec<String> {
s.set_item_template(id, v);
s.run(&format!(
r#"local a = CreateFrame("Button", "S{id}"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
if not TT then CreateFrame("GameTooltip", "TT") end
TT:SetOwner(S{id}, "ANCHOR_RIGHT"); TT:BenillaSetItemById({id})"#
))
.unwrap();
lines_of(s).into_iter().map(|(t, _)| t).collect()
};
let weapon = |damages: Vec<(f32, f32, u32)>| ItemTemplateView {
name: "Probe".into(),
class: 2,
subclass: 7,
sub_class_display: Some("Sword".into()),
inventory_type: 13,
damages,
delay_ms: 2600,
..Default::default()
};
// The plain two-number arm, the SKIP, and the PLUS_ twin — the skipped middle slot does not
// consume the first flag, so slot 2 still prints as a later line, and only the FIRST emitted
// line carries the Speed cell.
let lines = show(
&mut s,
900,
weapon(vec![(5.0, 9.0, 0), (0.0, 0.0, 0), (3.0, 6.0, 0)]),
);
assert_eq!(
&lines[2..5],
// (9+5)/2 + (6+3)/2 = 11.5 raw, over 2.6 s.
["[DMG 5 - 9]", "[+DMG 3 - 6]", "[DPS 4.4]"],
"plain then PLUS_, the zero slot skipped: {lines:?}"
);
let speed: String = s
.eval("return TTTextRight3:GetText() or ''")
.expect("right cell 3");
assert_eq!(speed, "Speed 2.60", "the SPEED cell is the first line's");
let later: String = s
.eval("return TTTextRight4:GetText() or ''")
.expect("right cell 4");
assert_eq!(later, "", "a later damage line renders left-only");
// floor(min) / ceil(max) on fractional bounds — Fang of the Mystics' real numbers. A
// round-half pair would say "39 - 86"; the biased pair says 38.
let lines = show(&mut s, 901, weapon(vec![(38.7, 85.7, 0)]));
assert_eq!(lines[2], "[DMG 38 - 86]", "{lines:?}");
// The SINGLE arm — the two ROUNDED bounds equal. It is a no-school, non-ammo leaf only.
let lines = show(&mut s, 902, weapon(vec![(7.0, 7.0, 0)]));
assert_eq!(lines[2], "[DMG1 7]", "{lines:?}");
// …and a school'd slot with equal bounds still takes the WITH_SCHOOL arm, two identical
// numbers and all: there is no SINGLE_…_WITH_SCHOOL template.
let lines = show(&mut s, 903, weapon(vec![(7.0, 7.0, 4)]));
assert_eq!(lines[2], "[DMGS 7 - 7 [SCHOOL4]]", "{lines:?}");
// AMMO is `ItemClass == 6` alone. The `%g` value is the two rounded bounds averaged and is
// NOT divided by anything — Rough Arrow's 1–2 reads 1.5 — and ammo gets neither a Speed
// cell nor a DPS line, because both gate on class 2.
let arrow = ItemTemplateView {
name: "Rough Arrow".into(),
class: 6,
subclass: 2,
sub_class_display: Some("Arrow".into()),
// `ItemClass.dbc` row 6's own name — the LEFT cell for a class-6 item, which never
// consults the InventoryType key table.
item_type: Some("Projectile".into()),
inventory_type: 24,
damages: vec![(1.0, 2.0, 0)],
delay_ms: 3000,
..Default::default()
};
let lines = show(&mut s, 904, arrow.clone());
// The slot|type line above it reads "Projectile | Arrow": a class-6 item takes
// `ItemClass.dbc`'s own row-6 name on the LEFT (`0x52c0bc`, read verbatim — never a
// GlobalString), not the empty `INVTYPE_AMMO` key.
assert_eq!(lines[1], "Projectile", "{lines:?}");
let ty: String = s
.eval("return TTTextRight2:GetText() or ''")
.expect("type cell");
assert_eq!(
ty, "Arrow",
"the RIGHT cell is unchanged on the class-6 leg"
);
assert_eq!(lines[2], "[AMMO 1.5]", "{lines:?}");
assert!(
!lines.iter().any(|l| l.starts_with("[DPS")),
"ammo never reaches the DPS line: {lines:?}"
);
let right: String = s
.eval("return TTTextRight3:GetText() or ''")
.expect("right cell");
assert_eq!(right, "", "ammo gets no Speed cell either");
let lines = show(
&mut s,
905,
ItemTemplateView {
damages: vec![(1.0, 2.0, 3), (5.0, 6.0, 3)],
..arrow
},
);
assert_eq!(
&lines[2..4],
["[AMMOS 1.5 [SCHOOL3]]", "[+AMMOS 5.5 [SCHOOL3]]"],
"{lines:?}"
);
// A non-weapon that carries damage takes the ordinary arms but neither class-2 gate.
let lines = show(
&mut s,
906,
ItemTemplateView {
name: "Odd Trinket".into(),
class: 4,
subclass: 0,
hide_subclass: true,
inventory_type: 12,
damages: vec![(4.0, 8.0, 0)],
delay_ms: 2000,
..Default::default()
},
);
assert_eq!(lines[2], "[DMG 4 - 8]", "{lines:?}");
assert!(
!lines.iter().any(|l| l.starts_with("[DPS")),
"DPS is weapons-only: {lines:?}"
);
}
/// **The bag line** (wow-re §D2, VERIFIED): its gate is `InventoryType == 0x12` alone — never the
/// slot count — and its second hole is the same `ItemSubClass` DisplayName the type cell reads, so
/// the noun is per-subclass. A container whose row names nothing prints no slot line at all.
#[test]
fn container_slots_line_names_its_subclass() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let bag = |sub: u32, name: Option<&str>, slots: u32| ItemTemplateView {
name: "Pouch".into(),
class: 1,
subclass: sub,
sub_class_display: name.map(str::to_string),
inventory_type: 18,
container_slots: slots,
..Default::default()
};
let show = |s: &mut UiScript, id: u32, v: ItemTemplateView| -> Vec<String> {
s.set_item_template(id, v);
s.run(&format!(
r#"local a = CreateFrame("Button", "B{id}"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
if not TT then CreateFrame("GameTooltip", "TT") end
TT:SetOwner(B{id}, "ANCHOR_RIGHT"); TT:BenillaSetItemById({id})"#
))
.unwrap();
lines_of(s).into_iter().map(|(t, _)| t).collect()
};
assert_eq!(
show(&mut s, 910, bag(0, Some("Bag"), 16))[1],
"[SLOTS 16 Bag]"
);
assert_eq!(
show(&mut s, 911, bag(1, Some("Soul Bag"), 24))[1],
"[SLOTS 24 Soul Bag]",
"the noun is the subclass's, not a constant"
);
// A quiver is InventoryType 18 like every other container — INVTYPE_QUIVER is a dead slot
// name in 1.12 — so it reaches this line and names itself.
let mut quiver = bag(2, Some("Quiver"), 8);
quiver.class = 11;
assert_eq!(show(&mut s, 912, quiver)[1], "[SLOTS 8 Quiver]");
// The gate never tests the slot count.
assert_eq!(
show(&mut s, 913, bag(0, Some("Bag"), 0))[1],
"[SLOTS 0 Bag]"
);
// No row name: NEITHER this line nor the ordinary slot|type one.
let lines = show(&mut s, 914, bag(0, None, 16));
assert_eq!(lines.len(), 1, "name line only: {lines:?}");
}
@@ -56,6 +56,18 @@ fn seed_item_strings(s: &mut UiScript) {
SPELL_SCHOOL3_CAP = "[SCHOOL3]"; SPELL_SCHOOL4_CAP = "[SCHOOL4]"
SPELL_SCHOOL5_CAP = "[SCHOOL5]"; SPELL_SCHOOL6_CAP = "[SCHOOL6]"
DAMAGE_TEMPLATE = "[DMG %d - %d]"
PLUS_DAMAGE_TEMPLATE = "[+DMG %d - %d]"
SINGLE_DAMAGE_TEMPLATE = "[DMG1 %d]"
PLUS_SINGLE_DAMAGE_TEMPLATE = "[+DMG1 %d]"
DAMAGE_TEMPLATE_WITH_SCHOOL = "[DMGS %d - %d %s]"
PLUS_DAMAGE_TEMPLATE_WITH_SCHOOL = "[+DMGS %d - %d %s]"
AMMO_DAMAGE_TEMPLATE = "[AMMO %g]"
PLUS_AMMO_DAMAGE_TEMPLATE = "[+AMMO %g]"
AMMO_SCHOOL_DAMAGE_TEMPLATE = "[AMMOS %g %s]"
PLUS_AMMO_SCHOOL_DAMAGE_TEMPLATE = "[+AMMOS %g %s]"
SPEED = "Speed"
CONTAINER_SLOTS = "[SLOTS %d %s]"
DPS_TEMPLATE = "[DPS %.1f]"
ARMOR_TEMPLATE = "[ARMOR %d]"
SHIELD_BLOCK_TEMPLATE = "[BLOCK %d]"
@@ -123,7 +135,10 @@ fn axe() -> ItemTemplateView {
name: "Ravager".into(),
quality: 3,
class: 2,
subclass: 1, // two-hand axe shares the "Axe" display name
subclass: 1,
// ItemSubClass.dbc's DisplayName for (2, 1) — the app-resolved word the type cell
// prints; the two axe subclasses share it.
sub_class_display: Some("Axe".into()),
inventory_type: 17,
bonding: 2,
stats: vec![(7, 12), (4, 9)],
@@ -701,6 +701,9 @@ fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
quality: 1,
class: 2,
subclass: 7,
// The type cell's word is app-resolved off ItemSubClass.dbc, never composed in the
// renderer — a view that carries none prints no type cell.
sub_class_display: Some("Sword".into()),
..Default::default()
},
);
@@ -9,10 +9,12 @@
//! everywhere else; a table that stored the English could not tell them apart, and this one had
//! them collapsed into a single arm until 2045's sweep.
//!
//! **What stays a Rust literal here, and why it is not the same thing**: [`subclass_name`],
//! [`CLASS_NAMES`] and [`RACE_NAMES`] name rows the reference reads out of DBC records (the
//! `{class,subclass}` table `0xc0db90`, ChrClasses, ChrRaces), never out of `GlobalStrings.lua`.
//! Those belong to the DBC-feed question, not to this one.
//! **What stays a Rust literal here, and why it is not the same thing**: [`CLASS_NAMES`] and
//! [`RACE_NAMES`] name rows the reference reads out of DBC records (ChrClasses, ChrRaces), never
//! out of `GlobalStrings.lua`. Those belong to the DBC-feed question, not to this one — and the
//! subclass names that used to sit beside them have gone the whole way there: the type cell and
//! the bag line both read `ItemSubClass.dbc`'s own DisplayName off the app-resolved view now,
//! rather than a hand-typed copy of it.
/// The client's 7-entry quality→color table (wow-re RF-0055, VERIFIED at `0xc0d3c8` behind
/// `GetItemQualityColor 0x48dfb0`): Poor gray, Common white, Uncommon green, Rare blue, Epic
@@ -54,9 +56,15 @@ pub(super) const CREAM: [f32; 4] = [1.0, 1.0, 151.0 / 255.0, 1.0];
/// **Four of these keys ship no value, and that is the reference's behaviour, not a gap.**
/// `INVTYPE_AMMO` (24), `INVTYPE_THROWN` (25), `INVTYPE_RANGEDRIGHT` (26) and `INVTYPE_QUIVER`
/// (27) are in the exe's table but absent from `GlobalStrings.lua`, so `FrameScript_GetText`
/// hands the builder an empty left cell and an arrow shows only its type word. We had written
/// "Projectile", "Thrown" and "Ranged" into those arms — three sentences 1.12 never shows, and
/// exactly the invention class decision 2045 says a text-matching tripwire cannot catch.
/// hands the builder an empty left cell. We had written "Projectile", "Thrown" and "Ranged" into
/// those arms — the invention class decision 2045 says a text-matching tripwire cannot catch.
///
/// **But an arrow's cell is not empty, and this table is not why.** A class-6 item never reaches
/// here at all: the builder forks to `ItemClass.dbc`'s own name for it one instruction earlier
/// (`0x52c0bc`), so ammunition reads "Projectile | Arrow" — the word 2080 deleted, restored by
/// the mechanism that actually produces it rather than by the key that does not. `INVTYPE_RANGED`
/// (15) likewise **does** ship, so a bow reads "Ranged | Bow"; it is 25 and 26 — thrown weapons
/// and guns/crossbows/wands, all ItemClass 2 — that genuinely draw the type word alone.
pub(super) fn invtype_key(t: u32) -> Option<&'static str> {
Some(match t {
1 => "INVTYPE_HEAD",
@@ -89,33 +97,22 @@ pub(super) fn invtype_key(t: u32) -> Option<&'static str> {
})
}
/// (class, subclass) → the slot line's right column (the client's ItemSubClass display names,
/// enUS). Absent pairs (consumables, trade goods, armor Miscellaneous…) show no right column.
pub(super) fn subclass_name(class: u32, sub: u32) -> Option<&'static str> {
Some(match (class, sub) {
(2, 0) | (2, 1) => "Axe",
(2, 2) => "Bow",
(2, 3) => "Gun",
(2, 4) | (2, 5) => "Mace",
(2, 6) => "Polearm",
(2, 7) | (2, 8) => "Sword",
(2, 10) => "Staff",
(2, 13) => "Fist Weapon",
(2, 15) => "Dagger",
(2, 16) => "Thrown",
(2, 17) => "Spear",
(2, 18) => "Crossbow",
(2, 19) => "Wand",
(2, 20) => "Fishing Pole",
(4, 1) => "Cloth",
(4, 2) => "Leather",
(4, 3) => "Mail",
(4, 4) => "Plate",
(4, 6) => "Shield",
(6, 2) => "Arrow",
(6, 3) => "Bullet",
_ => return None,
})
/// The damage block's bias constant — `[0x808120]`, byte-exact `0.9999899864196777`
/// (`0x3f7fff58`). Neither `0.5` nor `1.0`: the epsilon is what stops an exactly-integral max
/// from being bumped by one, and a true `ceil()` differs from this expression for any value
/// within ~1e-5 above an integer.
const DAMAGE_BIAS: f32 = f32::from_bits(0x3f7f_ff58);
/// `floor(DamageMin)` as the builder computes it — `fsub [0x808120]` iff the value is **not**
/// greater than 0, then `__ftol`'s truncate-toward-zero (`0x52c253`–`0x52c276`).
pub(super) fn floor_min(m: f32) -> i32 {
(if m > 0.0 { m } else { m - DAMAGE_BIAS }) as i32
}
/// `ceil(DamageMax)` — the mirror: `fadd [0x808120]` iff the value **is** greater than 0, then
/// the same truncating conversion (`0x52c26e`–`0x52c28c`).
pub(super) fn ceil_max(m: f32) -> i32 {
(if m > 0.0 { m + DAMAGE_BIAS } else { m }) as i32
}
/// A damage/resistance school's name key — `SPELL_SCHOOL%d_CAP`, the one string the builder
@@ -265,17 +265,53 @@ pub(super) fn render_view(
// proficient, which reds the SLOT cell instead. Independently, an off-hand weapon
// (InventoryType 22) reds the SLOT cell without Dual Wield (`0x5eab70` = the learned
// effect-40 spell), even when the type itself is proficient.
if v.container_slots > 0 {
add((format!("{} Slot Bag", v.container_slots), WHITE))?;
// The bag line's gate is `InventoryType == 0x12` **alone** (`0x52b754 sete`, read at
// `0x52bffe`) — never the slot count, which the gate does not test at all: a 0-slot bag
// prints "0 Slot Bag". Every shipped container carries 18, quivers and ammo pouches
// included, which is why `INVTYPE_QUIVER` is a dead slot name in 1.12 (wow-re §D2.4:
// InventoryType 27 occurs in none of the 848 records of the reference install's own
// `itemcache.wdb`, and `0x809200[27]` maps to no equipment slot at all).
if v.inventory_type == 18 {
// `%d` is `ContainerSlots`; `%s` is the SAME `ItemSubClass` DisplayName the type cell
// reads — so the noun is per-subclass ("24 Slot Soul Bag", "8 Slot Quiver", "6 Slot
// Ammo Pouch"), never the constant "Bag" this line used to print for every container.
// A container whose row has no display name emits NEITHER this line nor the ordinary
// slot|type one (`0x52c006`/`0x52c018`/`0x52c021` all jump past the whole region), and
// displayFlags bit 0 is not consulted on this path.
if let Some(name) = v.sub_class_display.as_deref() {
keyed(
"CONTAINER_SLOTS",
&[Arg::D(v.container_slots.into()), Arg::S(name)],
WHITE,
false,
)?;
}
} else {
// The slot cell is the `0x83ddb0` key table's entry for this InventoryType, resolved here
// — so the four types whose keys the string table does not carry (ammo, thrown,
// ranged-right, quiver) draw no slot cell, exactly as the reference does.
let slot = invtype_key(v.inventory_type).and_then(&get);
// The slot cell. For **`ItemClass == 6` the InventoryType key table is not consulted at
// all** (`0x52c0bc cmp dword [esi],6`): the reference reads `ItemClass.dbc` row 6's own
// localized name — "Projectile" — verbatim out of the store at `[0xc0dc24]`, never
// through `FrameScript_GetText`. That is the same `[classRow + 4·locale + 0xc]` column
// `GetItemInfo`'s `itemType` answers with, which the view already carries.
//
// Every other class takes the `0x83ddb0` key table for its InventoryType, so the types
// whose keys `GlobalStrings.lua` does not carry (24 ammo, 25 thrown, 26 ranged-right,
// 27 quiver) draw no slot cell — but 15 `INVTYPE_RANGED` *does* ship, so a bow reads
// "Ranged | Bow" while a gun reads "Gun" alone. Decision 2080 removed three invented
// words here and was right about the KEYS; an arrow's cell is nonetheless not empty,
// because ammunition never reaches them (wow-re §D2.5j, VERIFIED).
let slot = if v.class == 6 {
v.item_type.clone()
} else {
invtype_key(v.inventory_type).and_then(&get)
};
// The type cell is `ItemSubClass.dbc`'s DisplayName for the pair, app-resolved (the
// builder's `0xc0db90` row cache read) — never a table in here: 2080 found the same
// shape one file over, where a hand-typed "Lockpicking" shadowed `LockType.dbc`'s own
// "Pick Lock".
let ty = if v.inventory_type == 16 || v.hide_subclass {
None
} else {
subclass_name(v.class, v.subclass)
v.sub_class_display.as_deref()
};
let mut left_red = false;
let mut right_red = false;
@@ -294,63 +330,115 @@ pub(super) fn render_view(
left_red = true;
}
match (slot, ty) {
(Some(s), Some(t)) => {
add2((s, req_color(!left_red)), (t.into(), req_color(!right_red)))?
}
(Some(s), Some(t)) => add2(
(s, req_color(!left_red)),
(t.to_string(), req_color(!right_red)),
)?,
(Some(s), None) => add((s, req_color(!left_red)))?,
// No slot name: the type stands alone and takes the hard-miss color (the
// builder's single-cell fallback keeps flag-1).
(None, Some(t)) => add((t.into(), req_color(!right_red)))?,
(None, Some(t)) => add((t.to_string(), req_color(!right_red)))?,
_ => {}
}
}
// Damage | speed (block 1) + extra damage lines + the dps line.
if let Some(&(min, max, sch)) = v.damages.first().filter(|d| d.1 > 0.0) {
let mut dmg = format!(
"{} - {}",
(min + 0.5).floor() as i64,
(max + 0.5).floor() as i64
);
if let Some(s) = school_key(sch).and_then(|k| get(&k)) {
dmg = format!("{dmg} {s}");
// **The damage block** — five slots, a five-arm template matrix and a first/PLUS_ flag
// (wow-re `tooltip-damage-matrix-and-container-slots.md` §D1, VERIFIED at
// `[0x52c22b, 0x52c5a1)`; §5 trio). Every arm is a key, and every hole's shape is the
// binary's own push list — this block composed its English in Rust until it was converted.
//
// The three predicates and the flag: **hasSchool** is the slot's school field being nonzero
// (school 0 takes the no-school subtree — `SPELL_SCHOOL0_CAP` is never looked up);
// **isAmmo** is `ItemClass == 6` and *only* that (not InventoryType, not a flag);
// **isSingle** is `floor(min) == ceil(max)` on the ROUNDED integers, tested only on the
// no-school non-ammo leaf (there is no `SINGLE_…_WITH_SCHOOL`); and **isFirst** is per-ITEM,
// cleared on the first EMITTED slot, so a skipped slot does not consume it.
let mut first = true;
let mut dps_acc = 0.0f32;
for &(min, max, school) in v.damages.iter().take(5) {
// `floor(min)` / `ceil(max)` — NOT a round-half pair, which is what this block used to
// do on both bounds. The reference biases by `0x808120` (0.9999899864196777, neither 0.5
// nor 1.0 — the epsilon is what stops an exactly-integral max being bumped) and converts
// with `__ftol`'s truncate-toward-zero. Fang of the Mystics' 38.7–85.7 reads "38 - 86";
// rounding to nearest would say "39 - 86" (43 shipped items carry fractional damage).
let (lo, hi) = (floor_min(min), ceil_max(max));
// A slot is emitted iff either rounded bound is nonzero (`0x52c292`).
if lo == 0 && hi == 0 {
continue;
}
dmg.push_str(" Damage");
let speed = f64::from(v.delay_ms) / 1000.0;
if speed > 0.0 {
add2((dmg, WHITE), (format!("Speed {speed:.2}"), WHITE))?;
// The school WORD is the resolved key; the school NUMBER is what picks the arm. A chain
// that carries no `SPELL_SCHOOL%d_CAP` still takes the with-school template and fills
// the hole with the empty string, exactly as `FrameScript_GetText` does.
let school_name = school_key(school).and_then(|k| get(&k)).unwrap_or_default();
// `avg` is the two ROUNDED bounds averaged, round-tripped through f32 — and it is NOT
// divided by anything: `AMMO_DAMAGE_TEMPLATE`'s shipped "Adds %g damage per second" is
// FrameXML's phrasing over a number the binary never makes per-second (Rough Arrow's
// 1–2 reads "Adds 1.5 damage per second").
let avg = f64::from((lo + hi) as f32 * 0.5);
let plus = |k: &str| {
if first {
k.to_string()
} else {
format!("PLUS_{k}")
}
};
let (key, args): (String, Vec<Arg<'_>>) = if school != 0 {
if v.class == 6 {
(
plus("AMMO_SCHOOL_DAMAGE_TEMPLATE"),
vec![Arg::F(avg), Arg::S(&school_name)],
)
} else {
(
plus("DAMAGE_TEMPLATE_WITH_SCHOOL"),
vec![Arg::D(lo.into()), Arg::D(hi.into()), Arg::S(&school_name)],
)
}
} else if v.class == 6 {
(plus("AMMO_DAMAGE_TEMPLATE"), vec![Arg::F(avg)])
} else if lo == hi {
(plus("SINGLE_DAMAGE_TEMPLATE"), vec![Arg::D(lo.into())])
} else {
add((dmg, WHITE))?;
}
for &(emin, emax, esch) in v.damages.iter().skip(1).filter(|d| d.1 > 0.0) {
let s = school_key(esch).and_then(|k| get(&k)).unwrap_or_default();
let sep = if s.is_empty() { "" } else { " " };
add((
format!(
"+ {} - {}{sep}{s} Damage",
(emin + 0.5).floor() as i64,
(emax + 0.5).floor() as i64
),
WHITE,
))?;
}
// DPS — weapons only (the byte law gates on class==2), Σ(min+max)·0.5 / speed.
if speed > 0.0 && v.class == 2 {
let total: f32 = v
.damages
.iter()
.filter(|d| d.1 > 0.0)
.map(|&(a, b, _)| (a + b) * 0.5)
.sum();
// The precision is DPS_TEMPLATE's own `%.1f`, not ours — a locale that respells it
// gets its own number of decimals with no code change (law §12: "the print precision
// is FRAMEXML-DATA").
keyed(
"DPS_TEMPLATE",
&[Arg::F(f64::from(total) / speed)],
WHITE,
false,
)?;
(
plus("DAMAGE_TEMPLATE"),
vec![Arg::D(lo.into()), Arg::D(hi.into())],
)
};
// The RIGHT cell is the FIRST emitted line's alone, and weapons' alone (`0x52c494`/
// `0x52c49c` — the only two conjuncts): every later damage line, and every line of a
// non-weapon, renders left-text-only. `"%s %.2f"` is an `.rdata` LITERAL, not a key —
// only the word "Speed" is looked up — over `Delay × 0.001`.
let speed = (first && v.class == 2).then(|| {
let word = get("SPEED").unwrap_or_default();
let secs = f64::from(v.delay_ms) * f64::from(0.001_f32);
format!("{word} {secs:.2}")
});
// The damage line is white in BOTH cells unconditionally — it never reddens for an item
// the player cannot use (`0x52c4fa`–`0x52c516`).
if let Some(t) = get(&key) {
let line = (fill(&t, &args), WHITE);
match speed {
Some(s) => add2(line, (s, WHITE))?,
None => add(line)?,
}
}
// The DPS accumulator runs on the emit path, and it uses the RAW floats — so the
// printed range and the printed DPS are computed from different numbers by design.
dps_acc += (max + min) * 0.5;
first = false;
}
// DPS — two conjuncts: a line was emitted AND `ItemClass == 2`. Ammo can satisfy neither
// (its arms require class 6), so an arrow gets no DPS line and no Speed cell.
if !first && v.class == 2 {
// The precision is DPS_TEMPLATE's own `%.1f`, not ours — a locale that respells it gets
// its own number of decimals with no code change (law §12: "the print precision is
// FRAMEXML-DATA"). There is no divide-by-zero guard in the reference either.
let secs = f64::from(v.delay_ms) * f64::from(0.001_f32);
keyed(
"DPS_TEMPLATE",
&[Arg::F(f64::from(dps_acc) / secs)],
WHITE,
false,
)?;
}
if v.armor > 0 {
keyed("ARMOR_TEMPLATE", &[Arg::D(v.armor.into())], WHITE, false)?;
@@ -397,14 +485,20 @@ pub(super) fn render_view(
false,
)?;
} else {
for (i, &r) in v.resistances.iter().enumerate() {
if r == 0 || i == 0 {
continue; // Holy (i == 0) never prints singly in 1.12
// **The singles come out in the BUILDER's order, not the field order.** `0x52c8ad–
// 0x52c93e` runs `edi` 1..5 and reads school `esi = (edi == 1) ? 6 : edi`, so the lines
// are Arcane, Fire, Nature, Frost, Shadow — and that 1→6 remap is *how* Holy is excluded:
// it is displaced by Arcane rather than skipped by a test. Read while converting the
// block's keys and named in decision 2080; we emitted plain field order (Fire first,
// Arcane last) until it was converted.
const RESIST_EMIT_ORDER: [u32; 5] = [6, 2, 3, 4, 5];
for school in RESIST_EMIT_ORDER {
let r = v.resistances[school as usize - 1];
if r == 0 {
continue;
}
let sign = if r > 0 { "+" } else { "-" };
let school = school_key(i as u32 + 1)
.and_then(|k| get(&k))
.unwrap_or_default();
let school = school_key(school).and_then(|k| get(&k)).unwrap_or_default();
keyed(
"ITEM_RESIST_SINGLE",
&[Arg::S(sign), Arg::D(r.abs().into()), Arg::S(&school)],