diff --git a/crates/benilla-app/src/spell/usable.rs b/crates/benilla-app/src/spell/usable.rs index 8b276ff0..f245a1a9 100644 --- a/crates/benilla-app/src/spell/usable.rs +++ b/crates/benilla-app/src/spell/usable.rs @@ -447,9 +447,9 @@ pub(crate) fn spell_usable( /// The resolved power cost, `GetPowerCost 0x6e31b0`: `manaCost`, plus the signed /// `(level - baseLevel) * manaCostPerlevel` (only creature spells carry it), plus -/// `ManaCostPercentage` of a per-type basis: base mana for a mana spell, as the reference's -/// `0x612c50`; else the max pool, where `0x612c50` takes 1000 for rage, 100 for focus and energy -/// and base health for health. Then `SPELLMOD_COST` through `0x6e6af0` (`6e32e3`), clamped at 0. +/// `ManaCostPercentage` of the reference's per-type basis (`0x612c50`): base health for health, +/// base mana for mana, 1000 for rage, 100 for focus and energy, and 0 otherwise. Then +/// `SPELLMOD_COST` through `0x6e6af0` (`6e32e3`), clamped at 0. /// The reference's per-school unit modifiers are not applied. /// /// One cost for leg 12, the press-path gate and the tooltip, as `0x6e31b0` serves all six of its @@ -474,8 +474,8 @@ pub(crate) fn power_cost_at( let base = match cost_basis(d) { CostBasis::None => 0, CostBasis::BaseMana => unit.unit_base_mana().unwrap_or(0), - CostBasis::MaxHealth => unit.unit_max_health().unwrap_or(0), - CostBasis::MaxPower(ty) => unit.unit_max_power(ty).unwrap_or(0), + CostBasis::BaseHealth => unit.unit_base_health().unwrap_or(0), + CostBasis::Fixed(value) => value, }; let level_delta = i64::from(level) - i64::from(d.base_level); let cost = i64::from(d.mana_cost) @@ -486,29 +486,30 @@ pub(crate) fn power_cost_at( u32::try_from(mods.apply(d, OP_COST, cost)).unwrap_or(0) } -/// The unit field [`power_cost_at`] scales `d`'s percentage cost by, the reference's `0x612c50` -/// basis: base mana for a mana spell, else the max pool of the power type, or base health for a -/// negative one. +/// What [`power_cost_at`] scales `d`'s percentage cost by: `0x612c50`, a jump table on the power +/// type plus 2 (`612c53`-`612c5e`), on the caster's fields. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum CostBasis { - /// No percentage: the cost reads no unit field. + /// No percentage, or a type the table sends to 0 (`612c97`): the cost reads no unit field. None, + /// Mana: `UNIT_FIELD_BASE_MANA`, unit block `+0x270` (`612c65`-`612c6b`). BaseMana, - MaxHealth, - /// `UNIT_FIELD_MAXPOWER` of the type; a type past the five reads nothing. - MaxPower(u8), + /// Health (-2): `UNIT_FIELD_BASE_HEALTH`, unit block `+0x274` (`612c87`-`612c8d`). + BaseHealth, + /// A constant, not the pool: 1000 for rage (`612c7e`), 100 for focus and energy (`612c75`). + Fixed(u32), } pub(crate) fn cost_basis(d: &SpellDisplay) -> CostBasis { - let power_type = d.power_type as i32; if d.mana_cost_pct == 0 { - CostBasis::None - } else if d.power_type == 0 { - CostBasis::BaseMana - } else if power_type < 0 { - CostBasis::MaxHealth - } else { - CostBasis::MaxPower(power_type as u8) + return CostBasis::None; + } + match d.power_type as i32 { + -2 => CostBasis::BaseHealth, + 0 => CostBasis::BaseMana, + 1 => CostBasis::Fixed(1000), + 2 | 3 => CostBasis::Fixed(100), + _ => CostBasis::None, } } @@ -764,6 +765,64 @@ mod tests { ) } + /// Bloodrage (2687) takes 20% of base health, even when gear raises max health. + #[test] + fn bloodrage_cost_uses_base_health_for_cost_and_gates() { + let bloodrage = SpellDisplay { + power_type: (-2i32) as u32, + mana_cost_pct: 20, + ..Default::default() + }; + let mods = SpellModifiers::default(); + let with_gear = player(&[(22, 400), (28, 4_000), (163, 1_689)]); + assert_eq!(power_cost(&bloodrage, &with_gear, &mods), 337); + assert!(can_afford(&bloodrage, &with_gear, &mods)); + assert_eq!(walk(&bloodrage, &with_gear), (true, false)); + + let below_cost = player(&[(22, 336), (28, 4_000), (163, 1_689)]); + assert!(!can_afford(&bloodrage, &below_cost, &mods)); + assert_eq!(walk(&bloodrage, &below_cost), (false, true)); + + let without_gear = player(&[(22, 400), (28, 1_689), (163, 1_689)]); + assert_eq!(power_cost(&bloodrage, &without_gear, &mods), 337); + } + + /// `0x612c50`'s jump table (`0x612ca0`, indexed by the type plus 2): base health for -2, base + /// mana, fixed bases for rage, focus and energy, and 0 for -1, happiness and past the table. + #[test] + fn percentage_cost_bases_follow_power_type() { + let store = player(&[ + (28, 4_000), // max health + (29, 2_000), // max mana + (30, 800), // max rage + (31, 80), // max focus + (32, 120), // max energy + (33, 500), // max happiness + (162, 1_500), + (163, 1_689), + ]); + let mods = SpellModifiers::default(); + let cases = [ + (-3, 0), + (-2, 337), + (-1, 0), + (0, 300), + (1, 200), + (2, 20), + (3, 20), + (4, 0), + (5, 0), + ]; + for (power_type, cost) in cases { + let spell = SpellDisplay { + power_type: power_type as u32, + mana_cost_pct: 20, + ..Default::default() + }; + assert_eq!(power_cost(&spell, &store, &mods), cost, "type {power_type}"); + } + } + /// A -30% cost cell turns an unaffordable 100-mana spell into an affordable 70 with 80 mana. #[test] fn a_talent_cost_cut_reaches_the_cost_and_the_press_path_gate() { diff --git a/crates/benilla-app/src/ui_tooltip/deps_tests.rs b/crates/benilla-app/src/ui_tooltip/deps_tests.rs index d7a76293..af17461b 100644 --- a/crates/benilla-app/src/ui_tooltip/deps_tests.rs +++ b/crates/benilla-app/src/ui_tooltip/deps_tests.rs @@ -44,8 +44,7 @@ struct Inputs { worn: u32, /// The disarm flag. disarm: bool, - /// A [`UnitField`] bit each: level, base mana, max health, max power of each type, ranged - /// attack time. + /// A [`UnitField`] bit each: level, base mana, base health, ranged attack time. unit: u16, /// Bit `i`: the skill line `lines[i]` at 300. lines: u128, @@ -68,7 +67,7 @@ impl Inputs { pace: true, worn: EQUIPMENT_MASK, disarm: true, - unit: 0x1ff, + unit: 0xf, lines: u128::MAX, bits: u64::MAX, poke: None, @@ -215,7 +214,7 @@ impl Inputs { pace: !deps.range_units, worn: EQUIPMENT_MASK & !deps.worn, disarm: !deps.disarm, - unit: !deps.unit & 0x1ff, + unit: !deps.unit & 0xf, lines: layout .lines .iter() @@ -229,17 +228,7 @@ impl Inputs { } /// The [`UnitField`] names, by bit. -const UNIT_NAMES: [&str; 9] = [ - "level", - "base mana", - "max health", - "max mana", - "max rage", - "max focus", - "max energy", - "max happiness", - "ranged attack time", -]; +const UNIT_NAMES: [&str; 4] = ["level", "base mana", "base health", "ranged attack time"]; /// What a sweep holds fixed for a view: the form the form input puts on, the item kind worn /// items are of, and the class family the tables gate on. @@ -307,14 +296,6 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { let mut pairs: Vec<(u16, u32)> = vec![ (22, 3500), (34, if unit(UnitField::Level) { 60 } else { 20 }), - ( - 28, - if unit(UnitField::MaxHealth) { - 9000 - } else { - 4000 - }, - ), ( 162, if unit(UnitField::BaseMana) { @@ -323,6 +304,14 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { 1000 }, ), + ( + 163, + if unit(UnitField::BaseHealth) { + 1900 + } else { + 1689 + }, + ), ( 128, if unit(UnitField::RangedTime) { @@ -332,14 +321,6 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { }, ), ]; - for ty in 0..5u8 { - let max_power = if unit(UnitField::MaxPower(ty)) { - 1500 - } else { - 500 - }; - pairs.push((29 + u16::from(ty), max_power)); - } if inputs.disarm { pairs.push((46, 0x0020_0000)); } @@ -766,11 +747,7 @@ fn every_view_that_moves_with_an_input_is_requeued_by_that_input() { }; let s = sweep(&mut rig, empty, &any, &own, &cause(inputs)); assert_covered(&format!("the player's {name}"), &s); - // Mana costs scale by the base, and no 1.12 spell costs a percentage of a rage, focus, - // energy or happiness pool: those are watched all the same. - if !name.starts_with("max ") || name == &"max health" { - assert!(s.moved >= 1, "no view reads the player's {name}"); - } + assert!(s.moved >= 1, "no view reads the player's {name}"); row(name, &s); } let armed = Inputs { @@ -1008,6 +985,38 @@ fn a_pet_view_reads_no_player_form_equipment_reach_or_unit_field_but_the_ranged_ assert!(ranged >= 1, "no pet view reads the ranged attack time"); } +/// A pet view's cost reads `0x612c50` on the pet (`6e327b`), so the pet's base mana and base health +/// are pet inputs, and its max health and max pools, which no cost reads, are not. +#[test] +fn the_pets_cost_bases_are_its_inputs_and_its_max_pools_are_not() { + let unit = RangeUnit::still(1.5); + let inputs = |pairs: &[(u16, u32)]| { + PetInputs::of( + Some(1), + Some(&ObjectFields::from_pairs(pairs)), + RangeSeen::of(&unit, None), + ) + }; + let base = inputs(&[(34, 30), (162, 900), (163, 600)]); + assert!( + inputs(&[(34, 30), (162, 901), (163, 600)]) != base, + "base mana" + ); + assert!( + inputs(&[(34, 30), (162, 900), (163, 601)]) != base, + "base health" + ); + let pools = [ + (34, 30), + (162, 900), + (163, 600), + (28, 4000), + (29, 2000), + (32, 100), + ]; + assert!(inputs(&pools) == base, "max health and max pools"); +} + /// Every field of the pet's unit that moves a pet view is one of the pet inputs the feed rebuilds /// the pet views on. The fields go 48 at a time; a view that moves without a pet input moving is /// then narrowed to the fields that move it. @@ -1032,18 +1041,9 @@ fn no_pet_field_moves_a_pet_view_unwatched() { family: 1, }, ); - // Level, max health, max power of each type, reach, base mana. - let base_pairs: Vec<(u16, u32)> = vec![ - (34, 30), - (28, 600), - (29, 700), - (30, 1000), - (31, 100), - (32, 100), - (33, 10), - (130, 2.0f32.to_bits()), - (162, 900), - ]; + // Level, reach, base mana, base health. + let base_pairs: Vec<(u16, u32)> = + vec![(34, 30), (130, 2.0f32.to_bits()), (162, 900), (163, 600)]; let pet_with = |poke: std::ops::Range| { let mut pairs = base_pairs.clone(); pairs.extend(poke.map(|field| (field, POKE))); @@ -1443,21 +1443,21 @@ fn the_seen_diff_names_each_input_it_saw_move() { assert!(c.disarm); let now = player(&[(22, 100), (46, 0x0000_0008)]); assert!(seen(&now).changes_since(&seen(&base)).is_empty()); - // Each unit field on its own bit: level 34, max health 28, max power 29-33, base mana 162, - // ranged attack time 128. + // Each unit field on its own bit: level 34, base mana 162, base health 163, ranged attack + // time 128; max health 28 and the max pools 29-33 on none, which no cost reads. let unit = [ - (34u16, UnitField::Level), - (162, UnitField::BaseMana), - (28, UnitField::MaxHealth), - (29, UnitField::MaxPower(0)), - (31, UnitField::MaxPower(2)), - (33, UnitField::MaxPower(4)), - (128, UnitField::RangedTime), + (34u16, UnitField::Level.bit()), + (162, UnitField::BaseMana.bit()), + (163, UnitField::BaseHealth.bit()), + (128, UnitField::RangedTime.bit()), + (28, 0), + (29, 0), + (33, 0), ]; - for (field, which) in unit { + for (field, bit) in unit { let now = player(&[(22, 100), (field, 77)]); let c = seen(&now).changes_since(&seen(&base)); - assert_eq!(c.unit, which.bit(), "unit field {field}"); + assert_eq!(c.unit, bit, "unit field {field}"); } // A line's value moving names the line. let line = |value: u32| player(&[(22, 100), (718, 43), (719, value | 300 << 16)]); diff --git a/crates/benilla-app/src/ui_tooltip/spell_deps.rs b/crates/benilla-app/src/ui_tooltip/spell_deps.rs index 13242dd2..9be2e67b 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_deps.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_deps.rs @@ -48,9 +48,7 @@ impl Chance { pub(super) enum UnitField { Level, BaseMana, - MaxHealth, - /// `UNIT_FIELD_MAXPOWER` of a type; one past the five reads nothing. - MaxPower(u8), + BaseHealth, RangedTime, } @@ -59,10 +57,8 @@ impl UnitField { match self { UnitField::Level => 1, UnitField::BaseMana => 1 << 1, - UnitField::MaxHealth => 1 << 2, - UnitField::MaxPower(ty) if ty < 5 => 1 << (3 + ty), - UnitField::MaxPower(_) => 0, - UnitField::RangedTime => 1 << 8, + UnitField::BaseHealth => 1 << 2, + UnitField::RangedTime => 1 << 3, } } } @@ -73,8 +69,7 @@ impl UnitField { struct UnitFields { level: u32, base_mana: u32, - max_health: u32, - max_power: [u32; 5], + base_health: u32, ranged_time: u32, } @@ -86,8 +81,7 @@ impl UnitFields { Self { level: p.unit_level().unwrap_or(0), base_mana: p.unit_base_mana().unwrap_or(0), - max_health: p.unit_max_health().unwrap_or(0), - max_power: std::array::from_fn(|ty| p.unit_max_power(ty as u8).unwrap_or(0)), + base_health: p.unit_base_health().unwrap_or(0), ranged_time: p.unit_ranged_attack_time().unwrap_or(0), } } @@ -101,18 +95,10 @@ impl UnitFields { 0 } }; - let mut bits = moved(UnitField::Level, self.level, prev.level) + moved(UnitField::Level, self.level, prev.level) | moved(UnitField::BaseMana, self.base_mana, prev.base_mana) - | moved(UnitField::MaxHealth, self.max_health, prev.max_health) - | moved(UnitField::RangedTime, self.ranged_time, prev.ranged_time); - for ty in 0..5u8 { - bits |= moved( - UnitField::MaxPower(ty), - self.max_power[usize::from(ty)], - prev.max_power[usize::from(ty)], - ); - } - bits + | moved(UnitField::BaseHealth, self.base_health, prev.base_health) + | moved(UnitField::RangedTime, self.ranged_time, prev.ranged_time) } } diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs index ec122449..d84579b0 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_feed.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -151,10 +151,9 @@ pub(super) fn build_view( deps.unit(UnitField::Level); } match usable::cost_basis(d) { - CostBasis::None => {} + CostBasis::None | CostBasis::Fixed(_) => {} CostBasis::BaseMana => deps.unit(UnitField::BaseMana), - CostBasis::MaxHealth => deps.unit(UnitField::MaxHealth), - CostBasis::MaxPower(ty) => deps.unit(UnitField::MaxPower(ty)), + CostBasis::BaseHealth => deps.unit(UnitField::BaseHealth), } } let cost = { @@ -485,8 +484,7 @@ pub(super) struct PetInputs { guid: Option, level: u32, base_mana: u32, - max_health: u32, - max_power: [u32; 5], + base_health: u32, form: u8, range_units: RangeSeen, } @@ -507,8 +505,7 @@ impl PetInputs { guid, level: p.unit_level().unwrap_or(0), base_mana: p.unit_base_mana().unwrap_or(0), - max_health: p.unit_max_health().unwrap_or(0), - max_power: std::array::from_fn(|i| p.unit_max_power(i as u8).unwrap_or(0)), + base_health: p.unit_base_health().unwrap_or(0), form: p.unit_shapeshift_form(), range_units, } diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index 28d76aa6..d33e6829 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -469,24 +469,24 @@ fn cost_and_cast_cells_on_real_data() { let mut t = TestCtx::new(); let mut objs = no_objects(); let objects = objs.get(); - // A level-60 store: max health 4000, base mana 1000 (fields: health 22, max health 28, - // level 34, base mana 162). + // A level-60 store: max health 4000, base mana 1000 and base health 1689 (fields: health 22, + // max health 28, level 34, base mana 162, base health 163). let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ (22u16, 3500u32), (28, 4000), (34, 60), (162, 1000), + (163, 1689), ])); - // Bloodrage (2687): 20% of max health, a flat number through the health fallback; bare - // "Instant" on a non-mana type. + // Bloodrage (2687): 20% of base health; bare "Instant" on a non-mana type. let v = spell_tooltip_view( 2687, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)), ) .expect("Bloodrage view"); - assert_eq!(v.cost.as_deref(), Some("800 Health"), "20% of 4000"); + assert_eq!(v.cost.as_deref(), Some("337 Health"), "20% of 1689"); assert_eq!(v.cast_time.as_deref(), Some("Instant")); // Life Tap (1454): the 5875 data has no cost columns for any rank, so the cell is empty. diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index b657c5b9..79d4fa67 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -73,6 +73,7 @@ const FIELD_UNIT_AURASTATE: u16 = 125; const FIELD_UNIT_BOUNDINGRADIUS: u16 = 129; const FIELD_UNIT_COMBATREACH: u16 = 130; const FIELD_UNIT_BASE_MANA: u16 = 162; +const FIELD_UNIT_BASE_HEALTH: u16 = 163; const FIELD_UNIT_DISPLAYID: u16 = 131; /// The unshifted appearance, untouched by forms, morphs and polymorph (`UpdateFields_1_12_1.h:77`). /// The client sizes the mover collision box from it (`0x60b270`), so a shapeshift keeps the box. diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index 74984735..0b3cd3f1 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -889,3 +889,12 @@ fn rage_and_happiness_divide_for_display_but_not_for_the_raw_readers() { assert_eq!(feigning_warrior.unit_shown_power(RAGE), Some(0)); assert_eq!(feigning_warrior.unit_shown_max_power(RAGE), Some(100)); } + +/// `UNIT_FIELD_BASE_MANA` and `UNIT_FIELD_BASE_HEALTH` are fields 162 and 163 +/// (`UpdateFields_1_12_1.h:101-102`), the unit block's `+0x270` and `+0x274` `0x612c50` reads. +#[test] +fn base_mana_and_base_health_read_fields_162_and_163() { + let unit = ObjectFields::from_pairs(&[(162, 1500), (163, 1689)]); + assert_eq!(unit.unit_base_mana(), Some(1500)); + assert_eq!(unit.unit_base_health(), Some(1689)); +} diff --git a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs index 2feace7c..d1a772ca 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs @@ -268,6 +268,10 @@ impl ObjectFields { pub fn unit_base_mana(&self) -> Option { self.get_u32(FIELD_UNIT_BASE_MANA) } + /// `UNIT_FIELD_BASE_HEALTH` (field 163, owner only): the basis for percentage health costs. + pub fn unit_base_health(&self) -> Option { + self.get_u32(FIELD_UNIT_BASE_HEALTH) + } /// `UNIT_FIELD_FACTIONTEMPLATE` (public, `+0x74`): the `FactionTemplate.dbc` row reactions use. pub fn unit_faction_template(&self) -> Option { self.get_u32(FIELD_UNIT_FACTIONTEMPLATE)