Spell descriptions resolve $b, $f, $F, $i, $q, $u and $v (#622)
Spell descriptions printed `$b`, `$f`, `$F`, `$i`, `$q`, `$u` and `$v` raw. The reference's expander resolves each from Spell.dbc: | token | column | |---|---| | `$b` | EffectPointsPerComboPoint | | `$f`/`$F` | DmgMultiplier | | `$i` | MaxAffectedTargets | | `$q` | EffectMiscValue | | `$u` | StackAmount | | `$v` | MaxTargetLevel | `$b` chops toward zero. `$f` prints one decimal, and `$F` rounds half away from zero, both after the scale and in double precision. The integer arms print a signed `%d`. This change reads the new columns and adds the arms. `$f` and `$F` keep keying the `$l` plural, under the recorded deviation. Tests pin each arm, the schema's float columns and every shipped use of the six letters. Chain Heal now reads "Each jump is 50% as effective". Fixes #593 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
5b3c2a98d4
commit
2b16219357
@@ -622,6 +622,31 @@ fn real_spell_catalog_reads_tooltip_columns() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The WDBC schema must decode the new float arrays before `f32_at` can pass them to token
|
||||
/// expansion. An integer schema silently turns Chain Heal's 0.5 into the loader's 0.0 fallback.
|
||||
#[test]
|
||||
fn spell_schema_decodes_token_float_columns() {
|
||||
let mut raw = Vec::new();
|
||||
raw.extend_from_slice(b"WDBC");
|
||||
for n in [1u32, SPELL_FIELDS as u32, (SPELL_FIELDS * 4) as u32, 1] {
|
||||
raw.extend_from_slice(&n.to_le_bytes());
|
||||
}
|
||||
let mut row = vec![0u8; SPELL_FIELDS * 4];
|
||||
row[COL_DAMAGE_MULTIPLIER_1 * 4..COL_DAMAGE_MULTIPLIER_1 * 4 + 4]
|
||||
.copy_from_slice(&0.5f32.to_le_bytes());
|
||||
row[COL_EFFECT_POINTS_PER_COMBO_POINT_1 * 4..COL_EFFECT_POINTS_PER_COMBO_POINT_1 * 4 + 4]
|
||||
.copy_from_slice(&20.0f32.to_le_bytes());
|
||||
raw.extend_from_slice(&row);
|
||||
raw.push(0);
|
||||
let set = parse(&raw, spell_schema(), "Spell.dbc").expect("synthetic Spell.dbc");
|
||||
let spell = set.records().first().unwrap();
|
||||
assert_eq!(f32_at(spell, COL_DAMAGE_MULTIPLIER_1), Some(0.5));
|
||||
assert_eq!(
|
||||
f32_at(spell, COL_EFFECT_POINTS_PER_COMBO_POINT_1),
|
||||
Some(20.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// The attack-start masks, rows from vmangos: [`SpellDisplay::on_next_swing`] (`0x404`),
|
||||
/// [`SpellDisplay::initiates_auto_attack`] (adding `AttributesEx & 0x200`) and
|
||||
/// [`SpellDisplay::initiates_auto_attack_at_go`] (`AttributesEx2 & 0x100000`), whose union is
|
||||
|
||||
@@ -162,6 +162,12 @@ pub struct SpellDisplay {
|
||||
pub proc_chance: u32,
|
||||
/// `ProcCharges` (column 26): the description expander's `$n` value before op 4.
|
||||
pub proc_charges: u32,
|
||||
/// `StackAmount` (column 39): the description expander's `$u` (`507ef1`).
|
||||
pub stack_amount: u32,
|
||||
/// `MaxTargetLevel` (column 159): the description expander's `$v` (`507f4f`).
|
||||
pub max_target_level: u32,
|
||||
/// `MaxAffectedTargets` (column 163): the description expander's `$i` (`507fc3`).
|
||||
pub max_affected_targets: u32,
|
||||
/// `EffectBasePoints[3]` (columns 76-78, signed): each roll's floor; -1 on weapon damage.
|
||||
pub effect_base_points: [i32; 3],
|
||||
/// `EffectDieSides[3]` (columns 64-66, signed): with n dice, the value runs base + n to
|
||||
@@ -182,6 +188,10 @@ pub struct SpellDisplay {
|
||||
pub effect_chain_targets: [u32; 3],
|
||||
/// `EffectMultipleValue[3]` (columns 97-99, float): the chain or multi-target falloff.
|
||||
pub effect_multiple_value: [f32; 3],
|
||||
/// `DmgMultiplier[3]` (columns 167-169, float): `$f`/`$F` print it scaled (`507ff7`, `508025`).
|
||||
pub damage_multiplier: [f32; 3],
|
||||
/// `EffectPointsPerComboPoint[3]` (columns 112-114, float): `$b` prints it chopped (`507ed4`).
|
||||
pub effect_points_per_combo_point: [f32; 3],
|
||||
/// `EffectTriggerSpell[3]` (columns 109-111): each effect's triggered spell, 0 for none.
|
||||
pub effect_trigger_spell: [u32; 3],
|
||||
/// `EffectItemType[3]` (columns 103-105): the item a `SPELL_EFFECT_CREATE_ITEM` effect makes.
|
||||
@@ -264,6 +274,9 @@ impl Default for SpellDisplay {
|
||||
proc_flags: 0,
|
||||
proc_chance: 0,
|
||||
proc_charges: 0,
|
||||
stack_amount: 0,
|
||||
max_target_level: 0,
|
||||
max_affected_targets: 0,
|
||||
effect_base_points: [0; 3],
|
||||
effect_die_sides: [0; 3],
|
||||
effect_base_dice: [0; 3],
|
||||
@@ -277,6 +290,8 @@ impl Default for SpellDisplay {
|
||||
effect_radius_index: [0; 3],
|
||||
effect_chain_targets: [0; 3],
|
||||
effect_multiple_value: [0.0; 3],
|
||||
damage_multiplier: [0.0; 3],
|
||||
effect_points_per_combo_point: [0.0; 3],
|
||||
effect_trigger_spell: [0; 3],
|
||||
effect_item_type: [0; 3],
|
||||
effect_misc_value: [0; 3],
|
||||
|
||||
@@ -168,6 +168,11 @@ const COL_CASTING_TIME_INDEX: usize = 18;
|
||||
const COL_PROC_FLAGS: usize = 24;
|
||||
const COL_PROC_CHANCE: usize = 25;
|
||||
const COL_PROC_CHARGES: usize = 26;
|
||||
const COL_STACK_AMOUNT: usize = 39;
|
||||
const COL_MAX_TARGET_LEVEL: usize = 159;
|
||||
const COL_MAX_AFFECTED_TARGETS: usize = 163;
|
||||
const COL_DAMAGE_MULTIPLIER_1: usize = 167;
|
||||
const COL_EFFECT_POINTS_PER_COMBO_POINT_1: usize = 112;
|
||||
|
||||
/// The per-effect `[3]` arrays, each constant slot 0; die sides and base points are signed.
|
||||
const COL_EFFECT_DIE_SIDES_1: usize = 64;
|
||||
@@ -362,8 +367,8 @@ impl SpellCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// 173 fields, `u32` but for the floats (`Speed`, the two float effect arrays) and the four enUS
|
||||
/// string heads; the signed columns stay `u32`, as [`i32_at`] reads the same bits.
|
||||
/// 173 fields, `u32` but for `Speed`, the four float effect arrays and the four enUS string heads;
|
||||
/// the signed columns stay `u32`, as [`i32_at`] reads the same bits.
|
||||
fn spell_schema() -> Schema {
|
||||
let mut s = Schema::new("Spell");
|
||||
for i in 0..SPELL_FIELDS {
|
||||
@@ -392,6 +397,21 @@ fn spell_schema() -> Schema {
|
||||
format!("EffectMultipleValue{}", i - COL_EFFECT_MULTIPLE_VALUE_1),
|
||||
FieldType::Float32,
|
||||
));
|
||||
} else if (COL_EFFECT_POINTS_PER_COMBO_POINT_1..COL_EFFECT_POINTS_PER_COMBO_POINT_1 + 3)
|
||||
.contains(&i)
|
||||
{
|
||||
s.add_field(SchemaField::new(
|
||||
format!(
|
||||
"EffectPointsPerComboPoint{}",
|
||||
i - COL_EFFECT_POINTS_PER_COMBO_POINT_1
|
||||
),
|
||||
FieldType::Float32,
|
||||
));
|
||||
} else if (COL_DAMAGE_MULTIPLIER_1..COL_DAMAGE_MULTIPLIER_1 + 3).contains(&i) {
|
||||
s.add_field(SchemaField::new(
|
||||
format!("DmgMultiplier{}", i - COL_DAMAGE_MULTIPLIER_1),
|
||||
FieldType::Float32,
|
||||
));
|
||||
} else {
|
||||
s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32));
|
||||
}
|
||||
@@ -585,6 +605,9 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
proc_flags: u32_at(r, COL_PROC_FLAGS).unwrap_or(0),
|
||||
proc_chance: u32_at(r, COL_PROC_CHANCE).unwrap_or(0),
|
||||
proc_charges: u32_at(r, COL_PROC_CHARGES).unwrap_or(0),
|
||||
stack_amount: u32_at(r, COL_STACK_AMOUNT).unwrap_or(0),
|
||||
max_target_level: u32_at(r, COL_MAX_TARGET_LEVEL).unwrap_or(0),
|
||||
max_affected_targets: u32_at(r, COL_MAX_AFFECTED_TARGETS).unwrap_or(0),
|
||||
effect_base_points: std::array::from_fn(|i| {
|
||||
i32_at(r, COL_EFFECT_BASE_POINTS_1 + i).unwrap_or(0)
|
||||
}),
|
||||
@@ -613,6 +636,12 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
effect_multiple_value: std::array::from_fn(|i| {
|
||||
f32_at(r, COL_EFFECT_MULTIPLE_VALUE_1 + i).unwrap_or(0.0)
|
||||
}),
|
||||
damage_multiplier: std::array::from_fn(|i| {
|
||||
f32_at(r, COL_DAMAGE_MULTIPLIER_1 + i).unwrap_or(0.0)
|
||||
}),
|
||||
effect_points_per_combo_point: std::array::from_fn(|i| {
|
||||
f32_at(r, COL_EFFECT_POINTS_PER_COMBO_POINT_1 + i).unwrap_or(0.0)
|
||||
}),
|
||||
effect_trigger_spell,
|
||||
effect_item_type: std::array::from_fn(|i| {
|
||||
u32_at(r, COL_EFFECT_ITEM_TYPE_1 + i).unwrap_or(0)
|
||||
|
||||
@@ -1,7 +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`. Values
|
||||
//! print unsigned, as the client's do. `$g` always takes the first form, as there is no gender
|
||||
//! input, and `$u` and any unknown or unresolved token stay raw.
|
||||
//! 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.
|
||||
|
||||
use super::soft_float;
|
||||
use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog};
|
||||
@@ -55,8 +56,8 @@ fn keyed(ctx: &TokenContext, key: &str, args: &[TokenNumber]) -> Option<String>
|
||||
}
|
||||
|
||||
/// `"%.1f"` (`0x84f398`).
|
||||
fn tenths(ctx: &TokenContext, v: f32) -> String {
|
||||
(ctx.printf)("%.1f", &[TokenNumber::Float(f64::from(v))])
|
||||
fn tenths(ctx: &TokenContext, v: impl Into<f64>) -> String {
|
||||
(ctx.printf)("%.1f", &[TokenNumber::Float(v.into())])
|
||||
}
|
||||
|
||||
/// `0x6e3130` for the active player (`0x6e318d`: `CGPlayer`'s `[vtbl+0xa8]`, `0x5ea690`).
|
||||
@@ -303,6 +304,42 @@ fn token_value(
|
||||
) -> Option<(String, f64)> {
|
||||
match letter.to_ascii_lowercase() {
|
||||
's' | 'm' | 'o' => points_text(letter, slot, d, ctx, scale, level),
|
||||
'b' => {
|
||||
// `507ed4`: `EffectPointsPerComboPoint`, truncated toward zero by `0x40a2b0`.
|
||||
let v = d.effect_points_per_combo_point[slot].trunc() as i32;
|
||||
Some((v.to_string(), f64::from(v)))
|
||||
}
|
||||
'f' => {
|
||||
// `507ff7` / `508025`: the scale times `DmgMultiplier`, exact at the x87's PC_53.
|
||||
let v = f64::from(scale) * f64::from(d.damage_multiplier[slot]);
|
||||
if letter == 'F' {
|
||||
// ±0.5 by the sign, then chopped by `0x40a2b0` (`50802f`-`50804a`).
|
||||
let n = v.round() as i32;
|
||||
Some((n.to_string(), f64::from(n)))
|
||||
} else {
|
||||
Some((tenths(ctx, v), v))
|
||||
}
|
||||
}
|
||||
'i' => {
|
||||
// `507fc3`: `MaxAffectedTargets` through `"%d"`.
|
||||
let v = d.max_affected_targets as i32;
|
||||
Some((v.to_string(), f64::from(v)))
|
||||
}
|
||||
'q' => {
|
||||
// `50806e`: the slot's `EffectMiscValue` through `"%d"`.
|
||||
let v = d.effect_misc_value[slot];
|
||||
Some((v.to_string(), f64::from(v)))
|
||||
}
|
||||
'u' => {
|
||||
// `507ef1`: `StackAmount` through `"%d"`.
|
||||
let v = d.stack_amount as i32;
|
||||
Some((v.to_string(), f64::from(v)))
|
||||
}
|
||||
'v' => {
|
||||
// `507f4f`: `MaxTargetLevel` through `"%d"`.
|
||||
let v = d.max_target_level as i32;
|
||||
Some((v.to_string(), f64::from(v)))
|
||||
}
|
||||
'd' => {
|
||||
let ms = duration_ms(d, ctx);
|
||||
let v = if ms < 0 { 0.0 } else { ms as f64 / 1000.0 };
|
||||
@@ -548,6 +585,20 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Every spell-data formatter key needed by a corpus walk; individual formatter tests above
|
||||
/// pin the reference's unit and spread choices separately.
|
||||
fn corpus_global(key: &str) -> Option<String> {
|
||||
global(key).or_else(|| {
|
||||
if key.starts_with("INT_SPELL_DURATION_") {
|
||||
Some("%d".into())
|
||||
} else if key.starts_with("SPELL_DURATION_") {
|
||||
Some("%.1f".into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `%d`, `%.Nf` and `%%` in order; the tests keep clear of the CRT's rounding ties.
|
||||
fn printf(template: &str, args: &[TokenNumber]) -> String {
|
||||
let mut out = String::new();
|
||||
@@ -1233,7 +1284,8 @@ mod tests {
|
||||
substitute("as strong as $1234s1 hits", &d, &c),
|
||||
"as strong as 100 hits"
|
||||
);
|
||||
assert_eq!(substitute("stacks $u times", &d, &c), "stacks $u times");
|
||||
assert_eq!(substitute("stacks $u times", &d, &c), "stacks 0 times");
|
||||
assert_eq!(substitute("unknown $j", &d, &c), "unknown $j");
|
||||
assert_eq!(
|
||||
substitute("Returns you to $z.", &d, &c),
|
||||
"Returns you to Goldshire."
|
||||
@@ -1334,6 +1386,213 @@ mod tests {
|
||||
),
|
||||
"25 Frost damage every 1 second"
|
||||
);
|
||||
// `$f` and `$F` key it too, though their arms (`507ff7`, `508025`) leave it alone.
|
||||
let multiplier = SpellDisplay {
|
||||
effect_base_points: [24, 0, 0],
|
||||
effect_base_dice: [1, 1, 0],
|
||||
effect_die_sides: [1, 1, 0],
|
||||
damage_multiplier: [1.0, 1.5, 0.0],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
substitute("$s1 then $F1 $lpoint:points;", &multiplier, &c),
|
||||
"25 then 1 point"
|
||||
);
|
||||
assert_eq!(
|
||||
substitute("$s2 then $f2 $lpoint:points;", &multiplier, &c),
|
||||
"1 then 1.5 points"
|
||||
);
|
||||
}
|
||||
|
||||
/// The field arms read the referenced row, `$b`, `$q`, `$f` and `$F` at the slot digit; `$b`,
|
||||
/// `$u`, `$v`, `$i` and `$q` print `"%d"` and key the `$l` plural (`507ee0`, `507ef7`,
|
||||
/// `507f55`, `507fc9`, `508075`).
|
||||
#[test]
|
||||
fn field_tokens_read_their_columns_and_plural_counts() {
|
||||
let durations = SpellDurationCatalog::default();
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let rows = [
|
||||
SpellDisplay {
|
||||
id: 42,
|
||||
effect_points_per_combo_point: [0.0, -2.9, 0.0],
|
||||
damage_multiplier: [0.5, 1.3, -0.25],
|
||||
effect_misc_value: [135, -1, 0],
|
||||
max_affected_targets: 4,
|
||||
stack_amount: 5,
|
||||
max_target_level: 40,
|
||||
..Default::default()
|
||||
},
|
||||
SpellDisplay {
|
||||
id: 43,
|
||||
effect_points_per_combo_point: [1.9, 0.0, 0.0],
|
||||
effect_misc_value: [0, 0, 1],
|
||||
max_affected_targets: 1,
|
||||
stack_amount: 1,
|
||||
max_target_level: 1,
|
||||
..Default::default()
|
||||
},
|
||||
SpellDisplay {
|
||||
id: 44,
|
||||
max_affected_targets: 0x8000_0000,
|
||||
stack_amount: u32::MAX,
|
||||
max_target_level: u32::MAX - 1,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let lookup = |id| rows.iter().find(|r| r.id == id);
|
||||
let c = ctx(&durations, &radii, &lookup);
|
||||
let empty = SpellDisplay::default();
|
||||
assert_eq!(substitute("$42b2", &empty, &c), "-2");
|
||||
assert_eq!(substitute("$42q1 $42Q2", &empty, &c), "135 -1");
|
||||
assert_eq!(substitute("$42i $42u $42v", &empty, &c), "4 5 40");
|
||||
assert_eq!(substitute("$42I $42U $42V", &empty, &c), "4 5 40");
|
||||
assert_eq!(substitute("$*100;42F1", &empty, &c), "50");
|
||||
assert_eq!(substitute("$42f2", &empty, &c), "1.3");
|
||||
assert_eq!(substitute("$*10;42F3", &empty, &c), "-3");
|
||||
assert_eq!(
|
||||
substitute("$44i $44u $44v", &empty, &c),
|
||||
"-2147483648 -1 -2"
|
||||
);
|
||||
for token in ["$43b1", "$43q3", "$43i", "$43u", "$43v"] {
|
||||
assert_eq!(
|
||||
substitute(&format!("{token} $lone:many;"), &empty, &c),
|
||||
"1 one",
|
||||
"{token}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `$f` and `$F` (`507ff7`, `508025`) multiply on the x87 at PC_53, so the product of the two
|
||||
/// floats is exact: rounded to a float first, these would read 1.1 and 3.
|
||||
#[test]
|
||||
fn the_multiplier_tokens_multiply_in_double() {
|
||||
let durations = SpellDurationCatalog::default();
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let c = ctx(&durations, &radii, &none_lookup);
|
||||
let d = SpellDisplay {
|
||||
damage_multiplier: [0.23, 0.833_333_3, 0.0],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(substitute("$*5;f1", &d, &c), "1.2");
|
||||
assert_eq!(substitute("$*3;F2", &d, &c), "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_spell_data_tokens_resolve() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let spells = crate::load_spell_catalog(&mut chain).expect("Spell.dbc");
|
||||
let durations = crate::load_spell_durations(&mut chain).expect("SpellDuration.dbc");
|
||||
let radii = crate::load_spell_radii(&mut chain).expect("SpellRadius.dbc");
|
||||
let ranges = crate::load_spell_ranges(&mut chain).expect("SpellRange.dbc");
|
||||
let lookup = |id| spells.get(id);
|
||||
let c = TokenContext {
|
||||
ranges: Some(&ranges),
|
||||
global: &corpus_global,
|
||||
..ctx(&durations, &radii, &lookup)
|
||||
};
|
||||
for (id, text, want) in [
|
||||
(1064, "$*100;F1", "50"),
|
||||
(10622, "$*100;F1", "50"),
|
||||
(10623, "$*100;F1", "50"),
|
||||
(22568, "$f1", "1.0"),
|
||||
(2006, "$q1", "135"),
|
||||
(1680, "$i", "4"),
|
||||
(22959, "$u", "5"),
|
||||
(453, "$v", "40"),
|
||||
(14179, "$b1", "20"),
|
||||
] {
|
||||
assert_eq!(
|
||||
substitute(text, spells.get(id).unwrap(), &c),
|
||||
want,
|
||||
"spell {id}"
|
||||
);
|
||||
}
|
||||
for id in [1064, 10622, 10623] {
|
||||
let spell = spells.get(id).unwrap();
|
||||
assert_eq!(spell.damage_multiplier[0], 0.5, "Chain Heal {id}");
|
||||
let description = spell.description.as_deref().unwrap();
|
||||
assert!(
|
||||
description.contains("$*100;F1"),
|
||||
"Chain Heal {id}: {description}"
|
||||
);
|
||||
assert!(
|
||||
substitute(description, spell, &c).contains("50%"),
|
||||
"Chain Heal {id} must show 50%"
|
||||
);
|
||||
}
|
||||
|
||||
// Walk every shipped spell-data token, including other ranks, effect slots, scale
|
||||
// prefixes and cross-spell references. `$z` needs the player's home area, not Spell.dbc.
|
||||
let mut seen = [0usize; 26];
|
||||
for (id, spell) in spells.iter() {
|
||||
for description in [
|
||||
spell.description.as_deref(),
|
||||
spell.aura_description.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for (arm, token) in spell_data_tokens_in(description) {
|
||||
let expanded = substitute(token, spell, &c);
|
||||
assert_ne!(expanded, token, "spell {id} leaves {token} raw");
|
||||
seen[arm] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for &arm in b"bfiquv" {
|
||||
assert!(
|
||||
seen[usize::from(arm - b'a')] > 0,
|
||||
"missing ${} in {seen:?}",
|
||||
arm as char
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
seen.iter().sum::<usize>() >= 50,
|
||||
"too few shipped spell-data tokens: {seen:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Isolate spell-data tokens, including a scale prefix, cross-spell id and one slot digit,
|
||||
/// bounded as `substitute` bounds them. `$l`, `$g` and `$z` need other context.
|
||||
fn spell_data_tokens_in(text: &str) -> Vec<(usize, &str)> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut found = Vec::new();
|
||||
for start in 0..bytes.len() {
|
||||
if bytes[start] != b'$' {
|
||||
continue;
|
||||
}
|
||||
let mut end = start + 1;
|
||||
if matches!(bytes.get(end), Some(b'*' | b'/')) {
|
||||
let Some(semi) = bytes[end..].iter().position(|&b| b == b';') else {
|
||||
continue;
|
||||
};
|
||||
end += semi + 1;
|
||||
}
|
||||
while bytes.get(end).is_some_and(u8::is_ascii_digit) {
|
||||
end += 1;
|
||||
}
|
||||
let Some(letter) = bytes.get(end).copied().map(|b| b.to_ascii_lowercase()) else {
|
||||
continue;
|
||||
};
|
||||
if !b"abdefhimnoqrstuvx".contains(&letter) {
|
||||
continue;
|
||||
}
|
||||
end += 1;
|
||||
if bytes.get(end).is_some_and(u8::is_ascii_digit) {
|
||||
end += 1;
|
||||
}
|
||||
found.push((usize::from(letter - b'a'), &text[start..end]));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spell_data_token_scan_keeps_scale_reference_and_slot() {
|
||||
assert_eq!(
|
||||
spell_data_tokens_in("Each jump is $*100;F1%, stacks $22959u times; $s1 is separate."),
|
||||
vec![(5, "$*100;F1"), (20, "$22959u"), (18, "$s1")]
|
||||
);
|
||||
}
|
||||
|
||||
/// `$r` (`507d5b`) reads row 1 for an index at or below 1 and prints one decimal through
|
||||
|
||||
Reference in New Issue
Block a user