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:
goran2711
2026-10-04 08:29:04 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent cf891dc375
commit 9bc7276dd8
8 changed files with 259 additions and 55 deletions
+15
View File
@@ -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.
+1
View File
@@ -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,
+80 -31
View File
@@ -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,
+46 -15
View File
@@ -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()
+59 -4
View File
@@ -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();