diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 207a821e..c77825e8 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -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`, diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index 8230c97b..cbbafff4 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -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::().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 diff --git a/crates/benilla-app/src/net/apply/combat.rs b/crates/benilla-app/src/net/apply/combat.rs index 77a70e71..efcd52e5 100644 --- a/crates/benilla-app/src/net/apply/combat.rs +++ b/crates/benilla-app/src/net/apply/combat.rs @@ -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::(); + commands + .entity(e) + .insert(Engaged(victim)) + .remove::(); } } diff --git a/crates/benilla-app/src/net/apply/spells.rs b/crates/benilla-app/src/net/apply/spells.rs index 144c1643..02a74364 100644 --- a/crates/benilla-app/src/net/apply/spells.rs +++ b/crates/benilla-app/src/net/apply/spells.rs @@ -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() diff --git a/crates/benilla-app/src/ui_action/cast_send.rs b/crates/benilla-app/src/ui_action/cast_send.rs index 914bcb13..1b2dabb0 100644 --- a/crates/benilla-app/src/ui_action/cast_send.rs +++ b/crates/benilla-app/src/ui_action/cast_send.rs @@ -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) } diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 12dd45ac..3b9881be 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -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, dist_sq: Option, ) -> Option { - 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, -) -> 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. diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index 1d02ee7c..a1ee62b5 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/merchant_tests.rs b/crates/benilla-app/src/ui_script/merchant_tests.rs index 2b922736..4b195f40 100644 --- a/crates/benilla-app/src/ui_script/merchant_tests.rs +++ b/crates/benilla-app/src/ui_script/merchant_tests.rs @@ -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. diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index d6dd23eb..221ee0bb 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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, 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, 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, Option)>, /// 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, @@ -411,6 +432,9 @@ fn feed_spell_tooltips( selection: Res, stores: Query<&ObjectStore>, self_q: Query<&ObjectStore, With>, + // Who the player is auto-attacking, if anyone — the melee range cell's second reach. + engaged_q: Query<&crate::creature_anim::Engaged, With>, + guids: Res, home_bind: Option>, area_names: Option>, mut items: ResMut, @@ -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 = memory.reagents.keys().copied().collect(); let reagent_state: std::collections::BTreeMap = 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), diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index 12c9d03f..808dfe8c 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -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] diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 82828c8e..b5a4a3b3 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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::{ diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index 707430ea..2ad694fd 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -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 diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 43d162cf..7932a264 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -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; diff --git a/crates/benilla-formats/src/spells/ranges.rs b/crates/benilla-formats/src/spells/ranges.rs index 30d5b7fe..910ffaf3 100644 --- a/crates/benilla-formats/src/spells/ranges.rs +++ b/crates/benilla-formats/src/spells/ranges.rs @@ -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, +) -> 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 { 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. diff --git a/crates/benilla-ui/src/script/item_stats.rs b/crates/benilla-ui/src/script/item_stats.rs index 3c176687..50b35432 100644 --- a/crates/benilla-ui/src/script/item_stats.rs +++ b/crates/benilla-ui/src/script/item_stats.rs @@ -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, + /// 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, /// 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 diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/law.rs b/crates/benilla-ui/src/script/tests/tooltip_item/law.rs index 3e323f20..9fb0ede7 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/law.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/law.rs @@ -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 { + 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 { + 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:?}"); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tests/tooltip_item/mod.rs index a2372a28..531bd138 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/mod.rs @@ -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)], diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index b95eb448..c9b93445 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -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() }, ); diff --git a/crates/benilla-ui/src/script/tooltip_item/names.rs b/crates/benilla-ui/src/script/tooltip_item/names.rs index 7e5f7845..dbc54c17 100644 --- a/crates/benilla-ui/src/script/tooltip_item/names.rs +++ b/crates/benilla-ui/src/script/tooltip_item/names.rs @@ -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 diff --git a/crates/benilla-ui/src/script/tooltip_item/render.rs b/crates/benilla-ui/src/script/tooltip_item/render.rs index 6687bdb9..d74feed5 100644 --- a/crates/benilla-ui/src/script/tooltip_item/render.rs +++ b/crates/benilla-ui/src/script/tooltip_item/render.rs @@ -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>) = 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)],