Skip to main content

rakata_generics/decoded/
uti.rs

1//! Table-backed decoded views over typed generic models.
2//!
3//! Typed generic structs (`Uti`, `Utc`, ...) expose engine integers
4//! verbatim because the on-disk format does. This module layers a
5//! decoded view on top: callers hand in a [`TwoDaSource`] and ask
6//! for `view = thing.resolve(&mut tables)`, then query the view with
7//! semantics-bearing methods (e.g. `view.is_weapon()`,
8//! `view.damage_bonuses()`). The resolved view is built by composing a
9//! cheap projection with a per-scope resolution step; see
10//! [`UtiProjection`] / [`UtiResolved`] and [`Uti::project`] for the
11//! multi-scope path.
12//!
13//! ## Mod-friendliness
14//!
15//! Decoding reads against whatever tables the caller hands in, which
16//! against a live install means the user's actually-loaded 2DAs.
17//! Properties whose `PropertyName` does not resolve in
18//! `itempropdef.2da` round-trip into [`DecodedProperty::Unknown`]
19//! with `property_label = None`; properties whose row exists but
20//! whose label is not yet typed surface as `Unknown` carrying the
21//! resolved label so consumers can still display something useful.
22//! Subtype `u16` ids stay raw on every variant so mods that extend
23//! an existing property's `iprp_*.2da` subtype range do not get
24//! downgraded to `Unknown`.
25
26use rakata_core::tables;
27use rakata_formats::twoda::{TwoDa, TwoDaSource};
28
29use crate::uti::{Uti, UtiProperty};
30
31/// One decoded item-property entry.
32///
33/// The shape grows variants as more property kinds get typed; until
34/// a variant exists for a given engine kind, callers receive
35/// [`DecodedProperty::Unknown`] carrying every raw field plus the
36/// human-readable property label resolved from `itempropdef.2da`
37/// when reachable.
38///
39/// ## Subtype dispatch
40///
41/// The per-property subtype id is preserved as a raw `u16` on every
42/// variant. To resolve it to a human-readable label, call
43/// [`DecodedProperty::subtype_label`], which walks the dispatch
44/// chain documented under "Property Table Dispatch" in the UTI
45/// engine audit (`itempropdef.SubTypeResRef` -> per-property
46/// `iprp_*.2da` -> `label` column at the subtype row).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum DecodedProperty {
49    /// Ability-score bonus property (vanilla `itempropdef.2da` label
50    /// `Ability`, row 0). The subtype identifies which ability score
51    /// the bonus targets; the cost fields carry the magnitude
52    /// reference into the cost-table dispatch chain.
53    AbilityBonus {
54        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
55        /// `label` matched `Ability` at decode time).
56        property_id: u16,
57        /// Raw `Subtype` id (row index into `iprp_abilities.2da`).
58        /// Vanilla rows: 0=STR, 1=DEX, 2=CON, 3=INT, 4=WIS, 5=CHA.
59        subtype_id: u16,
60        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
61        cost_table: u8,
62        /// Raw `CostValue` id (row index into the cost-table's
63        /// per-property `iprp_*.2da`).
64        cost_value: u16,
65    },
66    /// Universal saving-throw bonus property (vanilla
67    /// `itempropdef.2da` label `ImprovedSavingThrows`, row 26). The
68    /// subtype identifies which save element (Universal, Acid, Cold,
69    /// ...) the bonus applies to; the cost fields carry the magnitude
70    /// reference into the cost-table dispatch chain.
71    ///
72    /// This variant covers `ImprovedSavingThrows` only. The narrower
73    /// `ImprovedSavingThrowsSpecific` (Fortitude/Reflex/Will) and the
74    /// matching `ReducedSavingThrows` / `ReducedSpecificSavingThrow`
75    /// kinds remain in `Unknown` until they get their own typed
76    /// variants.
77    SaveBonus {
78        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
79        /// `label` matched `ImprovedSavingThrows` at decode time).
80        property_id: u16,
81        /// Raw `Subtype` id (row index into `iprp_saveelement.2da`).
82        subtype_id: u16,
83        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
84        cost_table: u8,
85        /// Raw `CostValue` id (row index into the cost-table's
86        /// per-property `iprp_*.2da`).
87        cost_value: u16,
88    },
89    /// Specific saving-throw bonus property (vanilla
90    /// `itempropdef.2da` label `ImprovedSavingThrowsSpecific`, row
91    /// 27). Narrower sibling of [`Self::SaveBonus`]: the subtype
92    /// identifies which specific saving throw (Fortitude, Reflex,
93    /// Will) the bonus applies to, indexed into
94    /// `iprp_savingthrow.2da` rather than the element-based
95    /// `iprp_saveelement.2da`.
96    SaveBonusSpecific {
97        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
98        /// `label` matched `ImprovedSavingThrowsSpecific` at decode
99        /// time).
100        property_id: u16,
101        /// Raw `Subtype` id (row index into `iprp_savingthrow.2da`).
102        subtype_id: u16,
103        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
104        cost_table: u8,
105        /// Raw `CostValue` id (row index into the cost-table's
106        /// per-property `iprp_*.2da`).
107        cost_value: u16,
108    },
109    /// Universal saving-throw penalty property (vanilla
110    /// `itempropdef.2da` label `ReducedSavingThrows`, row 33).
111    /// Negative mirror of [`Self::SaveBonus`]: same subtype 2DA
112    /// (`iprp_saveelement`) but the cost fields carry a penalty
113    /// magnitude rather than a bonus.
114    SavePenalty {
115        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
116        /// `label` matched `ReducedSavingThrows` at decode time).
117        property_id: u16,
118        /// Raw `Subtype` id (row index into `iprp_saveelement.2da`).
119        subtype_id: u16,
120        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
121        cost_table: u8,
122        /// Raw `CostValue` id (row index into the cost-table's
123        /// per-property `iprp_*.2da`).
124        cost_value: u16,
125    },
126    /// Specific saving-throw penalty property (vanilla
127    /// `itempropdef.2da` label `ReducedSpecificSavingThrow`, row 34).
128    /// Negative mirror of [`Self::SaveBonusSpecific`]: same subtype
129    /// 2DA (`iprp_savingthrow`) but the cost fields carry a penalty
130    /// magnitude rather than a bonus.
131    ///
132    /// The vanilla label uses singular `Throw` (where the bonus
133    /// counterpart uses plural `Throws`); the dispatch matches the
134    /// exact vanilla spelling.
135    SavePenaltySpecific {
136        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
137        /// `label` matched `ReducedSpecificSavingThrow` at decode
138        /// time).
139        property_id: u16,
140        /// Raw `Subtype` id (row index into `iprp_savingthrow.2da`).
141        subtype_id: u16,
142        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
143        cost_table: u8,
144        /// Raw `CostValue` id (row index into the cost-table's
145        /// per-property `iprp_*.2da`).
146        cost_value: u16,
147    },
148    /// Damage bonus property (vanilla `itempropdef.2da` label
149    /// `Damage`, row 11). The subtype identifies which damage type
150    /// (Bludgeoning, Slashing, Acid, Cold, ...) the bonus applies to;
151    /// the cost fields carry the magnitude reference into the
152    /// cost-table dispatch chain.
153    ///
154    /// Distinct from `Damage_Vulnerability` (row 18) and the
155    /// alignment/race-conditional `DamageAlignmentGroup` /
156    /// `DamageRacialGroup` kinds, which remain in `Unknown`.
157    DamageBonus {
158        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
159        /// `label` matched `Damage` at decode time).
160        property_id: u16,
161        /// Raw `Subtype` id (row index into `iprp_damagetype.2da`).
162        subtype_id: u16,
163        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
164        cost_table: u8,
165        /// Raw `CostValue` id (row index into the cost-table's
166        /// per-property `iprp_*.2da`).
167        cost_value: u16,
168    },
169    /// Damage immunity property (vanilla `itempropdef.2da` label
170    /// `DamageImmunity`, row 14). The subtype identifies which damage
171    /// type the immunity applies to; the cost fields carry the
172    /// percentage reference into the cost-table dispatch chain.
173    DamageImmunity {
174        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
175        /// `label` matched `DamageImmunity` at decode time).
176        property_id: u16,
177        /// Raw `Subtype` id (row index into `iprp_damagetype.2da`).
178        subtype_id: u16,
179        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
180        cost_table: u8,
181        /// Raw `CostValue` id (row index into the cost-table's
182        /// per-property `iprp_*.2da`).
183        cost_value: u16,
184    },
185    /// Damage resistance property (vanilla `itempropdef.2da` label
186    /// `DamageResist`, row 17). The subtype identifies which damage
187    /// type the resistance applies to; the cost fields carry the
188    /// soak-amount reference into the cost-table dispatch chain.
189    ///
190    /// Distinct from `DamageReduced` (row 16, backed by
191    /// `iprp_protection.2da` rather than `iprp_damagetype.2da`),
192    /// which remains in `Unknown`.
193    DamageResistance {
194        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
195        /// `label` matched `DamageResist` at decode time).
196        property_id: u16,
197        /// Raw `Subtype` id (row index into `iprp_damagetype.2da`).
198        subtype_id: u16,
199        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
200        cost_table: u8,
201        /// Raw `CostValue` id (row index into the cost-table's
202        /// per-property `iprp_*.2da`).
203        cost_value: u16,
204    },
205    /// Conditional damage bonus against a racial group (vanilla
206    /// `itempropdef.2da` label `DamageRacialGroup`, row 13). The
207    /// subtype identifies which row of `racialtypes.2da` the bonus
208    /// applies against; the cost fields carry the magnitude.
209    DamageRacialGroup {
210        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
211        /// `label` matched `DamageRacialGroup` at decode time).
212        property_id: u16,
213        /// Raw `Subtype` id (row index into `racialtypes.2da`).
214        subtype_id: u16,
215        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
216        cost_table: u8,
217        /// Raw `CostValue` id (row index into the cost-table's
218        /// per-property `iprp_*.2da`).
219        cost_value: u16,
220    },
221    /// Conditional damage bonus against an alignment group (vanilla
222    /// `itempropdef.2da` label `DamageAlignmentGroup`, row 12). The
223    /// subtype identifies which row of `iprp_aligngrp.2da` the bonus
224    /// applies against; the cost fields carry the magnitude.
225    DamageAlignmentGroup {
226        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
227        /// `label` matched `DamageAlignmentGroup` at decode time).
228        property_id: u16,
229        /// Raw `Subtype` id (row index into `iprp_aligngrp.2da`).
230        subtype_id: u16,
231        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
232        cost_table: u8,
233        /// Raw `CostValue` id (row index into the cost-table's
234        /// per-property `iprp_*.2da`).
235        cost_value: u16,
236    },
237    /// Conditional enhancement bonus against a racial group (vanilla
238    /// `itempropdef.2da` label `EnhancementRacialGroup`, row 7). The
239    /// subtype identifies which row of `racialtypes.2da` the bonus
240    /// applies against; the cost fields carry the magnitude.
241    ///
242    /// Sibling of `DamageRacialGroup` and shares the `racialtypes.2da`
243    /// dispatch chain. The alignment-group equivalent is
244    /// [`Self::EnhancementAlignmentGroup`].
245    EnhancementRacialGroup {
246        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
247        /// `label` matched `EnhancementRacialGroup` at decode time).
248        property_id: u16,
249        /// Raw `Subtype` id (row index into `racialtypes.2da`).
250        subtype_id: u16,
251        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
252        cost_table: u8,
253        /// Raw `CostValue` id (row index into the cost-table's
254        /// per-property `iprp_*.2da`).
255        cost_value: u16,
256    },
257    /// Conditional enhancement bonus against an alignment group
258    /// (vanilla `itempropdef.2da` label `EnhancementAlignmentGroup`,
259    /// row 6). The subtype identifies which row of
260    /// `iprp_aligngrp.2da` the bonus applies against; the cost
261    /// fields carry the magnitude. Sibling of
262    /// [`Self::EnhancementRacialGroup`] and [`Self::DamageAlignmentGroup`].
263    EnhancementAlignmentGroup {
264        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
265        /// `label` matched `EnhancementAlignmentGroup` at decode time).
266        property_id: u16,
267        /// Raw `Subtype` id (row index into `iprp_aligngrp.2da`).
268        subtype_id: u16,
269        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
270        cost_table: u8,
271        /// Raw `CostValue` id (row index into the cost-table's
272        /// per-property `iprp_*.2da`).
273        cost_value: u16,
274    },
275    /// Conditional attack-bonus against an alignment group (vanilla
276    /// `itempropdef.2da` label `AttackBonusAlignmentGroup`, row 39).
277    /// The subtype identifies which row of `iprp_aligngrp.2da` the
278    /// bonus applies against; the cost fields carry the magnitude.
279    AttackBonusAlignmentGroup {
280        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
281        /// `label` matched `AttackBonusAlignmentGroup` at decode time).
282        property_id: u16,
283        /// Raw `Subtype` id (row index into `iprp_aligngrp.2da`).
284        subtype_id: u16,
285        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
286        cost_table: u8,
287        /// Raw `CostValue` id (row index into the cost-table's
288        /// per-property `iprp_*.2da`).
289        cost_value: u16,
290    },
291    /// True-seeing property (vanilla `itempropdef.2da` label
292    /// `True_Seeing`, row 47). Grants the wearer the ability to see
293    /// through invisibility / stealth. No subtype dimension; the
294    /// cost fields carry the engine-side magnitude reference.
295    TrueSeeing {
296        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
297        /// `label` matched `True_Seeing` at decode time).
298        property_id: u16,
299        /// Raw `Subtype` id. Not consumed by the engine for this
300        /// property kind; surfaced for round-trip fidelity.
301        subtype_id: u16,
302        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
303        cost_table: u8,
304        /// Raw `CostValue` id (row index into the cost-table's
305        /// per-property `iprp_*.2da`).
306        cost_value: u16,
307    },
308    /// Light-source property (vanilla `itempropdef.2da` label
309    /// `Light`, row 29). Makes the item emit light when equipped or
310    /// dropped. No subtype dimension, but row 29 in `itempropdef.2da`
311    /// declares a `param1resref`, so the engine consumes `param1` /
312    /// `param1_value` to convey brightness or colour parameters.
313    /// Both param fields are preserved on the typed variant so
314    /// consumers do not have to fall back to the raw `UtiProperty`
315    /// to read them.
316    Light {
317        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
318        /// `label` matched `Light` at decode time).
319        property_id: u16,
320        /// Raw `Subtype` id. Not consumed by the engine for this
321        /// property kind; surfaced for round-trip fidelity.
322        subtype_id: u16,
323        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
324        cost_table: u8,
325        /// Raw `CostValue` id (row index into the cost-table's
326        /// per-property `iprp_*.2da`).
327        cost_value: u16,
328        /// Raw `Param1` id (row index into `iprp_paramtable.2da`).
329        param1: u8,
330        /// Raw `Param1Value` id (row index into the param-table's
331        /// `iprp_*.2da`).
332        param1_value: u8,
333    },
334    /// Armour-class bonus property (vanilla `itempropdef.2da` label
335    /// `Armor`, row 1). No subtype dimension; the cost fields carry
336    /// the bonus magnitude reference.
337    ///
338    /// `subtype_id` is preserved for shape parity across variants
339    /// even though the engine ignores it for this kind. Vanilla
340    /// content writes 0 here.
341    AcBonus {
342        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
343        /// `label` matched `Armor` at decode time).
344        property_id: u16,
345        /// Raw `Subtype` id. Not consumed by the engine for this
346        /// property kind; surfaced for round-trip fidelity.
347        subtype_id: u16,
348        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
349        cost_table: u8,
350        /// Raw `CostValue` id (row index into the cost-table's
351        /// per-property `iprp_*.2da`).
352        cost_value: u16,
353    },
354    /// Enhancement bonus property (vanilla `itempropdef.2da` label
355    /// `Enhancement`, row 5). No subtype dimension; the cost fields
356    /// carry the bonus magnitude reference.
357    EnhancementBonus {
358        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
359        /// `label` matched `Enhancement` at decode time).
360        property_id: u16,
361        /// Raw `Subtype` id. Not consumed by the engine for this
362        /// property kind; surfaced for round-trip fidelity.
363        subtype_id: u16,
364        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
365        cost_table: u8,
366        /// Raw `CostValue` id (row index into the cost-table's
367        /// per-property `iprp_*.2da`).
368        cost_value: u16,
369    },
370    /// Flat attack bonus property (vanilla `itempropdef.2da` label
371    /// `AttackBonus`, row 38). No subtype dimension; the cost fields
372    /// carry the bonus magnitude reference. Heavily used in vanilla
373    /// across weapons and accessories.
374    AttackBonus {
375        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
376        /// `label` matched `AttackBonus` at decode time).
377        property_id: u16,
378        /// Raw `Subtype` id. Not consumed by the engine for this
379        /// property kind; surfaced for round-trip fidelity.
380        subtype_id: u16,
381        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
382        cost_table: u8,
383        /// Raw `CostValue` id (row index into the cost-table's
384        /// per-property `iprp_*.2da`).
385        cost_value: u16,
386    },
387    /// Keen property (vanilla `itempropdef.2da` label `Keen`, row
388    /// 28). Widens the critical-hit threat range on the weapon. No
389    /// subtype dimension; the cost fields carry the magnitude.
390    Keen {
391        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
392        /// `label` matched `Keen` at decode time).
393        property_id: u16,
394        /// Raw `Subtype` id. Not consumed by the engine for this
395        /// property kind; surfaced for round-trip fidelity.
396        subtype_id: u16,
397        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
398        cost_table: u8,
399        /// Raw `CostValue` id (row index into the cost-table's
400        /// per-property `iprp_*.2da`).
401        cost_value: u16,
402    },
403    /// Massive criticals property (vanilla `itempropdef.2da` label
404    /// `Massive_Criticals`, row 49). Adds extra damage on a critical
405    /// hit. No subtype dimension; the cost fields carry the damage
406    /// magnitude.
407    MassiveCriticals {
408        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
409        /// `label` matched `Massive_Criticals` at decode time).
410        property_id: u16,
411        /// Raw `Subtype` id. Not consumed by the engine for this
412        /// property kind; surfaced for round-trip fidelity.
413        subtype_id: u16,
414        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
415        cost_table: u8,
416        /// Raw `CostValue` id (row index into the cost-table's
417        /// per-property `iprp_*.2da`).
418        cost_value: u16,
419    },
420    /// Blaster-bolt deflection bonus (vanilla `itempropdef.2da` label
421    /// `Blaster_Bolt_Deflect_Increase`, row 55). Improves the deflect
422    /// chance on a lightsaber. No subtype dimension; the cost fields
423    /// carry the magnitude.
424    ///
425    /// Distinct from `Blaster_Bolt_Defect_Decrease` (row 56, vanilla
426    /// typo with `Defect`), which has near-zero corpus usage and
427    /// stays in `Unknown`.
428    BlasterBoltDeflectIncrease {
429        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
430        /// `label` matched `Blaster_Bolt_Deflect_Increase` at decode
431        /// time).
432        property_id: u16,
433        /// Raw `Subtype` id. Not consumed by the engine for this
434        /// property kind; surfaced for round-trip fidelity.
435        subtype_id: u16,
436        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
437        cost_table: u8,
438        /// Raw `CostValue` id (row index into the cost-table's
439        /// per-property `iprp_*.2da`).
440        cost_value: u16,
441    },
442    /// Monster damage property (vanilla `itempropdef.2da` label
443    /// `Monster_damage`, row 51). Engine uses this on monster claws,
444    /// bites, and similar natural weapons. No subtype dimension; the
445    /// cost fields carry the damage magnitude.
446    ///
447    /// The vanilla label uses lowercase `d` in `damage`; the dispatch
448    /// match arm matches that spelling verbatim.
449    MonsterDamage {
450        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
451        /// `label` matched `Monster_damage` at decode time).
452        property_id: u16,
453        /// Raw `Subtype` id. Not consumed by the engine for this
454        /// property kind; surfaced for round-trip fidelity.
455        subtype_id: u16,
456        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
457        cost_table: u8,
458        /// Raw `CostValue` id (row index into the cost-table's
459        /// per-property `iprp_*.2da`).
460        cost_value: u16,
461    },
462    /// Bonus-feat property (vanilla `itempropdef.2da` label
463    /// `BonusFeats`, row 9). The subtype identifies which row of
464    /// `feat.2da` the item grants while equipped. The cost fields
465    /// are usually unused for this property kind (the engine grants
466    /// the feat directly rather than scaling by magnitude).
467    BonusFeats {
468        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
469        /// `label` matched `BonusFeats` at decode time).
470        property_id: u16,
471        /// Raw `Subtype` id (row index into `feat.2da`).
472        subtype_id: u16,
473        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
474        cost_table: u8,
475        /// Raw `CostValue` id (row index into the cost-table's
476        /// per-property `iprp_*.2da`).
477        cost_value: u16,
478    },
479    /// Immunity property (vanilla `itempropdef.2da` label `Immunity`,
480    /// row 24). The subtype identifies which row of `iprp_immunity.2da`
481    /// the item grants immunity to (e.g. Mind-Affecting, Paralysis).
482    /// The cost fields carry the immunity strength reference.
483    Immunity {
484        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
485        /// `label` matched `Immunity` at decode time).
486        property_id: u16,
487        /// Raw `Subtype` id (row index into `iprp_immunity.2da`).
488        subtype_id: u16,
489        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
490        cost_table: u8,
491        /// Raw `CostValue` id (row index into the cost-table's
492        /// per-property `iprp_*.2da`).
493        cost_value: u16,
494    },
495    /// Skill bonus property (vanilla `itempropdef.2da` label `Skill`,
496    /// row 36). The subtype identifies which row of `skills.2da`
497    /// (Persuade, Demolitions, ...) gets the bonus; the cost fields
498    /// carry the magnitude.
499    ///
500    /// Distinct from `DecreasedSkill` (row 21, also backed by
501    /// `skills.2da`), which has zero corpus usage in vanilla items
502    /// and stays in `Unknown`.
503    Skill {
504        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
505        /// `label` matched `Skill` at decode time).
506        property_id: u16,
507        /// Raw `Subtype` id (row index into `skills.2da`).
508        subtype_id: u16,
509        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
510        cost_table: u8,
511        /// Raw `CostValue` id (row index into the cost-table's
512        /// per-property `iprp_*.2da`).
513        cost_value: u16,
514    },
515    /// Flat attack penalty property (vanilla `itempropdef.2da` label
516    /// `AttackPenalty`, row 8). Mirror of [`Self::AttackBonus`]: same
517    /// subtypeless shape, cost fields carry the magnitude of the
518    /// penalty rather than the bonus.
519    AttackPenalty {
520        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
521        /// `label` matched `AttackPenalty` at decode time).
522        property_id: u16,
523        /// Raw `Subtype` id. Not consumed by the engine for this
524        /// property kind; surfaced for round-trip fidelity.
525        subtype_id: u16,
526        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
527        cost_table: u8,
528        /// Raw `CostValue` id (row index into the cost-table's
529        /// per-property `iprp_*.2da`).
530        cost_value: u16,
531    },
532    /// Flat damage penalty property (vanilla `itempropdef.2da` label
533    /// `DamagePenalty`, row 15). Subtypeless mirror of
534    /// [`Self::DamageBonus`]: cost fields carry the penalty
535    /// magnitude.
536    DamagePenalty {
537        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
538        /// `label` matched `DamagePenalty` at decode time).
539        property_id: u16,
540        /// Raw `Subtype` id. Not consumed by the engine for this
541        /// property kind; surfaced for round-trip fidelity.
542        subtype_id: u16,
543        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
544        cost_table: u8,
545        /// Raw `CostValue` id (row index into the cost-table's
546        /// per-property `iprp_*.2da`).
547        cost_value: u16,
548    },
549    /// Magic-resistance bonus (vanilla `itempropdef.2da` label
550    /// `ImprovedMagicResist`, row 25). No subtype dimension; the cost
551    /// fields carry the resist magnitude.
552    MagicResistBonus {
553        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
554        /// `label` matched `ImprovedMagicResist` at decode time).
555        property_id: u16,
556        /// Raw `Subtype` id. Not consumed by the engine for this
557        /// property kind; surfaced for round-trip fidelity.
558        subtype_id: u16,
559        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
560        cost_table: u8,
561        /// Raw `CostValue` id (row index into the cost-table's
562        /// per-property `iprp_*.2da`).
563        cost_value: u16,
564    },
565    /// No-damage marker property (vanilla `itempropdef.2da` label
566    /// `DamageNone`, row 31). Flags a weapon as dealing no
567    /// damage on hit (used on unarmed strikes, training weapons,
568    /// and similar). No subtype dimension and no meaningful cost
569    /// payload; the fields are preserved for round-trip fidelity.
570    DamageNone {
571        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
572        /// `label` matched `DamageNone` at decode time).
573        property_id: u16,
574        /// Raw `Subtype` id. Not consumed by the engine for this
575        /// property kind; surfaced for round-trip fidelity.
576        subtype_id: u16,
577        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
578        cost_table: u8,
579        /// Raw `CostValue` id (row index into the cost-table's
580        /// per-property `iprp_*.2da`).
581        cost_value: u16,
582    },
583    /// Hit-point regeneration property (vanilla `itempropdef.2da`
584    /// label `Regeneration`, row 35). Restores HP per round while
585    /// equipped. No subtype dimension; the cost fields carry the
586    /// magnitude.
587    ///
588    /// The force-points counterpart [`Self::RegenerationForcePoints`]
589    /// applies to FP rather than HP.
590    Regeneration {
591        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
592        /// `label` matched `Regeneration` at decode time).
593        property_id: u16,
594        /// Raw `Subtype` id. Not consumed by the engine for this
595        /// property kind; surfaced for round-trip fidelity.
596        subtype_id: u16,
597        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
598        cost_table: u8,
599        /// Raw `CostValue` id (row index into the cost-table's
600        /// per-property `iprp_*.2da`).
601        cost_value: u16,
602    },
603    /// Force-point regeneration property (vanilla `itempropdef.2da`
604    /// label `Regeneration_Force_Points`, row 54). Restores FP per
605    /// round while equipped. No subtype dimension; the cost fields
606    /// carry the magnitude.
607    RegenerationForcePoints {
608        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
609        /// `label` matched `Regeneration_Force_Points` at decode time).
610        property_id: u16,
611        /// Raw `Subtype` id. Not consumed by the engine for this
612        /// property kind; surfaced for round-trip fidelity.
613        subtype_id: u16,
614        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
615        cost_table: u8,
616        /// Raw `CostValue` id (row index into the cost-table's
617        /// per-property `iprp_*.2da`).
618        cost_value: u16,
619    },
620    /// Disguise property (vanilla `itempropdef.2da` label `Disguise`,
621    /// row 59). The subtype identifies which row of `appearance.2da`
622    /// the wearer takes on while the item is equipped (used by mask
623    /// items that change the player's apparent species).
624    Disguise {
625        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
626        /// `label` matched `Disguise` at decode time).
627        property_id: u16,
628        /// Raw `Subtype` id (row index into `appearance.2da`).
629        subtype_id: u16,
630        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
631        cost_table: u8,
632        /// Raw `CostValue` id (row index into the cost-table's
633        /// per-property `iprp_*.2da`).
634        cost_value: u16,
635    },
636    /// Feat-restricted use property (vanilla `itempropdef.2da` label
637    /// `Use_Limitation_Feat`, row 57). The subtype identifies which
638    /// feat from `feat.2da` the wielder must possess to equip or
639    /// activate the item.
640    ///
641    /// Passive property: the engine consults the subtype at equip
642    /// time and rejects use if the wielder lacks the feat. `useable`
643    /// and `uses_per_day` are not consumed for this kind.
644    UseLimitationFeat {
645        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
646        /// `label` matched `Use_Limitation_Feat` at decode time).
647        property_id: u16,
648        /// Raw `Subtype` id (row index into `feat.2da`).
649        subtype_id: u16,
650        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
651        cost_table: u8,
652        /// Raw `CostValue` id (row index into the cost-table's
653        /// per-property `iprp_*.2da`).
654        cost_value: u16,
655    },
656    /// Race-restricted use property (vanilla `itempropdef.2da` label
657    /// `UseLimitationRacial`, row 45). The subtype identifies which
658    /// row of `racialtypes.2da` the wielder must match.
659    UseLimitationRacial {
660        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
661        /// `label` matched `UseLimitationRacial` at decode time).
662        property_id: u16,
663        /// Raw `Subtype` id (row index into `racialtypes.2da`).
664        subtype_id: u16,
665        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
666        cost_table: u8,
667        /// Raw `CostValue` id (row index into the cost-table's
668        /// per-property `iprp_*.2da`).
669        cost_value: u16,
670    },
671    /// Alignment-restricted use property (vanilla `itempropdef.2da`
672    /// label `UseLimitationAlignmentGroup`, row 43). The subtype
673    /// identifies which row of `iprp_aligngrp.2da` the wielder's
674    /// alignment must satisfy.
675    UseLimitationAlignmentGroup {
676        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
677        /// `label` matched `UseLimitationAlignmentGroup` at decode time).
678        property_id: u16,
679        /// Raw `Subtype` id (row index into `iprp_aligngrp.2da`).
680        subtype_id: u16,
681        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
682        cost_table: u8,
683        /// Raw `CostValue` id (row index into the cost-table's
684        /// per-property `iprp_*.2da`).
685        cost_value: u16,
686    },
687    /// Cast-spell active property (vanilla `itempropdef.2da` label
688    /// `CastSpell`, row 10). The engine routes this property into
689    /// the per-character usable-ability table (one of four
690    /// hardcoded "active" rows alongside `ThievesTools`, `Trap`, and
691    /// `Computer_Spike`). The subtype identifies which spell from
692    /// `spells.2da` the item casts.
693    ///
694    /// Active properties differ from passive ones in that the
695    /// `useable` and `uses_per_day` fields are load-bearing rather
696    /// than ignored. Both are decoded with the engine's defaults
697    /// applied:
698    /// - `useable` defaults to `true` for active properties when
699    ///   absent from the GFF; `false` only when explicitly set.
700    /// - `uses_per_day` decodes the `0xFF` engine sentinel into
701    ///   `None` (meaning "unlimited / not constrained"); explicit
702    ///   non-sentinel values come through as `Some(N)`.
703    CastSpell {
704        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
705        /// `label` matched `CastSpell` at decode time).
706        property_id: u16,
707        /// Raw `Subtype` id (row index into `spells.2da`).
708        subtype_id: u16,
709        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
710        cost_table: u8,
711        /// Raw `CostValue` id (row index into the cost-table's
712        /// per-property `iprp_*.2da`).
713        cost_value: u16,
714        /// Whether the item can be activated. `true` when the GFF
715        /// omits `Useable` (engine default for active properties)
716        /// or sets it to a non-zero value; `false` when explicitly
717        /// disabled.
718        useable: bool,
719        /// Daily-use cap. `None` when the GFF omits `UsesPerDay`
720        /// or stores the engine sentinel `0xFF` ("not set / no
721        /// limit"); `Some(N)` for explicit caps.
722        uses_per_day: Option<u8>,
723    },
724    /// Trap active property (vanilla `itempropdef.2da` label
725    /// `Trap`, row 46). Routed into the active-property table
726    /// alongside `CastSpell`. The subtype identifies which trap
727    /// from `traps.2da` deploys.
728    ///
729    /// `useable` and `uses_per_day` follow the same active-property
730    /// decoding rules documented on [`Self::CastSpell`].
731    Trap {
732        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
733        /// `label` matched `Trap` at decode time).
734        property_id: u16,
735        /// Raw `Subtype` id (row index into `traps.2da`).
736        subtype_id: u16,
737        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
738        cost_table: u8,
739        /// Raw `CostValue` id (row index into the cost-table's
740        /// per-property `iprp_*.2da`).
741        cost_value: u16,
742        /// Whether the item can be activated. See [`Self::CastSpell`].
743        useable: bool,
744        /// Daily-use cap. See [`Self::CastSpell`].
745        uses_per_day: Option<u8>,
746    },
747    /// Thieves-tools active property (vanilla `itempropdef.2da`
748    /// label `ThievesTools`, row 37). Routed into the active-property
749    /// table alongside `CastSpell`. No subtype dimension; the cost
750    /// fields carry the magnitude (the security-skill bonus the
751    /// tool grants while equipped).
752    ///
753    /// `subtype_id` is preserved for shape parity even though the
754    /// engine ignores it. `useable` and `uses_per_day` follow the
755    /// same active-property decoding rules documented on
756    /// [`Self::CastSpell`].
757    ThievesTools {
758        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
759        /// `label` matched `ThievesTools` at decode time).
760        property_id: u16,
761        /// Raw `Subtype` id. Not consumed by the engine for this
762        /// property kind; surfaced for round-trip fidelity.
763        subtype_id: u16,
764        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
765        cost_table: u8,
766        /// Raw `CostValue` id (row index into the cost-table's
767        /// per-property `iprp_*.2da`).
768        cost_value: u16,
769        /// Whether the item can be activated. See [`Self::CastSpell`].
770        useable: bool,
771        /// Daily-use cap. See [`Self::CastSpell`].
772        uses_per_day: Option<u8>,
773    },
774    /// Computer-spike active property (vanilla `itempropdef.2da`
775    /// label `Computer_Spike`, row 53). Routed into the
776    /// active-property table alongside `CastSpell`. No subtype
777    /// dimension; the cost fields carry the magnitude (the
778    /// computer-use skill bonus the spike grants).
779    ///
780    /// `subtype_id` is preserved for shape parity even though the
781    /// engine ignores it. `useable` and `uses_per_day` follow the
782    /// same active-property decoding rules documented on
783    /// [`Self::CastSpell`].
784    ComputerSpike {
785        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
786        /// `label` matched `Computer_Spike` at decode time).
787        property_id: u16,
788        /// Raw `Subtype` id. Not consumed by the engine for this
789        /// property kind; surfaced for round-trip fidelity.
790        subtype_id: u16,
791        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
792        cost_table: u8,
793        /// Raw `CostValue` id (row index into the cost-table's
794        /// per-property `iprp_*.2da`).
795        cost_value: u16,
796        /// Whether the item can be activated. See [`Self::CastSpell`].
797        useable: bool,
798        /// Daily-use cap. See [`Self::CastSpell`].
799        uses_per_day: Option<u8>,
800    },
801    /// On-hit effect property (vanilla `itempropdef.2da` label
802    /// `OnHit`, row 32). The subtype identifies which on-hit effect
803    /// (Daze, Stun, Wound, ...) fires when the weapon connects.
804    ///
805    /// Unlike the other passive variants, OnHit's `param1` /
806    /// `param1_value` fields are load-bearing: they convey the
807    /// effect's magnitude / DC / duration through the
808    /// `iprp_paramtable.2da` dispatch chain. Both fields are
809    /// preserved on the typed variant so consumers do not have to
810    /// fall back to the raw `UtiProperty` to read them.
811    OnHit {
812        /// Raw `PropertyName` (the row in `itempropdef.2da` whose
813        /// `label` matched `OnHit` at decode time).
814        property_id: u16,
815        /// Raw `Subtype` id (row index into `iprp_onhit.2da`).
816        subtype_id: u16,
817        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
818        cost_table: u8,
819        /// Raw `CostValue` id (row index into the cost-table's
820        /// per-property `iprp_*.2da`).
821        cost_value: u16,
822        /// Raw `Param1` id (row index into `iprp_paramtable.2da`).
823        /// Conveys the effect's parameter dimension (DC, magnitude,
824        /// duration depending on the on-hit kind).
825        param1: u8,
826        /// Raw `Param1Value` id (row index into the param-table's
827        /// `iprp_*.2da`).
828        param1_value: u8,
829    },
830    /// Property kind for which no typed variant exists yet, or whose
831    /// `PropertyName` does not resolve in `itempropdef.2da`. Carries
832    /// every raw field so consumers can still introspect or
833    /// round-trip.
834    Unknown {
835        /// Raw `PropertyName` (engine row index into
836        /// `itempropdef.2da`).
837        property_id: u16,
838        /// Human-readable label from `itempropdef.2da`'s `label`
839        /// column when the row resolves; `None` when the row is
840        /// absent or the table cannot be loaded.
841        property_label: Option<String>,
842        /// Raw `Subtype` id (row index into the property kind's
843        /// `iprp_*.2da` subtype table).
844        subtype: u16,
845        /// Raw `CostTable` id (row index into `iprp_costtable.2da`).
846        cost_table: u8,
847        /// Raw `CostValue` id (row index into the cost-table's
848        /// `iprp_*.2da`).
849        cost_value: u16,
850        /// Raw `Param1` id (row index into `iprp_paramtable.2da`).
851        param1: u8,
852        /// Raw `Param1Value` id (row index into the param-table's
853        /// `iprp_*.2da`).
854        param1_value: u8,
855    },
856}
857
858impl DecodedProperty {
859    /// Resolves a developer-readable label for this property's
860    /// subtype by walking the engine's per-property subtype dispatch
861    /// chain.
862    ///
863    /// Returns `None` when any step of the chain fails to resolve:
864    /// - the source `PropertyName` row is absent from
865    ///   `itempropdef.2da`,
866    /// - the row's `SubTypeResRef` cell is empty (the property has
867    ///   no subtype dimension at all),
868    /// - the per-property subtype 2DA cannot be loaded,
869    /// - the `Subtype` row is past the loaded subtype 2DA's row
870    ///   count, or
871    /// - any 2DA in the chain is missing or malformed.
872    ///
873    /// The returned string is the developer-readable `label` column
874    /// (e.g. `"Acid"` from `iprp_damagecost.2da`), not a TLK-resolved
875    /// display name. Tooling that needs the localized display name
876    /// must resolve the `Name` column against a talktable separately.
877    pub fn subtype_label(&self, cache: &mut impl TwoDaSource) -> Option<String> {
878        let (property_id, subtype) = match self {
879            DecodedProperty::AbilityBonus {
880                property_id,
881                subtype_id,
882                ..
883            }
884            | DecodedProperty::SaveBonus {
885                property_id,
886                subtype_id,
887                ..
888            }
889            | DecodedProperty::SaveBonusSpecific {
890                property_id,
891                subtype_id,
892                ..
893            }
894            | DecodedProperty::SavePenalty {
895                property_id,
896                subtype_id,
897                ..
898            }
899            | DecodedProperty::SavePenaltySpecific {
900                property_id,
901                subtype_id,
902                ..
903            }
904            | DecodedProperty::DamageBonus {
905                property_id,
906                subtype_id,
907                ..
908            }
909            | DecodedProperty::DamageImmunity {
910                property_id,
911                subtype_id,
912                ..
913            }
914            | DecodedProperty::DamageResistance {
915                property_id,
916                subtype_id,
917                ..
918            }
919            | DecodedProperty::AcBonus {
920                property_id,
921                subtype_id,
922                ..
923            }
924            | DecodedProperty::EnhancementBonus {
925                property_id,
926                subtype_id,
927                ..
928            }
929            | DecodedProperty::OnHit {
930                property_id,
931                subtype_id,
932                ..
933            }
934            | DecodedProperty::CastSpell {
935                property_id,
936                subtype_id,
937                ..
938            }
939            | DecodedProperty::Trap {
940                property_id,
941                subtype_id,
942                ..
943            }
944            | DecodedProperty::ThievesTools {
945                property_id,
946                subtype_id,
947                ..
948            }
949            | DecodedProperty::ComputerSpike {
950                property_id,
951                subtype_id,
952                ..
953            }
954            | DecodedProperty::UseLimitationFeat {
955                property_id,
956                subtype_id,
957                ..
958            }
959            | DecodedProperty::UseLimitationRacial {
960                property_id,
961                subtype_id,
962                ..
963            }
964            | DecodedProperty::UseLimitationAlignmentGroup {
965                property_id,
966                subtype_id,
967                ..
968            }
969            | DecodedProperty::DamageRacialGroup {
970                property_id,
971                subtype_id,
972                ..
973            }
974            | DecodedProperty::DamageAlignmentGroup {
975                property_id,
976                subtype_id,
977                ..
978            }
979            | DecodedProperty::EnhancementRacialGroup {
980                property_id,
981                subtype_id,
982                ..
983            }
984            | DecodedProperty::EnhancementAlignmentGroup {
985                property_id,
986                subtype_id,
987                ..
988            }
989            | DecodedProperty::AttackBonusAlignmentGroup {
990                property_id,
991                subtype_id,
992                ..
993            }
994            | DecodedProperty::TrueSeeing {
995                property_id,
996                subtype_id,
997                ..
998            }
999            | DecodedProperty::Light {
1000                property_id,
1001                subtype_id,
1002                ..
1003            }
1004            | DecodedProperty::AttackBonus {
1005                property_id,
1006                subtype_id,
1007                ..
1008            }
1009            | DecodedProperty::Keen {
1010                property_id,
1011                subtype_id,
1012                ..
1013            }
1014            | DecodedProperty::MassiveCriticals {
1015                property_id,
1016                subtype_id,
1017                ..
1018            }
1019            | DecodedProperty::BlasterBoltDeflectIncrease {
1020                property_id,
1021                subtype_id,
1022                ..
1023            }
1024            | DecodedProperty::MonsterDamage {
1025                property_id,
1026                subtype_id,
1027                ..
1028            }
1029            | DecodedProperty::BonusFeats {
1030                property_id,
1031                subtype_id,
1032                ..
1033            }
1034            | DecodedProperty::Immunity {
1035                property_id,
1036                subtype_id,
1037                ..
1038            }
1039            | DecodedProperty::Skill {
1040                property_id,
1041                subtype_id,
1042                ..
1043            }
1044            | DecodedProperty::AttackPenalty {
1045                property_id,
1046                subtype_id,
1047                ..
1048            }
1049            | DecodedProperty::DamagePenalty {
1050                property_id,
1051                subtype_id,
1052                ..
1053            }
1054            | DecodedProperty::MagicResistBonus {
1055                property_id,
1056                subtype_id,
1057                ..
1058            }
1059            | DecodedProperty::DamageNone {
1060                property_id,
1061                subtype_id,
1062                ..
1063            }
1064            | DecodedProperty::Regeneration {
1065                property_id,
1066                subtype_id,
1067                ..
1068            }
1069            | DecodedProperty::RegenerationForcePoints {
1070                property_id,
1071                subtype_id,
1072                ..
1073            }
1074            | DecodedProperty::Disguise {
1075                property_id,
1076                subtype_id,
1077                ..
1078            } => (*property_id, *subtype_id),
1079            DecodedProperty::Unknown {
1080                property_id,
1081                subtype,
1082                ..
1083            } => (*property_id, *subtype),
1084        };
1085        let subtype_resref = {
1086            let propdef = cache.twoda(tables::ITEMPROPDEF)?;
1087            propdef
1088                .cell(usize::from(property_id), "SubTypeResRef")?
1089                .to_string()
1090        };
1091        if subtype_resref.is_empty() {
1092            return None;
1093        }
1094        let subtype_table = cache.twoda(&subtype_resref)?;
1095        subtype_table
1096            .cell(usize::from(subtype), "label")
1097            .map(str::to_string)
1098    }
1099}
1100
1101/// File-native projection of a [`Uti`].
1102///
1103/// Each `UtiProperty` is routed to a typed [`DecodedProperty`]
1104/// variant by matching on the `itempropdef.2da` `label` value at
1105/// projection time. No further 2DA resolution happens at this stage;
1106/// the projection is the cheap, scope-free intermediate from which
1107/// one or more [`UtiResolved`]s are built.
1108///
1109/// Construct via [`Uti::project`]. Multiple resolutions from one
1110/// projection share typed-variant dispatch but each captures its own
1111/// per-scope resolved data (baseitems cells, cost-table magnitudes,
1112/// etc.). This is the projection-stage type referenced by the
1113/// "typed views are honest projections" rule: file-native dispatch
1114/// only, never resolved cross-resource data.
1115#[derive(Debug, Clone)]
1116pub struct UtiProjection<'a> {
1117    uti: &'a Uti,
1118    decoded_properties: Vec<DecodedProperty>,
1119}
1120
1121/// Snapshot view over a [`Uti`], resolved against a per-scope context.
1122///
1123/// Built by [`UtiProjection::resolve`] (or [`Uti::resolve`] as a
1124/// single-scope shortcut). Holds the projection's typed
1125/// [`DecodedProperty`] variants plus the cached resolutions the
1126/// view's query methods need.
1127///
1128/// All query methods take `&self` and read from this cached resolution.
1129/// The resolved view does not retain the [`TwoDaSource`] borrow
1130/// once constructed; to query under a different scope, build a
1131/// fresh resolve again from the same projection.
1132///
1133/// Combat / equip queries (`is_weapon`, `is_consumable`,
1134/// `equip_slot_mask`, `model_type`) read against a cached
1135/// `baseitems.2da` row taken at resolve time. When that 2DA is
1136/// unavailable or the item's `base_item` does not resolve to a row,
1137/// those queries return `false` / `None` as appropriate.
1138#[derive(Debug)]
1139pub struct UtiResolved<'a> {
1140    uti: &'a Uti,
1141    decoded_properties: Vec<DecodedProperty>,
1142    base_item_info: Option<BaseItemInfo>,
1143    /// Per-property resolved magnitudes, indexed in lockstep with
1144    /// `decoded_properties`. `None` for properties whose kind has no
1145    /// magnitude semantics (most current variants) or whose
1146    /// resolution failed at resolve time. Populated via
1147    /// [`resolve_magnitude`]; the cost-table magnitude resolution
1148    /// subsection of `docs/src/formats/gff/uti.md` documents the
1149    /// recipe per property kind.
1150    resolved_magnitudes: Vec<Option<i32>>,
1151}
1152
1153/// Cached subset of a `baseitems.2da` row, taken once at resolve
1154/// time so the combat / equip queries on [`UtiResolved`] can answer
1155/// without holding a 2DA cache borrow.
1156#[derive(Debug, Clone, Copy, Default)]
1157struct BaseItemInfo {
1158    /// `baseitems.2da#weaponwield` column. `0` means the item is
1159    /// not a wielded weapon.
1160    weapon_wield: u8,
1161    /// `baseitems.2da#stacking` column. Values greater than 1 mark
1162    /// the item as stackable (the engine's signal for consumables
1163    /// such as stim packs, grenades, and med kits).
1164    stacking: u8,
1165    /// `baseitems.2da#equipableslots` column, parsed from the
1166    /// hex-string form (e.g. `0x00010`). `None` when the cell is
1167    /// absent or unparseable.
1168    equip_slot_mask: Option<u32>,
1169    /// `baseitems.2da#modeltype` column. `None` when the cell is
1170    /// absent or unparseable. Vanilla content uses `0`, `1`, or
1171    /// `2`.
1172    model_type: Option<u8>,
1173}
1174
1175impl BaseItemInfo {
1176    /// Reads the row at `base_item` from a parsed `baseitems.2da`.
1177    /// Returns `None` if the row does not exist; missing per-cell
1178    /// values fall back to the type defaults.
1179    fn from_row(table: &TwoDa, base_item: i32) -> Option<Self> {
1180        let row = usize::try_from(base_item).ok()?;
1181        if row >= table.rows.len() {
1182            return None;
1183        }
1184        Some(Self {
1185            weapon_wield: parse_u8_cell(table, row, "weaponwield").unwrap_or(0),
1186            stacking: parse_u8_cell(table, row, "stacking").unwrap_or(0),
1187            equip_slot_mask: parse_hex_u32_cell(table, row, "equipableslots"),
1188            model_type: parse_u8_cell(table, row, "modeltype"),
1189        })
1190    }
1191}
1192
1193fn parse_u8_cell(table: &TwoDa, row: usize, column: &str) -> Option<u8> {
1194    table.cell(row, column).and_then(|s| s.trim().parse().ok())
1195}
1196
1197fn parse_i32_cell(table: &TwoDa, row: usize, column: &str) -> Option<i32> {
1198    table.cell(row, column).and_then(|s| s.trim().parse().ok())
1199}
1200
1201fn parse_hex_u32_cell(table: &TwoDa, row: usize, column: &str) -> Option<u32> {
1202    let raw = table.cell(row, column)?.trim();
1203    let stripped = raw
1204        .strip_prefix("0x")
1205        .or_else(|| raw.strip_prefix("0X"))
1206        .unwrap_or(raw);
1207    u32::from_str_radix(stripped, 16).ok()
1208}
1209
1210/// Resolves a single property's runtime magnitude in engine units.
1211///
1212/// Returns `None` for property kinds with no magnitude semantics
1213/// (subtype-only kinds, active uses-per-day kinds, etc.) and for
1214/// failed lookups (missing 2DA, out-of-range row, unparseable cell).
1215/// The per-kind recipe follows the "Cost-Table Magnitude Resolution"
1216/// subsection of `docs/src/formats/gff/uti.md`.
1217fn resolve_magnitude(prop: &DecodedProperty, cache: &mut impl TwoDaSource) -> Option<i32> {
1218    match prop {
1219        // Bypass handler per the audit: ApplyDamageBonus reads
1220        // CostValue directly as the damage amount with no per-cost
1221        // 2DA lookup. The iprp_damagecost.2da table is used for cost
1222        // calculation (GetCost), not for damage-magnitude resolution.
1223        DecodedProperty::DamageBonus { cost_value, .. } => Some(i32::from(*cost_value)),
1224        // ApplyAbilityBonus calls GetIPRPCostTable(1) -> iprp_bonuscost
1225        // with a hardcoded index. The property's cost_table field is
1226        // ignored by the handler, so the lookup table is fixed here
1227        // too.
1228        DecodedProperty::AbilityBonus { cost_value, .. } => {
1229            let table = cache.twoda("iprp_bonuscost")?;
1230            parse_i32_cell(table, usize::from(*cost_value), "Value")
1231        }
1232        // ApplyDamageImmunity reads the cost-table index from each
1233        // property's cost_table field (dynamic), then walks
1234        // iprp_costtable -> per-cost 2DA. Mod-extended cost tables
1235        // resolve through the same path.
1236        DecodedProperty::DamageImmunity {
1237            cost_table,
1238            cost_value,
1239            ..
1240        } => resolve_dynamic_magnitude(cache, *cost_table, *cost_value, "Value"),
1241        _ => None,
1242    }
1243}
1244
1245/// Walks the cost-table dispatch chain for property kinds whose
1246/// per-cost 2DA is read from the property's own `cost_table` field
1247/// rather than hardcoded by the engine handler.
1248///
1249/// `iprp_costtable.2da[cost_table]#Name` resolves to a per-cost 2DA
1250/// resref; that table's row at `cost_value`, column `column`, holds
1251/// the magnitude. Any missing link in the chain surfaces as `None`.
1252fn resolve_dynamic_magnitude(
1253    cache: &mut impl TwoDaSource,
1254    cost_table: u8,
1255    cost_value: u16,
1256    column: &str,
1257) -> Option<i32> {
1258    // The cost 2DA name has to be cloned out before re-borrowing the
1259    // cache for the second lookup; the borrow into iprp_costtable
1260    // would otherwise alias the cache mutation that loads the
1261    // per-cost table.
1262    let cost_2da_name = {
1263        let costtable = cache.twoda("iprp_costtable")?;
1264        costtable
1265            .cell(usize::from(cost_table), "Name")?
1266            .trim()
1267            .to_lowercase()
1268    };
1269    if cost_2da_name.is_empty() {
1270        return None;
1271    }
1272    let table = cache.twoda(&cost_2da_name)?;
1273    parse_i32_cell(table, usize::from(cost_value), column)
1274}
1275
1276impl<'a> UtiProjection<'a> {
1277    /// Returns the typed-variant decoded properties as a borrowed
1278    /// slice.
1279    ///
1280    /// The order matches the source [`Uti::properties`] ordering;
1281    /// callers that need a particular property look it up by index
1282    /// or filter the slice.
1283    pub fn properties(&self) -> &[DecodedProperty] {
1284        &self.decoded_properties
1285    }
1286
1287    /// Returns `true` when the source UTI's `BaseItem` belongs to
1288    /// the canonical armor base-item set.
1289    ///
1290    /// Pure delegation to [`Uti::is_armor`] -- the underlying check
1291    /// is a const lookup against the hardcoded armor base-item id
1292    /// set and needs no 2DA cache. Re-exposed here so callers that
1293    /// only hold a [`UtiProjection`] do not have to reach back to
1294    /// the source `Uti`.
1295    pub fn is_armor(&self) -> bool {
1296        self.uti.is_armor()
1297    }
1298
1299    /// Resolves this projection against the given context, building
1300    /// a [`UtiResolved`] whose query methods read from a cached
1301    /// resolution of every table the resolved view will be asked about.
1302    ///
1303    /// Multiple resolutions can be built from one projection, each
1304    /// capturing its own per-scope resolved data. The projection
1305    /// itself is unchanged and remains reusable across calls.
1306    pub fn resolve(&self, cache: &mut impl TwoDaSource) -> UtiResolved<'a> {
1307        let base_item_info = cache
1308            .twoda(tables::BASEITEMS)
1309            .and_then(|table| BaseItemInfo::from_row(table, self.uti.base_item));
1310        let resolved_magnitudes = self
1311            .decoded_properties
1312            .iter()
1313            .map(|prop| resolve_magnitude(prop, cache))
1314            .collect();
1315        UtiResolved {
1316            uti: self.uti,
1317            decoded_properties: self.decoded_properties.clone(),
1318            base_item_info,
1319            resolved_magnitudes,
1320        }
1321    }
1322}
1323
1324impl<'a> UtiResolved<'a> {
1325    /// Returns the typed-variant decoded properties as a borrowed
1326    /// slice.
1327    ///
1328    /// The order matches the source [`Uti::properties`] ordering;
1329    /// callers that need a particular property look it up by index
1330    /// or filter the slice.
1331    pub fn properties(&self) -> &[DecodedProperty] {
1332        &self.decoded_properties
1333    }
1334
1335    /// Returns `true` when the source UTI's `BaseItem` belongs to
1336    /// the canonical armor base-item set.
1337    ///
1338    /// Pure delegation to [`Uti::is_armor`] -- the underlying check
1339    /// is a const lookup against the hardcoded armor base-item id
1340    /// set and needs no 2DA cache. Re-exposed here so callers that
1341    /// only hold a [`UtiResolved`] do not have to reach back to the
1342    /// source `Uti`.
1343    pub fn is_armor(&self) -> bool {
1344        self.uti.is_armor()
1345    }
1346
1347    /// Returns `true` when the item's `baseitems.2da` row marks it
1348    /// as a wielded weapon (`weaponwield > 0`).
1349    ///
1350    /// Returns `false` when `baseitems.2da` is unavailable, the
1351    /// `base_item` does not resolve to a row, or the cell is
1352    /// missing / unparseable.
1353    pub fn is_weapon(&self) -> bool {
1354        self.base_item_info
1355            .as_ref()
1356            .is_some_and(|b| b.weapon_wield > 0)
1357    }
1358
1359    /// Returns `true` when the item is consumable, defined by the
1360    /// engine's signal `stacking > 1` (stim packs, grenades, med
1361    /// kits all stack; equipment does not).
1362    ///
1363    /// Returns `false` when `baseitems.2da` is unavailable, the
1364    /// `base_item` does not resolve to a row, or the cell is
1365    /// missing / unparseable.
1366    pub fn is_consumable(&self) -> bool {
1367        self.base_item_info.as_ref().is_some_and(|b| b.stacking > 1)
1368    }
1369
1370    /// Returns the item's equipable-slot bitmask from
1371    /// `baseitems.2da#equipableslots`, parsed from its hex-string
1372    /// form.
1373    ///
1374    /// Returns `None` when `baseitems.2da` is unavailable, the
1375    /// `base_item` does not resolve to a row, or the cell is
1376    /// missing / unparseable.
1377    pub fn equip_slot_mask(&self) -> Option<u32> {
1378        self.base_item_info.as_ref().and_then(|b| b.equip_slot_mask)
1379    }
1380
1381    /// Returns the item's model-type id from
1382    /// `baseitems.2da#modeltype`.
1383    ///
1384    /// The value gates whether the engine consults the UTI's
1385    /// `TextureVar` field (only when `model_type == 1` per the
1386    /// UTI engine audit). Returns `None` when `baseitems.2da` is
1387    /// unavailable, the `base_item` does not resolve to a row, or
1388    /// the cell is missing / unparseable.
1389    pub fn model_type(&self) -> Option<u8> {
1390        self.base_item_info.as_ref().and_then(|b| b.model_type)
1391    }
1392
1393    /// Returns `true` when any decoded property on this item falls
1394    /// into the given [`PropertyKindFilter`] family.
1395    ///
1396    /// `Unknown` variants never match any filter; consumers wanting
1397    /// to inspect unknown property kinds should walk
1398    /// [`Self::properties`] directly. For exact-variant queries (a
1399    /// specific kind rather than a family), pattern-match against
1400    /// the typed [`DecodedProperty`] variants directly.
1401    pub fn has_property_kind(&self, filter: PropertyKindFilter) -> bool {
1402        self.decoded_properties
1403            .iter()
1404            .any(|prop| filter.matches(prop))
1405    }
1406
1407    /// Iterates this item's [`DecodedProperty::AbilityBonus`]
1408    /// properties, yielding `(subtype_id, magnitude)` pairs.
1409    ///
1410    /// `subtype_id` indexes into `iprp_abilities.2da` (vanilla rows
1411    /// 0..=5 cover STR/DEX/CON/INT/WIS/CHA). `magnitude` is the
1412    /// resolved bonus value from `iprp_bonuscost.2da#Value` at row
1413    /// `cost_value`, per the cost-table magnitude resolution
1414    /// subsection of `docs/src/formats/gff/uti.md`. The cost-table
1415    /// index is hardcoded by the engine handler for this kind, so the
1416    /// property's `cost_table` field is ignored.
1417    ///
1418    /// Properties whose magnitude could not be resolved at resolve
1419    /// time are silently skipped.
1420    pub fn ability_bonuses(&self) -> impl Iterator<Item = (u16, i32)> + '_ {
1421        self.iter_resolved_magnitudes(|prop| match prop {
1422            DecodedProperty::AbilityBonus { subtype_id, .. } => Some(*subtype_id),
1423            _ => None,
1424        })
1425    }
1426
1427    /// Iterates this item's [`DecodedProperty::DamageBonus`]
1428    /// properties, yielding `(subtype_id, magnitude)` pairs.
1429    ///
1430    /// `subtype_id` indexes into `iprp_damagetype.2da`. `magnitude`
1431    /// is `cost_value` taken directly: per the audit, the engine's
1432    /// `ApplyDamageBonus` handler bypasses cost-table dispatch and
1433    /// reads `CostValue` as the damage amount.
1434    ///
1435    /// Properties whose magnitude could not be resolved at resolve
1436    /// time are silently skipped.
1437    pub fn damage_bonuses(&self) -> impl Iterator<Item = (u16, i32)> + '_ {
1438        self.iter_resolved_magnitudes(|prop| match prop {
1439            DecodedProperty::DamageBonus { subtype_id, .. } => Some(*subtype_id),
1440            _ => None,
1441        })
1442    }
1443
1444    /// Iterates this item's [`DecodedProperty::DamageImmunity`]
1445    /// properties, yielding `(subtype_id, magnitude)` pairs.
1446    ///
1447    /// `subtype_id` indexes into `iprp_damagetype.2da` (which damage
1448    /// type the immunity applies to). `magnitude` is read by walking
1449    /// the dynamic cost-table dispatch chain
1450    /// (`iprp_costtable.2da[cost_table]#Name` -> per-cost 2DA at row
1451    /// `cost_value`, column `Value`), per the cost-table magnitude
1452    /// resolution subsection of `docs/src/formats/gff/uti.md`. The
1453    /// cost-table index is read from each property's `cost_table`
1454    /// field, so mod-extended cost tables resolve through the same
1455    /// path.
1456    ///
1457    /// Properties whose magnitude could not be resolved at resolve
1458    /// time are silently skipped.
1459    pub fn damage_immunities(&self) -> impl Iterator<Item = (u16, i32)> + '_ {
1460        self.iter_resolved_magnitudes(|prop| match prop {
1461            DecodedProperty::DamageImmunity { subtype_id, .. } => Some(*subtype_id),
1462            _ => None,
1463        })
1464    }
1465
1466    /// Shared iterator skeleton for the per-kind magnitude iterators.
1467    /// `pick_subtype` returns `Some(subtype_id)` for properties of
1468    /// the kind the caller wants, `None` otherwise. The combined
1469    /// iterator yields `(subtype_id, magnitude)` only when the kind
1470    /// matches and the resolved view has a magnitude for that
1471    /// property.
1472    fn iter_resolved_magnitudes<F>(&self, pick_subtype: F) -> impl Iterator<Item = (u16, i32)> + '_
1473    where
1474        F: Fn(&DecodedProperty) -> Option<u16> + 'static,
1475    {
1476        self.decoded_properties
1477            .iter()
1478            .zip(self.resolved_magnitudes.iter())
1479            .filter_map(move |(prop, magnitude)| {
1480                let subtype = pick_subtype(prop)?;
1481                let value = (*magnitude)?;
1482                Some((subtype, value))
1483            })
1484    }
1485}
1486
1487/// Categorical filter for [`UtiResolved::has_property_kind`].
1488///
1489/// Each variant maps to a family of typed [`DecodedProperty`]
1490/// variants that share a semantic theme (damage modification,
1491/// wielder restriction, active-use, etc.). The filters are not
1492/// exclusive: `DamageImmunity` and `DamageResistance` count under
1493/// [`PropertyKindFilter::Damage`], so an item with one or the other
1494/// matches the `Damage` filter.
1495///
1496/// Property kinds that do not naturally cluster with a family
1497/// (`OnHit`, `Light`, `BonusFeats`, `Skill`, `Immunity`,
1498/// `Regeneration*`, `Disguise`, `MagicResistBonus`, `Keen`,
1499/// `MassiveCriticals`, `BlasterBoltDeflectIncrease`,
1500/// `MonsterDamage`, `DamageNone`, `TrueSeeing`) are not surfaced by
1501/// any filter; callers needing them pattern-match the typed
1502/// variants directly via [`UtiResolved::properties`].
1503#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1504pub enum PropertyKindFilter {
1505    /// Any damage-family property: `DamageBonus`, `DamageImmunity`,
1506    /// `DamageResistance`, `DamageRacialGroup`,
1507    /// `DamageAlignmentGroup`, or `DamagePenalty`.
1508    Damage,
1509    /// Ability-score bonus (`AbilityBonus`).
1510    Ability,
1511    /// Saving-throw bonus or penalty, universal or specific:
1512    /// `SaveBonus`, `SaveBonusSpecific`, `SavePenalty`,
1513    /// `SavePenaltySpecific`.
1514    Save,
1515    /// Attack bonus or penalty: `AttackBonus`,
1516    /// `AttackBonusAlignmentGroup`, `AttackPenalty`.
1517    Attack,
1518    /// Enhancement bonus, flat or conditional: `EnhancementBonus`,
1519    /// `EnhancementRacialGroup`, `EnhancementAlignmentGroup`.
1520    Enhancement,
1521    /// Wielder-restriction property: `UseLimitationFeat`,
1522    /// `UseLimitationRacial`, `UseLimitationAlignmentGroup`.
1523    UseLimitation,
1524    /// Engine-active property routed into the per-character
1525    /// usable-ability table: `CastSpell`, `Trap`, `ThievesTools`,
1526    /// `ComputerSpike`.
1527    Active,
1528}
1529
1530impl PropertyKindFilter {
1531    fn matches(self, prop: &DecodedProperty) -> bool {
1532        match self {
1533            Self::Damage => matches!(
1534                prop,
1535                DecodedProperty::DamageBonus { .. }
1536                    | DecodedProperty::DamageImmunity { .. }
1537                    | DecodedProperty::DamageResistance { .. }
1538                    | DecodedProperty::DamageRacialGroup { .. }
1539                    | DecodedProperty::DamageAlignmentGroup { .. }
1540                    | DecodedProperty::DamagePenalty { .. }
1541            ),
1542            Self::Ability => matches!(prop, DecodedProperty::AbilityBonus { .. }),
1543            Self::Save => matches!(
1544                prop,
1545                DecodedProperty::SaveBonus { .. }
1546                    | DecodedProperty::SaveBonusSpecific { .. }
1547                    | DecodedProperty::SavePenalty { .. }
1548                    | DecodedProperty::SavePenaltySpecific { .. }
1549            ),
1550            Self::Attack => matches!(
1551                prop,
1552                DecodedProperty::AttackBonus { .. }
1553                    | DecodedProperty::AttackBonusAlignmentGroup { .. }
1554                    | DecodedProperty::AttackPenalty { .. }
1555            ),
1556            Self::Enhancement => matches!(
1557                prop,
1558                DecodedProperty::EnhancementBonus { .. }
1559                    | DecodedProperty::EnhancementRacialGroup { .. }
1560                    | DecodedProperty::EnhancementAlignmentGroup { .. }
1561            ),
1562            Self::UseLimitation => matches!(
1563                prop,
1564                DecodedProperty::UseLimitationFeat { .. }
1565                    | DecodedProperty::UseLimitationRacial { .. }
1566                    | DecodedProperty::UseLimitationAlignmentGroup { .. }
1567            ),
1568            Self::Active => matches!(
1569                prop,
1570                DecodedProperty::CastSpell { .. }
1571                    | DecodedProperty::Trap { .. }
1572                    | DecodedProperty::ThievesTools { .. }
1573                    | DecodedProperty::ComputerSpike { .. }
1574            ),
1575        }
1576    }
1577}
1578
1579impl Uti {
1580    /// Builds a [`UtiProjection`] of this UTI's properties using the
1581    /// supplied `itempropdef.2da`.
1582    ///
1583    /// Each `UtiProperty` is routed by its `itempropdef.2da` `label`
1584    /// to a typed [`DecodedProperty`] variant where one exists, or to
1585    /// [`DecodedProperty::Unknown`] otherwise. Pass `None` for
1586    /// `itempropdef` to surface every property as `Unknown` with
1587    /// `property_label = None`; the engine itself tolerates a
1588    /// missing table the same way.
1589    ///
1590    /// The projection is the cheap, scope-free intermediate. Use
1591    /// [`UtiProjection::resolve`] (or [`Uti::resolve`] for the
1592    /// single-scope shortcut) to resolve it against a per-scope
1593    /// context.
1594    pub fn project(&self, itempropdef: Option<&TwoDa>) -> UtiProjection<'_> {
1595        let decoded_properties = self
1596            .properties
1597            .iter()
1598            .map(|p| {
1599                let label = itempropdef
1600                    .and_then(|table| table.cell(usize::from(p.property_name), "label"))
1601                    .map(str::to_string);
1602                decode_property(p, label)
1603            })
1604            .collect();
1605        UtiProjection {
1606            uti: self,
1607            decoded_properties,
1608        }
1609    }
1610
1611    /// Builds a [`UtiResolved`] of this UTI against the given 2DA
1612    /// cache. Single-scope shortcut equivalent to
1613    /// `self.project(itempropdef).resolve(cache)`, where
1614    /// `itempropdef` comes from `cache`.
1615    ///
1616    /// Missing or malformed `itempropdef.2da` / `baseitems.2da` is
1617    /// tolerated -- the corresponding resolved state degrades
1618    /// gracefully (all properties land in `Unknown`; combat / equip
1619    /// queries return `false` / `None`).
1620    ///
1621    /// For multi-scope workflows (mod conflict analysis, vanilla vs
1622    /// modded diffs), call [`Uti::project`] once and
1623    /// [`UtiProjection::resolve`] per scope so the typed-variant
1624    /// dispatch is not redone for each context.
1625    pub fn resolve(&self, cache: &mut impl TwoDaSource) -> UtiResolved<'_> {
1626        // Inner block releases the itempropdef borrow before
1627        // `UtiProjection::resolve` re-borrows the cache for
1628        // baseitems.
1629        let projection = {
1630            let propdef = cache.twoda(tables::ITEMPROPDEF);
1631            self.project(propdef)
1632        };
1633        projection.resolve(cache)
1634    }
1635}
1636
1637/// Routes a single [`UtiProperty`] to a typed [`DecodedProperty`]
1638/// variant by matching on the `itempropdef.2da` `label` value
1639/// resolved at decode time.
1640///
1641/// Properties without a matching typed arm fall through to
1642/// [`DecodedProperty::Unknown`] carrying the raw fields plus the
1643/// resolved label.
1644fn decode_property(p: &UtiProperty, label: Option<String>) -> DecodedProperty {
1645    match label.as_deref() {
1646        Some("Ability") => DecodedProperty::AbilityBonus {
1647            property_id: p.property_name,
1648            subtype_id: p.subtype,
1649            cost_table: p.cost_table,
1650            cost_value: p.cost_value,
1651        },
1652        Some("ImprovedSavingThrows") => DecodedProperty::SaveBonus {
1653            property_id: p.property_name,
1654            subtype_id: p.subtype,
1655            cost_table: p.cost_table,
1656            cost_value: p.cost_value,
1657        },
1658        Some("ImprovedSavingThrowsSpecific") => DecodedProperty::SaveBonusSpecific {
1659            property_id: p.property_name,
1660            subtype_id: p.subtype,
1661            cost_table: p.cost_table,
1662            cost_value: p.cost_value,
1663        },
1664        Some("ReducedSavingThrows") => DecodedProperty::SavePenalty {
1665            property_id: p.property_name,
1666            subtype_id: p.subtype,
1667            cost_table: p.cost_table,
1668            cost_value: p.cost_value,
1669        },
1670        Some("ReducedSpecificSavingThrow") => DecodedProperty::SavePenaltySpecific {
1671            property_id: p.property_name,
1672            subtype_id: p.subtype,
1673            cost_table: p.cost_table,
1674            cost_value: p.cost_value,
1675        },
1676        Some("Damage") => DecodedProperty::DamageBonus {
1677            property_id: p.property_name,
1678            subtype_id: p.subtype,
1679            cost_table: p.cost_table,
1680            cost_value: p.cost_value,
1681        },
1682        Some("DamageImmunity") => DecodedProperty::DamageImmunity {
1683            property_id: p.property_name,
1684            subtype_id: p.subtype,
1685            cost_table: p.cost_table,
1686            cost_value: p.cost_value,
1687        },
1688        Some("DamageResist") => DecodedProperty::DamageResistance {
1689            property_id: p.property_name,
1690            subtype_id: p.subtype,
1691            cost_table: p.cost_table,
1692            cost_value: p.cost_value,
1693        },
1694        Some("Armor") => DecodedProperty::AcBonus {
1695            property_id: p.property_name,
1696            subtype_id: p.subtype,
1697            cost_table: p.cost_table,
1698            cost_value: p.cost_value,
1699        },
1700        Some("Enhancement") => DecodedProperty::EnhancementBonus {
1701            property_id: p.property_name,
1702            subtype_id: p.subtype,
1703            cost_table: p.cost_table,
1704            cost_value: p.cost_value,
1705        },
1706        Some("OnHit") => DecodedProperty::OnHit {
1707            property_id: p.property_name,
1708            subtype_id: p.subtype,
1709            cost_table: p.cost_table,
1710            cost_value: p.cost_value,
1711            param1: p.param1,
1712            param1_value: p.param1_value,
1713        },
1714        Some("CastSpell") => DecodedProperty::CastSpell {
1715            property_id: p.property_name,
1716            subtype_id: p.subtype,
1717            cost_table: p.cost_table,
1718            cost_value: p.cost_value,
1719            useable: active_useable(p.useable),
1720            uses_per_day: active_uses_per_day(p.uses_per_day),
1721        },
1722        Some("Trap") => DecodedProperty::Trap {
1723            property_id: p.property_name,
1724            subtype_id: p.subtype,
1725            cost_table: p.cost_table,
1726            cost_value: p.cost_value,
1727            useable: active_useable(p.useable),
1728            uses_per_day: active_uses_per_day(p.uses_per_day),
1729        },
1730        Some("ThievesTools") => DecodedProperty::ThievesTools {
1731            property_id: p.property_name,
1732            subtype_id: p.subtype,
1733            cost_table: p.cost_table,
1734            cost_value: p.cost_value,
1735            useable: active_useable(p.useable),
1736            uses_per_day: active_uses_per_day(p.uses_per_day),
1737        },
1738        Some("Computer_Spike") => DecodedProperty::ComputerSpike {
1739            property_id: p.property_name,
1740            subtype_id: p.subtype,
1741            cost_table: p.cost_table,
1742            cost_value: p.cost_value,
1743            useable: active_useable(p.useable),
1744            uses_per_day: active_uses_per_day(p.uses_per_day),
1745        },
1746        Some("Use_Limitation_Feat") => DecodedProperty::UseLimitationFeat {
1747            property_id: p.property_name,
1748            subtype_id: p.subtype,
1749            cost_table: p.cost_table,
1750            cost_value: p.cost_value,
1751        },
1752        Some("UseLimitationRacial") => DecodedProperty::UseLimitationRacial {
1753            property_id: p.property_name,
1754            subtype_id: p.subtype,
1755            cost_table: p.cost_table,
1756            cost_value: p.cost_value,
1757        },
1758        Some("UseLimitationAlignmentGroup") => DecodedProperty::UseLimitationAlignmentGroup {
1759            property_id: p.property_name,
1760            subtype_id: p.subtype,
1761            cost_table: p.cost_table,
1762            cost_value: p.cost_value,
1763        },
1764        Some("DamageRacialGroup") => DecodedProperty::DamageRacialGroup {
1765            property_id: p.property_name,
1766            subtype_id: p.subtype,
1767            cost_table: p.cost_table,
1768            cost_value: p.cost_value,
1769        },
1770        Some("DamageAlignmentGroup") => DecodedProperty::DamageAlignmentGroup {
1771            property_id: p.property_name,
1772            subtype_id: p.subtype,
1773            cost_table: p.cost_table,
1774            cost_value: p.cost_value,
1775        },
1776        Some("EnhancementRacialGroup") => DecodedProperty::EnhancementRacialGroup {
1777            property_id: p.property_name,
1778            subtype_id: p.subtype,
1779            cost_table: p.cost_table,
1780            cost_value: p.cost_value,
1781        },
1782        Some("EnhancementAlignmentGroup") => DecodedProperty::EnhancementAlignmentGroup {
1783            property_id: p.property_name,
1784            subtype_id: p.subtype,
1785            cost_table: p.cost_table,
1786            cost_value: p.cost_value,
1787        },
1788        Some("AttackBonusAlignmentGroup") => DecodedProperty::AttackBonusAlignmentGroup {
1789            property_id: p.property_name,
1790            subtype_id: p.subtype,
1791            cost_table: p.cost_table,
1792            cost_value: p.cost_value,
1793        },
1794        Some("True_Seeing") => DecodedProperty::TrueSeeing {
1795            property_id: p.property_name,
1796            subtype_id: p.subtype,
1797            cost_table: p.cost_table,
1798            cost_value: p.cost_value,
1799        },
1800        Some("Light") => DecodedProperty::Light {
1801            property_id: p.property_name,
1802            subtype_id: p.subtype,
1803            cost_table: p.cost_table,
1804            cost_value: p.cost_value,
1805            param1: p.param1,
1806            param1_value: p.param1_value,
1807        },
1808        Some("AttackBonus") => DecodedProperty::AttackBonus {
1809            property_id: p.property_name,
1810            subtype_id: p.subtype,
1811            cost_table: p.cost_table,
1812            cost_value: p.cost_value,
1813        },
1814        Some("Keen") => DecodedProperty::Keen {
1815            property_id: p.property_name,
1816            subtype_id: p.subtype,
1817            cost_table: p.cost_table,
1818            cost_value: p.cost_value,
1819        },
1820        Some("Massive_Criticals") => DecodedProperty::MassiveCriticals {
1821            property_id: p.property_name,
1822            subtype_id: p.subtype,
1823            cost_table: p.cost_table,
1824            cost_value: p.cost_value,
1825        },
1826        Some("Blaster_Bolt_Deflect_Increase") => DecodedProperty::BlasterBoltDeflectIncrease {
1827            property_id: p.property_name,
1828            subtype_id: p.subtype,
1829            cost_table: p.cost_table,
1830            cost_value: p.cost_value,
1831        },
1832        Some("Monster_damage") => DecodedProperty::MonsterDamage {
1833            property_id: p.property_name,
1834            subtype_id: p.subtype,
1835            cost_table: p.cost_table,
1836            cost_value: p.cost_value,
1837        },
1838        Some("BonusFeats") => DecodedProperty::BonusFeats {
1839            property_id: p.property_name,
1840            subtype_id: p.subtype,
1841            cost_table: p.cost_table,
1842            cost_value: p.cost_value,
1843        },
1844        Some("Immunity") => DecodedProperty::Immunity {
1845            property_id: p.property_name,
1846            subtype_id: p.subtype,
1847            cost_table: p.cost_table,
1848            cost_value: p.cost_value,
1849        },
1850        Some("Skill") => DecodedProperty::Skill {
1851            property_id: p.property_name,
1852            subtype_id: p.subtype,
1853            cost_table: p.cost_table,
1854            cost_value: p.cost_value,
1855        },
1856        Some("AttackPenalty") => DecodedProperty::AttackPenalty {
1857            property_id: p.property_name,
1858            subtype_id: p.subtype,
1859            cost_table: p.cost_table,
1860            cost_value: p.cost_value,
1861        },
1862        Some("DamagePenalty") => DecodedProperty::DamagePenalty {
1863            property_id: p.property_name,
1864            subtype_id: p.subtype,
1865            cost_table: p.cost_table,
1866            cost_value: p.cost_value,
1867        },
1868        Some("ImprovedMagicResist") => DecodedProperty::MagicResistBonus {
1869            property_id: p.property_name,
1870            subtype_id: p.subtype,
1871            cost_table: p.cost_table,
1872            cost_value: p.cost_value,
1873        },
1874        Some("DamageNone") => DecodedProperty::DamageNone {
1875            property_id: p.property_name,
1876            subtype_id: p.subtype,
1877            cost_table: p.cost_table,
1878            cost_value: p.cost_value,
1879        },
1880        Some("Regeneration") => DecodedProperty::Regeneration {
1881            property_id: p.property_name,
1882            subtype_id: p.subtype,
1883            cost_table: p.cost_table,
1884            cost_value: p.cost_value,
1885        },
1886        Some("Regeneration_Force_Points") => DecodedProperty::RegenerationForcePoints {
1887            property_id: p.property_name,
1888            subtype_id: p.subtype,
1889            cost_table: p.cost_table,
1890            cost_value: p.cost_value,
1891        },
1892        Some("Disguise") => DecodedProperty::Disguise {
1893            property_id: p.property_name,
1894            subtype_id: p.subtype,
1895            cost_table: p.cost_table,
1896            cost_value: p.cost_value,
1897        },
1898        // The itempropdef.2da rows below have no vanilla .uti
1899        // references per `vanilla-inspector uti property-stats` and
1900        // fall through to Unknown by design. Anyone needing one can
1901        // add a typed variant in minutes: the uti.md reference table
1902        // names each row's label and subtype 2DA.
1903        //
1904        // Grouped by family for promotion convenience:
1905        //   Armor-conditional        : rows 2, 3, 4
1906        //   AttackBonus-conditional  : row 40
1907        //   Damage extensions        : rows 16, 18, 22, 23
1908        //   Negative/decrease mirrors: rows 19, 20, 21, 41
1909        //   Use-limitation extras    : row 44
1910        //   Special properties       : 30, 42, 48, 50, 52, 56, 58
1911        //
1912        // Re-run `vanilla-inspector uti property-stats` before
1913        // promoting any row to confirm this list is still current.
1914        _ => DecodedProperty::Unknown {
1915            property_id: p.property_name,
1916            property_label: label,
1917            subtype: p.subtype,
1918            cost_table: p.cost_table,
1919            cost_value: p.cost_value,
1920            param1: p.param1,
1921            param1_value: p.param1_value,
1922        },
1923    }
1924}
1925
1926/// Coalesces the engine's "missing GFF field" default for active
1927/// properties. Per the Ghidra audit, when an active-property entry
1928/// omits `Useable`, the engine defaults the flag to `1` (active);
1929/// passive entries default it to `0`. Active variants carry that
1930/// default into the decoded model so consumers do not have to know
1931/// the kind-specific rule.
1932fn active_useable(raw: Option<bool>) -> bool {
1933    raw.unwrap_or(true)
1934}
1935
1936/// Coalesces the engine's `0xFF` "not set" sentinel on `UsesPerDay`
1937/// into `None`. Both an absent GFF field and an explicit `0xFF`
1938/// decode the same way; explicit non-sentinel values pass through
1939/// unchanged.
1940fn active_uses_per_day(raw: Option<u8>) -> Option<u8> {
1941    match raw {
1942        Some(0xFF) | None => None,
1943        Some(value) => Some(value),
1944    }
1945}
1946
1947#[cfg(test)]
1948mod tests {
1949    use super::*;
1950    use crate::decoded::test_tables::TestTables;
1951    use crate::uti::UtiProperty;
1952    use rakata_formats::twoda::{TwoDa, TwoDaRow};
1953
1954    fn property(property_name: u16, subtype: u16) -> UtiProperty {
1955        UtiProperty {
1956            cost_table: 1,
1957            cost_value: 5,
1958            param1: 0xFF,
1959            param1_value: 0,
1960            property_name,
1961            subtype,
1962            chance_appear: 100,
1963            useable: None,
1964            uses_per_day: None,
1965            upgrade_type: None,
1966        }
1967    }
1968
1969    fn itempropdef_with_labels(labels: &[(usize, &str)]) -> TwoDa {
1970        let max_row = labels.iter().map(|(idx, _)| *idx).max().unwrap_or(0);
1971        let mut rows = Vec::with_capacity(max_row + 1);
1972        for row_index in 0..=max_row {
1973            let label = labels
1974                .iter()
1975                .find(|(idx, _)| *idx == row_index)
1976                .map(|(_, label)| (*label).to_string())
1977                .unwrap_or_default();
1978            rows.push(TwoDaRow {
1979                label: row_index.to_string(),
1980                cells: vec![label],
1981            });
1982        }
1983        TwoDa {
1984            headers: vec!["label".to_string()],
1985            rows,
1986        }
1987    }
1988
1989    #[test]
1990    fn decodes_every_property_into_unknown_variant() {
1991        let uti = Uti {
1992            properties: vec![property(0, 1), property(7, 12), property(45, 0)],
1993            ..Uti::default()
1994        };
1995        let mut tables = TestTables::new();
1996
1997        let view = uti.resolve(&mut tables);
1998        assert_eq!(view.properties().len(), 3);
1999        assert!(view
2000            .decoded_properties
2001            .iter()
2002            .all(|p| matches!(p, DecodedProperty::Unknown { .. })));
2003    }
2004
2005    #[test]
2006    fn unknown_carries_raw_fields_through_unchanged() {
2007        let raw = property(0, 0);
2008        let mut shaped = raw.clone();
2009        shaped.property_name = 7;
2010        shaped.subtype = 12;
2011        shaped.cost_table = 3;
2012        shaped.cost_value = 99;
2013        shaped.param1 = 4;
2014        shaped.param1_value = 200;
2015        let uti = Uti {
2016            properties: vec![shaped.clone()],
2017            ..Uti::default()
2018        };
2019        let mut tables = TestTables::new();
2020
2021        let view = uti.resolve(&mut tables);
2022        let DecodedProperty::Unknown {
2023            property_id,
2024            subtype,
2025            cost_table,
2026            cost_value,
2027            param1,
2028            param1_value,
2029            ..
2030        } = &view.properties()[0]
2031        else {
2032            panic!("expected Unknown variant for unloaded itempropdef");
2033        };
2034        assert_eq!(*property_id, 7);
2035        assert_eq!(*subtype, 12);
2036        assert_eq!(*cost_table, 3);
2037        assert_eq!(*cost_value, 99);
2038        assert_eq!(*param1, 4);
2039        assert_eq!(*param1_value, 200);
2040    }
2041
2042    #[test]
2043    fn property_label_resolves_into_unknown_for_untyped_kinds() {
2044        // Synthetic labels with no matching typed routing arm; both
2045        // properties end up in `Unknown` carrying the resolved label.
2046        let table = itempropdef_with_labels(&[(0, "Test_Kind_A"), (7, "Test_Kind_B")]);
2047        let mut tables = TestTables::with("itempropdef", &table);
2048
2049        let uti = Uti {
2050            properties: vec![property(7, 0), property(0, 0)],
2051            ..Uti::default()
2052        };
2053        let view = uti.resolve(&mut tables);
2054
2055        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2056            panic!("expected Unknown variant for synthetic label");
2057        };
2058        assert_eq!(property_label.as_deref(), Some("Test_Kind_B"));
2059        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[1] else {
2060            panic!("expected Unknown variant for synthetic label");
2061        };
2062        assert_eq!(property_label.as_deref(), Some("Test_Kind_A"));
2063    }
2064
2065    #[test]
2066    fn property_label_is_none_when_row_is_absent() {
2067        // itempropdef has rows 0, 1 only; PropertyName 99 is past
2068        // the last row, so the label resolves to None and decode
2069        // routes to Unknown.
2070        let table = itempropdef_with_labels(&[(0, "Test_Kind_A"), (1, "Test_Kind_B")]);
2071        let mut tables = TestTables::with("itempropdef", &table);
2072
2073        let uti = Uti {
2074            properties: vec![property(99, 0)],
2075            ..Uti::default()
2076        };
2077        let view = uti.resolve(&mut tables);
2078
2079        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2080            panic!("expected Unknown variant for absent row");
2081        };
2082        assert!(property_label.is_none());
2083    }
2084
2085    #[test]
2086    fn property_label_is_none_when_itempropdef_is_missing() {
2087        // No source contains itempropdef.2da. Decode succeeds, every
2088        // property routes to Unknown with label = None.
2089        let mut tables = TestTables::new();
2090
2091        let uti = Uti {
2092            properties: vec![property(0, 0), property(7, 0)],
2093            ..Uti::default()
2094        };
2095        let view = uti.resolve(&mut tables);
2096
2097        for prop in view.properties() {
2098            let DecodedProperty::Unknown { property_label, .. } = prop else {
2099                panic!("expected Unknown variant when itempropdef is missing");
2100            };
2101            assert!(property_label.is_none());
2102        }
2103    }
2104
2105    #[test]
2106    fn mod_added_property_label_surfaces_via_unknown() {
2107        // A mod adds row 200 to itempropdef with a custom label that
2108        // matches no typed routing arm. Decode produces Unknown with
2109        // the mod's label preserved.
2110        let table = itempropdef_with_labels(&[(200, "FakeMod_GrantsCookies")]);
2111        let mut tables = TestTables::with("itempropdef", &table);
2112
2113        let uti = Uti {
2114            properties: vec![property(200, 5)],
2115            ..Uti::default()
2116        };
2117        let view = uti.resolve(&mut tables);
2118
2119        let DecodedProperty::Unknown {
2120            property_id,
2121            property_label,
2122            subtype,
2123            ..
2124        } = &view.properties()[0]
2125        else {
2126            panic!("expected Unknown variant for mod-added label");
2127        };
2128        assert_eq!(*property_id, 200);
2129        assert_eq!(property_label.as_deref(), Some("FakeMod_GrantsCookies"));
2130        assert_eq!(*subtype, 5);
2131    }
2132
2133    #[test]
2134    fn empty_property_list_decodes_to_empty_view() {
2135        let uti = Uti::default();
2136        let mut tables = TestTables::new();
2137
2138        let view = uti.resolve(&mut tables);
2139        assert!(view.properties().is_empty());
2140    }
2141
2142    #[test]
2143    fn ability_bonus_label_routes_to_typed_variant() {
2144        // Vanilla itempropdef row 0 carries the label `Ability`. The
2145        // decoder routes that property to the typed AbilityBonus
2146        // variant rather than Unknown.
2147        let table = itempropdef_with_labels(&[(0, "Ability")]);
2148        let mut tables = TestTables::with("itempropdef", &table);
2149
2150        let mut shaped = property(0, 2);
2151        shaped.cost_table = 1;
2152        shaped.cost_value = 4;
2153        let uti = Uti {
2154            properties: vec![shaped],
2155            ..Uti::default()
2156        };
2157        let view = uti.resolve(&mut tables);
2158
2159        let DecodedProperty::AbilityBonus {
2160            property_id,
2161            subtype_id,
2162            cost_table,
2163            cost_value,
2164        } = &view.properties()[0]
2165        else {
2166            panic!("expected AbilityBonus variant for `Ability` label");
2167        };
2168        assert_eq!(*property_id, 0);
2169        assert_eq!(*subtype_id, 2); // CON in vanilla iprp_abilities ordering
2170        assert_eq!(*cost_table, 1);
2171        assert_eq!(*cost_value, 4);
2172    }
2173
2174    #[test]
2175    fn ability_bonus_preserves_raw_subtype_for_mod_extended_rows() {
2176        // A mod adds a new ability subtype past the vanilla 0..=5
2177        // range. The decode still routes to AbilityBonus and the raw
2178        // subtype id passes through.
2179        let table = itempropdef_with_labels(&[(0, "Ability")]);
2180        let mut tables = TestTables::with("itempropdef", &table);
2181
2182        let uti = Uti {
2183            properties: vec![property(0, 99)],
2184            ..Uti::default()
2185        };
2186        let view = uti.resolve(&mut tables);
2187
2188        let DecodedProperty::AbilityBonus { subtype_id, .. } = &view.properties()[0] else {
2189            panic!("expected AbilityBonus variant for mod-extended subtype");
2190        };
2191        assert_eq!(*subtype_id, 99);
2192    }
2193
2194    #[test]
2195    fn ability_bonus_subtype_label_resolves_via_iprp_abilities() {
2196        // The subtype_label helper walks itempropdef.SubTypeResRef ->
2197        // iprp_abilities.label for an AbilityBonus variant.
2198        let propdef = itempropdef_with_subtypes(&[(0, "Ability", "iprp_abilities")]);
2199        let abilities = subtype_2da(&[
2200            (0, "STR"),
2201            (1, "DEX"),
2202            (2, "CON"),
2203            (3, "INT"),
2204            (4, "WIS"),
2205            (5, "CHA"),
2206        ]);
2207        let mut tables = TestTables::new();
2208        add_2da_entry(&mut tables, "itempropdef", &propdef);
2209        add_2da_entry(&mut tables, "iprp_abilities", &abilities);
2210
2211        let prop = DecodedProperty::AbilityBonus {
2212            property_id: 0,
2213            subtype_id: 3,
2214            cost_table: 0,
2215            cost_value: 0,
2216        };
2217        assert_eq!(prop.subtype_label(&mut tables).as_deref(), Some("INT"));
2218    }
2219
2220    #[test]
2221    fn save_bonus_label_routes_to_typed_variant() {
2222        // Vanilla itempropdef row 26 carries the label
2223        // `ImprovedSavingThrows`. The decoder routes that property to
2224        // the typed SaveBonus variant rather than Unknown.
2225        let table = itempropdef_with_labels(&[(26, "ImprovedSavingThrows")]);
2226        let mut tables = TestTables::with("itempropdef", &table);
2227
2228        let mut shaped = property(26, 1);
2229        shaped.cost_table = 2;
2230        shaped.cost_value = 3;
2231        let uti = Uti {
2232            properties: vec![shaped],
2233            ..Uti::default()
2234        };
2235        let view = uti.resolve(&mut tables);
2236
2237        let DecodedProperty::SaveBonus {
2238            property_id,
2239            subtype_id,
2240            cost_table,
2241            cost_value,
2242        } = &view.properties()[0]
2243        else {
2244            panic!("expected SaveBonus variant for `ImprovedSavingThrows` label");
2245        };
2246        assert_eq!(*property_id, 26);
2247        assert_eq!(*subtype_id, 1);
2248        assert_eq!(*cost_table, 2);
2249        assert_eq!(*cost_value, 3);
2250    }
2251
2252    #[test]
2253    fn save_bonus_preserves_raw_subtype_for_mod_extended_rows() {
2254        // A mod adds a save element past the vanilla iprp_saveelement
2255        // range. The decode still routes to SaveBonus and the raw
2256        // subtype id passes through.
2257        let table = itempropdef_with_labels(&[(26, "ImprovedSavingThrows")]);
2258        let mut tables = TestTables::with("itempropdef", &table);
2259
2260        let uti = Uti {
2261            properties: vec![property(26, 200)],
2262            ..Uti::default()
2263        };
2264        let view = uti.resolve(&mut tables);
2265
2266        let DecodedProperty::SaveBonus { subtype_id, .. } = &view.properties()[0] else {
2267            panic!("expected SaveBonus variant for mod-extended subtype");
2268        };
2269        assert_eq!(*subtype_id, 200);
2270    }
2271
2272    #[test]
2273    fn save_bonus_does_not_match_save_penalty_or_specific_kinds() {
2274        // The four save-throw labels split into four distinct typed
2275        // variants: SaveBonus (universal positive), SaveBonusSpecific
2276        // (per-throw positive), SavePenalty (universal negative),
2277        // SavePenaltySpecific (per-throw negative). This test pins
2278        // the routing for all four to catch any future regression
2279        // that collapses them or mis-routes one into another.
2280        let table = itempropdef_with_labels(&[
2281            (26, "ImprovedSavingThrows"),
2282            (27, "ImprovedSavingThrowsSpecific"),
2283            (33, "ReducedSavingThrows"),
2284            (34, "ReducedSpecificSavingThrow"),
2285        ]);
2286        let mut tables = TestTables::with("itempropdef", &table);
2287
2288        let uti = Uti {
2289            properties: vec![
2290                property(26, 0),
2291                property(27, 0),
2292                property(33, 0),
2293                property(34, 0),
2294            ],
2295            ..Uti::default()
2296        };
2297        let view = uti.resolve(&mut tables);
2298
2299        assert!(matches!(
2300            view.properties()[0],
2301            DecodedProperty::SaveBonus { .. }
2302        ));
2303        assert!(matches!(
2304            view.properties()[1],
2305            DecodedProperty::SaveBonusSpecific { .. }
2306        ));
2307        assert!(matches!(
2308            view.properties()[2],
2309            DecodedProperty::SavePenalty { .. }
2310        ));
2311        assert!(matches!(
2312            view.properties()[3],
2313            DecodedProperty::SavePenaltySpecific { .. }
2314        ));
2315    }
2316
2317    #[test]
2318    fn save_bonus_subtype_label_resolves_via_iprp_saveelement() {
2319        // The subtype_label helper walks itempropdef.SubTypeResRef ->
2320        // iprp_saveelement.label for a SaveBonus variant.
2321        let propdef =
2322            itempropdef_with_subtypes(&[(26, "ImprovedSavingThrows", "iprp_saveelement")]);
2323        let saveelement = subtype_2da(&[(0, "Universal"), (1, "Acid"), (2, "Cold")]);
2324        let mut tables = TestTables::new();
2325        add_2da_entry(&mut tables, "itempropdef", &propdef);
2326        add_2da_entry(&mut tables, "iprp_saveelement", &saveelement);
2327
2328        let prop = DecodedProperty::SaveBonus {
2329            property_id: 26,
2330            subtype_id: 2,
2331            cost_table: 0,
2332            cost_value: 0,
2333        };
2334        assert_eq!(prop.subtype_label(&mut tables).as_deref(), Some("Cold"));
2335    }
2336
2337    #[test]
2338    fn save_throw_family_subtype_labels_resolve_via_their_2das() {
2339        // SaveBonus / SavePenalty use `iprp_saveelement.2da` (the
2340        // universal element table); SaveBonusSpecific /
2341        // SavePenaltySpecific use `iprp_savingthrow.2da` (the
2342        // per-throw table). Verify each variant resolves through
2343        // the correct table.
2344        let propdef = itempropdef_with_subtypes(&[
2345            (26, "ImprovedSavingThrows", "iprp_saveelement"),
2346            (27, "ImprovedSavingThrowsSpecific", "iprp_savingthrow"),
2347            (33, "ReducedSavingThrows", "iprp_saveelement"),
2348            (34, "ReducedSpecificSavingThrow", "iprp_savingthrow"),
2349        ]);
2350        let saveelement = subtype_2da(&[(0, "Universal"), (1, "Acid"), (2, "Cold")]);
2351        let savingthrow = subtype_2da(&[(0, "Fortitude"), (1, "Reflex"), (2, "Will")]);
2352
2353        let mut tables = TestTables::new();
2354        add_2da_entry(&mut tables, "itempropdef", &propdef);
2355        add_2da_entry(&mut tables, "iprp_saveelement", &saveelement);
2356        add_2da_entry(&mut tables, "iprp_savingthrow", &savingthrow);
2357
2358        let specific_bonus = DecodedProperty::SaveBonusSpecific {
2359            property_id: 27,
2360            subtype_id: 2,
2361            cost_table: 0,
2362            cost_value: 0,
2363        };
2364        assert_eq!(
2365            specific_bonus.subtype_label(&mut tables).as_deref(),
2366            Some("Will")
2367        );
2368
2369        let universal_penalty = DecodedProperty::SavePenalty {
2370            property_id: 33,
2371            subtype_id: 1,
2372            cost_table: 0,
2373            cost_value: 0,
2374        };
2375        assert_eq!(
2376            universal_penalty.subtype_label(&mut tables).as_deref(),
2377            Some("Acid")
2378        );
2379
2380        let specific_penalty = DecodedProperty::SavePenaltySpecific {
2381            property_id: 34,
2382            subtype_id: 0,
2383            cost_table: 0,
2384            cost_value: 0,
2385        };
2386        assert_eq!(
2387            specific_penalty.subtype_label(&mut tables).as_deref(),
2388            Some("Fortitude")
2389        );
2390    }
2391
2392    #[test]
2393    fn damage_bonus_label_routes_to_typed_variant() {
2394        // Vanilla itempropdef row 11 carries the label `Damage`. The
2395        // decoder routes that property to the typed DamageBonus
2396        // variant rather than Unknown.
2397        let table = itempropdef_with_labels(&[(11, "Damage")]);
2398        let mut tables = TestTables::with("itempropdef", &table);
2399
2400        let mut shaped = property(11, 5);
2401        shaped.cost_table = 4;
2402        shaped.cost_value = 2;
2403        let uti = Uti {
2404            properties: vec![shaped],
2405            ..Uti::default()
2406        };
2407        let view = uti.resolve(&mut tables);
2408
2409        let DecodedProperty::DamageBonus {
2410            property_id,
2411            subtype_id,
2412            cost_table,
2413            cost_value,
2414        } = &view.properties()[0]
2415        else {
2416            panic!("expected DamageBonus variant for `Damage` label");
2417        };
2418        assert_eq!(*property_id, 11);
2419        assert_eq!(*subtype_id, 5);
2420        assert_eq!(*cost_table, 4);
2421        assert_eq!(*cost_value, 2);
2422    }
2423
2424    #[test]
2425    fn damage_bonus_does_not_match_vulnerability_or_unrelated_kinds() {
2426        // Vanilla row 18 (`Damage_Vulnerability`) sits adjacent to
2427        // `Damage` in itempropdef and shares the prefix. It must not
2428        // route to DamageBonus; the decoder leaves it in Unknown
2429        // until it gets its own typed variant. The conditional
2430        // siblings DamageAlignmentGroup (row 12) and DamageRacialGroup
2431        // (row 13) now have their own typed variants and are covered
2432        // elsewhere; this test focuses on the prefix-trap case.
2433        let table = itempropdef_with_labels(&[(18, "Damage_Vulnerability")]);
2434        let mut tables = TestTables::with("itempropdef", &table);
2435
2436        let uti = Uti {
2437            properties: vec![property(18, 0)],
2438            ..Uti::default()
2439        };
2440        let view = uti.resolve(&mut tables);
2441
2442        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2443            panic!("expected Unknown variant for `Damage_Vulnerability`");
2444        };
2445        assert_eq!(property_label.as_deref(), Some("Damage_Vulnerability"));
2446    }
2447
2448    #[test]
2449    fn damage_immunity_label_routes_to_typed_variant() {
2450        let table = itempropdef_with_labels(&[(14, "DamageImmunity")]);
2451        let mut tables = TestTables::with("itempropdef", &table);
2452
2453        let uti = Uti {
2454            properties: vec![property(14, 7)],
2455            ..Uti::default()
2456        };
2457        let view = uti.resolve(&mut tables);
2458
2459        let DecodedProperty::DamageImmunity {
2460            property_id,
2461            subtype_id,
2462            ..
2463        } = &view.properties()[0]
2464        else {
2465            panic!("expected DamageImmunity variant for `DamageImmunity` label");
2466        };
2467        assert_eq!(*property_id, 14);
2468        assert_eq!(*subtype_id, 7);
2469    }
2470
2471    #[test]
2472    fn damage_resistance_label_routes_to_typed_variant() {
2473        // Vanilla label is `DamageResist` (no `-ance` suffix), and
2474        // that's what the decoder must match.
2475        let table = itempropdef_with_labels(&[(17, "DamageResist")]);
2476        let mut tables = TestTables::with("itempropdef", &table);
2477
2478        let uti = Uti {
2479            properties: vec![property(17, 3)],
2480            ..Uti::default()
2481        };
2482        let view = uti.resolve(&mut tables);
2483
2484        let DecodedProperty::DamageResistance {
2485            property_id,
2486            subtype_id,
2487            ..
2488        } = &view.properties()[0]
2489        else {
2490            panic!("expected DamageResistance variant for `DamageResist` label");
2491        };
2492        assert_eq!(*property_id, 17);
2493        assert_eq!(*subtype_id, 3);
2494    }
2495
2496    #[test]
2497    fn damage_resistance_does_not_match_damage_reduced() {
2498        // Vanilla row 16 is `DamageReduced`, backed by
2499        // iprp_protection.2da rather than iprp_damagetype.2da. The
2500        // DamageResistance arm must not absorb it; the decoder routes
2501        // it to Unknown until it gets its own typed variant.
2502        let table = itempropdef_with_labels(&[(16, "DamageReduced")]);
2503        let mut tables = TestTables::with("itempropdef", &table);
2504
2505        let uti = Uti {
2506            properties: vec![property(16, 0)],
2507            ..Uti::default()
2508        };
2509        let view = uti.resolve(&mut tables);
2510
2511        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2512            panic!("expected Unknown variant for `DamageReduced`");
2513        };
2514        assert_eq!(property_label.as_deref(), Some("DamageReduced"));
2515    }
2516
2517    #[test]
2518    fn damage_family_subtype_labels_resolve_via_iprp_damagetype() {
2519        // All three damage-family variants share the iprp_damagetype
2520        // subtype 2DA. Verify the subtype_label helper resolves each
2521        // through that table.
2522        let propdef = itempropdef_with_subtypes(&[
2523            (11, "Damage", "iprp_damagetype"),
2524            (14, "DamageImmunity", "iprp_damagetype"),
2525            (17, "DamageResist", "iprp_damagetype"),
2526        ]);
2527        let damagetype = subtype_2da(&[
2528            (0, "Bludgeoning"),
2529            (1, "Slashing"),
2530            (2, "Piercing"),
2531            (5, "Acid"),
2532            (6, "Cold"),
2533        ]);
2534        let mut tables = TestTables::new();
2535        add_2da_entry(&mut tables, "itempropdef", &propdef);
2536        add_2da_entry(&mut tables, "iprp_damagetype", &damagetype);
2537
2538        let bonus = DecodedProperty::DamageBonus {
2539            property_id: 11,
2540            subtype_id: 5,
2541            cost_table: 0,
2542            cost_value: 0,
2543        };
2544        assert_eq!(bonus.subtype_label(&mut tables).as_deref(), Some("Acid"));
2545
2546        let immunity = DecodedProperty::DamageImmunity {
2547            property_id: 14,
2548            subtype_id: 6,
2549            cost_table: 0,
2550            cost_value: 0,
2551        };
2552        assert_eq!(immunity.subtype_label(&mut tables).as_deref(), Some("Cold"));
2553
2554        let resistance = DecodedProperty::DamageResistance {
2555            property_id: 17,
2556            subtype_id: 1,
2557            cost_table: 0,
2558            cost_value: 0,
2559        };
2560        assert_eq!(
2561            resistance.subtype_label(&mut tables).as_deref(),
2562            Some("Slashing")
2563        );
2564    }
2565
2566    #[test]
2567    fn ac_bonus_label_routes_to_typed_variant() {
2568        // Vanilla itempropdef row 1 carries the label `Armor`. The
2569        // decoder routes it to AcBonus. The property has no subtype
2570        // dimension; subtype_id is preserved for round-trip fidelity
2571        // even though the engine ignores it.
2572        let table = itempropdef_with_labels(&[(1, "Armor")]);
2573        let mut tables = TestTables::with("itempropdef", &table);
2574
2575        let mut shaped = property(1, 0);
2576        shaped.cost_table = 2;
2577        shaped.cost_value = 5;
2578        let uti = Uti {
2579            properties: vec![shaped],
2580            ..Uti::default()
2581        };
2582        let view = uti.resolve(&mut tables);
2583
2584        let DecodedProperty::AcBonus {
2585            property_id,
2586            subtype_id,
2587            cost_table,
2588            cost_value,
2589        } = &view.properties()[0]
2590        else {
2591            panic!("expected AcBonus variant for `Armor` label");
2592        };
2593        assert_eq!(*property_id, 1);
2594        assert_eq!(*subtype_id, 0);
2595        assert_eq!(*cost_table, 2);
2596        assert_eq!(*cost_value, 5);
2597    }
2598
2599    #[test]
2600    fn ac_bonus_does_not_match_armor_conditional_kinds() {
2601        // Vanilla rows 2 / 3 / 4 (`ArmorAlignmentGroup`,
2602        // `ArmorDamageType`, `ArmorRacialGroup`) sit adjacent to
2603        // `Armor` and start with the same prefix. None belong in
2604        // AcBonus; all must fall through to Unknown.
2605        let table = itempropdef_with_labels(&[
2606            (2, "ArmorAlignmentGroup"),
2607            (3, "ArmorDamageType"),
2608            (4, "ArmorRacialGroup"),
2609        ]);
2610        let mut tables = TestTables::with("itempropdef", &table);
2611
2612        let uti = Uti {
2613            properties: vec![property(2, 0), property(3, 0), property(4, 0)],
2614            ..Uti::default()
2615        };
2616        let view = uti.resolve(&mut tables);
2617
2618        for prop in view.properties() {
2619            assert!(
2620                matches!(prop, DecodedProperty::Unknown { .. }),
2621                "expected Unknown for non-Armor label, got {prop:?}"
2622            );
2623        }
2624    }
2625
2626    #[test]
2627    fn enhancement_bonus_label_routes_to_typed_variant() {
2628        let table = itempropdef_with_labels(&[(5, "Enhancement")]);
2629        let mut tables = TestTables::with("itempropdef", &table);
2630
2631        let mut shaped = property(5, 0);
2632        shaped.cost_table = 2;
2633        shaped.cost_value = 3;
2634        let uti = Uti {
2635            properties: vec![shaped],
2636            ..Uti::default()
2637        };
2638        let view = uti.resolve(&mut tables);
2639
2640        let DecodedProperty::EnhancementBonus {
2641            property_id,
2642            cost_table,
2643            cost_value,
2644            ..
2645        } = &view.properties()[0]
2646        else {
2647            panic!("expected EnhancementBonus variant for `Enhancement` label");
2648        };
2649        assert_eq!(*property_id, 5);
2650        assert_eq!(*cost_table, 2);
2651        assert_eq!(*cost_value, 3);
2652    }
2653
2654    #[test]
2655    fn enhancement_bonus_does_not_match_its_conditional_siblings() {
2656        // Rows 6 (`EnhancementAlignmentGroup`) and 7
2657        // (`EnhancementRacialGroup`) share the `Enhancement` prefix
2658        // but are distinct typed variants. The EnhancementBonus arm
2659        // must not absorb either; each routes to its own typed kind.
2660        let table = itempropdef_with_labels(&[
2661            (6, "EnhancementAlignmentGroup"),
2662            (7, "EnhancementRacialGroup"),
2663        ]);
2664        let mut tables = TestTables::with("itempropdef", &table);
2665
2666        let uti = Uti {
2667            properties: vec![property(6, 0), property(7, 0)],
2668            ..Uti::default()
2669        };
2670        let view = uti.resolve(&mut tables);
2671
2672        assert!(matches!(
2673            view.properties()[0],
2674            DecodedProperty::EnhancementAlignmentGroup { .. }
2675        ));
2676        assert!(matches!(
2677            view.properties()[1],
2678            DecodedProperty::EnhancementRacialGroup { .. }
2679        ));
2680    }
2681
2682    #[test]
2683    fn on_hit_label_routes_to_typed_variant_with_param_fields() {
2684        // Vanilla itempropdef row 32 carries the label `OnHit`. The
2685        // decoder routes it to OnHit and preserves the param fields
2686        // (which the engine consumes via iprp_paramtable.2da to convey
2687        // the effect's magnitude / DC / duration).
2688        let table = itempropdef_with_labels(&[(32, "OnHit")]);
2689        let mut tables = TestTables::with("itempropdef", &table);
2690
2691        let mut shaped = property(32, 4);
2692        shaped.cost_table = 25;
2693        shaped.cost_value = 1;
2694        shaped.param1 = 1;
2695        shaped.param1_value = 7;
2696        let uti = Uti {
2697            properties: vec![shaped],
2698            ..Uti::default()
2699        };
2700        let view = uti.resolve(&mut tables);
2701
2702        let DecodedProperty::OnHit {
2703            property_id,
2704            subtype_id,
2705            cost_table,
2706            cost_value,
2707            param1,
2708            param1_value,
2709        } = &view.properties()[0]
2710        else {
2711            panic!("expected OnHit variant for `OnHit` label");
2712        };
2713        assert_eq!(*property_id, 32);
2714        assert_eq!(*subtype_id, 4);
2715        assert_eq!(*cost_table, 25);
2716        assert_eq!(*cost_value, 1);
2717        assert_eq!(*param1, 1);
2718        assert_eq!(*param1_value, 7);
2719    }
2720
2721    #[test]
2722    fn on_hit_does_not_match_on_monster_hit() {
2723        // Vanilla row 48 is `OnMonsterHit`, a related but distinct
2724        // kind that the OnHit arm must not absorb.
2725        let table = itempropdef_with_labels(&[(48, "OnMonsterHit")]);
2726        let mut tables = TestTables::with("itempropdef", &table);
2727
2728        let uti = Uti {
2729            properties: vec![property(48, 0)],
2730            ..Uti::default()
2731        };
2732        let view = uti.resolve(&mut tables);
2733
2734        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2735            panic!("expected Unknown variant for `OnMonsterHit`");
2736        };
2737        assert_eq!(property_label.as_deref(), Some("OnMonsterHit"));
2738    }
2739
2740    #[test]
2741    fn on_hit_subtype_label_resolves_via_iprp_onhit() {
2742        let propdef = itempropdef_with_subtypes(&[(32, "OnHit", "iprp_onhit")]);
2743        let onhit = subtype_2da(&[(0, "Sleep"), (1, "Daze"), (4, "Stun")]);
2744        let mut tables = TestTables::new();
2745        add_2da_entry(&mut tables, "itempropdef", &propdef);
2746        add_2da_entry(&mut tables, "iprp_onhit", &onhit);
2747
2748        let prop = DecodedProperty::OnHit {
2749            property_id: 32,
2750            subtype_id: 4,
2751            cost_table: 0,
2752            cost_value: 0,
2753            param1: 0,
2754            param1_value: 0,
2755        };
2756        assert_eq!(prop.subtype_label(&mut tables).as_deref(), Some("Stun"));
2757    }
2758
2759    #[test]
2760    fn ac_bonus_subtype_label_returns_none_for_subtypeless_property() {
2761        // `Armor` carries no SubTypeResRef in vanilla. The helper must
2762        // short-circuit to None rather than treating subtype_id=0 as a
2763        // valid row index against some unrelated table.
2764        let propdef = itempropdef_with_subtypes(&[(1, "Armor", "")]);
2765        let mut tables = TestTables::with("itempropdef", &propdef);
2766
2767        let prop = DecodedProperty::AcBonus {
2768            property_id: 1,
2769            subtype_id: 0,
2770            cost_table: 2,
2771            cost_value: 5,
2772        };
2773        assert!(prop.subtype_label(&mut tables).is_none());
2774    }
2775
2776    fn active_property(property_name: u16, subtype: u16) -> UtiProperty {
2777        // Variant of `property` for active-property tests: starts with
2778        // every active-loop field at "GFF omitted" so each test can opt
2779        // into explicit values where it matters.
2780        let mut p = property(property_name, subtype);
2781        p.useable = None;
2782        p.uses_per_day = None;
2783        p
2784    }
2785
2786    #[test]
2787    fn cast_spell_label_routes_to_typed_variant_with_active_defaults() {
2788        // Vanilla itempropdef row 10 carries the label `CastSpell`.
2789        // The decoder routes it to CastSpell. With Useable and
2790        // UsesPerDay omitted in the GFF, the active-property
2791        // defaults apply: useable = true, uses_per_day = None.
2792        let table = itempropdef_with_labels(&[(10, "CastSpell")]);
2793        let mut tables = TestTables::with("itempropdef", &table);
2794
2795        let mut shaped = active_property(10, 42);
2796        shaped.cost_table = 3;
2797        shaped.cost_value = 7;
2798        let uti = Uti {
2799            properties: vec![shaped],
2800            ..Uti::default()
2801        };
2802        let view = uti.resolve(&mut tables);
2803
2804        let DecodedProperty::CastSpell {
2805            property_id,
2806            subtype_id,
2807            cost_table,
2808            cost_value,
2809            useable,
2810            uses_per_day,
2811        } = &view.properties()[0]
2812        else {
2813            panic!("expected CastSpell variant for `CastSpell` label");
2814        };
2815        assert_eq!(*property_id, 10);
2816        assert_eq!(*subtype_id, 42);
2817        assert_eq!(*cost_table, 3);
2818        assert_eq!(*cost_value, 7);
2819        assert!(*useable);
2820        assert!(uses_per_day.is_none());
2821    }
2822
2823    #[test]
2824    fn cast_spell_preserves_explicit_useable_false() {
2825        // The active-loop default only applies when the GFF omits
2826        // Useable. An explicit `Useable=false` must pass through; the
2827        // engine treats the item as inactive in that case.
2828        let table = itempropdef_with_labels(&[(10, "CastSpell")]);
2829        let mut tables = TestTables::with("itempropdef", &table);
2830
2831        let mut shaped = active_property(10, 0);
2832        shaped.useable = Some(false);
2833        let uti = Uti {
2834            properties: vec![shaped],
2835            ..Uti::default()
2836        };
2837        let view = uti.resolve(&mut tables);
2838
2839        let DecodedProperty::CastSpell { useable, .. } = &view.properties()[0] else {
2840            panic!("expected CastSpell variant");
2841        };
2842        assert!(!*useable);
2843    }
2844
2845    #[test]
2846    fn cast_spell_coalesces_uses_per_day_sentinel_into_none() {
2847        // The engine's `0xFF` sentinel means "not set"; both an
2848        // omitted UsesPerDay and an explicit `0xFF` decode to None.
2849        // A non-sentinel cap passes through as Some(N).
2850        let table = itempropdef_with_labels(&[(10, "CastSpell")]);
2851        let mut tables = TestTables::with("itempropdef", &table);
2852
2853        let mut sentinel = active_property(10, 0);
2854        sentinel.uses_per_day = Some(0xFF);
2855        let mut explicit = active_property(10, 0);
2856        explicit.uses_per_day = Some(3);
2857        let uti = Uti {
2858            properties: vec![sentinel, explicit],
2859            ..Uti::default()
2860        };
2861        let view = uti.resolve(&mut tables);
2862
2863        let DecodedProperty::CastSpell { uses_per_day, .. } = &view.properties()[0] else {
2864            panic!("expected CastSpell variant for sentinel");
2865        };
2866        assert!(uses_per_day.is_none());
2867
2868        let DecodedProperty::CastSpell { uses_per_day, .. } = &view.properties()[1] else {
2869            panic!("expected CastSpell variant for explicit cap");
2870        };
2871        assert_eq!(*uses_per_day, Some(3));
2872    }
2873
2874    #[test]
2875    fn cast_spell_subtype_label_resolves_via_spells_2da() {
2876        let propdef = itempropdef_with_subtypes(&[(10, "CastSpell", "spells")]);
2877        let spells = subtype_2da(&[(0, "Cure_Wounds"), (5, "Force_Push")]);
2878        let mut tables = TestTables::new();
2879        add_2da_entry(&mut tables, "itempropdef", &propdef);
2880        add_2da_entry(&mut tables, "spells", &spells);
2881
2882        let prop = DecodedProperty::CastSpell {
2883            property_id: 10,
2884            subtype_id: 5,
2885            cost_table: 0,
2886            cost_value: 0,
2887            useable: true,
2888            uses_per_day: None,
2889        };
2890        assert_eq!(
2891            prop.subtype_label(&mut tables).as_deref(),
2892            Some("Force_Push")
2893        );
2894    }
2895
2896    #[test]
2897    fn trap_label_routes_to_typed_variant() {
2898        let table = itempropdef_with_labels(&[(46, "Trap")]);
2899        let mut tables = TestTables::with("itempropdef", &table);
2900
2901        let mut shaped = active_property(46, 2);
2902        shaped.uses_per_day = Some(1);
2903        let uti = Uti {
2904            properties: vec![shaped],
2905            ..Uti::default()
2906        };
2907        let view = uti.resolve(&mut tables);
2908
2909        let DecodedProperty::Trap {
2910            property_id,
2911            subtype_id,
2912            useable,
2913            uses_per_day,
2914            ..
2915        } = &view.properties()[0]
2916        else {
2917            panic!("expected Trap variant for `Trap` label");
2918        };
2919        assert_eq!(*property_id, 46);
2920        assert_eq!(*subtype_id, 2);
2921        assert!(*useable);
2922        assert_eq!(*uses_per_day, Some(1));
2923    }
2924
2925    #[test]
2926    fn trap_subtype_label_resolves_via_traps_2da() {
2927        let propdef = itempropdef_with_subtypes(&[(46, "Trap", "traps")]);
2928        let traps = subtype_2da(&[(0, "Minor_Frag"), (3, "Deadly_Plasma")]);
2929        let mut tables = TestTables::new();
2930        add_2da_entry(&mut tables, "itempropdef", &propdef);
2931        add_2da_entry(&mut tables, "traps", &traps);
2932
2933        let prop = DecodedProperty::Trap {
2934            property_id: 46,
2935            subtype_id: 3,
2936            cost_table: 0,
2937            cost_value: 0,
2938            useable: true,
2939            uses_per_day: Some(1),
2940        };
2941        assert_eq!(
2942            prop.subtype_label(&mut tables).as_deref(),
2943            Some("Deadly_Plasma")
2944        );
2945    }
2946
2947    #[test]
2948    fn thieves_tools_label_routes_to_typed_variant_with_active_defaults() {
2949        // Vanilla itempropdef row 37 carries the label `ThievesTools`.
2950        // The decoder routes it to ThievesTools and applies the
2951        // active-property defaults (useable = true when GFF omits
2952        // Useable, uses_per_day = None for the 0xFF sentinel).
2953        let table = itempropdef_with_labels(&[(37, "ThievesTools")]);
2954        let mut tables = TestTables::with("itempropdef", &table);
2955
2956        let mut shaped = active_property(37, 0);
2957        shaped.cost_table = 1;
2958        shaped.cost_value = 4;
2959        shaped.uses_per_day = Some(0xFF);
2960        let uti = Uti {
2961            properties: vec![shaped],
2962            ..Uti::default()
2963        };
2964        let view = uti.resolve(&mut tables);
2965
2966        let DecodedProperty::ThievesTools {
2967            property_id,
2968            cost_table,
2969            cost_value,
2970            useable,
2971            uses_per_day,
2972            ..
2973        } = &view.properties()[0]
2974        else {
2975            panic!("expected ThievesTools variant for `ThievesTools` label");
2976        };
2977        assert_eq!(*property_id, 37);
2978        assert_eq!(*cost_table, 1);
2979        assert_eq!(*cost_value, 4);
2980        assert!(*useable);
2981        assert!(uses_per_day.is_none());
2982    }
2983
2984    #[test]
2985    fn thieves_tools_subtype_label_returns_none_for_subtypeless_property() {
2986        // ThievesTools carries no SubTypeResRef in vanilla. The
2987        // helper must short-circuit to None rather than dispatching
2988        // against an unrelated table.
2989        let propdef = itempropdef_with_subtypes(&[(37, "ThievesTools", "")]);
2990        let mut tables = TestTables::with("itempropdef", &propdef);
2991
2992        let prop = DecodedProperty::ThievesTools {
2993            property_id: 37,
2994            subtype_id: 0,
2995            cost_table: 1,
2996            cost_value: 4,
2997            useable: true,
2998            uses_per_day: None,
2999        };
3000        assert!(prop.subtype_label(&mut tables).is_none());
3001    }
3002
3003    #[test]
3004    fn computer_spike_label_routes_to_typed_variant() {
3005        // Vanilla itempropdef row 53 carries the label
3006        // `Computer_Spike` (with underscore). The decoder routes it
3007        // to ComputerSpike. Note the Rust variant drops the
3008        // underscore per Rust naming conventions, but the dispatch
3009        // matches the verbatim vanilla label.
3010        let table = itempropdef_with_labels(&[(53, "Computer_Spike")]);
3011        let mut tables = TestTables::with("itempropdef", &table);
3012
3013        let mut shaped = active_property(53, 0);
3014        shaped.cost_table = 1;
3015        shaped.cost_value = 5;
3016        shaped.useable = Some(true);
3017        shaped.uses_per_day = Some(2);
3018        let uti = Uti {
3019            properties: vec![shaped],
3020            ..Uti::default()
3021        };
3022        let view = uti.resolve(&mut tables);
3023
3024        let DecodedProperty::ComputerSpike {
3025            property_id,
3026            cost_table,
3027            cost_value,
3028            useable,
3029            uses_per_day,
3030            ..
3031        } = &view.properties()[0]
3032        else {
3033            panic!("expected ComputerSpike variant for `Computer_Spike` label");
3034        };
3035        assert_eq!(*property_id, 53);
3036        assert_eq!(*cost_table, 1);
3037        assert_eq!(*cost_value, 5);
3038        assert!(*useable);
3039        assert_eq!(*uses_per_day, Some(2));
3040    }
3041
3042    #[test]
3043    fn computer_spike_subtype_label_returns_none_for_subtypeless_property() {
3044        let propdef = itempropdef_with_subtypes(&[(53, "Computer_Spike", "")]);
3045        let mut tables = TestTables::with("itempropdef", &propdef);
3046
3047        let prop = DecodedProperty::ComputerSpike {
3048            property_id: 53,
3049            subtype_id: 0,
3050            cost_table: 1,
3051            cost_value: 5,
3052            useable: true,
3053            uses_per_day: Some(2),
3054        };
3055        assert!(prop.subtype_label(&mut tables).is_none());
3056    }
3057
3058    #[test]
3059    fn attack_bonus_label_routes_to_typed_variant() {
3060        // Vanilla itempropdef row 38 carries the label `AttackBonus`.
3061        // The decoder routes that property to AttackBonus.
3062        let table = itempropdef_with_labels(&[(38, "AttackBonus")]);
3063        let mut tables = TestTables::with("itempropdef", &table);
3064
3065        let mut shaped = property(38, 0);
3066        shaped.cost_table = 2;
3067        shaped.cost_value = 3;
3068        let uti = Uti {
3069            properties: vec![shaped],
3070            ..Uti::default()
3071        };
3072        let view = uti.resolve(&mut tables);
3073
3074        let DecodedProperty::AttackBonus {
3075            property_id,
3076            cost_table,
3077            cost_value,
3078            ..
3079        } = &view.properties()[0]
3080        else {
3081            panic!("expected AttackBonus variant for `AttackBonus` label");
3082        };
3083        assert_eq!(*property_id, 38);
3084        assert_eq!(*cost_table, 2);
3085        assert_eq!(*cost_value, 3);
3086    }
3087
3088    #[test]
3089    fn attack_bonus_does_not_match_its_conditional_siblings() {
3090        // Vanilla row 39 (`AttackBonusAlignmentGroup`) has corpus
3091        // usage and is now a typed variant; row 40
3092        // (`AttackBonusRacialGroup`) has zero corpus usage and stays
3093        // in Unknown. The plain `AttackBonus` arm must not absorb
3094        // either: row 39 routes to its own typed kind, row 40 to
3095        // Unknown.
3096        let table = itempropdef_with_labels(&[
3097            (39, "AttackBonusAlignmentGroup"),
3098            (40, "AttackBonusRacialGroup"),
3099        ]);
3100        let mut tables = TestTables::with("itempropdef", &table);
3101
3102        let uti = Uti {
3103            properties: vec![property(39, 0), property(40, 0)],
3104            ..Uti::default()
3105        };
3106        let view = uti.resolve(&mut tables);
3107
3108        assert!(matches!(
3109            view.properties()[0],
3110            DecodedProperty::AttackBonusAlignmentGroup { .. }
3111        ));
3112        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[1] else {
3113            panic!("expected Unknown for `AttackBonusRacialGroup`");
3114        };
3115        assert_eq!(property_label.as_deref(), Some("AttackBonusRacialGroup"));
3116    }
3117
3118    #[test]
3119    fn keen_label_routes_to_typed_variant() {
3120        let table = itempropdef_with_labels(&[(28, "Keen")]);
3121        let mut tables = TestTables::with("itempropdef", &table);
3122
3123        let uti = Uti {
3124            properties: vec![property(28, 0)],
3125            ..Uti::default()
3126        };
3127        let view = uti.resolve(&mut tables);
3128
3129        let DecodedProperty::Keen { property_id, .. } = &view.properties()[0] else {
3130            panic!("expected Keen variant for `Keen` label");
3131        };
3132        assert_eq!(*property_id, 28);
3133    }
3134
3135    #[test]
3136    fn massive_criticals_label_routes_to_typed_variant() {
3137        // Vanilla label has an underscore (`Massive_Criticals`); the
3138        // Rust variant drops it per naming convention but the
3139        // dispatch matches the underscored form verbatim.
3140        let table = itempropdef_with_labels(&[(49, "Massive_Criticals")]);
3141        let mut tables = TestTables::with("itempropdef", &table);
3142
3143        let uti = Uti {
3144            properties: vec![property(49, 0)],
3145            ..Uti::default()
3146        };
3147        let view = uti.resolve(&mut tables);
3148
3149        let DecodedProperty::MassiveCriticals { property_id, .. } = &view.properties()[0] else {
3150            panic!("expected MassiveCriticals variant for `Massive_Criticals` label");
3151        };
3152        assert_eq!(*property_id, 49);
3153    }
3154
3155    #[test]
3156    fn blaster_bolt_deflect_increase_label_routes_to_typed_variant() {
3157        let table = itempropdef_with_labels(&[(55, "Blaster_Bolt_Deflect_Increase")]);
3158        let mut tables = TestTables::with("itempropdef", &table);
3159
3160        let uti = Uti {
3161            properties: vec![property(55, 0)],
3162            ..Uti::default()
3163        };
3164        let view = uti.resolve(&mut tables);
3165
3166        let DecodedProperty::BlasterBoltDeflectIncrease { property_id, .. } = &view.properties()[0]
3167        else {
3168            panic!("expected BlasterBoltDeflectIncrease variant for the vanilla label");
3169        };
3170        assert_eq!(*property_id, 55);
3171    }
3172
3173    #[test]
3174    fn blaster_bolt_deflect_does_not_absorb_vanilla_typo_sibling() {
3175        // Vanilla row 56 is `Blaster_Bolt_Defect_Decrease` (note the
3176        // typo: `Defect` rather than `Deflect`). It has near-zero
3177        // corpus usage and stays in Unknown by design. The
3178        // BlasterBoltDeflectIncrease arm must not absorb it; the
3179        // dispatch matches the exact vanilla spellings, not
3180        // normalized forms.
3181        let table = itempropdef_with_labels(&[(56, "Blaster_Bolt_Defect_Decrease")]);
3182        let mut tables = TestTables::with("itempropdef", &table);
3183
3184        let uti = Uti {
3185            properties: vec![property(56, 0)],
3186            ..Uti::default()
3187        };
3188        let view = uti.resolve(&mut tables);
3189
3190        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
3191            panic!("expected Unknown for the vanilla-typo sibling");
3192        };
3193        assert_eq!(
3194            property_label.as_deref(),
3195            Some("Blaster_Bolt_Defect_Decrease")
3196        );
3197    }
3198
3199    #[test]
3200    fn monster_damage_label_routes_to_typed_variant() {
3201        // Vanilla label uses lowercase `d` (`Monster_damage`); the
3202        // dispatch matches that exact spelling.
3203        let table = itempropdef_with_labels(&[(51, "Monster_damage")]);
3204        let mut tables = TestTables::with("itempropdef", &table);
3205
3206        let uti = Uti {
3207            properties: vec![property(51, 0)],
3208            ..Uti::default()
3209        };
3210        let view = uti.resolve(&mut tables);
3211
3212        let DecodedProperty::MonsterDamage { property_id, .. } = &view.properties()[0] else {
3213            panic!("expected MonsterDamage variant for `Monster_damage` label");
3214        };
3215        assert_eq!(*property_id, 51);
3216    }
3217
3218    #[test]
3219    fn subtypeless_single_magnitude_variants_short_circuit_subtype_label() {
3220        // None of the new subtypeless variants carry a SubTypeResRef
3221        // in vanilla, so the helper must short-circuit to None
3222        // rather than dispatching against an unrelated table.
3223        let propdef = itempropdef_with_subtypes(&[
3224            (28, "Keen", ""),
3225            (38, "AttackBonus", ""),
3226            (49, "Massive_Criticals", ""),
3227            (51, "Monster_damage", ""),
3228            (55, "Blaster_Bolt_Deflect_Increase", ""),
3229        ]);
3230        let mut tables = TestTables::with("itempropdef", &propdef);
3231
3232        for prop in [
3233            DecodedProperty::Keen {
3234                property_id: 28,
3235                subtype_id: 0,
3236                cost_table: 0,
3237                cost_value: 0,
3238            },
3239            DecodedProperty::AttackBonus {
3240                property_id: 38,
3241                subtype_id: 0,
3242                cost_table: 0,
3243                cost_value: 0,
3244            },
3245            DecodedProperty::MassiveCriticals {
3246                property_id: 49,
3247                subtype_id: 0,
3248                cost_table: 0,
3249                cost_value: 0,
3250            },
3251            DecodedProperty::MonsterDamage {
3252                property_id: 51,
3253                subtype_id: 0,
3254                cost_table: 0,
3255                cost_value: 0,
3256            },
3257            DecodedProperty::BlasterBoltDeflectIncrease {
3258                property_id: 55,
3259                subtype_id: 0,
3260                cost_table: 0,
3261                cost_value: 0,
3262            },
3263        ] {
3264            assert!(
3265                prop.subtype_label(&mut tables).is_none(),
3266                "expected None subtype_label for subtypeless variant, got {prop:?}"
3267            );
3268        }
3269    }
3270
3271    #[test]
3272    fn bonus_feats_label_routes_to_typed_variant() {
3273        let table = itempropdef_with_labels(&[(9, "BonusFeats")]);
3274        let mut tables = TestTables::with("itempropdef", &table);
3275
3276        let uti = Uti {
3277            properties: vec![property(9, 12)],
3278            ..Uti::default()
3279        };
3280        let view = uti.resolve(&mut tables);
3281
3282        let DecodedProperty::BonusFeats {
3283            property_id,
3284            subtype_id,
3285            ..
3286        } = &view.properties()[0]
3287        else {
3288            panic!("expected BonusFeats variant for `BonusFeats` label");
3289        };
3290        assert_eq!(*property_id, 9);
3291        assert_eq!(*subtype_id, 12);
3292    }
3293
3294    #[test]
3295    fn immunity_label_routes_to_typed_variant() {
3296        let table = itempropdef_with_labels(&[(24, "Immunity")]);
3297        let mut tables = TestTables::with("itempropdef", &table);
3298
3299        let uti = Uti {
3300            properties: vec![property(24, 5)],
3301            ..Uti::default()
3302        };
3303        let view = uti.resolve(&mut tables);
3304
3305        let DecodedProperty::Immunity {
3306            property_id,
3307            subtype_id,
3308            ..
3309        } = &view.properties()[0]
3310        else {
3311            panic!("expected Immunity variant for `Immunity` label");
3312        };
3313        assert_eq!(*property_id, 24);
3314        assert_eq!(*subtype_id, 5);
3315    }
3316
3317    #[test]
3318    fn skill_label_routes_to_typed_variant() {
3319        let table = itempropdef_with_labels(&[(36, "Skill")]);
3320        let mut tables = TestTables::with("itempropdef", &table);
3321
3322        let uti = Uti {
3323            properties: vec![property(36, 2)],
3324            ..Uti::default()
3325        };
3326        let view = uti.resolve(&mut tables);
3327
3328        let DecodedProperty::Skill {
3329            property_id,
3330            subtype_id,
3331            ..
3332        } = &view.properties()[0]
3333        else {
3334            panic!("expected Skill variant for `Skill` label");
3335        };
3336        assert_eq!(*property_id, 36);
3337        assert_eq!(*subtype_id, 2);
3338    }
3339
3340    #[test]
3341    fn skill_does_not_match_decreased_skill_sibling() {
3342        // Vanilla row 21 is `DecreasedSkill`, also backed by
3343        // `skills.2da`. It has zero corpus usage in vanilla items
3344        // and stays in Unknown until a consumer asks; the Skill arm
3345        // must not absorb it.
3346        let table = itempropdef_with_labels(&[(21, "DecreasedSkill")]);
3347        let mut tables = TestTables::with("itempropdef", &table);
3348
3349        let uti = Uti {
3350            properties: vec![property(21, 0)],
3351            ..Uti::default()
3352        };
3353        let view = uti.resolve(&mut tables);
3354
3355        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
3356            panic!("expected Unknown for `DecreasedSkill`");
3357        };
3358        assert_eq!(property_label.as_deref(), Some("DecreasedSkill"));
3359    }
3360
3361    #[test]
3362    fn misc_2da_backed_singleton_subtype_labels_resolve_via_their_2das() {
3363        // BonusFeats -> feat.2da, Immunity -> iprp_immunity.2da,
3364        // Skill -> skills.2da. Verify each resolves through the
3365        // correct table.
3366        let propdef = itempropdef_with_subtypes(&[
3367            (9, "BonusFeats", "feat"),
3368            (24, "Immunity", "iprp_immunity"),
3369            (36, "Skill", "skills"),
3370        ]);
3371        let feats = subtype_2da(&[(0, "Toughness"), (12, "Force_Sensitive")]);
3372        let immunities = subtype_2da(&[(0, "Mind_Affecting"), (5, "Paralysis")]);
3373        let skills = subtype_2da(&[(0, "Computer_Use"), (2, "Persuade")]);
3374
3375        let mut tables = TestTables::new();
3376        add_2da_entry(&mut tables, "itempropdef", &propdef);
3377        add_2da_entry(&mut tables, "feat", &feats);
3378        add_2da_entry(&mut tables, "iprp_immunity", &immunities);
3379        add_2da_entry(&mut tables, "skills", &skills);
3380
3381        let feat = DecodedProperty::BonusFeats {
3382            property_id: 9,
3383            subtype_id: 12,
3384            cost_table: 0,
3385            cost_value: 0,
3386        };
3387        assert_eq!(
3388            feat.subtype_label(&mut tables).as_deref(),
3389            Some("Force_Sensitive")
3390        );
3391
3392        let immunity = DecodedProperty::Immunity {
3393            property_id: 24,
3394            subtype_id: 5,
3395            cost_table: 0,
3396            cost_value: 0,
3397        };
3398        assert_eq!(
3399            immunity.subtype_label(&mut tables).as_deref(),
3400            Some("Paralysis")
3401        );
3402
3403        let skill = DecodedProperty::Skill {
3404            property_id: 36,
3405            subtype_id: 2,
3406            cost_table: 0,
3407            cost_value: 0,
3408        };
3409        assert_eq!(
3410            skill.subtype_label(&mut tables).as_deref(),
3411            Some("Persuade")
3412        );
3413    }
3414
3415    #[test]
3416    fn use_limitation_feat_label_routes_to_typed_variant() {
3417        // Vanilla itempropdef row 57 carries the label
3418        // `Use_Limitation_Feat` (with underscores). The decoder
3419        // routes that property to UseLimitationFeat with the raw
3420        // feat row as subtype.
3421        let table = itempropdef_with_labels(&[(57, "Use_Limitation_Feat")]);
3422        let mut tables = TestTables::with("itempropdef", &table);
3423
3424        let uti = Uti {
3425            properties: vec![property(57, 42)],
3426            ..Uti::default()
3427        };
3428        let view = uti.resolve(&mut tables);
3429
3430        let DecodedProperty::UseLimitationFeat {
3431            property_id,
3432            subtype_id,
3433            ..
3434        } = &view.properties()[0]
3435        else {
3436            panic!("expected UseLimitationFeat variant for `Use_Limitation_Feat` label");
3437        };
3438        assert_eq!(*property_id, 57);
3439        assert_eq!(*subtype_id, 42);
3440    }
3441
3442    #[test]
3443    fn use_limitation_racial_label_routes_to_typed_variant() {
3444        let table = itempropdef_with_labels(&[(45, "UseLimitationRacial")]);
3445        let mut tables = TestTables::with("itempropdef", &table);
3446
3447        let uti = Uti {
3448            properties: vec![property(45, 3)],
3449            ..Uti::default()
3450        };
3451        let view = uti.resolve(&mut tables);
3452
3453        let DecodedProperty::UseLimitationRacial {
3454            property_id,
3455            subtype_id,
3456            ..
3457        } = &view.properties()[0]
3458        else {
3459            panic!("expected UseLimitationRacial variant for `UseLimitationRacial` label");
3460        };
3461        assert_eq!(*property_id, 45);
3462        assert_eq!(*subtype_id, 3);
3463    }
3464
3465    #[test]
3466    fn use_limitation_alignment_group_label_routes_to_typed_variant() {
3467        let table = itempropdef_with_labels(&[(43, "UseLimitationAlignmentGroup")]);
3468        let mut tables = TestTables::with("itempropdef", &table);
3469
3470        let uti = Uti {
3471            properties: vec![property(43, 2)],
3472            ..Uti::default()
3473        };
3474        let view = uti.resolve(&mut tables);
3475
3476        let DecodedProperty::UseLimitationAlignmentGroup {
3477            property_id,
3478            subtype_id,
3479            ..
3480        } = &view.properties()[0]
3481        else {
3482            panic!(
3483                "expected UseLimitationAlignmentGroup variant for \
3484                 `UseLimitationAlignmentGroup` label"
3485            );
3486        };
3487        assert_eq!(*property_id, 43);
3488        assert_eq!(*subtype_id, 2);
3489    }
3490
3491    #[test]
3492    fn use_limitation_does_not_match_unused_class_sibling() {
3493        // Vanilla row 44 is `UseLimitationClass`, the fourth member
3494        // of the family. It has zero corpus usage and is intentionally
3495        // left in Unknown until a consumer asks. The other
3496        // use-limitation arms must not absorb it.
3497        let table = itempropdef_with_labels(&[(44, "UseLimitationClass")]);
3498        let mut tables = TestTables::with("itempropdef", &table);
3499
3500        let uti = Uti {
3501            properties: vec![property(44, 0)],
3502            ..Uti::default()
3503        };
3504        let view = uti.resolve(&mut tables);
3505
3506        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
3507            panic!("expected Unknown variant for `UseLimitationClass`");
3508        };
3509        assert_eq!(property_label.as_deref(), Some("UseLimitationClass"));
3510    }
3511
3512    #[test]
3513    fn damage_racial_group_label_routes_to_typed_variant() {
3514        let table = itempropdef_with_labels(&[(13, "DamageRacialGroup")]);
3515        let mut tables = TestTables::with("itempropdef", &table);
3516
3517        let mut shaped = property(13, 3);
3518        shaped.cost_table = 4;
3519        shaped.cost_value = 2;
3520        let uti = Uti {
3521            properties: vec![shaped],
3522            ..Uti::default()
3523        };
3524        let view = uti.resolve(&mut tables);
3525
3526        let DecodedProperty::DamageRacialGroup {
3527            property_id,
3528            subtype_id,
3529            cost_table,
3530            cost_value,
3531        } = &view.properties()[0]
3532        else {
3533            panic!("expected DamageRacialGroup variant for `DamageRacialGroup` label");
3534        };
3535        assert_eq!(*property_id, 13);
3536        assert_eq!(*subtype_id, 3);
3537        assert_eq!(*cost_table, 4);
3538        assert_eq!(*cost_value, 2);
3539    }
3540
3541    #[test]
3542    fn damage_alignment_group_label_routes_to_typed_variant() {
3543        let table = itempropdef_with_labels(&[(12, "DamageAlignmentGroup")]);
3544        let mut tables = TestTables::with("itempropdef", &table);
3545
3546        let uti = Uti {
3547            properties: vec![property(12, 4)],
3548            ..Uti::default()
3549        };
3550        let view = uti.resolve(&mut tables);
3551
3552        let DecodedProperty::DamageAlignmentGroup {
3553            property_id,
3554            subtype_id,
3555            ..
3556        } = &view.properties()[0]
3557        else {
3558            panic!("expected DamageAlignmentGroup variant for `DamageAlignmentGroup` label");
3559        };
3560        assert_eq!(*property_id, 12);
3561        assert_eq!(*subtype_id, 4);
3562    }
3563
3564    #[test]
3565    fn enhancement_racial_group_label_routes_to_typed_variant() {
3566        let table = itempropdef_with_labels(&[(7, "EnhancementRacialGroup")]);
3567        let mut tables = TestTables::with("itempropdef", &table);
3568
3569        let uti = Uti {
3570            properties: vec![property(7, 3)],
3571            ..Uti::default()
3572        };
3573        let view = uti.resolve(&mut tables);
3574
3575        let DecodedProperty::EnhancementRacialGroup {
3576            property_id,
3577            subtype_id,
3578            ..
3579        } = &view.properties()[0]
3580        else {
3581            panic!("expected EnhancementRacialGroup variant for `EnhancementRacialGroup` label");
3582        };
3583        assert_eq!(*property_id, 7);
3584        assert_eq!(*subtype_id, 3);
3585    }
3586
3587    #[test]
3588    fn conditional_bonus_family_subtype_labels_resolve_via_their_2das() {
3589        // DamageRacialGroup and EnhancementRacialGroup share
3590        // `racialtypes.2da`; DamageAlignmentGroup uses
3591        // `iprp_aligngrp.2da`. Verify each resolves through the
3592        // correct table.
3593        let propdef = itempropdef_with_subtypes(&[
3594            (7, "EnhancementRacialGroup", "racialtypes"),
3595            (12, "DamageAlignmentGroup", "iprp_aligngrp"),
3596            (13, "DamageRacialGroup", "racialtypes"),
3597        ]);
3598        let racial = subtype_2da(&[(0, "Human"), (3, "Wookiee")]);
3599        let aligngrp = subtype_2da(&[(0, "Lawful_Good"), (4, "Chaotic_Evil")]);
3600
3601        let mut tables = TestTables::new();
3602        add_2da_entry(&mut tables, "itempropdef", &propdef);
3603        add_2da_entry(&mut tables, "racialtypes", &racial);
3604        add_2da_entry(&mut tables, "iprp_aligngrp", &aligngrp);
3605
3606        let damage_racial = DecodedProperty::DamageRacialGroup {
3607            property_id: 13,
3608            subtype_id: 3,
3609            cost_table: 0,
3610            cost_value: 0,
3611        };
3612        assert_eq!(
3613            damage_racial.subtype_label(&mut tables).as_deref(),
3614            Some("Wookiee")
3615        );
3616
3617        let damage_alignment = DecodedProperty::DamageAlignmentGroup {
3618            property_id: 12,
3619            subtype_id: 4,
3620            cost_table: 0,
3621            cost_value: 0,
3622        };
3623        assert_eq!(
3624            damage_alignment.subtype_label(&mut tables).as_deref(),
3625            Some("Chaotic_Evil")
3626        );
3627
3628        let enhancement_racial = DecodedProperty::EnhancementRacialGroup {
3629            property_id: 7,
3630            subtype_id: 3,
3631            cost_table: 0,
3632            cost_value: 0,
3633        };
3634        assert_eq!(
3635            enhancement_racial.subtype_label(&mut tables).as_deref(),
3636            Some("Wookiee")
3637        );
3638    }
3639
3640    #[test]
3641    fn use_limitation_family_subtype_labels_resolve_via_their_2das() {
3642        // All three use-limitation variants back onto different
3643        // subtype tables. Verify the helper resolves each through
3644        // the correct 2DA.
3645        let propdef = itempropdef_with_subtypes(&[
3646            (43, "UseLimitationAlignmentGroup", "iprp_aligngrp"),
3647            (45, "UseLimitationRacial", "racialtypes"),
3648            (57, "Use_Limitation_Feat", "feat"),
3649        ]);
3650        let aligngrp = subtype_2da(&[(0, "Lawful_Good"), (4, "Chaotic_Evil")]);
3651        let racial = subtype_2da(&[(0, "Human"), (3, "Wookiee")]);
3652        let feats = subtype_2da(&[(0, "Toughness"), (42, "Force_Sensitive")]);
3653
3654        let mut tables = TestTables::new();
3655        add_2da_entry(&mut tables, "itempropdef", &propdef);
3656        add_2da_entry(&mut tables, "iprp_aligngrp", &aligngrp);
3657        add_2da_entry(&mut tables, "racialtypes", &racial);
3658        add_2da_entry(&mut tables, "feat", &feats);
3659
3660        let alignment = DecodedProperty::UseLimitationAlignmentGroup {
3661            property_id: 43,
3662            subtype_id: 4,
3663            cost_table: 0,
3664            cost_value: 0,
3665        };
3666        assert_eq!(
3667            alignment.subtype_label(&mut tables).as_deref(),
3668            Some("Chaotic_Evil")
3669        );
3670
3671        let racial = DecodedProperty::UseLimitationRacial {
3672            property_id: 45,
3673            subtype_id: 3,
3674            cost_table: 0,
3675            cost_value: 0,
3676        };
3677        assert_eq!(
3678            racial.subtype_label(&mut tables).as_deref(),
3679            Some("Wookiee")
3680        );
3681
3682        let feat = DecodedProperty::UseLimitationFeat {
3683            property_id: 57,
3684            subtype_id: 42,
3685            cost_table: 0,
3686            cost_value: 0,
3687        };
3688        assert_eq!(
3689            feat.subtype_label(&mut tables).as_deref(),
3690            Some("Force_Sensitive")
3691        );
3692    }
3693
3694    #[test]
3695    fn low_volume_catchall_variants_route_to_typed_variants() {
3696        // Seven low-volume vanilla rows get their own typed variants
3697        // in this batch: AttackPenalty (4 items), DamagePenalty (1),
3698        // ImprovedMagicResist (4), DamageNone (3), Regeneration (6),
3699        // Regeneration_Force_Points (4), and Disguise (2). This test
3700        // pins the routing for all seven at once.
3701        let table = itempropdef_with_labels(&[
3702            (8, "AttackPenalty"),
3703            (15, "DamagePenalty"),
3704            (25, "ImprovedMagicResist"),
3705            (31, "DamageNone"),
3706            (35, "Regeneration"),
3707            (54, "Regeneration_Force_Points"),
3708            (59, "Disguise"),
3709        ]);
3710        let mut tables = TestTables::with("itempropdef", &table);
3711
3712        let uti = Uti {
3713            properties: vec![
3714                property(8, 0),
3715                property(15, 0),
3716                property(25, 0),
3717                property(31, 0),
3718                property(35, 0),
3719                property(54, 0),
3720                property(59, 7),
3721            ],
3722            ..Uti::default()
3723        };
3724        let view = uti.resolve(&mut tables);
3725
3726        assert!(matches!(
3727            view.properties()[0],
3728            DecodedProperty::AttackPenalty { .. }
3729        ));
3730        assert!(matches!(
3731            view.properties()[1],
3732            DecodedProperty::DamagePenalty { .. }
3733        ));
3734        assert!(matches!(
3735            view.properties()[2],
3736            DecodedProperty::MagicResistBonus { .. }
3737        ));
3738        assert!(matches!(
3739            view.properties()[3],
3740            DecodedProperty::DamageNone { .. }
3741        ));
3742        assert!(matches!(
3743            view.properties()[4],
3744            DecodedProperty::Regeneration { .. }
3745        ));
3746        assert!(matches!(
3747            view.properties()[5],
3748            DecodedProperty::RegenerationForcePoints { .. }
3749        ));
3750        let DecodedProperty::Disguise { subtype_id, .. } = &view.properties()[6] else {
3751            panic!("expected Disguise variant for `Disguise` label");
3752        };
3753        assert_eq!(*subtype_id, 7);
3754    }
3755
3756    #[test]
3757    fn low_volume_subtypeless_variants_short_circuit_subtype_label() {
3758        // Six of the seven low-volume variants have no SubTypeResRef
3759        // in vanilla. The helper must short-circuit to None for each.
3760        let propdef = itempropdef_with_subtypes(&[
3761            (8, "AttackPenalty", ""),
3762            (15, "DamagePenalty", ""),
3763            (25, "ImprovedMagicResist", ""),
3764            (31, "DamageNone", ""),
3765            (35, "Regeneration", ""),
3766            (54, "Regeneration_Force_Points", ""),
3767        ]);
3768        let mut tables = TestTables::with("itempropdef", &propdef);
3769
3770        for prop in [
3771            DecodedProperty::AttackPenalty {
3772                property_id: 8,
3773                subtype_id: 0,
3774                cost_table: 0,
3775                cost_value: 0,
3776            },
3777            DecodedProperty::DamagePenalty {
3778                property_id: 15,
3779                subtype_id: 0,
3780                cost_table: 0,
3781                cost_value: 0,
3782            },
3783            DecodedProperty::MagicResistBonus {
3784                property_id: 25,
3785                subtype_id: 0,
3786                cost_table: 0,
3787                cost_value: 0,
3788            },
3789            DecodedProperty::DamageNone {
3790                property_id: 31,
3791                subtype_id: 0,
3792                cost_table: 0,
3793                cost_value: 0,
3794            },
3795            DecodedProperty::Regeneration {
3796                property_id: 35,
3797                subtype_id: 0,
3798                cost_table: 0,
3799                cost_value: 0,
3800            },
3801            DecodedProperty::RegenerationForcePoints {
3802                property_id: 54,
3803                subtype_id: 0,
3804                cost_table: 0,
3805                cost_value: 0,
3806            },
3807        ] {
3808            assert!(
3809                prop.subtype_label(&mut tables).is_none(),
3810                "expected None subtype_label for subtypeless variant, got {prop:?}"
3811            );
3812        }
3813    }
3814
3815    #[test]
3816    fn disguise_subtype_label_resolves_via_appearance_2da() {
3817        let propdef = itempropdef_with_subtypes(&[(59, "Disguise", "appearance")]);
3818        let appearance = subtype_2da(&[(0, "Human"), (7, "Tusken_Raider")]);
3819        let mut tables = TestTables::new();
3820        add_2da_entry(&mut tables, "itempropdef", &propdef);
3821        add_2da_entry(&mut tables, "appearance", &appearance);
3822
3823        let prop = DecodedProperty::Disguise {
3824            property_id: 59,
3825            subtype_id: 7,
3826            cost_table: 0,
3827            cost_value: 0,
3828        };
3829        assert_eq!(
3830            prop.subtype_label(&mut tables).as_deref(),
3831            Some("Tusken_Raider")
3832        );
3833    }
3834
3835    #[test]
3836    fn newly_visible_alignment_and_special_variants_route_to_typed() {
3837        // After the GFF reader's cycle-detection bound was widened
3838        // to allow flat one-entry property lists, four more rows
3839        // surfaced in the corpus survey and got typed variants:
3840        // EnhancementAlignmentGroup (row 6, 5 items),
3841        // AttackBonusAlignmentGroup (row 39, 1 item), Light (row 29,
3842        // 3 items, with param fields), and TrueSeeing (row 47,
3843        // 1 item). Pins routing for all four.
3844        let table = itempropdef_with_labels(&[
3845            (6, "EnhancementAlignmentGroup"),
3846            (29, "Light"),
3847            (39, "AttackBonusAlignmentGroup"),
3848            (47, "True_Seeing"),
3849        ]);
3850        let mut tables = TestTables::with("itempropdef", &table);
3851
3852        let mut light_prop = property(29, 0);
3853        light_prop.param1 = 9;
3854        light_prop.param1_value = 4;
3855        let uti = Uti {
3856            properties: vec![property(6, 2), light_prop, property(39, 3), property(47, 0)],
3857            ..Uti::default()
3858        };
3859        let view = uti.resolve(&mut tables);
3860
3861        let DecodedProperty::EnhancementAlignmentGroup {
3862            subtype_id: ealignment_subtype,
3863            ..
3864        } = &view.properties()[0]
3865        else {
3866            panic!("expected EnhancementAlignmentGroup for row 6");
3867        };
3868        assert_eq!(*ealignment_subtype, 2);
3869
3870        let DecodedProperty::Light {
3871            param1,
3872            param1_value,
3873            ..
3874        } = &view.properties()[1]
3875        else {
3876            panic!("expected Light for row 29");
3877        };
3878        assert_eq!(*param1, 9);
3879        assert_eq!(*param1_value, 4);
3880
3881        let DecodedProperty::AttackBonusAlignmentGroup {
3882            subtype_id: ab_alignment_subtype,
3883            ..
3884        } = &view.properties()[2]
3885        else {
3886            panic!("expected AttackBonusAlignmentGroup for row 39");
3887        };
3888        assert_eq!(*ab_alignment_subtype, 3);
3889
3890        assert!(matches!(
3891            view.properties()[3],
3892            DecodedProperty::TrueSeeing { .. }
3893        ));
3894    }
3895
3896    #[test]
3897    fn alignment_group_variants_subtype_labels_resolve_via_iprp_aligngrp() {
3898        // EnhancementAlignmentGroup (row 6) and
3899        // AttackBonusAlignmentGroup (row 39) both back onto
3900        // iprp_aligngrp.2da, the same table as the existing
3901        // UseLimitationAlignmentGroup and DamageAlignmentGroup
3902        // variants. Verify both new variants resolve through it.
3903        let propdef = itempropdef_with_subtypes(&[
3904            (6, "EnhancementAlignmentGroup", "iprp_aligngrp"),
3905            (39, "AttackBonusAlignmentGroup", "iprp_aligngrp"),
3906        ]);
3907        let aligngrp = subtype_2da(&[(0, "Lawful_Good"), (2, "Neutral"), (4, "Chaotic_Evil")]);
3908
3909        let mut tables = TestTables::new();
3910        add_2da_entry(&mut tables, "itempropdef", &propdef);
3911        add_2da_entry(&mut tables, "iprp_aligngrp", &aligngrp);
3912
3913        let enhancement = DecodedProperty::EnhancementAlignmentGroup {
3914            property_id: 6,
3915            subtype_id: 4,
3916            cost_table: 0,
3917            cost_value: 0,
3918        };
3919        assert_eq!(
3920            enhancement.subtype_label(&mut tables).as_deref(),
3921            Some("Chaotic_Evil")
3922        );
3923
3924        let attack = DecodedProperty::AttackBonusAlignmentGroup {
3925            property_id: 39,
3926            subtype_id: 0,
3927            cost_table: 0,
3928            cost_value: 0,
3929        };
3930        assert_eq!(
3931            attack.subtype_label(&mut tables).as_deref(),
3932            Some("Lawful_Good")
3933        );
3934    }
3935
3936    #[test]
3937    fn light_and_true_seeing_short_circuit_subtype_label() {
3938        // Light (row 29) and TrueSeeing (row 47) carry no
3939        // SubTypeResRef in vanilla; the helper must return None.
3940        let propdef = itempropdef_with_subtypes(&[(29, "Light", ""), (47, "True_Seeing", "")]);
3941        let mut tables = TestTables::with("itempropdef", &propdef);
3942
3943        let light = DecodedProperty::Light {
3944            property_id: 29,
3945            subtype_id: 0,
3946            cost_table: 0,
3947            cost_value: 0,
3948            param1: 9,
3949            param1_value: 4,
3950        };
3951        assert!(light.subtype_label(&mut tables).is_none());
3952
3953        let true_seeing = DecodedProperty::TrueSeeing {
3954            property_id: 47,
3955            subtype_id: 0,
3956            cost_table: 0,
3957            cost_value: 0,
3958        };
3959        assert!(true_seeing.subtype_label(&mut tables).is_none());
3960    }
3961
3962    #[test]
3963    fn deferred_zero_use_rows_stay_in_unknown_by_design() {
3964        // The decoder intentionally leaves the vanilla itempropdef
3965        // rows with no .uti corpus references in Unknown. This test
3966        // pins a representative subset across the families listed in
3967        // the deferral comment at the `_ => Unknown` arm in
3968        // `decode_property`. If a future batch types any of these,
3969        // remove the corresponding entry here and update the
3970        // deferral comment.
3971        let table = itempropdef_with_labels(&[
3972            (2, "ArmorAlignmentGroup"),
3973            (16, "DamageReduced"),
3974            (19, "DecreaseAbilityScore"),
3975            (22, "DamageMelee"),
3976            (40, "AttackBonusRacialGroup"),
3977            (44, "UseLimitationClass"),
3978            (48, "OnMonsterHit"),
3979            (56, "Blaster_Bolt_Defect_Decrease"),
3980        ]);
3981        let mut tables = TestTables::with("itempropdef", &table);
3982
3983        let uti = Uti {
3984            properties: vec![
3985                property(2, 0),
3986                property(16, 0),
3987                property(19, 0),
3988                property(22, 0),
3989                property(40, 0),
3990                property(44, 0),
3991                property(48, 0),
3992                property(56, 0),
3993            ],
3994            ..Uti::default()
3995        };
3996        let view = uti.resolve(&mut tables);
3997
3998        for prop in view.properties() {
3999            assert!(
4000                matches!(prop, DecodedProperty::Unknown { .. }),
4001                "expected Unknown for deferred row, got {prop:?}"
4002            );
4003        }
4004    }
4005
4006    #[test]
4007    fn properties_accessor_preserves_source_order() {
4008        let uti = Uti {
4009            properties: vec![property(7, 0), property(0, 0), property(45, 0)],
4010            ..Uti::default()
4011        };
4012        let mut tables = TestTables::new();
4013
4014        let view = uti.resolve(&mut tables);
4015        let ids: Vec<u16> = view
4016            .properties()
4017            .iter()
4018            .map(|prop| match prop {
4019                DecodedProperty::AbilityBonus { property_id, .. }
4020                | DecodedProperty::SaveBonus { property_id, .. }
4021                | DecodedProperty::SaveBonusSpecific { property_id, .. }
4022                | DecodedProperty::SavePenalty { property_id, .. }
4023                | DecodedProperty::SavePenaltySpecific { property_id, .. }
4024                | DecodedProperty::DamageBonus { property_id, .. }
4025                | DecodedProperty::DamageImmunity { property_id, .. }
4026                | DecodedProperty::DamageResistance { property_id, .. }
4027                | DecodedProperty::AcBonus { property_id, .. }
4028                | DecodedProperty::EnhancementBonus { property_id, .. }
4029                | DecodedProperty::OnHit { property_id, .. }
4030                | DecodedProperty::CastSpell { property_id, .. }
4031                | DecodedProperty::Trap { property_id, .. }
4032                | DecodedProperty::ThievesTools { property_id, .. }
4033                | DecodedProperty::ComputerSpike { property_id, .. }
4034                | DecodedProperty::UseLimitationFeat { property_id, .. }
4035                | DecodedProperty::UseLimitationRacial { property_id, .. }
4036                | DecodedProperty::UseLimitationAlignmentGroup { property_id, .. }
4037                | DecodedProperty::DamageRacialGroup { property_id, .. }
4038                | DecodedProperty::DamageAlignmentGroup { property_id, .. }
4039                | DecodedProperty::EnhancementRacialGroup { property_id, .. }
4040                | DecodedProperty::EnhancementAlignmentGroup { property_id, .. }
4041                | DecodedProperty::AttackBonusAlignmentGroup { property_id, .. }
4042                | DecodedProperty::TrueSeeing { property_id, .. }
4043                | DecodedProperty::Light { property_id, .. }
4044                | DecodedProperty::AttackBonus { property_id, .. }
4045                | DecodedProperty::Keen { property_id, .. }
4046                | DecodedProperty::MassiveCriticals { property_id, .. }
4047                | DecodedProperty::BlasterBoltDeflectIncrease { property_id, .. }
4048                | DecodedProperty::MonsterDamage { property_id, .. }
4049                | DecodedProperty::BonusFeats { property_id, .. }
4050                | DecodedProperty::Immunity { property_id, .. }
4051                | DecodedProperty::Skill { property_id, .. }
4052                | DecodedProperty::AttackPenalty { property_id, .. }
4053                | DecodedProperty::DamagePenalty { property_id, .. }
4054                | DecodedProperty::MagicResistBonus { property_id, .. }
4055                | DecodedProperty::DamageNone { property_id, .. }
4056                | DecodedProperty::Regeneration { property_id, .. }
4057                | DecodedProperty::RegenerationForcePoints { property_id, .. }
4058                | DecodedProperty::Disguise { property_id, .. }
4059                | DecodedProperty::Unknown { property_id, .. } => *property_id,
4060            })
4061            .collect();
4062        assert_eq!(ids, vec![7, 0, 45]);
4063    }
4064
4065    #[test]
4066    fn is_armor_delegates_to_source_uti_for_armor_base_item() {
4067        // Base item 35 is in the armor block per `is_armor_base_item`.
4068        let uti = Uti {
4069            base_item: 35,
4070            ..Uti::default()
4071        };
4072        let mut tables = TestTables::new();
4073
4074        let view = uti.resolve(&mut tables);
4075        assert!(view.is_armor());
4076        // Sanity: matches the source's own pure check.
4077        assert_eq!(view.is_armor(), uti.is_armor());
4078    }
4079
4080    #[test]
4081    fn is_armor_returns_false_for_non_armor_base_item() {
4082        // Base item 0 (typically a melee weapon slot) is not armor.
4083        let uti = Uti {
4084            base_item: 0,
4085            ..Uti::default()
4086        };
4087        let mut tables = TestTables::new();
4088
4089        let view = uti.resolve(&mut tables);
4090        assert!(!view.is_armor());
4091    }
4092
4093    /// Builds a `baseitems.2da` fixture with the columns the combat /
4094    /// equip queries read. Each `(row_index, weaponwield, stacking,
4095    /// equipableslots, modeltype)` tuple populates one row; intermediate
4096    /// rows get blank cells.
4097    fn baseitems_with_rows(rows: &[(usize, &str, &str, &str, &str)]) -> TwoDa {
4098        let max_row = rows.iter().map(|(idx, ..)| *idx).max().unwrap_or(0);
4099        let mut table_rows = Vec::with_capacity(max_row + 1);
4100        for row_index in 0..=max_row {
4101            let cells = rows
4102                .iter()
4103                .find(|(idx, ..)| *idx == row_index)
4104                .map(|(_, ww, st, eq, mt)| {
4105                    vec![
4106                        (*ww).to_string(),
4107                        (*st).to_string(),
4108                        (*eq).to_string(),
4109                        (*mt).to_string(),
4110                    ]
4111                })
4112                .unwrap_or_else(|| vec![String::new(); 4]);
4113            table_rows.push(TwoDaRow {
4114                label: row_index.to_string(),
4115                cells,
4116            });
4117        }
4118        TwoDa {
4119            headers: vec![
4120                "weaponwield".to_string(),
4121                "stacking".to_string(),
4122                "equipableslots".to_string(),
4123                "modeltype".to_string(),
4124            ],
4125            rows: table_rows,
4126        }
4127    }
4128
4129    #[test]
4130    fn combat_equip_queries_default_to_safe_values_without_baseitems() {
4131        // No `baseitems.2da` in any source: queries return their
4132        // documented "we don't know" defaults rather than panicking.
4133        let uti = Uti {
4134            base_item: 2,
4135            ..Uti::default()
4136        };
4137        let mut tables = TestTables::new();
4138
4139        let view = uti.resolve(&mut tables);
4140        assert!(!view.is_weapon());
4141        assert!(!view.is_consumable());
4142        assert!(view.equip_slot_mask().is_none());
4143        assert!(view.model_type().is_none());
4144    }
4145
4146    #[test]
4147    fn combat_equip_queries_default_when_base_item_row_is_absent() {
4148        // `baseitems.2da` is loaded but does not contain row 99.
4149        // Queries should behave as if the table were missing.
4150        let table = baseitems_with_rows(&[(2, "2", "1", "0x00030", "0")]);
4151        let mut tables = TestTables::with("baseitems", &table);
4152
4153        let uti = Uti {
4154            base_item: 99,
4155            ..Uti::default()
4156        };
4157        let view = uti.resolve(&mut tables);
4158        assert!(!view.is_weapon());
4159        assert!(!view.is_consumable());
4160        assert!(view.equip_slot_mask().is_none());
4161        assert!(view.model_type().is_none());
4162    }
4163
4164    #[test]
4165    fn is_weapon_reads_weaponwield_column() {
4166        // Row 2 in vanilla baseitems is Long_Sword with weaponwield=2.
4167        // Row 35 is an armor row with weaponwield blank (-> 0).
4168        let table =
4169            baseitems_with_rows(&[(2, "2", "1", "0x00030", "0"), (35, "", "1", "0x00018", "0")]);
4170        let mut tables = TestTables::with("baseitems", &table);
4171
4172        let weapon = Uti {
4173            base_item: 2,
4174            ..Uti::default()
4175        };
4176        assert!(weapon.resolve(&mut tables).is_weapon());
4177
4178        let armor = Uti {
4179            base_item: 35,
4180            ..Uti::default()
4181        };
4182        assert!(!armor.resolve(&mut tables).is_weapon());
4183    }
4184
4185    #[test]
4186    fn is_consumable_reads_stacking_column() {
4187        // Vanilla consumables like stim packs / grenades / med kits
4188        // have stacking > 1 (typically 99). Equipment has stacking
4189        // exactly 1. The threshold is `> 1`.
4190        let table = baseitems_with_rows(&[
4191            (60, "0", "99", "0x40000", "0"),
4192            (35, "0", "1", "0x00018", "0"),
4193        ]);
4194        let mut tables = TestTables::with("baseitems", &table);
4195
4196        let stim = Uti {
4197            base_item: 60,
4198            ..Uti::default()
4199        };
4200        assert!(stim.resolve(&mut tables).is_consumable());
4201
4202        let armor = Uti {
4203            base_item: 35,
4204            ..Uti::default()
4205        };
4206        assert!(!armor.resolve(&mut tables).is_consumable());
4207    }
4208
4209    #[test]
4210    fn equip_slot_mask_parses_hex_string_with_or_without_prefix() {
4211        // The vanilla cell stores hex with `0x` prefix
4212        // (e.g. `0x00030`). Tolerate uppercase prefix and a bare
4213        // hex string too, in case mod tables omit the prefix.
4214        let table = baseitems_with_rows(&[
4215            (2, "2", "1", "0x00030", "0"),
4216            (3, "2", "1", "0X00030", "0"),
4217            (4, "2", "1", "00030", "0"),
4218        ]);
4219        let mut tables = TestTables::with("baseitems", &table);
4220
4221        for base in [2, 3, 4] {
4222            let uti = Uti {
4223                base_item: base,
4224                ..Uti::default()
4225            };
4226            assert_eq!(uti.resolve(&mut tables).equip_slot_mask(), Some(0x0030));
4227        }
4228    }
4229
4230    #[test]
4231    fn model_type_reads_numeric_column() {
4232        let table = baseitems_with_rows(&[
4233            (2, "2", "1", "0x00030", "0"),
4234            (38, "0", "1", "0x00018", "1"),
4235            (75, "0", "1", "0x00400", "2"),
4236        ]);
4237        let mut tables = TestTables::with("baseitems", &table);
4238
4239        for (base, expected) in [(2, 0_u8), (38, 1), (75, 2)] {
4240            let uti = Uti {
4241                base_item: base,
4242                ..Uti::default()
4243            };
4244            assert_eq!(uti.resolve(&mut tables).model_type(), Some(expected));
4245        }
4246    }
4247
4248    #[test]
4249    fn has_property_kind_matches_each_family() {
4250        // Build a Uti carrying one property from each family the
4251        // filter enum covers, then confirm each filter matches.
4252        let table = itempropdef_with_labels(&[
4253            (0, "Ability"),
4254            (10, "CastSpell"),
4255            (11, "Damage"),
4256            (26, "ImprovedSavingThrows"),
4257            (38, "AttackBonus"),
4258            (5, "Enhancement"),
4259            (57, "Use_Limitation_Feat"),
4260        ]);
4261        let mut tables = TestTables::with("itempropdef", &table);
4262
4263        let uti = Uti {
4264            properties: vec![
4265                property(0, 0),
4266                property(10, 0),
4267                property(11, 0),
4268                property(26, 0),
4269                property(38, 0),
4270                property(5, 0),
4271                property(57, 0),
4272            ],
4273            ..Uti::default()
4274        };
4275        let view = uti.resolve(&mut tables);
4276
4277        assert!(view.has_property_kind(PropertyKindFilter::Ability));
4278        assert!(view.has_property_kind(PropertyKindFilter::Active));
4279        assert!(view.has_property_kind(PropertyKindFilter::Damage));
4280        assert!(view.has_property_kind(PropertyKindFilter::Save));
4281        assert!(view.has_property_kind(PropertyKindFilter::Attack));
4282        assert!(view.has_property_kind(PropertyKindFilter::Enhancement));
4283        assert!(view.has_property_kind(PropertyKindFilter::UseLimitation));
4284    }
4285
4286    #[test]
4287    fn has_property_kind_returns_false_for_unrepresented_families() {
4288        // Item with only an AbilityBonus should match Ability but
4289        // none of the other filters.
4290        let table = itempropdef_with_labels(&[(0, "Ability")]);
4291        let mut tables = TestTables::with("itempropdef", &table);
4292
4293        let uti = Uti {
4294            properties: vec![property(0, 0)],
4295            ..Uti::default()
4296        };
4297        let view = uti.resolve(&mut tables);
4298
4299        assert!(view.has_property_kind(PropertyKindFilter::Ability));
4300        for filter in [
4301            PropertyKindFilter::Damage,
4302            PropertyKindFilter::Save,
4303            PropertyKindFilter::Attack,
4304            PropertyKindFilter::Enhancement,
4305            PropertyKindFilter::UseLimitation,
4306            PropertyKindFilter::Active,
4307        ] {
4308            assert!(
4309                !view.has_property_kind(filter),
4310                "unexpected match for {filter:?}"
4311            );
4312        }
4313    }
4314
4315    #[test]
4316    fn has_property_kind_damage_covers_full_damage_family() {
4317        // The Damage filter is the broadest: it should match
4318        // DamageBonus, DamageImmunity, DamageResistance,
4319        // DamageRacialGroup, DamageAlignmentGroup, and DamagePenalty.
4320        // (DamageNone is intentionally excluded from the filter set.)
4321        let table = itempropdef_with_labels(&[
4322            (11, "Damage"),
4323            (12, "DamageAlignmentGroup"),
4324            (13, "DamageRacialGroup"),
4325            (14, "DamageImmunity"),
4326            (15, "DamagePenalty"),
4327            (17, "DamageResist"),
4328        ]);
4329        let mut tables = TestTables::with("itempropdef", &table);
4330
4331        for row in [11_u16, 12, 13, 14, 15, 17] {
4332            let uti = Uti {
4333                properties: vec![property(row, 0)],
4334                ..Uti::default()
4335            };
4336            let view = uti.resolve(&mut tables);
4337            assert!(
4338                view.has_property_kind(PropertyKindFilter::Damage),
4339                "expected Damage filter to match row {row}"
4340            );
4341        }
4342    }
4343
4344    #[test]
4345    fn has_property_kind_does_not_match_unknown_variants() {
4346        // A property whose label has no typed dispatch arm becomes
4347        // Unknown; no filter family includes Unknown.
4348        let table = itempropdef_with_labels(&[(200, "FakeMod_GrantsCookies")]);
4349        let mut tables = TestTables::with("itempropdef", &table);
4350
4351        let uti = Uti {
4352            properties: vec![property(200, 0)],
4353            ..Uti::default()
4354        };
4355        let view = uti.resolve(&mut tables);
4356
4357        for filter in [
4358            PropertyKindFilter::Damage,
4359            PropertyKindFilter::Ability,
4360            PropertyKindFilter::Save,
4361            PropertyKindFilter::Attack,
4362            PropertyKindFilter::Enhancement,
4363            PropertyKindFilter::UseLimitation,
4364            PropertyKindFilter::Active,
4365        ] {
4366            assert!(
4367                !view.has_property_kind(filter),
4368                "Unknown variant should not match {filter:?}"
4369            );
4370        }
4371    }
4372
4373    #[test]
4374    fn has_property_kind_returns_false_for_empty_property_list() {
4375        let uti = Uti::default();
4376        let mut tables = TestTables::new();
4377        let view = uti.resolve(&mut tables);
4378
4379        for filter in [
4380            PropertyKindFilter::Damage,
4381            PropertyKindFilter::Ability,
4382            PropertyKindFilter::Save,
4383            PropertyKindFilter::Attack,
4384            PropertyKindFilter::Enhancement,
4385            PropertyKindFilter::UseLimitation,
4386            PropertyKindFilter::Active,
4387        ] {
4388            assert!(!view.has_property_kind(filter));
4389        }
4390    }
4391
4392    #[test]
4393    fn combat_equip_queries_return_field_defaults_for_unparseable_cells() {
4394        // A row exists but cells are unparseable (non-numeric). Each
4395        // query falls back to its documented safe default rather than
4396        // crashing or surfacing garbage.
4397        let table = baseitems_with_rows(&[(2, "junk", "stuff", "not-hex", "wat")]);
4398        let mut tables = TestTables::with("baseitems", &table);
4399
4400        let uti = Uti {
4401            base_item: 2,
4402            ..Uti::default()
4403        };
4404        let view = uti.resolve(&mut tables);
4405        assert!(!view.is_weapon());
4406        assert!(!view.is_consumable());
4407        assert!(view.equip_slot_mask().is_none());
4408        assert!(view.model_type().is_none());
4409    }
4410
4411    /// Builds an `itempropdef.2da` fixture with both the `label` and
4412    /// `SubTypeResRef` columns populated per row. Empty `subtype_resref`
4413    /// matches a property that has no subtype dimension (engine
4414    /// short-circuits the dispatch).
4415    fn itempropdef_with_subtypes(rows: &[(usize, &str, &str)]) -> TwoDa {
4416        let max_row = rows.iter().map(|(idx, _, _)| *idx).max().unwrap_or(0);
4417        let mut table_rows = Vec::with_capacity(max_row + 1);
4418        for row_index in 0..=max_row {
4419            let (label, subtype_resref) = rows
4420                .iter()
4421                .find(|(idx, _, _)| *idx == row_index)
4422                .map(|(_, label, sr)| ((*label).to_string(), (*sr).to_string()))
4423                .unwrap_or_default();
4424            table_rows.push(TwoDaRow {
4425                label: row_index.to_string(),
4426                cells: vec![label, subtype_resref],
4427            });
4428        }
4429        TwoDa {
4430            headers: vec!["label".to_string(), "SubTypeResRef".to_string()],
4431            rows: table_rows,
4432        }
4433    }
4434
4435    /// Builds a per-property subtype 2DA (e.g. `iprp_damagecost.2da`)
4436    /// with just the `label` column populated.
4437    fn subtype_2da(labels: &[(usize, &str)]) -> TwoDa {
4438        // The actual game tables carry a Name (StrRef) column too, but
4439        // the helper only consumes `label`, so we keep the fixture
4440        // minimal.
4441        itempropdef_with_labels(labels)
4442    }
4443
4444    fn add_2da_entry(tables: &mut TestTables, name: &str, table: &TwoDa) {
4445        tables.insert(name, table);
4446    }
4447
4448    #[test]
4449    fn subtype_label_resolves_full_chain_for_known_property() {
4450        // PropertyName 7 -> "Damage" -> iprp_damagecost.2da
4451        // Subtype 5 -> "Acid"
4452        let propdef = itempropdef_with_subtypes(&[
4453            (0, "Ability", "iprp_abilities"),
4454            (7, "Damage", "iprp_damagecost"),
4455        ]);
4456        let damagecost = subtype_2da(&[(0, "Bludgeoning"), (5, "Acid")]);
4457
4458        let mut tables = TestTables::new();
4459        add_2da_entry(&mut tables, "itempropdef", &propdef);
4460        add_2da_entry(&mut tables, "iprp_damagecost", &damagecost);
4461
4462        let prop = DecodedProperty::Unknown {
4463            property_id: 7,
4464            property_label: Some("Damage".to_string()),
4465            subtype: 5,
4466            cost_table: 0,
4467            cost_value: 0,
4468            param1: 0,
4469            param1_value: 0,
4470        };
4471        assert_eq!(prop.subtype_label(&mut tables).as_deref(), Some("Acid"));
4472    }
4473
4474    #[test]
4475    fn subtype_label_returns_none_for_property_with_empty_subtype_resref() {
4476        // Property 7 exists but has no subtype dimension (empty
4477        // SubTypeResRef cell). Engine short-circuits; helper does too.
4478        let propdef = itempropdef_with_subtypes(&[(7, "Light", "")]);
4479        let mut tables = TestTables::new();
4480        add_2da_entry(&mut tables, "itempropdef", &propdef);
4481
4482        let prop = DecodedProperty::Unknown {
4483            property_id: 7,
4484            property_label: Some("Light".to_string()),
4485            subtype: 0,
4486            cost_table: 0,
4487            cost_value: 0,
4488            param1: 0,
4489            param1_value: 0,
4490        };
4491        assert!(prop.subtype_label(&mut tables).is_none());
4492    }
4493
4494    #[test]
4495    fn subtype_label_returns_none_when_subtype_table_resref_is_missing() {
4496        // Property 99 isn't in itempropdef at all (row absent). No
4497        // dispatch chain to walk.
4498        let propdef = itempropdef_with_subtypes(&[(0, "Ability", "iprp_abilities")]);
4499        let mut tables = TestTables::new();
4500        add_2da_entry(&mut tables, "itempropdef", &propdef);
4501
4502        let prop = DecodedProperty::Unknown {
4503            property_id: 99,
4504            property_label: None,
4505            subtype: 0,
4506            cost_table: 0,
4507            cost_value: 0,
4508            param1: 0,
4509            param1_value: 0,
4510        };
4511        assert!(prop.subtype_label(&mut tables).is_none());
4512    }
4513
4514    #[test]
4515    fn subtype_label_returns_none_when_subtype_table_cannot_be_loaded() {
4516        // itempropdef points at iprp_damagecost but no source provides
4517        // that 2DA. Helper must not panic.
4518        let propdef = itempropdef_with_subtypes(&[(7, "Damage", "iprp_damagecost")]);
4519        let mut tables = TestTables::new();
4520        add_2da_entry(&mut tables, "itempropdef", &propdef);
4521
4522        let prop = DecodedProperty::Unknown {
4523            property_id: 7,
4524            property_label: Some("Damage".to_string()),
4525            subtype: 0,
4526            cost_table: 0,
4527            cost_value: 0,
4528            param1: 0,
4529            param1_value: 0,
4530        };
4531        assert!(prop.subtype_label(&mut tables).is_none());
4532    }
4533
4534    #[test]
4535    fn subtype_label_returns_none_when_subtype_row_is_out_of_bounds() {
4536        // Subtype 99 is past the loaded subtype table's row count.
4537        let propdef = itempropdef_with_subtypes(&[(7, "Damage", "iprp_damagecost")]);
4538        let damagecost = subtype_2da(&[(0, "Bludgeoning"), (1, "Slashing")]);
4539        let mut tables = TestTables::new();
4540        add_2da_entry(&mut tables, "itempropdef", &propdef);
4541        add_2da_entry(&mut tables, "iprp_damagecost", &damagecost);
4542
4543        let prop = DecodedProperty::Unknown {
4544            property_id: 7,
4545            property_label: Some("Damage".to_string()),
4546            subtype: 99,
4547            cost_table: 0,
4548            cost_value: 0,
4549            param1: 0,
4550            param1_value: 0,
4551        };
4552        assert!(prop.subtype_label(&mut tables).is_none());
4553    }
4554
4555    #[test]
4556    fn subtype_label_returns_none_when_itempropdef_is_missing() {
4557        // No 2DAs at all. Helper must not panic.
4558        let mut tables = TestTables::new();
4559
4560        let prop = DecodedProperty::Unknown {
4561            property_id: 7,
4562            property_label: None,
4563            subtype: 5,
4564            cost_table: 0,
4565            cost_value: 0,
4566            param1: 0,
4567            param1_value: 0,
4568        };
4569        assert!(prop.subtype_label(&mut tables).is_none());
4570    }
4571
4572    #[test]
4573    fn subtype_label_resolves_mod_extended_subtype_row() {
4574        // Vanilla iprp_damagecost typically stops short of row 200; a
4575        // mod adds row 200 carrying a custom label. The helper must
4576        // surface that label without downgrading to None.
4577        let propdef = itempropdef_with_subtypes(&[(7, "Damage", "iprp_damagecost")]);
4578        let damagecost = subtype_2da(&[(200, "FakeMod_PsionicBurn")]);
4579        let mut tables = TestTables::new();
4580        add_2da_entry(&mut tables, "itempropdef", &propdef);
4581        add_2da_entry(&mut tables, "iprp_damagecost", &damagecost);
4582
4583        let prop = DecodedProperty::Unknown {
4584            property_id: 7,
4585            property_label: Some("Damage".to_string()),
4586            subtype: 200,
4587            cost_table: 0,
4588            cost_value: 0,
4589            param1: 0,
4590            param1_value: 0,
4591        };
4592        assert_eq!(
4593            prop.subtype_label(&mut tables).as_deref(),
4594            Some("FakeMod_PsionicBurn")
4595        );
4596    }
4597
4598    // -- Projection / resolve two-stage path --
4599
4600    #[test]
4601    fn project_with_itempropdef_dispatches_typed_variants() {
4602        // Property name 0 (`Ability`) routes to AbilityBonus when the
4603        // projection has the itempropdef table to dispatch from.
4604        let uti = Uti {
4605            properties: vec![property(0, 2)],
4606            ..Uti::default()
4607        };
4608        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
4609
4610        let projection = uti.project(Some(&propdef));
4611        assert!(matches!(
4612            projection.properties()[0],
4613            DecodedProperty::AbilityBonus { subtype_id: 2, .. }
4614        ));
4615    }
4616
4617    #[test]
4618    fn project_without_itempropdef_falls_back_to_unknown_no_label() {
4619        // Passing None for itempropdef must not panic and must
4620        // gracefully degrade every property to Unknown with no label.
4621        // Equivalent to the engine's tolerance of a missing
4622        // itempropdef.2da at load time.
4623        let uti = Uti {
4624            properties: vec![property(0, 2), property(11, 4)],
4625            ..Uti::default()
4626        };
4627
4628        let projection = uti.project(None);
4629        let labels: Vec<Option<&str>> = projection
4630            .properties()
4631            .iter()
4632            .map(|prop| match prop {
4633                DecodedProperty::Unknown { property_label, .. } => property_label.as_deref(),
4634                _ => panic!("expected every property to land in Unknown without itempropdef"),
4635            })
4636            .collect();
4637        assert_eq!(labels, vec![None, None]);
4638    }
4639
4640    #[test]
4641    fn projection_resolve_loads_baseitems_for_queries() {
4642        // Build a projection with no itempropdef (irrelevant for the
4643        // baseitems-backed query under test), then resolve it through
4644        // a cache that exposes a weapon row at the UTI's base_item id.
4645        let uti = Uti {
4646            base_item: 0,
4647            ..Uti::default()
4648        };
4649        let baseitems = baseitems_with_rows(&[(0, "5", "0", "0x0010", "3")]);
4650        let mut tables = TestTables::with("baseitems", &baseitems);
4651
4652        let projection = uti.project(None);
4653        let resolved = projection.resolve(&mut tables);
4654
4655        assert!(resolved.is_weapon());
4656        assert_eq!(resolved.equip_slot_mask(), Some(0x0010));
4657        assert_eq!(resolved.model_type(), Some(3));
4658    }
4659
4660    #[test]
4661    fn one_projection_feeds_independent_resolutions_per_scope() {
4662        // Mod conflict scenario: same UTI bytes, two scopes whose
4663        // baseitems.2da rows disagree on whether base_item 0 is a
4664        // weapon. The projection's typed dispatch is shared (no
4665        // re-projection); each resolution reports the value under
4666        // its own context.
4667        let uti = Uti {
4668            base_item: 0,
4669            ..Uti::default()
4670        };
4671
4672        let vanilla_baseitems = baseitems_with_rows(&[(0, "5", "0", "0x0010", "3")]);
4673        let mut vanilla_tables = TestTables::with("baseitems", &vanilla_baseitems);
4674
4675        // Mod rebalances the row so the same base_item id is no
4676        // longer wielded as a weapon (weaponwield = 0).
4677        let mod_baseitems = baseitems_with_rows(&[(0, "0", "10", "0x0000", "0")]);
4678        let mut mod_tables = TestTables::with("baseitems", &mod_baseitems);
4679
4680        let projection = uti.project(None);
4681        let vanilla = projection.resolve(&mut vanilla_tables);
4682        let modded = projection.resolve(&mut mod_tables);
4683
4684        assert!(vanilla.is_weapon());
4685        assert!(!modded.is_weapon());
4686        assert!(modded.is_consumable(), "mod row marks the item stackable");
4687        assert!(!vanilla.is_consumable());
4688    }
4689
4690    #[test]
4691    fn resolve_sugar_matches_project_then_resolve() {
4692        // `Uti::resolve(&mut tables)` is defined as sugar for
4693        // `project(propdef_from_cache).resolve(cache)`. Both paths
4694        // must produce identical resolutions on every query they
4695        // expose.
4696        let uti = Uti {
4697            base_item: 0,
4698            properties: vec![property(0, 2)],
4699            ..Uti::default()
4700        };
4701        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
4702        let baseitems = baseitems_with_rows(&[(0, "5", "0", "0x0010", "3")]);
4703        let mut sugar_tables = TestTables::new();
4704        add_2da_entry(&mut sugar_tables, "itempropdef", &propdef);
4705        add_2da_entry(&mut sugar_tables, "baseitems", &baseitems);
4706        let mut explicit_tables = TestTables::new();
4707        add_2da_entry(&mut explicit_tables, "itempropdef", &propdef);
4708        add_2da_entry(&mut explicit_tables, "baseitems", &baseitems);
4709
4710        let sugar = uti.resolve(&mut sugar_tables);
4711
4712        let projection = {
4713            let propdef_ref = explicit_tables.twoda(tables::ITEMPROPDEF);
4714            uti.project(propdef_ref)
4715        };
4716        let explicit = projection.resolve(&mut explicit_tables);
4717
4718        assert_eq!(sugar.properties(), explicit.properties());
4719        assert_eq!(sugar.is_weapon(), explicit.is_weapon());
4720        assert_eq!(sugar.is_consumable(), explicit.is_consumable());
4721        assert_eq!(sugar.equip_slot_mask(), explicit.equip_slot_mask());
4722        assert_eq!(sugar.model_type(), explicit.model_type());
4723    }
4724
4725    // -- Property-bundle iterators (magnitude resolution) --
4726
4727    /// Builds an `iprp_costtable.2da` fixture with a `Name` column
4728    /// holding the per-cost 2DA resref for each declared row.
4729    /// Intermediate rows get an empty `Name`.
4730    fn iprp_costtable_with_entries(rows: &[(usize, &str)]) -> TwoDa {
4731        let max_row = rows.iter().map(|(idx, _)| *idx).max().unwrap_or(0);
4732        let mut table_rows = Vec::with_capacity(max_row + 1);
4733        for row_index in 0..=max_row {
4734            let name = rows
4735                .iter()
4736                .find(|(idx, _)| *idx == row_index)
4737                .map(|(_, name)| (*name).to_string())
4738                .unwrap_or_default();
4739            table_rows.push(TwoDaRow {
4740                label: row_index.to_string(),
4741                cells: vec![name],
4742            });
4743        }
4744        TwoDa {
4745            headers: vec!["Name".to_string()],
4746            rows: table_rows,
4747        }
4748    }
4749
4750    /// Builds a generic per-cost 2DA (`iprp_bonuscost`,
4751    /// `iprp_immuncost`, etc.) with a `Value` column holding the
4752    /// magnitude for each declared row. Intermediate rows get an
4753    /// empty cell.
4754    fn cost_value_2da(rows: &[(usize, i32)]) -> TwoDa {
4755        let max_row = rows.iter().map(|(idx, _)| *idx).max().unwrap_or(0);
4756        let mut table_rows = Vec::with_capacity(max_row + 1);
4757        for row_index in 0..=max_row {
4758            let value = rows
4759                .iter()
4760                .find(|(idx, _)| *idx == row_index)
4761                .map(|(_, value)| value.to_string())
4762                .unwrap_or_default();
4763            table_rows.push(TwoDaRow {
4764                label: row_index.to_string(),
4765                cells: vec![value],
4766            });
4767        }
4768        TwoDa {
4769            headers: vec!["Value".to_string()],
4770            rows: table_rows,
4771        }
4772    }
4773
4774    /// Builds a UTI carrying one property with explicit cost-table
4775    /// addressing. The decoder cares only about `property_name`,
4776    /// `subtype`, `cost_table`, and `cost_value` for the kinds the
4777    /// iterators yield; other fields take their default sentinels.
4778    fn property_with_cost(
4779        property_name: u16,
4780        subtype: u16,
4781        cost_table: u8,
4782        cost_value: u16,
4783    ) -> UtiProperty {
4784        UtiProperty {
4785            cost_table,
4786            cost_value,
4787            param1: 0xFF,
4788            param1_value: 0,
4789            property_name,
4790            subtype,
4791            chance_appear: 100,
4792            useable: None,
4793            uses_per_day: None,
4794            upgrade_type: None,
4795        }
4796    }
4797
4798    #[test]
4799    fn damage_bonuses_yields_cost_value_as_magnitude() {
4800        // ApplyDamageBonus is a bypass handler: CostValue is the
4801        // damage amount directly. No per-cost 2DA needs to be in
4802        // the resolver, only itempropdef for typed dispatch.
4803        let uti = Uti {
4804            properties: vec![
4805                property_with_cost(11, 3, 4, 7),
4806                property_with_cost(11, 5, 4, 12),
4807            ],
4808            ..Uti::default()
4809        };
4810        let propdef = itempropdef_with_labels(&[(11, "Damage")]);
4811        let mut tables = TestTables::with("itempropdef", &propdef);
4812
4813        let resolved = uti.resolve(&mut tables);
4814        let yielded: Vec<(u16, i32)> = resolved.damage_bonuses().collect();
4815        assert_eq!(yielded, vec![(3, 7), (5, 12)]);
4816    }
4817
4818    #[test]
4819    fn ability_bonuses_resolve_magnitude_from_iprp_bonuscost_value() {
4820        // ApplyAbilityBonus reads iprp_bonuscost row at CostValue,
4821        // column Value, with the cost-table index hardcoded by the
4822        // handler. The property's cost_table field is irrelevant
4823        // and should not influence resolution.
4824        let uti = Uti {
4825            properties: vec![
4826                property_with_cost(0, 2, 99, 4), // STR, cost_value 4 -> 2
4827                property_with_cost(0, 4, 99, 7), // WIS, cost_value 7 -> 5
4828            ],
4829            ..Uti::default()
4830        };
4831        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
4832        let bonuscost = cost_value_2da(&[(4, 2), (7, 5)]);
4833        let mut tables = TestTables::new();
4834        add_2da_entry(&mut tables, "itempropdef", &propdef);
4835        add_2da_entry(&mut tables, "iprp_bonuscost", &bonuscost);
4836
4837        let resolved = uti.resolve(&mut tables);
4838        let yielded: Vec<(u16, i32)> = resolved.ability_bonuses().collect();
4839        assert_eq!(yielded, vec![(2, 2), (4, 5)]);
4840    }
4841
4842    #[test]
4843    fn ability_bonuses_yield_nothing_when_iprp_bonuscost_missing() {
4844        // The source has itempropdef so the AbilityBonus variant
4845        // dispatches, but iprp_bonuscost is absent. The iterator
4846        // silently skips properties whose magnitude could not be
4847        // resolved rather than panicking or yielding a sentinel.
4848        let uti = Uti {
4849            properties: vec![property_with_cost(0, 2, 1, 4)],
4850            ..Uti::default()
4851        };
4852        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
4853        let mut tables = TestTables::with("itempropdef", &propdef);
4854
4855        let resolved = uti.resolve(&mut tables);
4856        assert_eq!(resolved.ability_bonuses().count(), 0);
4857    }
4858
4859    #[test]
4860    fn damage_immunities_walk_dynamic_cost_table_dispatch() {
4861        // ApplyDamageImmunity reads the cost-table index from each
4862        // property's cost_table field. The vanilla path uses index
4863        // 5 -> iprp_immuncost. Verify the dispatch follows the
4864        // iprp_costtable.Name resref and looks up the magnitude.
4865        let uti = Uti {
4866            properties: vec![property_with_cost(14, 8, 5, 3)],
4867            ..Uti::default()
4868        };
4869        let propdef = itempropdef_with_labels(&[(14, "DamageImmunity")]);
4870        // Index 5 -> "iprp_immuncost", matching vanilla's layout.
4871        let costtable = iprp_costtable_with_entries(&[(5, "IPRP_IMMUNCOST")]);
4872        let immuncost = cost_value_2da(&[(3, 50)]);
4873        let mut tables = TestTables::new();
4874        add_2da_entry(&mut tables, "itempropdef", &propdef);
4875        add_2da_entry(&mut tables, "iprp_costtable", &costtable);
4876        add_2da_entry(&mut tables, "iprp_immuncost", &immuncost);
4877
4878        let resolved = uti.resolve(&mut tables);
4879        let yielded: Vec<(u16, i32)> = resolved.damage_immunities().collect();
4880        assert_eq!(yielded, vec![(8, 50)]);
4881    }
4882
4883    #[test]
4884    fn damage_immunities_follow_mod_extended_cost_table_index() {
4885        // Mod-extended cost-table scenario: iprp_costtable carries a
4886        // row past vanilla's range pointing at a new cost 2DA. The
4887        // iterator must follow the dispatch via the property's own
4888        // cost_table field rather than hardcoding the vanilla index.
4889        let uti = Uti {
4890            properties: vec![property_with_cost(14, 8, 26, 1)],
4891            ..Uti::default()
4892        };
4893        let propdef = itempropdef_with_labels(&[(14, "DamageImmunity")]);
4894        let costtable = iprp_costtable_with_entries(&[(26, "mod_immuncost")]);
4895        let modded_cost = cost_value_2da(&[(1, 75)]);
4896        let mut tables = TestTables::new();
4897        add_2da_entry(&mut tables, "itempropdef", &propdef);
4898        add_2da_entry(&mut tables, "iprp_costtable", &costtable);
4899        add_2da_entry(&mut tables, "mod_immuncost", &modded_cost);
4900
4901        let resolved = uti.resolve(&mut tables);
4902        let yielded: Vec<(u16, i32)> = resolved.damage_immunities().collect();
4903        assert_eq!(yielded, vec![(8, 75)]);
4904    }
4905
4906    #[test]
4907    fn iterators_only_yield_matching_kind() {
4908        // An item carries one of each magnitude-resolvable kind.
4909        // Each iterator must yield only its own kind.
4910        let uti = Uti {
4911            properties: vec![
4912                property_with_cost(0, 2, 1, 4),  // AbilityBonus
4913                property_with_cost(11, 3, 4, 7), // DamageBonus
4914                property_with_cost(14, 8, 5, 3), // DamageImmunity
4915            ],
4916            ..Uti::default()
4917        };
4918        let propdef =
4919            itempropdef_with_labels(&[(0, "Ability"), (11, "Damage"), (14, "DamageImmunity")]);
4920        let bonuscost = cost_value_2da(&[(4, 2)]);
4921        let costtable = iprp_costtable_with_entries(&[(5, "iprp_immuncost")]);
4922        let immuncost = cost_value_2da(&[(3, 50)]);
4923        let mut tables = TestTables::new();
4924        add_2da_entry(&mut tables, "itempropdef", &propdef);
4925        add_2da_entry(&mut tables, "iprp_bonuscost", &bonuscost);
4926        add_2da_entry(&mut tables, "iprp_costtable", &costtable);
4927        add_2da_entry(&mut tables, "iprp_immuncost", &immuncost);
4928
4929        let resolved = uti.resolve(&mut tables);
4930        assert_eq!(resolved.ability_bonuses().collect::<Vec<_>>(), vec![(2, 2)]);
4931        assert_eq!(resolved.damage_bonuses().collect::<Vec<_>>(), vec![(3, 7)]);
4932        assert_eq!(
4933            resolved.damage_immunities().collect::<Vec<_>>(),
4934            vec![(8, 50)]
4935        );
4936    }
4937
4938    #[test]
4939    fn one_projection_yields_diverging_magnitudes_per_scope() {
4940        // Mod conflict scenario for magnitude resolution: same UTI
4941        // bytes, two scopes whose iprp_bonuscost rebalances the
4942        // ability bonus magnitude. The shared projection survives;
4943        // each per-scope resolution reports its own value.
4944        let uti = Uti {
4945            properties: vec![property_with_cost(0, 2, 1, 4)],
4946            ..Uti::default()
4947        };
4948        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
4949
4950        let vanilla_bonus = cost_value_2da(&[(4, 2)]);
4951        let mut vanilla_tables = TestTables::new();
4952        add_2da_entry(&mut vanilla_tables, "itempropdef", &propdef);
4953        add_2da_entry(&mut vanilla_tables, "iprp_bonuscost", &vanilla_bonus);
4954
4955        let mod_bonus = cost_value_2da(&[(4, 6)]); // Mod rebalances row 4 from +2 to +6.
4956        let mut mod_tables = TestTables::new();
4957        add_2da_entry(&mut mod_tables, "itempropdef", &propdef);
4958        add_2da_entry(&mut mod_tables, "iprp_bonuscost", &mod_bonus);
4959
4960        let projection = {
4961            let propdef_ref = vanilla_tables.twoda(tables::ITEMPROPDEF);
4962            uti.project(propdef_ref)
4963        };
4964        let vanilla = projection.resolve(&mut vanilla_tables);
4965        let modded = projection.resolve(&mut mod_tables);
4966
4967        assert_eq!(vanilla.ability_bonuses().collect::<Vec<_>>(), vec![(2, 2)]);
4968        assert_eq!(modded.ability_bonuses().collect::<Vec<_>>(), vec![(2, 6)]);
4969    }
4970}