fix: spell text $g takes the player's gender, as 1.12.1 does (#787)
- spell text: $g/$G take the active player's gender and the chosen form is space-trimmed, so a female character reads "her" (0x508180, 0x508214) - spell text: trim the $g docs, drop the colon-less $g pin - spell tooltips: $g reads the view's player store; nets cover gender - item tooltips: re-substitute $-texts when the gender byte moves - tests: share one real-spells loader beside Spells 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:
co-authored by
Claude Opus 5.5
Sam
parent
cf891dc375
commit
9bc7276dd8
@@ -144,6 +144,21 @@ impl Spells {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 5875 spell data; `None` skips where the install is absent.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn real_spells() -> Option<Spells> {
|
||||
let data = benilla_formats::wow_data_or_skip!(None);
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
Some(Spells {
|
||||
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
|
||||
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"),
|
||||
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"),
|
||||
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"),
|
||||
durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"),
|
||||
radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The reference's learned-ability latches: at learn time `0x4b25e0` stores a spell's id by its
|
||||
/// `Effect[0]`, and the unlearn path `0x4b2c50` clears it. The skin cursor requires the latch
|
||||
/// (`0x482589`), so a player who never learned Skinning gets no skin cursor.
|
||||
|
||||
@@ -135,6 +135,7 @@ fn feed_craft(
|
||||
lookup: &|id| spells.catalog.get(id),
|
||||
mods: Some(&*spell_mods),
|
||||
unmodified_points: false,
|
||||
gender: &|| store.0.unit_gender().unwrap_or(0),
|
||||
home_area: &|| None,
|
||||
global: &global,
|
||||
printf: &crate::ui_script::token_printf,
|
||||
|
||||
@@ -60,6 +60,8 @@ fn spell_desc_text(
|
||||
// The player's skill in a spell's line, the `$`-tokens' level (`TokenContext::skill`).
|
||||
skill: &dyn Fn(u32) -> u32,
|
||||
home_area: Option<&str>,
|
||||
// The active player's `UNIT_FIELD_BYTES_0` byte 2, the `$g`/`$G` branch's input (`0x508214`).
|
||||
gender: &dyn Fn() -> u8,
|
||||
mods: Option<&crate::spell::SpellModifiers>,
|
||||
// The VM's strings for the keyed `$d`/`$s` tokens.
|
||||
global: &dyn Fn(&str) -> Option<String>,
|
||||
@@ -77,6 +79,7 @@ fn spell_desc_text(
|
||||
lookup: &|i| sp.catalog.get(i),
|
||||
mods: mods.map(|m| m as &dyn benilla_formats::SpellMods),
|
||||
unmodified_points: false,
|
||||
gender,
|
||||
home_area: &|| home_area,
|
||||
global,
|
||||
printf: &crate::ui_script::token_printf,
|
||||
@@ -87,6 +90,15 @@ fn spell_desc_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `$`-tokens read of the player beside the modifiers: the skills the per-level terms
|
||||
/// scale by, and the gender byte `$g`/`$G` branch on (`0x508214`).
|
||||
type TokenReads = (crate::spell::SkillSnapshot, u8);
|
||||
|
||||
fn token_reads(me: Option<&ObjectStore>) -> TokenReads {
|
||||
let gender = me.and_then(|s| s.0.unit_gender()).unwrap_or(0);
|
||||
(crate::spell::skill_snapshot(me), gender)
|
||||
}
|
||||
|
||||
/// Only descriptions with substitution tokens can change when the caster's spell mods change.
|
||||
fn spell_has_mod_tokens(spells: Option<&crate::ui_action::Spells>, id: u32) -> bool {
|
||||
spells
|
||||
@@ -137,6 +149,8 @@ fn template_view(
|
||||
skill: &dyn Fn(u32) -> u32,
|
||||
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
|
||||
home_area: Option<&str>,
|
||||
// The active player's gender byte, the trigger text's `$g`/`$G` branch (`0x508214`).
|
||||
gender: &dyn Fn() -> u8,
|
||||
factions: Option<&benilla_formats::FactionCatalog>,
|
||||
sub_classes: Option<&benilla_formats::ItemSubClassCatalog>,
|
||||
classes: Option<&benilla_formats::ItemClassCatalog>,
|
||||
@@ -150,7 +164,7 @@ fn template_view(
|
||||
.and_then(|s| s.catalog.get(id))
|
||||
.map(|sd| sd.name.clone())
|
||||
};
|
||||
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, mods, get);
|
||||
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, gender, mods, get);
|
||||
benilla_ui::script::ItemTemplateView {
|
||||
name: t.name.clone(),
|
||||
quality: t.quality,
|
||||
@@ -254,7 +268,7 @@ pub(super) fn feed_item_sets(
|
||||
crate::ui_script::VmMemo<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
|
||||
>,
|
||||
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
|
||||
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
|
||||
mut last_reads: Local<crate::ui_script::VmMemo<Option<TokenReads>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -265,11 +279,12 @@ pub(super) fn feed_item_sets(
|
||||
for id in script.take_item_set_asks() {
|
||||
pending.entry(id).or_default();
|
||||
}
|
||||
// The `$`-tokens' per-level terms follow the player's skills.
|
||||
let skills = Some(crate::spell::skill_snapshot(me));
|
||||
let skills_changed = *last_skills.get(&script) != skills;
|
||||
*last_skills.get(&script) = skills;
|
||||
if spell_mods.is_changed() || skills_changed {
|
||||
// The `$`-tokens follow the player's skills and gender.
|
||||
let reads = token_reads(me);
|
||||
let gender = reads.1;
|
||||
let reads_changed = last_reads.get(&script).as_ref() != Some(&reads);
|
||||
*last_reads.get(&script) = Some(reads);
|
||||
if spell_mods.is_changed() || reads_changed {
|
||||
for &id in mod_sensitive.iter() {
|
||||
pending.entry(id).or_default();
|
||||
}
|
||||
@@ -308,8 +323,16 @@ pub(super) fn feed_item_sets(
|
||||
.bonuses
|
||||
.iter()
|
||||
.filter_map(|&(n, spell)| {
|
||||
spell_desc_text(spell_res, spell, &skill, None, Some(&spell_mods), &global)
|
||||
.map(|desc| (n, desc))
|
||||
spell_desc_text(
|
||||
spell_res,
|
||||
spell,
|
||||
&skill,
|
||||
None,
|
||||
&|| gender,
|
||||
Some(&spell_mods),
|
||||
&global,
|
||||
)
|
||||
.map(|desc| (n, desc))
|
||||
})
|
||||
.collect(),
|
||||
required_skill: row.required_skill,
|
||||
@@ -392,7 +415,7 @@ pub(super) fn feed_item_stats(
|
||||
mut pending: Local<crate::ui_script::VmMemo<std::collections::HashSet<u32>>>,
|
||||
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
|
||||
mut last_home: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
|
||||
mut last_reads: Local<crate::ui_script::VmMemo<Option<TokenReads>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -401,10 +424,11 @@ pub(super) fn feed_item_stats(
|
||||
let mod_sensitive = mod_sensitive.get(&script);
|
||||
let last_home = last_home.get(&script);
|
||||
let me = self_q.single().ok();
|
||||
// The `$`-tokens' per-level terms follow the player's skills.
|
||||
let skills = Some(crate::spell::skill_snapshot(me));
|
||||
let skills_changed = *last_skills.get(&script) != skills;
|
||||
*last_skills.get(&script) = skills;
|
||||
// The `$`-tokens follow the player's skills and gender.
|
||||
let reads = token_reads(me);
|
||||
let gender = reads.1;
|
||||
let reads_changed = last_reads.get(&script).as_ref() != Some(&reads);
|
||||
*last_reads.get(&script) = Some(reads);
|
||||
// `GetBindLocation()`'s push, here so it and the `$z` token share one name, and ahead of the
|
||||
// pending gate below: the bind point can arrive while the feed idles.
|
||||
let home_area: Option<&str> = home_bind
|
||||
@@ -421,7 +445,7 @@ pub(super) fn feed_item_stats(
|
||||
|
||||
pending.extend(items.take_fresh());
|
||||
pending.extend(script.take_item_stat_asks());
|
||||
if spell_mods.is_changed() || skills_changed {
|
||||
if spell_mods.is_changed() || reads_changed {
|
||||
pending.extend(mod_sensitive.iter().copied());
|
||||
}
|
||||
if pending.is_empty() {
|
||||
@@ -464,6 +488,7 @@ pub(super) fn feed_item_stats(
|
||||
&skill,
|
||||
skill_catalog,
|
||||
home_area,
|
||||
&|| gender,
|
||||
factions.as_deref().map(|f| f.catalog()),
|
||||
sub_classes.as_deref().map(|s| &s.0),
|
||||
classes.as_deref().map(|c| &c.0),
|
||||
@@ -1692,6 +1717,7 @@ mod tests {
|
||||
this return"
|
||||
);
|
||||
}
|
||||
use crate::ui_action::real_spells;
|
||||
use benilla_protocol::messages::ItemSpellEntry;
|
||||
use benilla_ui::script::{ContainerState, UiScript};
|
||||
use std::collections::HashMap;
|
||||
@@ -1911,19 +1937,7 @@ mod tests {
|
||||
/// line (`0x52da29`-`0x52da31`), and a described one does.
|
||||
#[test]
|
||||
fn an_undescribed_spell_prints_no_trigger_line_on_real_data() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let spells = crate::ui_action::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 Some(spells) = real_spells() else { return };
|
||||
|
||||
// No string table: Fireball's description reaches no keyed token.
|
||||
let no_strings = |_: &str| None;
|
||||
@@ -1936,7 +1950,7 @@ mod tests {
|
||||
"spell {id} has a name — which is exactly what must NOT leak into the tooltip"
|
||||
);
|
||||
assert_eq!(
|
||||
super::spell_desc_text(Some(&spells), id, &|_| 0, None, None, &no_strings),
|
||||
super::spell_desc_text(Some(&spells), id, &|_| 0, None, &|| 0, None, &no_strings),
|
||||
None,
|
||||
"spell {id} ({:?}) has no description, so the reference prints no trigger line",
|
||||
d.name
|
||||
@@ -1944,11 +1958,46 @@ mod tests {
|
||||
}
|
||||
|
||||
// The control: Fireball (133), described, still yields its line.
|
||||
let fireball = super::spell_desc_text(Some(&spells), 133, &|_| 0, None, None, &no_strings)
|
||||
.expect("a described spell still yields its line");
|
||||
let fireball =
|
||||
super::spell_desc_text(Some(&spells), 133, &|_| 0, None, &|| 0, None, &no_strings)
|
||||
.expect("a described spell still yields its line");
|
||||
assert!(
|
||||
fireball.contains("damage"),
|
||||
"expected the substituted Fireball description, got {fireball:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trigger text's `$g` branch reads the gender handed in (`0x508214`): Conjure Food 587's
|
||||
/// "providing the mage and `$ghis:her;` allies" takes "her" on 1, "his" on 0.
|
||||
#[test]
|
||||
fn a_trigger_texts_gender_branch_follows_the_player() {
|
||||
let Some(spells) = real_spells() else { return };
|
||||
let no_strings = |_: &str| None;
|
||||
let line = |gender: u8| {
|
||||
super::spell_desc_text(
|
||||
Some(&spells),
|
||||
587,
|
||||
&|_| 0,
|
||||
None,
|
||||
&move || gender,
|
||||
None,
|
||||
&no_strings,
|
||||
)
|
||||
.expect("a described spell")
|
||||
};
|
||||
assert!(line(1).contains("the mage and her allies"), "{}", line(1));
|
||||
assert!(line(0).contains("the mage and his allies"), "{}", line(0));
|
||||
}
|
||||
|
||||
/// The item feeds re-substitute a held `$`-text when what the tokens read moves: the gender
|
||||
/// byte `$g`/`$G` branch on (`0x508214`), and no other byte of `UNIT_FIELD_BYTES_0`.
|
||||
#[test]
|
||||
fn the_token_reads_move_with_the_gender_byte_alone() {
|
||||
let store = |bytes: u32| {
|
||||
crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(36, bytes)]))
|
||||
};
|
||||
let male = super::token_reads(Some(&store(4 | 1 << 8)));
|
||||
assert_ne!(super::token_reads(Some(&store(4 | 1 << 8 | 1 << 16))), male);
|
||||
assert_eq!(super::token_reads(Some(&store(1 | 2 << 8 | 1 << 24))), male);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,23 +14,25 @@ use benilla_ui::script::SpellTooltipView;
|
||||
|
||||
use super::spell_deps::{Changes, Deps, RangeSeen, Reagents, Seen, UnitField};
|
||||
use super::spell_feed::{build_view, reagent_state, PetInputs, ViewCaster, ViewCtx};
|
||||
use super::tests::{real_spells, TestCtx};
|
||||
use super::tests::TestCtx;
|
||||
use crate::items::Items;
|
||||
use crate::net::{NetCommands, ObjectStore, Objects};
|
||||
use crate::spell::usable::{
|
||||
slot_item_cached, worn_slots_read, SlotItem, EQUIPMENT_MASK, EQUIPMENT_SLOTS,
|
||||
};
|
||||
use crate::spell::{ModsDiff, SpellModifiers};
|
||||
use crate::ui_action::Spells;
|
||||
use crate::ui_action::{real_spells, Spells};
|
||||
use crate::ui_items::TestObjects;
|
||||
use benilla_formats::{RangeUnit, UnitMotion};
|
||||
|
||||
/// One side of each input group, so that moving an input is an xor: `false` is the empty side (no
|
||||
/// bind point, form 0, no percentages, default reach, no auto-attack target, both units standing,
|
||||
/// nothing worn, no skills, zero cells), `true` the populated one.
|
||||
/// bind point, gender 0, form 0, no percentages, default reach, no auto-attack target, both units
|
||||
/// standing, nothing worn, no skills, zero cells), `true` the populated one.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)]
|
||||
struct Inputs {
|
||||
home: bool,
|
||||
/// The player's gender byte, `UNIT_FIELD_BYTES_0` byte 2.
|
||||
gender: bool,
|
||||
form: bool,
|
||||
/// A `Chance` bit each: block, dodge, parry, crit.
|
||||
avoid: u8,
|
||||
@@ -60,6 +62,7 @@ const POKE: u32 = 0x4020_0000;
|
||||
impl Inputs {
|
||||
const ALL: Inputs = Inputs {
|
||||
home: true,
|
||||
gender: true,
|
||||
form: true,
|
||||
avoid: 0xf,
|
||||
reach: true,
|
||||
@@ -76,6 +79,7 @@ impl Inputs {
|
||||
fn xor(self, o: Inputs) -> Inputs {
|
||||
Inputs {
|
||||
home: self.home ^ o.home,
|
||||
gender: self.gender ^ o.gender,
|
||||
form: self.form ^ o.form,
|
||||
avoid: self.avoid ^ o.avoid,
|
||||
reach: self.reach ^ o.reach,
|
||||
@@ -103,6 +107,15 @@ impl Inputs {
|
||||
},
|
||||
));
|
||||
}
|
||||
if self.gender {
|
||||
parts.push((
|
||||
"the gender byte".to_string(),
|
||||
Inputs {
|
||||
gender: true,
|
||||
..alone
|
||||
},
|
||||
));
|
||||
}
|
||||
if self.form {
|
||||
parts.push((
|
||||
"the form".to_string(),
|
||||
@@ -207,6 +220,7 @@ impl Inputs {
|
||||
fn outside(deps: &Deps, layout: &Layout) -> Inputs {
|
||||
Inputs {
|
||||
home: !deps.home,
|
||||
gender: !deps.gender,
|
||||
form: !deps.form,
|
||||
avoid: !deps.avoidance & 0xf,
|
||||
reach: !deps.range_units,
|
||||
@@ -324,6 +338,9 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat {
|
||||
if inputs.disarm {
|
||||
pairs.push((46, 0x0020_0000));
|
||||
}
|
||||
if inputs.gender {
|
||||
pairs.push((36, 1 << 16));
|
||||
}
|
||||
let form = if inputs.form { p.form } else { 0 };
|
||||
if form != 0 {
|
||||
pairs.push((138, u32::from(form) << 16));
|
||||
@@ -666,6 +683,19 @@ fn every_view_that_moves_with_an_input_is_requeued_by_that_input() {
|
||||
assert_covered("the bind point", &s);
|
||||
assert!(s.moved >= 1, "no view reads the bind point");
|
||||
row("home bind ($z)", &s);
|
||||
let s = sweep(
|
||||
&mut rig,
|
||||
empty,
|
||||
&any,
|
||||
&own,
|
||||
&cause(Inputs {
|
||||
gender: true,
|
||||
..empty
|
||||
}),
|
||||
);
|
||||
assert_covered("the gender byte", &s);
|
||||
assert!(s.moved >= 50, "only {} views read the gender byte", s.moved);
|
||||
row("gender byte ($g)", &s);
|
||||
let s = sweep(
|
||||
&mut rig,
|
||||
empty,
|
||||
@@ -1337,7 +1367,7 @@ fn the_modifier_diff_names_the_family_flag_bit_of_a_changed_cell() {
|
||||
}
|
||||
|
||||
/// A percentage's own bit, a worn slot's own bit, a skill line by its id, each unit field by its
|
||||
/// own bit and a player appearing, through the diff the feed runs each frame.
|
||||
/// own bit, the gender byte and a player appearing, through the diff the feed runs each frame.
|
||||
#[test]
|
||||
fn the_seen_diff_names_each_input_it_saw_move() {
|
||||
let mut objs = TestObjects::new();
|
||||
@@ -1443,6 +1473,14 @@ 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());
|
||||
// The gender byte, `UNIT_FIELD_BYTES_0` byte 2, and no other byte of the field.
|
||||
let bytes_0 = |bytes: u32| player(&[(22, 100), (36, bytes)]);
|
||||
let c = seen(&bytes_0(1 << 16)).changes_since(&seen(&base));
|
||||
assert!(c.gender && !c.is_empty());
|
||||
let race_class_power = bytes_0(4 | 1 << 8 | 1 << 24);
|
||||
assert!(seen(&race_class_power)
|
||||
.changes_since(&seen(&base))
|
||||
.is_empty());
|
||||
// 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 = [
|
||||
|
||||
@@ -109,6 +109,8 @@ impl UnitFields {
|
||||
pub(super) struct Deps {
|
||||
/// A `$z` expanded: the bind point.
|
||||
pub(super) home: bool,
|
||||
/// A `$g`/`$G` expanded: the branch read the active player's gender byte (`0x508214`).
|
||||
pub(super) gender: bool,
|
||||
/// The required-form test read the shapeshift form.
|
||||
pub(super) form: bool,
|
||||
/// The range cell's melee arm read the caster's and the auto-attack target's combat reach and
|
||||
@@ -169,6 +171,7 @@ impl Deps {
|
||||
pub(super) fn hits(&self, changes: &Changes) -> bool {
|
||||
changes.everything
|
||||
|| (changes.home && self.home)
|
||||
|| (changes.gender && self.gender)
|
||||
|| (changes.form && self.form)
|
||||
|| (changes.range_units && self.range_units)
|
||||
|| changes.avoidance & self.avoidance != 0
|
||||
@@ -193,6 +196,7 @@ pub(super) struct Changes {
|
||||
/// An input no view can be ruled out of: every view is due.
|
||||
pub(super) everything: bool,
|
||||
pub(super) home: bool,
|
||||
pub(super) gender: bool,
|
||||
pub(super) form: bool,
|
||||
pub(super) range_units: bool,
|
||||
/// A [`Chance`] bit each.
|
||||
@@ -212,6 +216,7 @@ impl Changes {
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
!self.everything
|
||||
&& !self.home
|
||||
&& !self.gender
|
||||
&& !self.form
|
||||
&& !self.range_units
|
||||
&& self.avoidance == 0
|
||||
@@ -274,6 +279,8 @@ impl RangeSeen {
|
||||
pub(super) struct Seen {
|
||||
/// The bind point `$z` names.
|
||||
home: Option<String>,
|
||||
/// The gender byte `$g`/`$G` branch on (`0x508214`), 0 without a player as the views read it.
|
||||
gender: u8,
|
||||
/// The form the required-form line's colour follows (`0x52f1e3`).
|
||||
form: u8,
|
||||
/// The skills the descriptions' per-level terms scale by ([`crate::spell::skill_snapshot`]).
|
||||
@@ -301,6 +308,7 @@ impl Seen {
|
||||
) -> Self {
|
||||
Seen {
|
||||
home: home.map(str::to_string),
|
||||
gender: player.and_then(|s| s.0.unit_gender()).unwrap_or(0),
|
||||
form: player.map_or(0, |s| s.0.unit_shapeshift_form()),
|
||||
skills: crate::spell::skill_snapshot(player),
|
||||
worn: player
|
||||
@@ -317,6 +325,7 @@ impl Seen {
|
||||
pub(super) fn changes_since(&self, prev: &Seen) -> Changes {
|
||||
let mut changes = Changes {
|
||||
home: self.home != prev.home,
|
||||
gender: self.gender != prev.gender,
|
||||
form: self.form != prev.form,
|
||||
range_units: self.range_units != prev.range_units,
|
||||
disarm: self.disarmed != prev.disarmed,
|
||||
|
||||
@@ -112,6 +112,11 @@ pub(super) fn build_view(
|
||||
deps.borrow_mut().home = true;
|
||||
home
|
||||
};
|
||||
// `$g`/`$G` read the active player's gender byte (`0x508214`), a pet view's too.
|
||||
let gender = || {
|
||||
deps.borrow_mut().gender = true;
|
||||
vctx.store.and_then(|s| s.0.unit_gender()).unwrap_or(0)
|
||||
};
|
||||
// `0x6e3130` for this spell: the level the cost's and the cast time's per-level terms read.
|
||||
let pet_level = |pet| d.skill_level(ViewCaster::pet_skill_value(pet));
|
||||
let ctx = benilla_formats::TokenContext {
|
||||
@@ -122,6 +127,7 @@ pub(super) fn build_view(
|
||||
lookup: &lookup,
|
||||
mods: Some(vctx.spell_mods),
|
||||
unmodified_points: false,
|
||||
gender: &gender,
|
||||
home_area: &home_area,
|
||||
global: vctx.get,
|
||||
printf: &crate::ui_script::token_printf,
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::spell_feed::{build_view, feed_spell_tooltips, ViewCaster, ViewCtx};
|
||||
use super::*;
|
||||
use crate::items::Items;
|
||||
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
|
||||
use crate::ui_action::{PlayerActions, Spells};
|
||||
use crate::ui_action::{real_spells, PlayerActions, Spells};
|
||||
|
||||
/// The view alone, for the cell tests; the feed keeps what it read beside it.
|
||||
fn spell_tooltip_view(
|
||||
@@ -105,20 +105,6 @@ impl TestCtx {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 5875 spell data the view builder reads; `None` skips where the install is absent.
|
||||
pub(super) fn real_spells() -> Option<Spells> {
|
||||
let data = benilla_formats::wow_data_or_skip!(None);
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
Some(Spells {
|
||||
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
|
||||
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"),
|
||||
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"),
|
||||
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"),
|
||||
durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"),
|
||||
radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A unit's fields: level (34) and base mana (162).
|
||||
fn unit(level: u32, base_mana: u32) -> benilla_protocol::ObjectFields {
|
||||
benilla_protocol::ObjectFields::from_pairs(&[(22u16, 100u32), (34, level), (162, base_mana)])
|
||||
@@ -220,6 +206,51 @@ fn a_pet_view_costs_seduction_from_the_pets_base_mana() {
|
||||
assert_eq!(v.cost, None);
|
||||
}
|
||||
|
||||
/// The `$g`/`$G` branch (`0x508180`) reads the active player's `UNIT_FIELD_BYTES_0` byte 2
|
||||
/// (`0x508214`): the first form on 0, the second on anything else. A female player's Conjure Food
|
||||
/// and Water (587, 5504) tooltips read "her allies" and Hellfire (1949) "to herself".
|
||||
#[test]
|
||||
fn a_female_players_spell_tooltips_take_the_second_gender_form() {
|
||||
let Some(spells) = real_spells() else { return };
|
||||
let mut t = TestCtx::new();
|
||||
let mut objs = no_objects();
|
||||
let objects = objs.get();
|
||||
// A level 60 player whose `UNIT_FIELD_BYTES_0` (36) holds `gender` in byte 2.
|
||||
let player = |gender: u32| {
|
||||
let mut fields = unit(60, 1373);
|
||||
fields.merge(benilla_protocol::ObjectFields::from_pairs(&[(
|
||||
36,
|
||||
gender << 16,
|
||||
)]));
|
||||
ObjectStore(fields)
|
||||
};
|
||||
let (her, him) = (player(1), player(0));
|
||||
let mut text = |id: u32, store: &ObjectStore| {
|
||||
spell_tooltip_view(id, &spells, &mut t.ctx_for(&objects, 0, None, Some(store)))
|
||||
.unwrap_or_else(|| panic!("spell {id} view"))
|
||||
.description
|
||||
};
|
||||
for id in [587u32, 5504] {
|
||||
let female = text(id, &her);
|
||||
assert!(female.contains("the mage and her allies"), "{id}: {female}");
|
||||
let male = text(id, &him);
|
||||
assert!(male.contains("the mage and his allies"), "{id}: {male}");
|
||||
}
|
||||
let hellfire = text(1949, &her);
|
||||
assert!(hellfire.contains("Fire damage to herself"), "{hellfire}");
|
||||
// The branch looks the active player up (`0x508189`-`0x5081a4`): a pet view reads the
|
||||
// player's gender, never the pet's.
|
||||
let pet = unit(20, 0);
|
||||
let pets = spell_tooltip_view(
|
||||
587,
|
||||
&spells,
|
||||
&mut t.pet_ctx(&objects, Some(&her), Some(&pet)),
|
||||
)
|
||||
.expect("Conjure Food view")
|
||||
.description;
|
||||
assert!(pets.contains("the mage and her allies"), "{pets}");
|
||||
}
|
||||
|
||||
/// An object index with nothing streamed: no worn item, and every reagent count 0.
|
||||
fn no_objects() -> crate::ui_items::TestObjects {
|
||||
crate::ui_items::TestObjects::new()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The spell-description `$`-token engine the 1.12 client runs over `Spell.dbc` description and
|
||||
//! aura text (`0x5075f0` → `0x507710`), effect values from `GetEffectPoints 0x6e3800`. Effect
|
||||
//! point values print unsigned, as the client's do. `$g` takes the first form without gender
|
||||
//! input. `$c` and `$p` (`507dde`, `507ded`), which no shipped text uses, and unknown or
|
||||
//! unresolved tokens stay raw.
|
||||
//! point values print unsigned, as the client's do. `$g` and `$G` branch on the active player's
|
||||
//! gender ([`TokenContext::gender`]). `$c` and `$p` (`507dde`, `507ded`), which no shipped text
|
||||
//! uses, and unknown or unresolved tokens stay raw.
|
||||
|
||||
use super::soft_float;
|
||||
use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog};
|
||||
@@ -40,6 +40,9 @@ pub struct TokenContext<'a> {
|
||||
/// points skip their modifiers (`6e3925`), and so does the duration of `$d` and `$o`
|
||||
/// (`0x6ea000`'s flag, pushed at `507ccd` and `507917`).
|
||||
pub unmodified_points: bool,
|
||||
/// The active player's gender, `UNIT_FIELD_BYTES_0` byte 2 (`0x508214`), which `$g`/`$G`
|
||||
/// branch on. Asked only where a branch expands.
|
||||
pub gender: &'a dyn Fn() -> u8,
|
||||
/// The `$z` token: the home-bind area's name, from `SMSG_BINDPOINTUPDATE`'s area id through
|
||||
/// `AreaTable.dbc`; `None` leaves the token raw. Asked only where a `$z` expands, so a caller
|
||||
/// can tell which texts read the bind point.
|
||||
@@ -416,6 +419,12 @@ fn scale_prefix(text: &str, at: usize) -> Option<(f32, usize)> {
|
||||
Some((scale, semi + 1))
|
||||
}
|
||||
|
||||
/// `0x5081c8`-`0x5081d7` and `0x50822a`-`0x50823b`: a `$g`/`$G` branch arm's leading spaces are
|
||||
/// skipped, and `0x508267`-`0x508280` trims its trailing spaces — both forms alike.
|
||||
fn trim_spaces(arm: &str) -> &str {
|
||||
arm.trim_matches(' ')
|
||||
}
|
||||
|
||||
/// The CRT's `atoi`: leading whitespace, an optional sign, then decimal digits up to the first
|
||||
/// other character; nothing parsed is 0.
|
||||
fn atoi(bytes: &[u8]) -> i32 {
|
||||
@@ -494,7 +503,8 @@ pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> Strin
|
||||
b
|
||||
}
|
||||
}
|
||||
_ => a, // $g: the first form, as there is no gender input
|
||||
// `$g`/`$G` (`0x508180`): the first form on gender 0, else the second.
|
||||
_ => trim_spaces(if (ctx.gender)() == 0 { a } else { b }),
|
||||
};
|
||||
out.push_str(pick);
|
||||
i = i + 1 + end + 1;
|
||||
@@ -671,6 +681,7 @@ mod tests {
|
||||
lookup,
|
||||
mods: None,
|
||||
unmodified_points: false,
|
||||
gender: &|| 0,
|
||||
global: &global,
|
||||
printf: &printf,
|
||||
}
|
||||
@@ -737,6 +748,50 @@ mod tests {
|
||||
assert_eq!(d(8), "<forever>");
|
||||
}
|
||||
|
||||
/// `0x508180`'s branch: the first form on gender 0, the second on anything else, the chosen
|
||||
/// form trimmed of spaces at both ends (`0x5081c8`-`0x5081d7`, `0x50822a`-`0x50823b`,
|
||||
/// `0x508267`-`0x508280`).
|
||||
#[test]
|
||||
fn gender_takes_the_players_side_of_the_branch() {
|
||||
let durations = SpellDurationCatalog::default();
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let d = SpellDisplay::default();
|
||||
let gender = std::cell::Cell::new(0u8);
|
||||
let c = TokenContext {
|
||||
gender: &|| gender.get(),
|
||||
..ctx(&durations, &radii, &none_lookup)
|
||||
};
|
||||
|
||||
assert_eq!(substitute("$ghis:her;", &d, &c), "his");
|
||||
gender.set(1);
|
||||
assert_eq!(substitute("$ghis:her;", &d, &c), "her");
|
||||
gender.set(2);
|
||||
assert_eq!(substitute("$ghis:her;", &d, &c), "her");
|
||||
// `G` is the same branch (`0x508138`'s table maps both spellings to `0x50786d`).
|
||||
assert_eq!(substitute("$GLad:LAss;", &d, &c), "LAss");
|
||||
// The chosen form's spaces go from both ends.
|
||||
gender.set(0);
|
||||
assert_eq!(substitute("$g his : her ;", &d, &c), "his");
|
||||
gender.set(1);
|
||||
assert_eq!(substitute("$g his : her ;", &d, &c), "her");
|
||||
// An empty chosen arm consumes the token.
|
||||
gender.set(0);
|
||||
assert_eq!(substitute("$g:male;", &d, &c), "");
|
||||
gender.set(1);
|
||||
assert_eq!(substitute("$g:male;", &d, &c), "male");
|
||||
assert_eq!(substitute("$g female:;", &d, &c), "");
|
||||
// The shipped wording: Conjure Food 587 and Hellfire 1949.
|
||||
assert_eq!(
|
||||
substitute("providing the mage and $ghis:her; allies", &d, &c),
|
||||
"providing the mage and her allies"
|
||||
);
|
||||
gender.set(0);
|
||||
assert_eq!(
|
||||
substitute("damage to $ghimself:herself;", &d, &c),
|
||||
"damage to himself"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_modifier_updates_description_and_overtime_total() {
|
||||
let mut durations = SpellDurationCatalog::default();
|
||||
|
||||
Reference in New Issue
Block a user