From e9fe05dab33de7e9f7f0b07fd0b1ced5027c1d88 Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 31 Oct 2026 18:43:17 -0600 Subject: [PATCH] The spell table's goldens, and four more realm changes in them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Spell.dbc` is 28046 rows where 5875 had 22357, with rows retuned as well as added, so every tally over it moved. Field count is still 173, so the layout is intact and these are data. Four are gameplay, not bookkeeping: - **Bloodrage is free.** 1.12 charged 20% health (power type `0xFFFF_FFFE`, `mana_cost_pct` 20); all three cost columns are zero here. Not a slip — the Life Tap loop directly above reads `0xFFFF_FFFE` out of the same column and passes. - **Health Funnel costs 3 up front and 6 per second**, where 1.12 took 11 and 5. - **Auto Shot works with thrown weapons.** Its mask is `0x5000c` — subclass bits 2 Bow, 3 Gun, **16 Thrown**, 18 Crossbow — where 1.12's `0x4000c` had the three ranged ones only. - **Shiv joined the deferred-start class**, so it starts the auto-attack at the GO rather than the send, like the other openers and positional strikes. And one piece of the realm's own authoring worth having written down: three custom spells carry a `SpellFamilyName` outside vmangos's set — 57091/57092 "Flamewave" and "Flamewave Effect" with 64, 57089 "Grellfire" with `0x100000` — a family *name* field filled in as though it were a bitmask. All three have zero family flags, so nothing the modifier gate walks ever reads them. The histogram names them rather than the test being widened to swallow them. The rest are tallies, each now carrying its 5875 figure beside it so the drift stays legible: `SpellDuration` 82 → 86, channeled rows 323 → 427 (own-name 9 → 14, bar-suppressing 2 → 6), `Targets == 0x10` 363 → 465 and `0x4000` 103 → 118, `modalNextSpell` 57 → 77 (52 → 67 naming Auto Shot), `manaCostPerlevel` 72 → 77, multi-bit family rows 322 → 466, and the highest family bit 35 → 53 — still inside the 64-bit pair, which is what that one is really guarding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../src/spells/catalog_tests.rs | 119 +++++++++++------- crates/benilla-formats/src/spells/duration.rs | 4 +- 2 files changed, 80 insertions(+), 43 deletions(-) diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 50961862..36c63a54 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -483,14 +483,16 @@ fn real_spell_catalog_reads_usable_walk_columns() { assert!(!cat.get(plain).unwrap().needs_combo_points(), "{plain}"); } - // Auto Shot: bows/guns/crossbows. Slow Fall: one Light Feather. + // Auto Shot. The realm added **thrown** to its weapon mask: `0x5000c` carries subclass bits + // 2 (Bow), 3 (Gun), 16 (Thrown) and 18 (Crossbow), where 1.12's `0x4000c` had the three + // ranged ones and not thrown. So a thrown weapon auto-attacks at range here. let auto_shot = cat.get(75).unwrap(); assert_eq!( ( auto_shot.equipped_item_class, auto_shot.equipped_item_subclass_mask ), - (2, 0x4000c) + (2, 0x5000c) ); assert_eq!(cat.get(130).unwrap().reagents[0], (17056, 1), "Slow Fall"); @@ -699,7 +701,7 @@ fn real_spell_catalog_classifies_combat_initiation() { ); } - // 36 shipped rows carry bit 20, every one an opener, a positional strike or Judgement. + // 38 shipped rows carry bit 20, every one an opener, a positional strike or Judgement. let deferred: Vec<(u32, &str)> = cat .iter() .filter(|(_, d)| d.initiates_auto_attack_at_go()) @@ -707,8 +709,9 @@ fn real_spell_catalog_classifies_combat_initiation() { .collect(); assert_eq!( deferred.len(), - 36, - "5875 ships 36 INITIATE_COMBAT_POST_CAST rows, got {}: {:?}", + 38, + "the realm ships 38 INITIATE_COMBAT_POST_CAST rows (36 in 5875 — the two extra are its \ + own Shiv and Shred ranks), got {}: {:?}", deferred.len(), deferred ); @@ -725,6 +728,9 @@ fn real_spell_catalog_classifies_combat_initiation() { "Judgement", "Pounce", "Ravage", + // The realm gave Shiv the deferred start too, which is of a piece with the rest of + // the class: it is a rogue strike that opens from stealth range. + "Shiv", "Shred", "Test Stab R50", ], @@ -733,7 +739,7 @@ fn real_spell_catalog_classifies_combat_initiation() { } /// `modalNextSpell`, column 38, which `0x6e7447` reads to start Auto Shot after a hunter shot: -/// nonzero on 57 of 22357 rows, 52 of them naming spell 75. +/// nonzero on 77 of the realm's rows, 67 of them naming spell 75 (57 and 52 in 5875). #[test] fn real_spell_catalog_reads_modal_next_spell() { let data = crate::wow_data_or_skip!(); @@ -773,14 +779,14 @@ fn real_spell_catalog_reads_modal_next_spell() { .collect(); assert_eq!( chained.len(), - 57, - "5875 ships 57 rows with a non-zero column 38, got {}", + 77, + "the realm ships 77 rows with a non-zero column 38 (57 in 5875), got {}", chained.len() ); let to_auto_shot = chained.iter().filter(|&&(_, next)| next == 75).count(); assert_eq!( - to_auto_shot, 52, - "52 of the 57 name Auto Shot (the rest: three (TEST) bow shot rows → 59, two Minigun → 23675)" + to_auto_shot, 67, + "67 of the 77 name Auto Shot (52 of 57 in 5875)" ); // Nothing points at a spell that chains onward: one hop, in data. for &(id, next) in &chained { @@ -939,10 +945,12 @@ fn real_prospecting_effect_is_absent_from_5875() { } } // A scan that read nothing would pass too, so pin the row count. + // 28046 rows now, where 5875 had 22357 reaching this scan — the realm's additions. Still + // three slots apiece, which is the shape this is really guarding. assert_eq!( slots, - 22357 * 3, - "every effect slot of every 5875 row was read" + 28046 * 3, + "every effect slot of every shipped row was read" ); } @@ -975,10 +983,13 @@ fn real_item_target_family_and_its_gate_columns() { } } assert_eq!( - item_only, 363, - "Targets == 0x10 — the enchant/poison family" + item_only, 465, + "Targets == 0x10 — the enchant/poison family (363 in 5875)" + ); + assert_eq!( + locked_only, 118, + "Targets == 0x4000 — the OPEN_LOCK family (103 in 5875)" ); - assert_eq!(locked_only, 103, "Targets == 0x4000 — the OPEN_LOCK family"); // The OPEN_LOCK implicit arm: `cast_target_mask` ORs `0x800` for arm 23 and `TF_UNIT` for arm // 25, so a lock word is `0x4000` or `0x4800`, nonzero under both `& 0x4010` and `& 0x4800`, @@ -989,8 +1000,8 @@ fn real_item_target_family_and_its_gate_columns() { } assert_eq!( arms.values().sum::(), - 103, - "every OPEN_LOCK row is counted" + 118, + "every OPEN_LOCK row is counted (103 in 5875)" ); assert_eq!( arms.get(&25), @@ -1110,7 +1121,10 @@ fn real_spell_catalog_cost_columns() { "Life Tap {id}" ); } - // Bloodrage: a percentage-only health cost. + // **Bloodrage is free on this realm.** In 1.12 it was the percentage-only health cost — + // power type `0xFFFF_FFFE` (health) with `mana_cost_pct` 20 — and here all three columns are + // zero, so it takes no health and no mana. Not a column slip: the Life Tap loop just above + // reads `0xFFFF_FFFE` out of the same column and passes. let bloodrage = cat.get(2687).unwrap(); assert_eq!( ( @@ -1118,13 +1132,17 @@ fn real_spell_catalog_cost_columns() { bloodrage.mana_cost, bloodrage.mana_cost_pct ), - (0xFFFF_FFFE, 0, 20) + (0, 0, 0), + "the realm charges nothing for Bloodrage" ); // Health Funnel: flat health plus per second (the `_PER_TIME` line), and channeled. let funnel = cat.get(755).unwrap(); + // The realm retuned it: 3 health up front and 6 per second, where 1.12 took 11 and 5. The + // power type is still health, so the column is read. assert_eq!( (funnel.power_type, funnel.mana_cost, funnel.mana_per_second), - (0xFFFF_FFFE, 11, 5) + (0xFFFF_FFFE, 3, 6), + "the realm's Health Funnel costs" ); assert!(funnel.tooltip_channeled()); // The cast cell's attribute arms: Heroic Strike (next melee; 150 on the wire is "15 Rage"), @@ -1165,7 +1183,10 @@ fn real_spell_catalog_cost_columns() { "manaPerSecondPerLevel on {id}" ); } - assert_eq!(per_level_rows, 72, "the manaCostPerlevel population"); + assert_eq!( + per_level_rows, 77, + "the manaCostPerlevel population (72 in 5875)" + ); let dark_offering = cat.get(7154).unwrap(); assert_eq!( ( @@ -1338,19 +1359,23 @@ fn real_channel_bar_name_law() { } let channeled: Vec<_> = cat.iter().filter(|(_, d)| d.tooltip_channeled()).collect(); - assert_eq!(channeled.len(), 323, "channeled rows in the shipped file"); + assert_eq!( + channeled.len(), + 427, + "channeled rows in the shipped file (323 in 5875)" + ); assert_eq!( channeled .iter() .filter(|(_, d)| d.channel_bar_own_name()) .count(), - 9, - "only nine channeled rows print their own name" + 14, + "fourteen channeled rows print their own name (nine in 5875)" ); assert_eq!( channeled.iter().filter(|(_, d)| d.no_channel_bar()).count(), - 2, - "only the two Blood Siphons suppress the bar" + 6, + "six rows suppress the bar, where 5875 had only the two Blood Siphons" ); } @@ -1378,29 +1403,39 @@ fn real_spell_family_columns_carry_the_modifier_gate() { assert_eq!( families, [ - (0, 18243), - (1, 47), - (3, 563), - (4, 350), - (5, 466), - (6, 493), - (7, 450), - (8, 316), - (9, 393), - (10, 362), - (11, 500), - (13, 174), + (0, 22387), + (1, 54), + (3, 679), + (4, 487), + (5, 604), + (6, 653), + (7, 620), + (8, 435), + (9, 637), + (10, 551), + (11, 729), + (13, 207), + // Two keys outside vmangos's `SpellFamilyNames` set, and both are the realm's own + // authoring rather than a column we misread: 57091 "Flamewave" and 57092 "Flamewave + // Effect" carry 64, and 57089 "Grellfire" carries `0x100000` — a family *name* field + // filled in as if it were a bitmask. All three have zero family flags, so nothing the + // modifier gate walks ever reads them. + (64, 2), + (1_048_576, 1), ] .into_iter() .collect::>(), - "SpellFamilyName histogram — the vmangos SpellFamilyNames set, and only it" + "SpellFamilyName histogram — vmangos's set plus three custom rows with junk names" ); assert_eq!( popcounts.values().skip(1).sum::() - popcounts[&1], - 322, - "rows setting MORE than one family bit — the reader's sum is live" + 466, + "rows setting MORE than one family bit (322 in 5875) — the reader's sum is live" ); - assert_eq!(max_bit, 35, "the highest family bit index in the file"); + // 53, where 5875's highest was 35: the realm's own spells reach further up the pair. Still + // inside the 64-bit pair, so the high dword being live is the thing this guards and it still + // is — a reader that dropped the high half would answer 31. + assert_eq!(max_bit, 53, "the highest family bit index in the file"); // Frostbolt spans one dword, Cleanse both, Cure Poison only the high one. for (id, family, bits) in [ diff --git a/crates/benilla-formats/src/spells/duration.rs b/crates/benilla-formats/src/spells/duration.rs index 98cf9b6d..50bbf1ca 100644 --- a/crates/benilla-formats/src/spells/duration.rs +++ b/crates/benilla-formats/src/spells/duration.rs @@ -137,7 +137,9 @@ mod tests { let short = durations.get(1).expect("row 1"); assert_eq!((short.base_ms, short.per_level_ms), (10_000, 0)); - assert_eq!(durations.len(), 82, "5875 ships 82 SpellDuration rows"); + // 86, where 5875 shipped 82: the realm adds durations for its own spells. 4 fields + // either way, which is what the decode depends on. + assert_eq!(durations.len(), 86, "the realm ships 86 SpellDuration rows"); // Row 427: a negative base with a positive per-level term, permanent under neither test. let scaling = durations.get(427).expect("row 427");