Percentage spell costs use the reference power bases (#646)

Percentage costs took their share of the max pool, so Bloodrage cost 20% of max health. The reference's basis table (`0x612c50`, reached from `GetPowerCost` `0x6e31b0`) reads the caster's fields:

| power type | basis |
|---|---|
| health | base health (`UNIT_FIELD_BASE_HEALTH`, 163) |
| mana | base mana (162) |
| rage | 1000 |
| focus, energy | 100 |
| anything else | 0 |

`cost_basis` now holds that table, and `power_cost_at` reads through it for the tooltip, the press-path gate and the action bar. The spell tooltip's rebuild inputs watch base health in place of max health, for the player and the pet. Tests pin each type's basis, and Bloodrage on real data: "337 Health" at level 60, whatever the gear.

Fixes #604

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
Juozas Polikevičius
2026-09-30 13:50:40 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent 010e41e600
commit c03ca1e4be
8 changed files with 169 additions and 113 deletions
+79 -20
View File
@@ -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() {
+59 -59
View File
@@ -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<u16>| {
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)]);
@@ -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)
}
}
@@ -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<u64>,
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,
}
+5 -5
View File
@@ -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.
@@ -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.
@@ -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));
}
@@ -268,6 +268,10 @@ impl ObjectFields {
pub fn unit_base_mana(&self) -> Option<u32> {
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<u32> {
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<u32> {
self.get_u32(FIELD_UNIT_FACTIONTEMPLATE)