diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index ee7dfb27..538680f0 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -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 { + 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. diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index 4b9fc387..82ea6d63 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -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, diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index e66bd879..ea330dee 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -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, @@ -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>, >, mut mod_sensitive: Local>>, - mut last_skills: Local>>, + mut last_reads: Local>>, ) { 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>>, mut mod_sensitive: Local>>, mut last_home: Local>>, - mut last_skills: Local>>, + mut last_reads: Local>>, ) { 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); + } } diff --git a/crates/benilla-app/src/ui_tooltip/deps_tests.rs b/crates/benilla-app/src/ui_tooltip/deps_tests.rs index af17461b..fe90a933 100644 --- a/crates/benilla-app/src/ui_tooltip/deps_tests.rs +++ b/crates/benilla-app/src/ui_tooltip/deps_tests.rs @@ -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 = [ diff --git a/crates/benilla-app/src/ui_tooltip/spell_deps.rs b/crates/benilla-app/src/ui_tooltip/spell_deps.rs index 9be2e67b..202fd588 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_deps.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_deps.rs @@ -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, + /// 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, diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs index d84579b0..d19c6125 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_feed.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -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, diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index d33e6829..41906606 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -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 { - 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() diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs index a86b4945..44af6d09 100644 --- a/crates/benilla-formats/src/spells/tokens.rs +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -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), ""); } + /// `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();