Skip to main content

rakata_generics/decoded/
uti.rs

1//! Resolver-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 borrow a [`TwoDaCache`] and ask for
6//! `view = thing.snapshot(&mut cache)`, then query the view with
7//! semantics-bearing methods (e.g. `view.is_weapon()`,
8//! `view.damage_bonuses()`). The snapshot is built by composing a
9//! cheap projection with a per-scope resolution step; see
10//! [`UtiProjection`] / [`UtiSnapshot`] and [`Uti::project`] for the
11//! multi-scope path.
12//!
13//! ## Mod-friendliness
14//!
15//! Decoding reads against the user's actually-loaded 2DA tables via
16//! the [`Resolver`](rakata_extract::Resolver) the cache borrows.
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_extract::{tables, TwoDaCache};
27use rakata_formats::twoda::TwoDa;
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 TwoDaCache) -> 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).ok()?;
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).ok()?;
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 [`UtiSnapshot`]s are built.
1108///
1109/// Construct via [`Uti::project`]. Multiple snapshots 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::snapshot`] (or [`Uti::snapshot`] 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 snapshot.
1129/// The snapshot does not retain the [`TwoDaCache`] borrow once
1130/// constructed; to query under a different scope, build a fresh
1131/// snapshot 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 snapshot 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 UtiSnapshot<'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 snapshot 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 snapshot
1154/// time so the combat / equip queries on [`UtiSnapshot`] 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 TwoDaCache) -> 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").ok()?;
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 TwoDaCache,
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").ok()?;
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).ok()?;
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 [`UtiSnapshot`] whose query methods read from a cached
1301    /// resolution of every table the snapshot will be asked about.
1302    ///
1303    /// Multiple snapshots 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 snapshot(&self, cache: &mut TwoDaCache) -> UtiSnapshot<'a> {
1307        let base_item_info = cache
1308            .twoda(tables::BASEITEMS)
1309            .ok()
1310            .and_then(|table| BaseItemInfo::from_row(table, self.uti.base_item));
1311        let resolved_magnitudes = self
1312            .decoded_properties
1313            .iter()
1314            .map(|prop| resolve_magnitude(prop, cache))
1315            .collect();
1316        UtiSnapshot {
1317            uti: self.uti,
1318            decoded_properties: self.decoded_properties.clone(),
1319            base_item_info,
1320            resolved_magnitudes,
1321        }
1322    }
1323}
1324
1325impl<'a> UtiSnapshot<'a> {
1326    /// Returns the typed-variant decoded properties as a borrowed
1327    /// slice.
1328    ///
1329    /// The order matches the source [`Uti::properties`] ordering;
1330    /// callers that need a particular property look it up by index
1331    /// or filter the slice.
1332    pub fn properties(&self) -> &[DecodedProperty] {
1333        &self.decoded_properties
1334    }
1335
1336    /// Returns `true` when the source UTI's `BaseItem` belongs to
1337    /// the canonical armor base-item set.
1338    ///
1339    /// Pure delegation to [`Uti::is_armor`] -- the underlying check
1340    /// is a const lookup against the hardcoded armor base-item id
1341    /// set and needs no 2DA cache. Re-exposed here so callers that
1342    /// only hold a [`UtiSnapshot`] do not have to reach back to the
1343    /// source `Uti`.
1344    pub fn is_armor(&self) -> bool {
1345        self.uti.is_armor()
1346    }
1347
1348    /// Returns `true` when the item's `baseitems.2da` row marks it
1349    /// as a wielded weapon (`weaponwield > 0`).
1350    ///
1351    /// Returns `false` when `baseitems.2da` is unavailable, the
1352    /// `base_item` does not resolve to a row, or the cell is
1353    /// missing / unparseable.
1354    pub fn is_weapon(&self) -> bool {
1355        self.base_item_info
1356            .as_ref()
1357            .is_some_and(|b| b.weapon_wield > 0)
1358    }
1359
1360    /// Returns `true` when the item is consumable, defined by the
1361    /// engine's signal `stacking > 1` (stim packs, grenades, med
1362    /// kits all stack; equipment does not).
1363    ///
1364    /// Returns `false` when `baseitems.2da` is unavailable, the
1365    /// `base_item` does not resolve to a row, or the cell is
1366    /// missing / unparseable.
1367    pub fn is_consumable(&self) -> bool {
1368        self.base_item_info.as_ref().is_some_and(|b| b.stacking > 1)
1369    }
1370
1371    /// Returns the item's equipable-slot bitmask from
1372    /// `baseitems.2da#equipableslots`, parsed from its hex-string
1373    /// form.
1374    ///
1375    /// Returns `None` when `baseitems.2da` is unavailable, the
1376    /// `base_item` does not resolve to a row, or the cell is
1377    /// missing / unparseable.
1378    pub fn equip_slot_mask(&self) -> Option<u32> {
1379        self.base_item_info.as_ref().and_then(|b| b.equip_slot_mask)
1380    }
1381
1382    /// Returns the item's model-type id from
1383    /// `baseitems.2da#modeltype`.
1384    ///
1385    /// The value gates whether the engine consults the UTI's
1386    /// `TextureVar` field (only when `model_type == 1` per the
1387    /// UTI engine audit). Returns `None` when `baseitems.2da` is
1388    /// unavailable, the `base_item` does not resolve to a row, or
1389    /// the cell is missing / unparseable.
1390    pub fn model_type(&self) -> Option<u8> {
1391        self.base_item_info.as_ref().and_then(|b| b.model_type)
1392    }
1393
1394    /// Returns `true` when any decoded property on this item falls
1395    /// into the given [`PropertyKindFilter`] family.
1396    ///
1397    /// `Unknown` variants never match any filter; consumers wanting
1398    /// to inspect unknown property kinds should walk
1399    /// [`Self::properties`] directly. For exact-variant queries (a
1400    /// specific kind rather than a family), pattern-match against
1401    /// the typed [`DecodedProperty`] variants directly.
1402    pub fn has_property_kind(&self, filter: PropertyKindFilter) -> bool {
1403        self.decoded_properties
1404            .iter()
1405            .any(|prop| filter.matches(prop))
1406    }
1407
1408    /// Iterates this item's [`DecodedProperty::AbilityBonus`]
1409    /// properties, yielding `(subtype_id, magnitude)` pairs.
1410    ///
1411    /// `subtype_id` indexes into `iprp_abilities.2da` (vanilla rows
1412    /// 0..=5 cover STR/DEX/CON/INT/WIS/CHA). `magnitude` is the
1413    /// resolved bonus value from `iprp_bonuscost.2da#Value` at row
1414    /// `cost_value`, per the cost-table magnitude resolution
1415    /// subsection of `docs/src/formats/gff/uti.md`. The cost-table
1416    /// index is hardcoded by the engine handler for this kind, so the
1417    /// property's `cost_table` field is ignored.
1418    ///
1419    /// Properties whose magnitude could not be resolved at snapshot
1420    /// time are silently skipped.
1421    pub fn ability_bonuses(&self) -> impl Iterator<Item = (u16, i32)> + '_ {
1422        self.iter_resolved_magnitudes(|prop| match prop {
1423            DecodedProperty::AbilityBonus { subtype_id, .. } => Some(*subtype_id),
1424            _ => None,
1425        })
1426    }
1427
1428    /// Iterates this item's [`DecodedProperty::DamageBonus`]
1429    /// properties, yielding `(subtype_id, magnitude)` pairs.
1430    ///
1431    /// `subtype_id` indexes into `iprp_damagetype.2da`. `magnitude`
1432    /// is `cost_value` taken directly: per the audit, the engine's
1433    /// `ApplyDamageBonus` handler bypasses cost-table dispatch and
1434    /// reads `CostValue` as the damage amount.
1435    ///
1436    /// Properties whose magnitude could not be resolved at snapshot
1437    /// time are silently skipped.
1438    pub fn damage_bonuses(&self) -> impl Iterator<Item = (u16, i32)> + '_ {
1439        self.iter_resolved_magnitudes(|prop| match prop {
1440            DecodedProperty::DamageBonus { subtype_id, .. } => Some(*subtype_id),
1441            _ => None,
1442        })
1443    }
1444
1445    /// Iterates this item's [`DecodedProperty::DamageImmunity`]
1446    /// properties, yielding `(subtype_id, magnitude)` pairs.
1447    ///
1448    /// `subtype_id` indexes into `iprp_damagetype.2da` (which damage
1449    /// type the immunity applies to). `magnitude` is read by walking
1450    /// the dynamic cost-table dispatch chain
1451    /// (`iprp_costtable.2da[cost_table]#Name` -> per-cost 2DA at row
1452    /// `cost_value`, column `Value`), per the cost-table magnitude
1453    /// resolution subsection of `docs/src/formats/gff/uti.md`. The
1454    /// cost-table index is read from each property's `cost_table`
1455    /// field, so mod-extended cost tables resolve through the same
1456    /// path.
1457    ///
1458    /// Properties whose magnitude could not be resolved at snapshot
1459    /// time are silently skipped.
1460    pub fn damage_immunities(&self) -> impl Iterator<Item = (u16, i32)> + '_ {
1461        self.iter_resolved_magnitudes(|prop| match prop {
1462            DecodedProperty::DamageImmunity { subtype_id, .. } => Some(*subtype_id),
1463            _ => None,
1464        })
1465    }
1466
1467    /// Shared iterator skeleton for the per-kind magnitude iterators.
1468    /// `pick_subtype` returns `Some(subtype_id)` for properties of
1469    /// the kind the caller wants, `None` otherwise. The combined
1470    /// iterator yields `(subtype_id, magnitude)` only when the kind
1471    /// matches and the snapshot has a resolved magnitude for that
1472    /// property.
1473    fn iter_resolved_magnitudes<F>(&self, pick_subtype: F) -> impl Iterator<Item = (u16, i32)> + '_
1474    where
1475        F: Fn(&DecodedProperty) -> Option<u16> + 'static,
1476    {
1477        self.decoded_properties
1478            .iter()
1479            .zip(self.resolved_magnitudes.iter())
1480            .filter_map(move |(prop, magnitude)| {
1481                let subtype = pick_subtype(prop)?;
1482                let value = (*magnitude)?;
1483                Some((subtype, value))
1484            })
1485    }
1486}
1487
1488/// Categorical filter for [`UtiSnapshot::has_property_kind`].
1489///
1490/// Each variant maps to a family of typed [`DecodedProperty`]
1491/// variants that share a semantic theme (damage modification,
1492/// wielder restriction, active-use, etc.). The filters are not
1493/// exclusive: `DamageImmunity` and `DamageResistance` count under
1494/// [`PropertyKindFilter::Damage`], so an item with one or the other
1495/// matches the `Damage` filter.
1496///
1497/// Property kinds that do not naturally cluster with a family
1498/// (`OnHit`, `Light`, `BonusFeats`, `Skill`, `Immunity`,
1499/// `Regeneration*`, `Disguise`, `MagicResistBonus`, `Keen`,
1500/// `MassiveCriticals`, `BlasterBoltDeflectIncrease`,
1501/// `MonsterDamage`, `DamageNone`, `TrueSeeing`) are not surfaced by
1502/// any filter; callers needing them pattern-match the typed
1503/// variants directly via [`UtiSnapshot::properties`].
1504#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1505pub enum PropertyKindFilter {
1506    /// Any damage-family property: `DamageBonus`, `DamageImmunity`,
1507    /// `DamageResistance`, `DamageRacialGroup`,
1508    /// `DamageAlignmentGroup`, or `DamagePenalty`.
1509    Damage,
1510    /// Ability-score bonus (`AbilityBonus`).
1511    Ability,
1512    /// Saving-throw bonus or penalty, universal or specific:
1513    /// `SaveBonus`, `SaveBonusSpecific`, `SavePenalty`,
1514    /// `SavePenaltySpecific`.
1515    Save,
1516    /// Attack bonus or penalty: `AttackBonus`,
1517    /// `AttackBonusAlignmentGroup`, `AttackPenalty`.
1518    Attack,
1519    /// Enhancement bonus, flat or conditional: `EnhancementBonus`,
1520    /// `EnhancementRacialGroup`, `EnhancementAlignmentGroup`.
1521    Enhancement,
1522    /// Wielder-restriction property: `UseLimitationFeat`,
1523    /// `UseLimitationRacial`, `UseLimitationAlignmentGroup`.
1524    UseLimitation,
1525    /// Engine-active property routed into the per-character
1526    /// usable-ability table: `CastSpell`, `Trap`, `ThievesTools`,
1527    /// `ComputerSpike`.
1528    Active,
1529}
1530
1531impl PropertyKindFilter {
1532    fn matches(self, prop: &DecodedProperty) -> bool {
1533        match self {
1534            Self::Damage => matches!(
1535                prop,
1536                DecodedProperty::DamageBonus { .. }
1537                    | DecodedProperty::DamageImmunity { .. }
1538                    | DecodedProperty::DamageResistance { .. }
1539                    | DecodedProperty::DamageRacialGroup { .. }
1540                    | DecodedProperty::DamageAlignmentGroup { .. }
1541                    | DecodedProperty::DamagePenalty { .. }
1542            ),
1543            Self::Ability => matches!(prop, DecodedProperty::AbilityBonus { .. }),
1544            Self::Save => matches!(
1545                prop,
1546                DecodedProperty::SaveBonus { .. }
1547                    | DecodedProperty::SaveBonusSpecific { .. }
1548                    | DecodedProperty::SavePenalty { .. }
1549                    | DecodedProperty::SavePenaltySpecific { .. }
1550            ),
1551            Self::Attack => matches!(
1552                prop,
1553                DecodedProperty::AttackBonus { .. }
1554                    | DecodedProperty::AttackBonusAlignmentGroup { .. }
1555                    | DecodedProperty::AttackPenalty { .. }
1556            ),
1557            Self::Enhancement => matches!(
1558                prop,
1559                DecodedProperty::EnhancementBonus { .. }
1560                    | DecodedProperty::EnhancementRacialGroup { .. }
1561                    | DecodedProperty::EnhancementAlignmentGroup { .. }
1562            ),
1563            Self::UseLimitation => matches!(
1564                prop,
1565                DecodedProperty::UseLimitationFeat { .. }
1566                    | DecodedProperty::UseLimitationRacial { .. }
1567                    | DecodedProperty::UseLimitationAlignmentGroup { .. }
1568            ),
1569            Self::Active => matches!(
1570                prop,
1571                DecodedProperty::CastSpell { .. }
1572                    | DecodedProperty::Trap { .. }
1573                    | DecodedProperty::ThievesTools { .. }
1574                    | DecodedProperty::ComputerSpike { .. }
1575            ),
1576        }
1577    }
1578}
1579
1580impl Uti {
1581    /// Builds a [`UtiProjection`] of this UTI's properties using the
1582    /// supplied `itempropdef.2da`.
1583    ///
1584    /// Each `UtiProperty` is routed by its `itempropdef.2da` `label`
1585    /// to a typed [`DecodedProperty`] variant where one exists, or to
1586    /// [`DecodedProperty::Unknown`] otherwise. Pass `None` for
1587    /// `itempropdef` to surface every property as `Unknown` with
1588    /// `property_label = None`; the engine itself tolerates a
1589    /// missing table the same way.
1590    ///
1591    /// The projection is the cheap, scope-free intermediate. Use
1592    /// [`UtiProjection::snapshot`] (or [`Uti::snapshot`] for the
1593    /// single-scope shortcut) to resolve it against a per-scope
1594    /// context.
1595    pub fn project(&self, itempropdef: Option<&TwoDa>) -> UtiProjection<'_> {
1596        let decoded_properties = self
1597            .properties
1598            .iter()
1599            .map(|p| {
1600                let label = itempropdef
1601                    .and_then(|table| table.cell(usize::from(p.property_name), "label"))
1602                    .map(str::to_string);
1603                decode_property(p, label)
1604            })
1605            .collect();
1606        UtiProjection {
1607            uti: self,
1608            decoded_properties,
1609        }
1610    }
1611
1612    /// Builds a [`UtiSnapshot`] of this UTI against the given 2DA
1613    /// cache. Single-scope shortcut equivalent to
1614    /// `self.project(itempropdef).snapshot(cache)`, where
1615    /// `itempropdef` comes from `cache`.
1616    ///
1617    /// Missing or malformed `itempropdef.2da` / `baseitems.2da` is
1618    /// tolerated -- the corresponding snapshot state degrades
1619    /// gracefully (all properties land in `Unknown`; combat / equip
1620    /// queries return `false` / `None`).
1621    ///
1622    /// For multi-scope workflows (mod conflict analysis, vanilla vs
1623    /// modded diffs), call [`Uti::project`] once and
1624    /// [`UtiProjection::snapshot`] per scope so the typed-variant
1625    /// dispatch is not redone for each context.
1626    pub fn snapshot(&self, cache: &mut TwoDaCache) -> UtiSnapshot<'_> {
1627        // Inner block releases the itempropdef borrow before
1628        // `UtiProjection::snapshot` re-borrows the cache for
1629        // baseitems.
1630        let projection = {
1631            let propdef = cache.twoda(tables::ITEMPROPDEF).ok();
1632            self.project(propdef)
1633        };
1634        projection.snapshot(cache)
1635    }
1636}
1637
1638/// Routes a single [`UtiProperty`] to a typed [`DecodedProperty`]
1639/// variant by matching on the `itempropdef.2da` `label` value
1640/// resolved at decode time.
1641///
1642/// Properties without a matching typed arm fall through to
1643/// [`DecodedProperty::Unknown`] carrying the raw fields plus the
1644/// resolved label.
1645fn decode_property(p: &UtiProperty, label: Option<String>) -> DecodedProperty {
1646    match label.as_deref() {
1647        Some("Ability") => DecodedProperty::AbilityBonus {
1648            property_id: p.property_name,
1649            subtype_id: p.subtype,
1650            cost_table: p.cost_table,
1651            cost_value: p.cost_value,
1652        },
1653        Some("ImprovedSavingThrows") => DecodedProperty::SaveBonus {
1654            property_id: p.property_name,
1655            subtype_id: p.subtype,
1656            cost_table: p.cost_table,
1657            cost_value: p.cost_value,
1658        },
1659        Some("ImprovedSavingThrowsSpecific") => DecodedProperty::SaveBonusSpecific {
1660            property_id: p.property_name,
1661            subtype_id: p.subtype,
1662            cost_table: p.cost_table,
1663            cost_value: p.cost_value,
1664        },
1665        Some("ReducedSavingThrows") => DecodedProperty::SavePenalty {
1666            property_id: p.property_name,
1667            subtype_id: p.subtype,
1668            cost_table: p.cost_table,
1669            cost_value: p.cost_value,
1670        },
1671        Some("ReducedSpecificSavingThrow") => DecodedProperty::SavePenaltySpecific {
1672            property_id: p.property_name,
1673            subtype_id: p.subtype,
1674            cost_table: p.cost_table,
1675            cost_value: p.cost_value,
1676        },
1677        Some("Damage") => DecodedProperty::DamageBonus {
1678            property_id: p.property_name,
1679            subtype_id: p.subtype,
1680            cost_table: p.cost_table,
1681            cost_value: p.cost_value,
1682        },
1683        Some("DamageImmunity") => DecodedProperty::DamageImmunity {
1684            property_id: p.property_name,
1685            subtype_id: p.subtype,
1686            cost_table: p.cost_table,
1687            cost_value: p.cost_value,
1688        },
1689        Some("DamageResist") => DecodedProperty::DamageResistance {
1690            property_id: p.property_name,
1691            subtype_id: p.subtype,
1692            cost_table: p.cost_table,
1693            cost_value: p.cost_value,
1694        },
1695        Some("Armor") => DecodedProperty::AcBonus {
1696            property_id: p.property_name,
1697            subtype_id: p.subtype,
1698            cost_table: p.cost_table,
1699            cost_value: p.cost_value,
1700        },
1701        Some("Enhancement") => DecodedProperty::EnhancementBonus {
1702            property_id: p.property_name,
1703            subtype_id: p.subtype,
1704            cost_table: p.cost_table,
1705            cost_value: p.cost_value,
1706        },
1707        Some("OnHit") => DecodedProperty::OnHit {
1708            property_id: p.property_name,
1709            subtype_id: p.subtype,
1710            cost_table: p.cost_table,
1711            cost_value: p.cost_value,
1712            param1: p.param1,
1713            param1_value: p.param1_value,
1714        },
1715        Some("CastSpell") => DecodedProperty::CastSpell {
1716            property_id: p.property_name,
1717            subtype_id: p.subtype,
1718            cost_table: p.cost_table,
1719            cost_value: p.cost_value,
1720            useable: active_useable(p.useable),
1721            uses_per_day: active_uses_per_day(p.uses_per_day),
1722        },
1723        Some("Trap") => DecodedProperty::Trap {
1724            property_id: p.property_name,
1725            subtype_id: p.subtype,
1726            cost_table: p.cost_table,
1727            cost_value: p.cost_value,
1728            useable: active_useable(p.useable),
1729            uses_per_day: active_uses_per_day(p.uses_per_day),
1730        },
1731        Some("ThievesTools") => DecodedProperty::ThievesTools {
1732            property_id: p.property_name,
1733            subtype_id: p.subtype,
1734            cost_table: p.cost_table,
1735            cost_value: p.cost_value,
1736            useable: active_useable(p.useable),
1737            uses_per_day: active_uses_per_day(p.uses_per_day),
1738        },
1739        Some("Computer_Spike") => DecodedProperty::ComputerSpike {
1740            property_id: p.property_name,
1741            subtype_id: p.subtype,
1742            cost_table: p.cost_table,
1743            cost_value: p.cost_value,
1744            useable: active_useable(p.useable),
1745            uses_per_day: active_uses_per_day(p.uses_per_day),
1746        },
1747        Some("Use_Limitation_Feat") => DecodedProperty::UseLimitationFeat {
1748            property_id: p.property_name,
1749            subtype_id: p.subtype,
1750            cost_table: p.cost_table,
1751            cost_value: p.cost_value,
1752        },
1753        Some("UseLimitationRacial") => DecodedProperty::UseLimitationRacial {
1754            property_id: p.property_name,
1755            subtype_id: p.subtype,
1756            cost_table: p.cost_table,
1757            cost_value: p.cost_value,
1758        },
1759        Some("UseLimitationAlignmentGroup") => DecodedProperty::UseLimitationAlignmentGroup {
1760            property_id: p.property_name,
1761            subtype_id: p.subtype,
1762            cost_table: p.cost_table,
1763            cost_value: p.cost_value,
1764        },
1765        Some("DamageRacialGroup") => DecodedProperty::DamageRacialGroup {
1766            property_id: p.property_name,
1767            subtype_id: p.subtype,
1768            cost_table: p.cost_table,
1769            cost_value: p.cost_value,
1770        },
1771        Some("DamageAlignmentGroup") => DecodedProperty::DamageAlignmentGroup {
1772            property_id: p.property_name,
1773            subtype_id: p.subtype,
1774            cost_table: p.cost_table,
1775            cost_value: p.cost_value,
1776        },
1777        Some("EnhancementRacialGroup") => DecodedProperty::EnhancementRacialGroup {
1778            property_id: p.property_name,
1779            subtype_id: p.subtype,
1780            cost_table: p.cost_table,
1781            cost_value: p.cost_value,
1782        },
1783        Some("EnhancementAlignmentGroup") => DecodedProperty::EnhancementAlignmentGroup {
1784            property_id: p.property_name,
1785            subtype_id: p.subtype,
1786            cost_table: p.cost_table,
1787            cost_value: p.cost_value,
1788        },
1789        Some("AttackBonusAlignmentGroup") => DecodedProperty::AttackBonusAlignmentGroup {
1790            property_id: p.property_name,
1791            subtype_id: p.subtype,
1792            cost_table: p.cost_table,
1793            cost_value: p.cost_value,
1794        },
1795        Some("True_Seeing") => DecodedProperty::TrueSeeing {
1796            property_id: p.property_name,
1797            subtype_id: p.subtype,
1798            cost_table: p.cost_table,
1799            cost_value: p.cost_value,
1800        },
1801        Some("Light") => DecodedProperty::Light {
1802            property_id: p.property_name,
1803            subtype_id: p.subtype,
1804            cost_table: p.cost_table,
1805            cost_value: p.cost_value,
1806            param1: p.param1,
1807            param1_value: p.param1_value,
1808        },
1809        Some("AttackBonus") => DecodedProperty::AttackBonus {
1810            property_id: p.property_name,
1811            subtype_id: p.subtype,
1812            cost_table: p.cost_table,
1813            cost_value: p.cost_value,
1814        },
1815        Some("Keen") => DecodedProperty::Keen {
1816            property_id: p.property_name,
1817            subtype_id: p.subtype,
1818            cost_table: p.cost_table,
1819            cost_value: p.cost_value,
1820        },
1821        Some("Massive_Criticals") => DecodedProperty::MassiveCriticals {
1822            property_id: p.property_name,
1823            subtype_id: p.subtype,
1824            cost_table: p.cost_table,
1825            cost_value: p.cost_value,
1826        },
1827        Some("Blaster_Bolt_Deflect_Increase") => DecodedProperty::BlasterBoltDeflectIncrease {
1828            property_id: p.property_name,
1829            subtype_id: p.subtype,
1830            cost_table: p.cost_table,
1831            cost_value: p.cost_value,
1832        },
1833        Some("Monster_damage") => DecodedProperty::MonsterDamage {
1834            property_id: p.property_name,
1835            subtype_id: p.subtype,
1836            cost_table: p.cost_table,
1837            cost_value: p.cost_value,
1838        },
1839        Some("BonusFeats") => DecodedProperty::BonusFeats {
1840            property_id: p.property_name,
1841            subtype_id: p.subtype,
1842            cost_table: p.cost_table,
1843            cost_value: p.cost_value,
1844        },
1845        Some("Immunity") => DecodedProperty::Immunity {
1846            property_id: p.property_name,
1847            subtype_id: p.subtype,
1848            cost_table: p.cost_table,
1849            cost_value: p.cost_value,
1850        },
1851        Some("Skill") => DecodedProperty::Skill {
1852            property_id: p.property_name,
1853            subtype_id: p.subtype,
1854            cost_table: p.cost_table,
1855            cost_value: p.cost_value,
1856        },
1857        Some("AttackPenalty") => DecodedProperty::AttackPenalty {
1858            property_id: p.property_name,
1859            subtype_id: p.subtype,
1860            cost_table: p.cost_table,
1861            cost_value: p.cost_value,
1862        },
1863        Some("DamagePenalty") => DecodedProperty::DamagePenalty {
1864            property_id: p.property_name,
1865            subtype_id: p.subtype,
1866            cost_table: p.cost_table,
1867            cost_value: p.cost_value,
1868        },
1869        Some("ImprovedMagicResist") => DecodedProperty::MagicResistBonus {
1870            property_id: p.property_name,
1871            subtype_id: p.subtype,
1872            cost_table: p.cost_table,
1873            cost_value: p.cost_value,
1874        },
1875        Some("DamageNone") => DecodedProperty::DamageNone {
1876            property_id: p.property_name,
1877            subtype_id: p.subtype,
1878            cost_table: p.cost_table,
1879            cost_value: p.cost_value,
1880        },
1881        Some("Regeneration") => DecodedProperty::Regeneration {
1882            property_id: p.property_name,
1883            subtype_id: p.subtype,
1884            cost_table: p.cost_table,
1885            cost_value: p.cost_value,
1886        },
1887        Some("Regeneration_Force_Points") => DecodedProperty::RegenerationForcePoints {
1888            property_id: p.property_name,
1889            subtype_id: p.subtype,
1890            cost_table: p.cost_table,
1891            cost_value: p.cost_value,
1892        },
1893        Some("Disguise") => DecodedProperty::Disguise {
1894            property_id: p.property_name,
1895            subtype_id: p.subtype,
1896            cost_table: p.cost_table,
1897            cost_value: p.cost_value,
1898        },
1899        // The itempropdef.2da rows below have no vanilla .uti
1900        // references per `vanilla-inspector uti property-stats` and
1901        // fall through to Unknown by design. Anyone needing one can
1902        // add a typed variant in minutes: the uti.md reference table
1903        // names each row's label and subtype 2DA.
1904        //
1905        // Grouped by family for promotion convenience:
1906        //   Armor-conditional        : rows 2, 3, 4
1907        //   AttackBonus-conditional  : row 40
1908        //   Damage extensions        : rows 16, 18, 22, 23
1909        //   Negative/decrease mirrors: rows 19, 20, 21, 41
1910        //   Use-limitation extras    : row 44
1911        //   Special properties       : 30, 42, 48, 50, 52, 56, 58
1912        //
1913        // Re-run `vanilla-inspector uti property-stats` before
1914        // promoting any row to confirm this list is still current.
1915        _ => DecodedProperty::Unknown {
1916            property_id: p.property_name,
1917            property_label: label,
1918            subtype: p.subtype,
1919            cost_table: p.cost_table,
1920            cost_value: p.cost_value,
1921            param1: p.param1,
1922            param1_value: p.param1_value,
1923        },
1924    }
1925}
1926
1927/// Coalesces the engine's "missing GFF field" default for active
1928/// properties. Per the Ghidra audit, when an active-property entry
1929/// omits `Useable`, the engine defaults the flag to `1` (active);
1930/// passive entries default it to `0`. Active variants carry that
1931/// default into the decoded model so consumers do not have to know
1932/// the kind-specific rule.
1933fn active_useable(raw: Option<bool>) -> bool {
1934    raw.unwrap_or(true)
1935}
1936
1937/// Coalesces the engine's `0xFF` "not set" sentinel on `UsesPerDay`
1938/// into `None`. Both an absent GFF field and an explicit `0xFF`
1939/// decode the same way; explicit non-sentinel values pass through
1940/// unchanged.
1941fn active_uses_per_day(raw: Option<u8>) -> Option<u8> {
1942    match raw {
1943        Some(0xFF) | None => None,
1944        Some(value) => Some(value),
1945    }
1946}
1947
1948#[cfg(test)]
1949mod tests {
1950    use super::*;
1951    use crate::uti::UtiProperty;
1952    use rakata_core::{ResourceType, ResourceTypeCode};
1953    use rakata_extract::{OverrideSource, Resolver, ResolverSourceRef};
1954    use rakata_formats::twoda::{write_twoda_to_vec, TwoDa, TwoDaRow};
1955
1956    fn property(property_name: u16, subtype: u16) -> UtiProperty {
1957        UtiProperty {
1958            cost_table: 1,
1959            cost_value: 5,
1960            param1: 0xFF,
1961            param1_value: 0,
1962            property_name,
1963            subtype,
1964            chance_appear: 100,
1965            useable: None,
1966            uses_per_day: None,
1967            upgrade_type: None,
1968        }
1969    }
1970
1971    fn itempropdef_with_labels(labels: &[(usize, &str)]) -> TwoDa {
1972        let max_row = labels.iter().map(|(idx, _)| *idx).max().unwrap_or(0);
1973        let mut rows = Vec::with_capacity(max_row + 1);
1974        for row_index in 0..=max_row {
1975            let label = labels
1976                .iter()
1977                .find(|(idx, _)| *idx == row_index)
1978                .map(|(_, label)| (*label).to_string())
1979                .unwrap_or_default();
1980            rows.push(TwoDaRow {
1981                label: row_index.to_string(),
1982                cells: vec![label],
1983            });
1984        }
1985        TwoDa {
1986            headers: vec!["label".to_string()],
1987            rows,
1988        }
1989    }
1990
1991    fn override_with_2da(name: &str, table: &TwoDa) -> OverrideSource {
1992        let bytes = write_twoda_to_vec(table).expect("write 2da fixture");
1993        let mut overrides = OverrideSource::new();
1994        overrides
1995            .add_entry(
1996                name,
1997                ResourceTypeCode::from(ResourceType::TwoDa),
1998                bytes,
1999                "test",
2000            )
2001            .expect("add override entry");
2002        overrides
2003    }
2004
2005    #[test]
2006    fn decodes_every_property_into_unknown_variant() {
2007        let uti = Uti {
2008            properties: vec![property(0, 1), property(7, 12), property(45, 0)],
2009            ..Uti::default()
2010        };
2011        let resolver = Resolver::new();
2012        let mut cache = TwoDaCache::new(&resolver);
2013
2014        let view = uti.snapshot(&mut cache);
2015        assert_eq!(view.properties().len(), 3);
2016        assert!(view
2017            .decoded_properties
2018            .iter()
2019            .all(|p| matches!(p, DecodedProperty::Unknown { .. })));
2020    }
2021
2022    #[test]
2023    fn unknown_carries_raw_fields_through_unchanged() {
2024        let raw = property(0, 0);
2025        let mut shaped = raw.clone();
2026        shaped.property_name = 7;
2027        shaped.subtype = 12;
2028        shaped.cost_table = 3;
2029        shaped.cost_value = 99;
2030        shaped.param1 = 4;
2031        shaped.param1_value = 200;
2032        let uti = Uti {
2033            properties: vec![shaped.clone()],
2034            ..Uti::default()
2035        };
2036        let resolver = Resolver::new();
2037        let mut cache = TwoDaCache::new(&resolver);
2038
2039        let view = uti.snapshot(&mut cache);
2040        let DecodedProperty::Unknown {
2041            property_id,
2042            subtype,
2043            cost_table,
2044            cost_value,
2045            param1,
2046            param1_value,
2047            ..
2048        } = &view.properties()[0]
2049        else {
2050            panic!("expected Unknown variant for unloaded itempropdef");
2051        };
2052        assert_eq!(*property_id, 7);
2053        assert_eq!(*subtype, 12);
2054        assert_eq!(*cost_table, 3);
2055        assert_eq!(*cost_value, 99);
2056        assert_eq!(*param1, 4);
2057        assert_eq!(*param1_value, 200);
2058    }
2059
2060    #[test]
2061    fn property_label_resolves_into_unknown_for_untyped_kinds() {
2062        // Synthetic labels with no matching typed routing arm; both
2063        // properties end up in `Unknown` carrying the resolved label.
2064        let table = itempropdef_with_labels(&[(0, "Test_Kind_A"), (7, "Test_Kind_B")]);
2065        let overrides = override_with_2da("itempropdef", &table);
2066        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2067        let mut cache = TwoDaCache::new(&resolver);
2068
2069        let uti = Uti {
2070            properties: vec![property(7, 0), property(0, 0)],
2071            ..Uti::default()
2072        };
2073        let view = uti.snapshot(&mut cache);
2074
2075        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2076            panic!("expected Unknown variant for synthetic label");
2077        };
2078        assert_eq!(property_label.as_deref(), Some("Test_Kind_B"));
2079        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[1] else {
2080            panic!("expected Unknown variant for synthetic label");
2081        };
2082        assert_eq!(property_label.as_deref(), Some("Test_Kind_A"));
2083    }
2084
2085    #[test]
2086    fn property_label_is_none_when_row_is_absent() {
2087        // itempropdef has rows 0, 1 only; PropertyName 99 is past
2088        // the last row, so the label resolves to None and decode
2089        // routes to Unknown.
2090        let table = itempropdef_with_labels(&[(0, "Test_Kind_A"), (1, "Test_Kind_B")]);
2091        let overrides = override_with_2da("itempropdef", &table);
2092        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2093        let mut cache = TwoDaCache::new(&resolver);
2094
2095        let uti = Uti {
2096            properties: vec![property(99, 0)],
2097            ..Uti::default()
2098        };
2099        let view = uti.snapshot(&mut cache);
2100
2101        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2102            panic!("expected Unknown variant for absent row");
2103        };
2104        assert!(property_label.is_none());
2105    }
2106
2107    #[test]
2108    fn property_label_is_none_when_itempropdef_is_missing() {
2109        // No source contains itempropdef.2da. Decode succeeds, every
2110        // property routes to Unknown with label = None.
2111        let resolver = Resolver::new();
2112        let mut cache = TwoDaCache::new(&resolver);
2113
2114        let uti = Uti {
2115            properties: vec![property(0, 0), property(7, 0)],
2116            ..Uti::default()
2117        };
2118        let view = uti.snapshot(&mut cache);
2119
2120        for prop in view.properties() {
2121            let DecodedProperty::Unknown { property_label, .. } = prop else {
2122                panic!("expected Unknown variant when itempropdef is missing");
2123            };
2124            assert!(property_label.is_none());
2125        }
2126    }
2127
2128    #[test]
2129    fn mod_added_property_label_surfaces_via_unknown() {
2130        // A mod adds row 200 to itempropdef with a custom label that
2131        // matches no typed routing arm. Decode produces Unknown with
2132        // the mod's label preserved.
2133        let table = itempropdef_with_labels(&[(200, "FakeMod_GrantsCookies")]);
2134        let overrides = override_with_2da("itempropdef", &table);
2135        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2136        let mut cache = TwoDaCache::new(&resolver);
2137
2138        let uti = Uti {
2139            properties: vec![property(200, 5)],
2140            ..Uti::default()
2141        };
2142        let view = uti.snapshot(&mut cache);
2143
2144        let DecodedProperty::Unknown {
2145            property_id,
2146            property_label,
2147            subtype,
2148            ..
2149        } = &view.properties()[0]
2150        else {
2151            panic!("expected Unknown variant for mod-added label");
2152        };
2153        assert_eq!(*property_id, 200);
2154        assert_eq!(property_label.as_deref(), Some("FakeMod_GrantsCookies"));
2155        assert_eq!(*subtype, 5);
2156    }
2157
2158    #[test]
2159    fn empty_property_list_decodes_to_empty_view() {
2160        let uti = Uti::default();
2161        let resolver = Resolver::new();
2162        let mut cache = TwoDaCache::new(&resolver);
2163
2164        let view = uti.snapshot(&mut cache);
2165        assert!(view.properties().is_empty());
2166    }
2167
2168    #[test]
2169    fn ability_bonus_label_routes_to_typed_variant() {
2170        // Vanilla itempropdef row 0 carries the label `Ability`. The
2171        // decoder routes that property to the typed AbilityBonus
2172        // variant rather than Unknown.
2173        let table = itempropdef_with_labels(&[(0, "Ability")]);
2174        let overrides = override_with_2da("itempropdef", &table);
2175        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2176        let mut cache = TwoDaCache::new(&resolver);
2177
2178        let mut shaped = property(0, 2);
2179        shaped.cost_table = 1;
2180        shaped.cost_value = 4;
2181        let uti = Uti {
2182            properties: vec![shaped],
2183            ..Uti::default()
2184        };
2185        let view = uti.snapshot(&mut cache);
2186
2187        let DecodedProperty::AbilityBonus {
2188            property_id,
2189            subtype_id,
2190            cost_table,
2191            cost_value,
2192        } = &view.properties()[0]
2193        else {
2194            panic!("expected AbilityBonus variant for `Ability` label");
2195        };
2196        assert_eq!(*property_id, 0);
2197        assert_eq!(*subtype_id, 2); // CON in vanilla iprp_abilities ordering
2198        assert_eq!(*cost_table, 1);
2199        assert_eq!(*cost_value, 4);
2200    }
2201
2202    #[test]
2203    fn ability_bonus_preserves_raw_subtype_for_mod_extended_rows() {
2204        // A mod adds a new ability subtype past the vanilla 0..=5
2205        // range. The decode still routes to AbilityBonus and the raw
2206        // subtype id passes through.
2207        let table = itempropdef_with_labels(&[(0, "Ability")]);
2208        let overrides = override_with_2da("itempropdef", &table);
2209        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2210        let mut cache = TwoDaCache::new(&resolver);
2211
2212        let uti = Uti {
2213            properties: vec![property(0, 99)],
2214            ..Uti::default()
2215        };
2216        let view = uti.snapshot(&mut cache);
2217
2218        let DecodedProperty::AbilityBonus { subtype_id, .. } = &view.properties()[0] else {
2219            panic!("expected AbilityBonus variant for mod-extended subtype");
2220        };
2221        assert_eq!(*subtype_id, 99);
2222    }
2223
2224    #[test]
2225    fn ability_bonus_subtype_label_resolves_via_iprp_abilities() {
2226        // The subtype_label helper walks itempropdef.SubTypeResRef ->
2227        // iprp_abilities.label for an AbilityBonus variant.
2228        let propdef = itempropdef_with_subtypes(&[(0, "Ability", "iprp_abilities")]);
2229        let abilities = subtype_2da(&[
2230            (0, "STR"),
2231            (1, "DEX"),
2232            (2, "CON"),
2233            (3, "INT"),
2234            (4, "WIS"),
2235            (5, "CHA"),
2236        ]);
2237        let mut overrides = OverrideSource::new();
2238        add_2da_entry(&mut overrides, "itempropdef", &propdef);
2239        add_2da_entry(&mut overrides, "iprp_abilities", &abilities);
2240        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2241        let mut cache = TwoDaCache::new(&resolver);
2242
2243        let prop = DecodedProperty::AbilityBonus {
2244            property_id: 0,
2245            subtype_id: 3,
2246            cost_table: 0,
2247            cost_value: 0,
2248        };
2249        assert_eq!(prop.subtype_label(&mut cache).as_deref(), Some("INT"));
2250    }
2251
2252    #[test]
2253    fn save_bonus_label_routes_to_typed_variant() {
2254        // Vanilla itempropdef row 26 carries the label
2255        // `ImprovedSavingThrows`. The decoder routes that property to
2256        // the typed SaveBonus variant rather than Unknown.
2257        let table = itempropdef_with_labels(&[(26, "ImprovedSavingThrows")]);
2258        let overrides = override_with_2da("itempropdef", &table);
2259        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2260        let mut cache = TwoDaCache::new(&resolver);
2261
2262        let mut shaped = property(26, 1);
2263        shaped.cost_table = 2;
2264        shaped.cost_value = 3;
2265        let uti = Uti {
2266            properties: vec![shaped],
2267            ..Uti::default()
2268        };
2269        let view = uti.snapshot(&mut cache);
2270
2271        let DecodedProperty::SaveBonus {
2272            property_id,
2273            subtype_id,
2274            cost_table,
2275            cost_value,
2276        } = &view.properties()[0]
2277        else {
2278            panic!("expected SaveBonus variant for `ImprovedSavingThrows` label");
2279        };
2280        assert_eq!(*property_id, 26);
2281        assert_eq!(*subtype_id, 1);
2282        assert_eq!(*cost_table, 2);
2283        assert_eq!(*cost_value, 3);
2284    }
2285
2286    #[test]
2287    fn save_bonus_preserves_raw_subtype_for_mod_extended_rows() {
2288        // A mod adds a save element past the vanilla iprp_saveelement
2289        // range. The decode still routes to SaveBonus and the raw
2290        // subtype id passes through.
2291        let table = itempropdef_with_labels(&[(26, "ImprovedSavingThrows")]);
2292        let overrides = override_with_2da("itempropdef", &table);
2293        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2294        let mut cache = TwoDaCache::new(&resolver);
2295
2296        let uti = Uti {
2297            properties: vec![property(26, 200)],
2298            ..Uti::default()
2299        };
2300        let view = uti.snapshot(&mut cache);
2301
2302        let DecodedProperty::SaveBonus { subtype_id, .. } = &view.properties()[0] else {
2303            panic!("expected SaveBonus variant for mod-extended subtype");
2304        };
2305        assert_eq!(*subtype_id, 200);
2306    }
2307
2308    #[test]
2309    fn save_bonus_does_not_match_save_penalty_or_specific_kinds() {
2310        // The four save-throw labels split into four distinct typed
2311        // variants: SaveBonus (universal positive), SaveBonusSpecific
2312        // (per-throw positive), SavePenalty (universal negative),
2313        // SavePenaltySpecific (per-throw negative). This test pins
2314        // the routing for all four to catch any future regression
2315        // that collapses them or mis-routes one into another.
2316        let table = itempropdef_with_labels(&[
2317            (26, "ImprovedSavingThrows"),
2318            (27, "ImprovedSavingThrowsSpecific"),
2319            (33, "ReducedSavingThrows"),
2320            (34, "ReducedSpecificSavingThrow"),
2321        ]);
2322        let overrides = override_with_2da("itempropdef", &table);
2323        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2324        let mut cache = TwoDaCache::new(&resolver);
2325
2326        let uti = Uti {
2327            properties: vec![
2328                property(26, 0),
2329                property(27, 0),
2330                property(33, 0),
2331                property(34, 0),
2332            ],
2333            ..Uti::default()
2334        };
2335        let view = uti.snapshot(&mut cache);
2336
2337        assert!(matches!(
2338            view.properties()[0],
2339            DecodedProperty::SaveBonus { .. }
2340        ));
2341        assert!(matches!(
2342            view.properties()[1],
2343            DecodedProperty::SaveBonusSpecific { .. }
2344        ));
2345        assert!(matches!(
2346            view.properties()[2],
2347            DecodedProperty::SavePenalty { .. }
2348        ));
2349        assert!(matches!(
2350            view.properties()[3],
2351            DecodedProperty::SavePenaltySpecific { .. }
2352        ));
2353    }
2354
2355    #[test]
2356    fn save_bonus_subtype_label_resolves_via_iprp_saveelement() {
2357        // The subtype_label helper walks itempropdef.SubTypeResRef ->
2358        // iprp_saveelement.label for a SaveBonus variant.
2359        let propdef =
2360            itempropdef_with_subtypes(&[(26, "ImprovedSavingThrows", "iprp_saveelement")]);
2361        let saveelement = subtype_2da(&[(0, "Universal"), (1, "Acid"), (2, "Cold")]);
2362        let mut overrides = OverrideSource::new();
2363        add_2da_entry(&mut overrides, "itempropdef", &propdef);
2364        add_2da_entry(&mut overrides, "iprp_saveelement", &saveelement);
2365        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2366        let mut cache = TwoDaCache::new(&resolver);
2367
2368        let prop = DecodedProperty::SaveBonus {
2369            property_id: 26,
2370            subtype_id: 2,
2371            cost_table: 0,
2372            cost_value: 0,
2373        };
2374        assert_eq!(prop.subtype_label(&mut cache).as_deref(), Some("Cold"));
2375    }
2376
2377    #[test]
2378    fn save_throw_family_subtype_labels_resolve_via_their_2das() {
2379        // SaveBonus / SavePenalty use `iprp_saveelement.2da` (the
2380        // universal element table); SaveBonusSpecific /
2381        // SavePenaltySpecific use `iprp_savingthrow.2da` (the
2382        // per-throw table). Verify each variant resolves through
2383        // the correct table.
2384        let propdef = itempropdef_with_subtypes(&[
2385            (26, "ImprovedSavingThrows", "iprp_saveelement"),
2386            (27, "ImprovedSavingThrowsSpecific", "iprp_savingthrow"),
2387            (33, "ReducedSavingThrows", "iprp_saveelement"),
2388            (34, "ReducedSpecificSavingThrow", "iprp_savingthrow"),
2389        ]);
2390        let saveelement = subtype_2da(&[(0, "Universal"), (1, "Acid"), (2, "Cold")]);
2391        let savingthrow = subtype_2da(&[(0, "Fortitude"), (1, "Reflex"), (2, "Will")]);
2392
2393        let mut overrides = OverrideSource::new();
2394        add_2da_entry(&mut overrides, "itempropdef", &propdef);
2395        add_2da_entry(&mut overrides, "iprp_saveelement", &saveelement);
2396        add_2da_entry(&mut overrides, "iprp_savingthrow", &savingthrow);
2397        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2398        let mut cache = TwoDaCache::new(&resolver);
2399
2400        let specific_bonus = DecodedProperty::SaveBonusSpecific {
2401            property_id: 27,
2402            subtype_id: 2,
2403            cost_table: 0,
2404            cost_value: 0,
2405        };
2406        assert_eq!(
2407            specific_bonus.subtype_label(&mut cache).as_deref(),
2408            Some("Will")
2409        );
2410
2411        let universal_penalty = DecodedProperty::SavePenalty {
2412            property_id: 33,
2413            subtype_id: 1,
2414            cost_table: 0,
2415            cost_value: 0,
2416        };
2417        assert_eq!(
2418            universal_penalty.subtype_label(&mut cache).as_deref(),
2419            Some("Acid")
2420        );
2421
2422        let specific_penalty = DecodedProperty::SavePenaltySpecific {
2423            property_id: 34,
2424            subtype_id: 0,
2425            cost_table: 0,
2426            cost_value: 0,
2427        };
2428        assert_eq!(
2429            specific_penalty.subtype_label(&mut cache).as_deref(),
2430            Some("Fortitude")
2431        );
2432    }
2433
2434    #[test]
2435    fn damage_bonus_label_routes_to_typed_variant() {
2436        // Vanilla itempropdef row 11 carries the label `Damage`. The
2437        // decoder routes that property to the typed DamageBonus
2438        // variant rather than Unknown.
2439        let table = itempropdef_with_labels(&[(11, "Damage")]);
2440        let overrides = override_with_2da("itempropdef", &table);
2441        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2442        let mut cache = TwoDaCache::new(&resolver);
2443
2444        let mut shaped = property(11, 5);
2445        shaped.cost_table = 4;
2446        shaped.cost_value = 2;
2447        let uti = Uti {
2448            properties: vec![shaped],
2449            ..Uti::default()
2450        };
2451        let view = uti.snapshot(&mut cache);
2452
2453        let DecodedProperty::DamageBonus {
2454            property_id,
2455            subtype_id,
2456            cost_table,
2457            cost_value,
2458        } = &view.properties()[0]
2459        else {
2460            panic!("expected DamageBonus variant for `Damage` label");
2461        };
2462        assert_eq!(*property_id, 11);
2463        assert_eq!(*subtype_id, 5);
2464        assert_eq!(*cost_table, 4);
2465        assert_eq!(*cost_value, 2);
2466    }
2467
2468    #[test]
2469    fn damage_bonus_does_not_match_vulnerability_or_unrelated_kinds() {
2470        // Vanilla row 18 (`Damage_Vulnerability`) sits adjacent to
2471        // `Damage` in itempropdef and shares the prefix. It must not
2472        // route to DamageBonus; the decoder leaves it in Unknown
2473        // until it gets its own typed variant. The conditional
2474        // siblings DamageAlignmentGroup (row 12) and DamageRacialGroup
2475        // (row 13) now have their own typed variants and are covered
2476        // elsewhere; this test focuses on the prefix-trap case.
2477        let table = itempropdef_with_labels(&[(18, "Damage_Vulnerability")]);
2478        let overrides = override_with_2da("itempropdef", &table);
2479        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2480        let mut cache = TwoDaCache::new(&resolver);
2481
2482        let uti = Uti {
2483            properties: vec![property(18, 0)],
2484            ..Uti::default()
2485        };
2486        let view = uti.snapshot(&mut cache);
2487
2488        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2489            panic!("expected Unknown variant for `Damage_Vulnerability`");
2490        };
2491        assert_eq!(property_label.as_deref(), Some("Damage_Vulnerability"));
2492    }
2493
2494    #[test]
2495    fn damage_immunity_label_routes_to_typed_variant() {
2496        let table = itempropdef_with_labels(&[(14, "DamageImmunity")]);
2497        let overrides = override_with_2da("itempropdef", &table);
2498        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2499        let mut cache = TwoDaCache::new(&resolver);
2500
2501        let uti = Uti {
2502            properties: vec![property(14, 7)],
2503            ..Uti::default()
2504        };
2505        let view = uti.snapshot(&mut cache);
2506
2507        let DecodedProperty::DamageImmunity {
2508            property_id,
2509            subtype_id,
2510            ..
2511        } = &view.properties()[0]
2512        else {
2513            panic!("expected DamageImmunity variant for `DamageImmunity` label");
2514        };
2515        assert_eq!(*property_id, 14);
2516        assert_eq!(*subtype_id, 7);
2517    }
2518
2519    #[test]
2520    fn damage_resistance_label_routes_to_typed_variant() {
2521        // Vanilla label is `DamageResist` (no `-ance` suffix), and
2522        // that's what the decoder must match.
2523        let table = itempropdef_with_labels(&[(17, "DamageResist")]);
2524        let overrides = override_with_2da("itempropdef", &table);
2525        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2526        let mut cache = TwoDaCache::new(&resolver);
2527
2528        let uti = Uti {
2529            properties: vec![property(17, 3)],
2530            ..Uti::default()
2531        };
2532        let view = uti.snapshot(&mut cache);
2533
2534        let DecodedProperty::DamageResistance {
2535            property_id,
2536            subtype_id,
2537            ..
2538        } = &view.properties()[0]
2539        else {
2540            panic!("expected DamageResistance variant for `DamageResist` label");
2541        };
2542        assert_eq!(*property_id, 17);
2543        assert_eq!(*subtype_id, 3);
2544    }
2545
2546    #[test]
2547    fn damage_resistance_does_not_match_damage_reduced() {
2548        // Vanilla row 16 is `DamageReduced`, backed by
2549        // iprp_protection.2da rather than iprp_damagetype.2da. The
2550        // DamageResistance arm must not absorb it; the decoder routes
2551        // it to Unknown until it gets its own typed variant.
2552        let table = itempropdef_with_labels(&[(16, "DamageReduced")]);
2553        let overrides = override_with_2da("itempropdef", &table);
2554        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2555        let mut cache = TwoDaCache::new(&resolver);
2556
2557        let uti = Uti {
2558            properties: vec![property(16, 0)],
2559            ..Uti::default()
2560        };
2561        let view = uti.snapshot(&mut cache);
2562
2563        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2564            panic!("expected Unknown variant for `DamageReduced`");
2565        };
2566        assert_eq!(property_label.as_deref(), Some("DamageReduced"));
2567    }
2568
2569    #[test]
2570    fn damage_family_subtype_labels_resolve_via_iprp_damagetype() {
2571        // All three damage-family variants share the iprp_damagetype
2572        // subtype 2DA. Verify the subtype_label helper resolves each
2573        // through that table.
2574        let propdef = itempropdef_with_subtypes(&[
2575            (11, "Damage", "iprp_damagetype"),
2576            (14, "DamageImmunity", "iprp_damagetype"),
2577            (17, "DamageResist", "iprp_damagetype"),
2578        ]);
2579        let damagetype = subtype_2da(&[
2580            (0, "Bludgeoning"),
2581            (1, "Slashing"),
2582            (2, "Piercing"),
2583            (5, "Acid"),
2584            (6, "Cold"),
2585        ]);
2586        let mut overrides = OverrideSource::new();
2587        add_2da_entry(&mut overrides, "itempropdef", &propdef);
2588        add_2da_entry(&mut overrides, "iprp_damagetype", &damagetype);
2589        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2590        let mut cache = TwoDaCache::new(&resolver);
2591
2592        let bonus = DecodedProperty::DamageBonus {
2593            property_id: 11,
2594            subtype_id: 5,
2595            cost_table: 0,
2596            cost_value: 0,
2597        };
2598        assert_eq!(bonus.subtype_label(&mut cache).as_deref(), Some("Acid"));
2599
2600        let immunity = DecodedProperty::DamageImmunity {
2601            property_id: 14,
2602            subtype_id: 6,
2603            cost_table: 0,
2604            cost_value: 0,
2605        };
2606        assert_eq!(immunity.subtype_label(&mut cache).as_deref(), Some("Cold"));
2607
2608        let resistance = DecodedProperty::DamageResistance {
2609            property_id: 17,
2610            subtype_id: 1,
2611            cost_table: 0,
2612            cost_value: 0,
2613        };
2614        assert_eq!(
2615            resistance.subtype_label(&mut cache).as_deref(),
2616            Some("Slashing")
2617        );
2618    }
2619
2620    #[test]
2621    fn ac_bonus_label_routes_to_typed_variant() {
2622        // Vanilla itempropdef row 1 carries the label `Armor`. The
2623        // decoder routes it to AcBonus. The property has no subtype
2624        // dimension; subtype_id is preserved for round-trip fidelity
2625        // even though the engine ignores it.
2626        let table = itempropdef_with_labels(&[(1, "Armor")]);
2627        let overrides = override_with_2da("itempropdef", &table);
2628        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2629        let mut cache = TwoDaCache::new(&resolver);
2630
2631        let mut shaped = property(1, 0);
2632        shaped.cost_table = 2;
2633        shaped.cost_value = 5;
2634        let uti = Uti {
2635            properties: vec![shaped],
2636            ..Uti::default()
2637        };
2638        let view = uti.snapshot(&mut cache);
2639
2640        let DecodedProperty::AcBonus {
2641            property_id,
2642            subtype_id,
2643            cost_table,
2644            cost_value,
2645        } = &view.properties()[0]
2646        else {
2647            panic!("expected AcBonus variant for `Armor` label");
2648        };
2649        assert_eq!(*property_id, 1);
2650        assert_eq!(*subtype_id, 0);
2651        assert_eq!(*cost_table, 2);
2652        assert_eq!(*cost_value, 5);
2653    }
2654
2655    #[test]
2656    fn ac_bonus_does_not_match_armor_conditional_kinds() {
2657        // Vanilla rows 2 / 3 / 4 (`ArmorAlignmentGroup`,
2658        // `ArmorDamageType`, `ArmorRacialGroup`) sit adjacent to
2659        // `Armor` and start with the same prefix. None belong in
2660        // AcBonus; all must fall through to Unknown.
2661        let table = itempropdef_with_labels(&[
2662            (2, "ArmorAlignmentGroup"),
2663            (3, "ArmorDamageType"),
2664            (4, "ArmorRacialGroup"),
2665        ]);
2666        let overrides = override_with_2da("itempropdef", &table);
2667        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2668        let mut cache = TwoDaCache::new(&resolver);
2669
2670        let uti = Uti {
2671            properties: vec![property(2, 0), property(3, 0), property(4, 0)],
2672            ..Uti::default()
2673        };
2674        let view = uti.snapshot(&mut cache);
2675
2676        for prop in view.properties() {
2677            assert!(
2678                matches!(prop, DecodedProperty::Unknown { .. }),
2679                "expected Unknown for non-Armor label, got {prop:?}"
2680            );
2681        }
2682    }
2683
2684    #[test]
2685    fn enhancement_bonus_label_routes_to_typed_variant() {
2686        let table = itempropdef_with_labels(&[(5, "Enhancement")]);
2687        let overrides = override_with_2da("itempropdef", &table);
2688        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2689        let mut cache = TwoDaCache::new(&resolver);
2690
2691        let mut shaped = property(5, 0);
2692        shaped.cost_table = 2;
2693        shaped.cost_value = 3;
2694        let uti = Uti {
2695            properties: vec![shaped],
2696            ..Uti::default()
2697        };
2698        let view = uti.snapshot(&mut cache);
2699
2700        let DecodedProperty::EnhancementBonus {
2701            property_id,
2702            cost_table,
2703            cost_value,
2704            ..
2705        } = &view.properties()[0]
2706        else {
2707            panic!("expected EnhancementBonus variant for `Enhancement` label");
2708        };
2709        assert_eq!(*property_id, 5);
2710        assert_eq!(*cost_table, 2);
2711        assert_eq!(*cost_value, 3);
2712    }
2713
2714    #[test]
2715    fn enhancement_bonus_does_not_match_its_conditional_siblings() {
2716        // Rows 6 (`EnhancementAlignmentGroup`) and 7
2717        // (`EnhancementRacialGroup`) share the `Enhancement` prefix
2718        // but are distinct typed variants. The EnhancementBonus arm
2719        // must not absorb either; each routes to its own typed kind.
2720        let table = itempropdef_with_labels(&[
2721            (6, "EnhancementAlignmentGroup"),
2722            (7, "EnhancementRacialGroup"),
2723        ]);
2724        let overrides = override_with_2da("itempropdef", &table);
2725        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2726        let mut cache = TwoDaCache::new(&resolver);
2727
2728        let uti = Uti {
2729            properties: vec![property(6, 0), property(7, 0)],
2730            ..Uti::default()
2731        };
2732        let view = uti.snapshot(&mut cache);
2733
2734        assert!(matches!(
2735            view.properties()[0],
2736            DecodedProperty::EnhancementAlignmentGroup { .. }
2737        ));
2738        assert!(matches!(
2739            view.properties()[1],
2740            DecodedProperty::EnhancementRacialGroup { .. }
2741        ));
2742    }
2743
2744    #[test]
2745    fn on_hit_label_routes_to_typed_variant_with_param_fields() {
2746        // Vanilla itempropdef row 32 carries the label `OnHit`. The
2747        // decoder routes it to OnHit and preserves the param fields
2748        // (which the engine consumes via iprp_paramtable.2da to convey
2749        // the effect's magnitude / DC / duration).
2750        let table = itempropdef_with_labels(&[(32, "OnHit")]);
2751        let overrides = override_with_2da("itempropdef", &table);
2752        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2753        let mut cache = TwoDaCache::new(&resolver);
2754
2755        let mut shaped = property(32, 4);
2756        shaped.cost_table = 25;
2757        shaped.cost_value = 1;
2758        shaped.param1 = 1;
2759        shaped.param1_value = 7;
2760        let uti = Uti {
2761            properties: vec![shaped],
2762            ..Uti::default()
2763        };
2764        let view = uti.snapshot(&mut cache);
2765
2766        let DecodedProperty::OnHit {
2767            property_id,
2768            subtype_id,
2769            cost_table,
2770            cost_value,
2771            param1,
2772            param1_value,
2773        } = &view.properties()[0]
2774        else {
2775            panic!("expected OnHit variant for `OnHit` label");
2776        };
2777        assert_eq!(*property_id, 32);
2778        assert_eq!(*subtype_id, 4);
2779        assert_eq!(*cost_table, 25);
2780        assert_eq!(*cost_value, 1);
2781        assert_eq!(*param1, 1);
2782        assert_eq!(*param1_value, 7);
2783    }
2784
2785    #[test]
2786    fn on_hit_does_not_match_on_monster_hit() {
2787        // Vanilla row 48 is `OnMonsterHit`, a related but distinct
2788        // kind that the OnHit arm must not absorb.
2789        let table = itempropdef_with_labels(&[(48, "OnMonsterHit")]);
2790        let overrides = override_with_2da("itempropdef", &table);
2791        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2792        let mut cache = TwoDaCache::new(&resolver);
2793
2794        let uti = Uti {
2795            properties: vec![property(48, 0)],
2796            ..Uti::default()
2797        };
2798        let view = uti.snapshot(&mut cache);
2799
2800        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
2801            panic!("expected Unknown variant for `OnMonsterHit`");
2802        };
2803        assert_eq!(property_label.as_deref(), Some("OnMonsterHit"));
2804    }
2805
2806    #[test]
2807    fn on_hit_subtype_label_resolves_via_iprp_onhit() {
2808        let propdef = itempropdef_with_subtypes(&[(32, "OnHit", "iprp_onhit")]);
2809        let onhit = subtype_2da(&[(0, "Sleep"), (1, "Daze"), (4, "Stun")]);
2810        let mut overrides = OverrideSource::new();
2811        add_2da_entry(&mut overrides, "itempropdef", &propdef);
2812        add_2da_entry(&mut overrides, "iprp_onhit", &onhit);
2813        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2814        let mut cache = TwoDaCache::new(&resolver);
2815
2816        let prop = DecodedProperty::OnHit {
2817            property_id: 32,
2818            subtype_id: 4,
2819            cost_table: 0,
2820            cost_value: 0,
2821            param1: 0,
2822            param1_value: 0,
2823        };
2824        assert_eq!(prop.subtype_label(&mut cache).as_deref(), Some("Stun"));
2825    }
2826
2827    #[test]
2828    fn ac_bonus_subtype_label_returns_none_for_subtypeless_property() {
2829        // `Armor` carries no SubTypeResRef in vanilla. The helper must
2830        // short-circuit to None rather than treating subtype_id=0 as a
2831        // valid row index against some unrelated table.
2832        let propdef = itempropdef_with_subtypes(&[(1, "Armor", "")]);
2833        let overrides = override_with_2da("itempropdef", &propdef);
2834        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2835        let mut cache = TwoDaCache::new(&resolver);
2836
2837        let prop = DecodedProperty::AcBonus {
2838            property_id: 1,
2839            subtype_id: 0,
2840            cost_table: 2,
2841            cost_value: 5,
2842        };
2843        assert!(prop.subtype_label(&mut cache).is_none());
2844    }
2845
2846    fn active_property(property_name: u16, subtype: u16) -> UtiProperty {
2847        // Variant of `property` for active-property tests: starts with
2848        // every active-loop field at "GFF omitted" so each test can opt
2849        // into explicit values where it matters.
2850        let mut p = property(property_name, subtype);
2851        p.useable = None;
2852        p.uses_per_day = None;
2853        p
2854    }
2855
2856    #[test]
2857    fn cast_spell_label_routes_to_typed_variant_with_active_defaults() {
2858        // Vanilla itempropdef row 10 carries the label `CastSpell`.
2859        // The decoder routes it to CastSpell. With Useable and
2860        // UsesPerDay omitted in the GFF, the active-property
2861        // defaults apply: useable = true, uses_per_day = None.
2862        let table = itempropdef_with_labels(&[(10, "CastSpell")]);
2863        let overrides = override_with_2da("itempropdef", &table);
2864        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2865        let mut cache = TwoDaCache::new(&resolver);
2866
2867        let mut shaped = active_property(10, 42);
2868        shaped.cost_table = 3;
2869        shaped.cost_value = 7;
2870        let uti = Uti {
2871            properties: vec![shaped],
2872            ..Uti::default()
2873        };
2874        let view = uti.snapshot(&mut cache);
2875
2876        let DecodedProperty::CastSpell {
2877            property_id,
2878            subtype_id,
2879            cost_table,
2880            cost_value,
2881            useable,
2882            uses_per_day,
2883        } = &view.properties()[0]
2884        else {
2885            panic!("expected CastSpell variant for `CastSpell` label");
2886        };
2887        assert_eq!(*property_id, 10);
2888        assert_eq!(*subtype_id, 42);
2889        assert_eq!(*cost_table, 3);
2890        assert_eq!(*cost_value, 7);
2891        assert!(*useable);
2892        assert!(uses_per_day.is_none());
2893    }
2894
2895    #[test]
2896    fn cast_spell_preserves_explicit_useable_false() {
2897        // The active-loop default only applies when the GFF omits
2898        // Useable. An explicit `Useable=false` must pass through; the
2899        // engine treats the item as inactive in that case.
2900        let table = itempropdef_with_labels(&[(10, "CastSpell")]);
2901        let overrides = override_with_2da("itempropdef", &table);
2902        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2903        let mut cache = TwoDaCache::new(&resolver);
2904
2905        let mut shaped = active_property(10, 0);
2906        shaped.useable = Some(false);
2907        let uti = Uti {
2908            properties: vec![shaped],
2909            ..Uti::default()
2910        };
2911        let view = uti.snapshot(&mut cache);
2912
2913        let DecodedProperty::CastSpell { useable, .. } = &view.properties()[0] else {
2914            panic!("expected CastSpell variant");
2915        };
2916        assert!(!*useable);
2917    }
2918
2919    #[test]
2920    fn cast_spell_coalesces_uses_per_day_sentinel_into_none() {
2921        // The engine's `0xFF` sentinel means "not set"; both an
2922        // omitted UsesPerDay and an explicit `0xFF` decode to None.
2923        // A non-sentinel cap passes through as Some(N).
2924        let table = itempropdef_with_labels(&[(10, "CastSpell")]);
2925        let overrides = override_with_2da("itempropdef", &table);
2926        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2927        let mut cache = TwoDaCache::new(&resolver);
2928
2929        let mut sentinel = active_property(10, 0);
2930        sentinel.uses_per_day = Some(0xFF);
2931        let mut explicit = active_property(10, 0);
2932        explicit.uses_per_day = Some(3);
2933        let uti = Uti {
2934            properties: vec![sentinel, explicit],
2935            ..Uti::default()
2936        };
2937        let view = uti.snapshot(&mut cache);
2938
2939        let DecodedProperty::CastSpell { uses_per_day, .. } = &view.properties()[0] else {
2940            panic!("expected CastSpell variant for sentinel");
2941        };
2942        assert!(uses_per_day.is_none());
2943
2944        let DecodedProperty::CastSpell { uses_per_day, .. } = &view.properties()[1] else {
2945            panic!("expected CastSpell variant for explicit cap");
2946        };
2947        assert_eq!(*uses_per_day, Some(3));
2948    }
2949
2950    #[test]
2951    fn cast_spell_subtype_label_resolves_via_spells_2da() {
2952        let propdef = itempropdef_with_subtypes(&[(10, "CastSpell", "spells")]);
2953        let spells = subtype_2da(&[(0, "Cure_Wounds"), (5, "Force_Push")]);
2954        let mut overrides = OverrideSource::new();
2955        add_2da_entry(&mut overrides, "itempropdef", &propdef);
2956        add_2da_entry(&mut overrides, "spells", &spells);
2957        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2958        let mut cache = TwoDaCache::new(&resolver);
2959
2960        let prop = DecodedProperty::CastSpell {
2961            property_id: 10,
2962            subtype_id: 5,
2963            cost_table: 0,
2964            cost_value: 0,
2965            useable: true,
2966            uses_per_day: None,
2967        };
2968        assert_eq!(
2969            prop.subtype_label(&mut cache).as_deref(),
2970            Some("Force_Push")
2971        );
2972    }
2973
2974    #[test]
2975    fn trap_label_routes_to_typed_variant() {
2976        let table = itempropdef_with_labels(&[(46, "Trap")]);
2977        let overrides = override_with_2da("itempropdef", &table);
2978        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2979        let mut cache = TwoDaCache::new(&resolver);
2980
2981        let mut shaped = active_property(46, 2);
2982        shaped.uses_per_day = Some(1);
2983        let uti = Uti {
2984            properties: vec![shaped],
2985            ..Uti::default()
2986        };
2987        let view = uti.snapshot(&mut cache);
2988
2989        let DecodedProperty::Trap {
2990            property_id,
2991            subtype_id,
2992            useable,
2993            uses_per_day,
2994            ..
2995        } = &view.properties()[0]
2996        else {
2997            panic!("expected Trap variant for `Trap` label");
2998        };
2999        assert_eq!(*property_id, 46);
3000        assert_eq!(*subtype_id, 2);
3001        assert!(*useable);
3002        assert_eq!(*uses_per_day, Some(1));
3003    }
3004
3005    #[test]
3006    fn trap_subtype_label_resolves_via_traps_2da() {
3007        let propdef = itempropdef_with_subtypes(&[(46, "Trap", "traps")]);
3008        let traps = subtype_2da(&[(0, "Minor_Frag"), (3, "Deadly_Plasma")]);
3009        let mut overrides = OverrideSource::new();
3010        add_2da_entry(&mut overrides, "itempropdef", &propdef);
3011        add_2da_entry(&mut overrides, "traps", &traps);
3012        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3013        let mut cache = TwoDaCache::new(&resolver);
3014
3015        let prop = DecodedProperty::Trap {
3016            property_id: 46,
3017            subtype_id: 3,
3018            cost_table: 0,
3019            cost_value: 0,
3020            useable: true,
3021            uses_per_day: Some(1),
3022        };
3023        assert_eq!(
3024            prop.subtype_label(&mut cache).as_deref(),
3025            Some("Deadly_Plasma")
3026        );
3027    }
3028
3029    #[test]
3030    fn thieves_tools_label_routes_to_typed_variant_with_active_defaults() {
3031        // Vanilla itempropdef row 37 carries the label `ThievesTools`.
3032        // The decoder routes it to ThievesTools and applies the
3033        // active-property defaults (useable = true when GFF omits
3034        // Useable, uses_per_day = None for the 0xFF sentinel).
3035        let table = itempropdef_with_labels(&[(37, "ThievesTools")]);
3036        let overrides = override_with_2da("itempropdef", &table);
3037        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3038        let mut cache = TwoDaCache::new(&resolver);
3039
3040        let mut shaped = active_property(37, 0);
3041        shaped.cost_table = 1;
3042        shaped.cost_value = 4;
3043        shaped.uses_per_day = Some(0xFF);
3044        let uti = Uti {
3045            properties: vec![shaped],
3046            ..Uti::default()
3047        };
3048        let view = uti.snapshot(&mut cache);
3049
3050        let DecodedProperty::ThievesTools {
3051            property_id,
3052            cost_table,
3053            cost_value,
3054            useable,
3055            uses_per_day,
3056            ..
3057        } = &view.properties()[0]
3058        else {
3059            panic!("expected ThievesTools variant for `ThievesTools` label");
3060        };
3061        assert_eq!(*property_id, 37);
3062        assert_eq!(*cost_table, 1);
3063        assert_eq!(*cost_value, 4);
3064        assert!(*useable);
3065        assert!(uses_per_day.is_none());
3066    }
3067
3068    #[test]
3069    fn thieves_tools_subtype_label_returns_none_for_subtypeless_property() {
3070        // ThievesTools carries no SubTypeResRef in vanilla. The
3071        // helper must short-circuit to None rather than dispatching
3072        // against an unrelated table.
3073        let propdef = itempropdef_with_subtypes(&[(37, "ThievesTools", "")]);
3074        let overrides = override_with_2da("itempropdef", &propdef);
3075        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3076        let mut cache = TwoDaCache::new(&resolver);
3077
3078        let prop = DecodedProperty::ThievesTools {
3079            property_id: 37,
3080            subtype_id: 0,
3081            cost_table: 1,
3082            cost_value: 4,
3083            useable: true,
3084            uses_per_day: None,
3085        };
3086        assert!(prop.subtype_label(&mut cache).is_none());
3087    }
3088
3089    #[test]
3090    fn computer_spike_label_routes_to_typed_variant() {
3091        // Vanilla itempropdef row 53 carries the label
3092        // `Computer_Spike` (with underscore). The decoder routes it
3093        // to ComputerSpike. Note the Rust variant drops the
3094        // underscore per Rust naming conventions, but the dispatch
3095        // matches the verbatim vanilla label.
3096        let table = itempropdef_with_labels(&[(53, "Computer_Spike")]);
3097        let overrides = override_with_2da("itempropdef", &table);
3098        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3099        let mut cache = TwoDaCache::new(&resolver);
3100
3101        let mut shaped = active_property(53, 0);
3102        shaped.cost_table = 1;
3103        shaped.cost_value = 5;
3104        shaped.useable = Some(true);
3105        shaped.uses_per_day = Some(2);
3106        let uti = Uti {
3107            properties: vec![shaped],
3108            ..Uti::default()
3109        };
3110        let view = uti.snapshot(&mut cache);
3111
3112        let DecodedProperty::ComputerSpike {
3113            property_id,
3114            cost_table,
3115            cost_value,
3116            useable,
3117            uses_per_day,
3118            ..
3119        } = &view.properties()[0]
3120        else {
3121            panic!("expected ComputerSpike variant for `Computer_Spike` label");
3122        };
3123        assert_eq!(*property_id, 53);
3124        assert_eq!(*cost_table, 1);
3125        assert_eq!(*cost_value, 5);
3126        assert!(*useable);
3127        assert_eq!(*uses_per_day, Some(2));
3128    }
3129
3130    #[test]
3131    fn computer_spike_subtype_label_returns_none_for_subtypeless_property() {
3132        let propdef = itempropdef_with_subtypes(&[(53, "Computer_Spike", "")]);
3133        let overrides = override_with_2da("itempropdef", &propdef);
3134        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3135        let mut cache = TwoDaCache::new(&resolver);
3136
3137        let prop = DecodedProperty::ComputerSpike {
3138            property_id: 53,
3139            subtype_id: 0,
3140            cost_table: 1,
3141            cost_value: 5,
3142            useable: true,
3143            uses_per_day: Some(2),
3144        };
3145        assert!(prop.subtype_label(&mut cache).is_none());
3146    }
3147
3148    #[test]
3149    fn attack_bonus_label_routes_to_typed_variant() {
3150        // Vanilla itempropdef row 38 carries the label `AttackBonus`.
3151        // The decoder routes that property to AttackBonus.
3152        let table = itempropdef_with_labels(&[(38, "AttackBonus")]);
3153        let overrides = override_with_2da("itempropdef", &table);
3154        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3155        let mut cache = TwoDaCache::new(&resolver);
3156
3157        let mut shaped = property(38, 0);
3158        shaped.cost_table = 2;
3159        shaped.cost_value = 3;
3160        let uti = Uti {
3161            properties: vec![shaped],
3162            ..Uti::default()
3163        };
3164        let view = uti.snapshot(&mut cache);
3165
3166        let DecodedProperty::AttackBonus {
3167            property_id,
3168            cost_table,
3169            cost_value,
3170            ..
3171        } = &view.properties()[0]
3172        else {
3173            panic!("expected AttackBonus variant for `AttackBonus` label");
3174        };
3175        assert_eq!(*property_id, 38);
3176        assert_eq!(*cost_table, 2);
3177        assert_eq!(*cost_value, 3);
3178    }
3179
3180    #[test]
3181    fn attack_bonus_does_not_match_its_conditional_siblings() {
3182        // Vanilla row 39 (`AttackBonusAlignmentGroup`) has corpus
3183        // usage and is now a typed variant; row 40
3184        // (`AttackBonusRacialGroup`) has zero corpus usage and stays
3185        // in Unknown. The plain `AttackBonus` arm must not absorb
3186        // either: row 39 routes to its own typed kind, row 40 to
3187        // Unknown.
3188        let table = itempropdef_with_labels(&[
3189            (39, "AttackBonusAlignmentGroup"),
3190            (40, "AttackBonusRacialGroup"),
3191        ]);
3192        let overrides = override_with_2da("itempropdef", &table);
3193        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3194        let mut cache = TwoDaCache::new(&resolver);
3195
3196        let uti = Uti {
3197            properties: vec![property(39, 0), property(40, 0)],
3198            ..Uti::default()
3199        };
3200        let view = uti.snapshot(&mut cache);
3201
3202        assert!(matches!(
3203            view.properties()[0],
3204            DecodedProperty::AttackBonusAlignmentGroup { .. }
3205        ));
3206        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[1] else {
3207            panic!("expected Unknown for `AttackBonusRacialGroup`");
3208        };
3209        assert_eq!(property_label.as_deref(), Some("AttackBonusRacialGroup"));
3210    }
3211
3212    #[test]
3213    fn keen_label_routes_to_typed_variant() {
3214        let table = itempropdef_with_labels(&[(28, "Keen")]);
3215        let overrides = override_with_2da("itempropdef", &table);
3216        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3217        let mut cache = TwoDaCache::new(&resolver);
3218
3219        let uti = Uti {
3220            properties: vec![property(28, 0)],
3221            ..Uti::default()
3222        };
3223        let view = uti.snapshot(&mut cache);
3224
3225        let DecodedProperty::Keen { property_id, .. } = &view.properties()[0] else {
3226            panic!("expected Keen variant for `Keen` label");
3227        };
3228        assert_eq!(*property_id, 28);
3229    }
3230
3231    #[test]
3232    fn massive_criticals_label_routes_to_typed_variant() {
3233        // Vanilla label has an underscore (`Massive_Criticals`); the
3234        // Rust variant drops it per naming convention but the
3235        // dispatch matches the underscored form verbatim.
3236        let table = itempropdef_with_labels(&[(49, "Massive_Criticals")]);
3237        let overrides = override_with_2da("itempropdef", &table);
3238        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3239        let mut cache = TwoDaCache::new(&resolver);
3240
3241        let uti = Uti {
3242            properties: vec![property(49, 0)],
3243            ..Uti::default()
3244        };
3245        let view = uti.snapshot(&mut cache);
3246
3247        let DecodedProperty::MassiveCriticals { property_id, .. } = &view.properties()[0] else {
3248            panic!("expected MassiveCriticals variant for `Massive_Criticals` label");
3249        };
3250        assert_eq!(*property_id, 49);
3251    }
3252
3253    #[test]
3254    fn blaster_bolt_deflect_increase_label_routes_to_typed_variant() {
3255        let table = itempropdef_with_labels(&[(55, "Blaster_Bolt_Deflect_Increase")]);
3256        let overrides = override_with_2da("itempropdef", &table);
3257        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3258        let mut cache = TwoDaCache::new(&resolver);
3259
3260        let uti = Uti {
3261            properties: vec![property(55, 0)],
3262            ..Uti::default()
3263        };
3264        let view = uti.snapshot(&mut cache);
3265
3266        let DecodedProperty::BlasterBoltDeflectIncrease { property_id, .. } = &view.properties()[0]
3267        else {
3268            panic!("expected BlasterBoltDeflectIncrease variant for the vanilla label");
3269        };
3270        assert_eq!(*property_id, 55);
3271    }
3272
3273    #[test]
3274    fn blaster_bolt_deflect_does_not_absorb_vanilla_typo_sibling() {
3275        // Vanilla row 56 is `Blaster_Bolt_Defect_Decrease` (note the
3276        // typo: `Defect` rather than `Deflect`). It has near-zero
3277        // corpus usage and stays in Unknown by design. The
3278        // BlasterBoltDeflectIncrease arm must not absorb it; the
3279        // dispatch matches the exact vanilla spellings, not
3280        // normalized forms.
3281        let table = itempropdef_with_labels(&[(56, "Blaster_Bolt_Defect_Decrease")]);
3282        let overrides = override_with_2da("itempropdef", &table);
3283        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3284        let mut cache = TwoDaCache::new(&resolver);
3285
3286        let uti = Uti {
3287            properties: vec![property(56, 0)],
3288            ..Uti::default()
3289        };
3290        let view = uti.snapshot(&mut cache);
3291
3292        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
3293            panic!("expected Unknown for the vanilla-typo sibling");
3294        };
3295        assert_eq!(
3296            property_label.as_deref(),
3297            Some("Blaster_Bolt_Defect_Decrease")
3298        );
3299    }
3300
3301    #[test]
3302    fn monster_damage_label_routes_to_typed_variant() {
3303        // Vanilla label uses lowercase `d` (`Monster_damage`); the
3304        // dispatch matches that exact spelling.
3305        let table = itempropdef_with_labels(&[(51, "Monster_damage")]);
3306        let overrides = override_with_2da("itempropdef", &table);
3307        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3308        let mut cache = TwoDaCache::new(&resolver);
3309
3310        let uti = Uti {
3311            properties: vec![property(51, 0)],
3312            ..Uti::default()
3313        };
3314        let view = uti.snapshot(&mut cache);
3315
3316        let DecodedProperty::MonsterDamage { property_id, .. } = &view.properties()[0] else {
3317            panic!("expected MonsterDamage variant for `Monster_damage` label");
3318        };
3319        assert_eq!(*property_id, 51);
3320    }
3321
3322    #[test]
3323    fn subtypeless_single_magnitude_variants_short_circuit_subtype_label() {
3324        // None of the new subtypeless variants carry a SubTypeResRef
3325        // in vanilla, so the helper must short-circuit to None
3326        // rather than dispatching against an unrelated table.
3327        let propdef = itempropdef_with_subtypes(&[
3328            (28, "Keen", ""),
3329            (38, "AttackBonus", ""),
3330            (49, "Massive_Criticals", ""),
3331            (51, "Monster_damage", ""),
3332            (55, "Blaster_Bolt_Deflect_Increase", ""),
3333        ]);
3334        let overrides = override_with_2da("itempropdef", &propdef);
3335        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3336        let mut cache = TwoDaCache::new(&resolver);
3337
3338        for prop in [
3339            DecodedProperty::Keen {
3340                property_id: 28,
3341                subtype_id: 0,
3342                cost_table: 0,
3343                cost_value: 0,
3344            },
3345            DecodedProperty::AttackBonus {
3346                property_id: 38,
3347                subtype_id: 0,
3348                cost_table: 0,
3349                cost_value: 0,
3350            },
3351            DecodedProperty::MassiveCriticals {
3352                property_id: 49,
3353                subtype_id: 0,
3354                cost_table: 0,
3355                cost_value: 0,
3356            },
3357            DecodedProperty::MonsterDamage {
3358                property_id: 51,
3359                subtype_id: 0,
3360                cost_table: 0,
3361                cost_value: 0,
3362            },
3363            DecodedProperty::BlasterBoltDeflectIncrease {
3364                property_id: 55,
3365                subtype_id: 0,
3366                cost_table: 0,
3367                cost_value: 0,
3368            },
3369        ] {
3370            assert!(
3371                prop.subtype_label(&mut cache).is_none(),
3372                "expected None subtype_label for subtypeless variant, got {prop:?}"
3373            );
3374        }
3375    }
3376
3377    #[test]
3378    fn bonus_feats_label_routes_to_typed_variant() {
3379        let table = itempropdef_with_labels(&[(9, "BonusFeats")]);
3380        let overrides = override_with_2da("itempropdef", &table);
3381        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3382        let mut cache = TwoDaCache::new(&resolver);
3383
3384        let uti = Uti {
3385            properties: vec![property(9, 12)],
3386            ..Uti::default()
3387        };
3388        let view = uti.snapshot(&mut cache);
3389
3390        let DecodedProperty::BonusFeats {
3391            property_id,
3392            subtype_id,
3393            ..
3394        } = &view.properties()[0]
3395        else {
3396            panic!("expected BonusFeats variant for `BonusFeats` label");
3397        };
3398        assert_eq!(*property_id, 9);
3399        assert_eq!(*subtype_id, 12);
3400    }
3401
3402    #[test]
3403    fn immunity_label_routes_to_typed_variant() {
3404        let table = itempropdef_with_labels(&[(24, "Immunity")]);
3405        let overrides = override_with_2da("itempropdef", &table);
3406        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3407        let mut cache = TwoDaCache::new(&resolver);
3408
3409        let uti = Uti {
3410            properties: vec![property(24, 5)],
3411            ..Uti::default()
3412        };
3413        let view = uti.snapshot(&mut cache);
3414
3415        let DecodedProperty::Immunity {
3416            property_id,
3417            subtype_id,
3418            ..
3419        } = &view.properties()[0]
3420        else {
3421            panic!("expected Immunity variant for `Immunity` label");
3422        };
3423        assert_eq!(*property_id, 24);
3424        assert_eq!(*subtype_id, 5);
3425    }
3426
3427    #[test]
3428    fn skill_label_routes_to_typed_variant() {
3429        let table = itempropdef_with_labels(&[(36, "Skill")]);
3430        let overrides = override_with_2da("itempropdef", &table);
3431        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3432        let mut cache = TwoDaCache::new(&resolver);
3433
3434        let uti = Uti {
3435            properties: vec![property(36, 2)],
3436            ..Uti::default()
3437        };
3438        let view = uti.snapshot(&mut cache);
3439
3440        let DecodedProperty::Skill {
3441            property_id,
3442            subtype_id,
3443            ..
3444        } = &view.properties()[0]
3445        else {
3446            panic!("expected Skill variant for `Skill` label");
3447        };
3448        assert_eq!(*property_id, 36);
3449        assert_eq!(*subtype_id, 2);
3450    }
3451
3452    #[test]
3453    fn skill_does_not_match_decreased_skill_sibling() {
3454        // Vanilla row 21 is `DecreasedSkill`, also backed by
3455        // `skills.2da`. It has zero corpus usage in vanilla items
3456        // and stays in Unknown until a consumer asks; the Skill arm
3457        // must not absorb it.
3458        let table = itempropdef_with_labels(&[(21, "DecreasedSkill")]);
3459        let overrides = override_with_2da("itempropdef", &table);
3460        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3461        let mut cache = TwoDaCache::new(&resolver);
3462
3463        let uti = Uti {
3464            properties: vec![property(21, 0)],
3465            ..Uti::default()
3466        };
3467        let view = uti.snapshot(&mut cache);
3468
3469        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
3470            panic!("expected Unknown for `DecreasedSkill`");
3471        };
3472        assert_eq!(property_label.as_deref(), Some("DecreasedSkill"));
3473    }
3474
3475    #[test]
3476    fn misc_2da_backed_singleton_subtype_labels_resolve_via_their_2das() {
3477        // BonusFeats -> feat.2da, Immunity -> iprp_immunity.2da,
3478        // Skill -> skills.2da. Verify each resolves through the
3479        // correct table.
3480        let propdef = itempropdef_with_subtypes(&[
3481            (9, "BonusFeats", "feat"),
3482            (24, "Immunity", "iprp_immunity"),
3483            (36, "Skill", "skills"),
3484        ]);
3485        let feats = subtype_2da(&[(0, "Toughness"), (12, "Force_Sensitive")]);
3486        let immunities = subtype_2da(&[(0, "Mind_Affecting"), (5, "Paralysis")]);
3487        let skills = subtype_2da(&[(0, "Computer_Use"), (2, "Persuade")]);
3488
3489        let mut overrides = OverrideSource::new();
3490        add_2da_entry(&mut overrides, "itempropdef", &propdef);
3491        add_2da_entry(&mut overrides, "feat", &feats);
3492        add_2da_entry(&mut overrides, "iprp_immunity", &immunities);
3493        add_2da_entry(&mut overrides, "skills", &skills);
3494        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3495        let mut cache = TwoDaCache::new(&resolver);
3496
3497        let feat = DecodedProperty::BonusFeats {
3498            property_id: 9,
3499            subtype_id: 12,
3500            cost_table: 0,
3501            cost_value: 0,
3502        };
3503        assert_eq!(
3504            feat.subtype_label(&mut cache).as_deref(),
3505            Some("Force_Sensitive")
3506        );
3507
3508        let immunity = DecodedProperty::Immunity {
3509            property_id: 24,
3510            subtype_id: 5,
3511            cost_table: 0,
3512            cost_value: 0,
3513        };
3514        assert_eq!(
3515            immunity.subtype_label(&mut cache).as_deref(),
3516            Some("Paralysis")
3517        );
3518
3519        let skill = DecodedProperty::Skill {
3520            property_id: 36,
3521            subtype_id: 2,
3522            cost_table: 0,
3523            cost_value: 0,
3524        };
3525        assert_eq!(skill.subtype_label(&mut cache).as_deref(), Some("Persuade"));
3526    }
3527
3528    #[test]
3529    fn use_limitation_feat_label_routes_to_typed_variant() {
3530        // Vanilla itempropdef row 57 carries the label
3531        // `Use_Limitation_Feat` (with underscores). The decoder
3532        // routes that property to UseLimitationFeat with the raw
3533        // feat row as subtype.
3534        let table = itempropdef_with_labels(&[(57, "Use_Limitation_Feat")]);
3535        let overrides = override_with_2da("itempropdef", &table);
3536        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3537        let mut cache = TwoDaCache::new(&resolver);
3538
3539        let uti = Uti {
3540            properties: vec![property(57, 42)],
3541            ..Uti::default()
3542        };
3543        let view = uti.snapshot(&mut cache);
3544
3545        let DecodedProperty::UseLimitationFeat {
3546            property_id,
3547            subtype_id,
3548            ..
3549        } = &view.properties()[0]
3550        else {
3551            panic!("expected UseLimitationFeat variant for `Use_Limitation_Feat` label");
3552        };
3553        assert_eq!(*property_id, 57);
3554        assert_eq!(*subtype_id, 42);
3555    }
3556
3557    #[test]
3558    fn use_limitation_racial_label_routes_to_typed_variant() {
3559        let table = itempropdef_with_labels(&[(45, "UseLimitationRacial")]);
3560        let overrides = override_with_2da("itempropdef", &table);
3561        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3562        let mut cache = TwoDaCache::new(&resolver);
3563
3564        let uti = Uti {
3565            properties: vec![property(45, 3)],
3566            ..Uti::default()
3567        };
3568        let view = uti.snapshot(&mut cache);
3569
3570        let DecodedProperty::UseLimitationRacial {
3571            property_id,
3572            subtype_id,
3573            ..
3574        } = &view.properties()[0]
3575        else {
3576            panic!("expected UseLimitationRacial variant for `UseLimitationRacial` label");
3577        };
3578        assert_eq!(*property_id, 45);
3579        assert_eq!(*subtype_id, 3);
3580    }
3581
3582    #[test]
3583    fn use_limitation_alignment_group_label_routes_to_typed_variant() {
3584        let table = itempropdef_with_labels(&[(43, "UseLimitationAlignmentGroup")]);
3585        let overrides = override_with_2da("itempropdef", &table);
3586        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3587        let mut cache = TwoDaCache::new(&resolver);
3588
3589        let uti = Uti {
3590            properties: vec![property(43, 2)],
3591            ..Uti::default()
3592        };
3593        let view = uti.snapshot(&mut cache);
3594
3595        let DecodedProperty::UseLimitationAlignmentGroup {
3596            property_id,
3597            subtype_id,
3598            ..
3599        } = &view.properties()[0]
3600        else {
3601            panic!(
3602                "expected UseLimitationAlignmentGroup variant for \
3603                 `UseLimitationAlignmentGroup` label"
3604            );
3605        };
3606        assert_eq!(*property_id, 43);
3607        assert_eq!(*subtype_id, 2);
3608    }
3609
3610    #[test]
3611    fn use_limitation_does_not_match_unused_class_sibling() {
3612        // Vanilla row 44 is `UseLimitationClass`, the fourth member
3613        // of the family. It has zero corpus usage and is intentionally
3614        // left in Unknown until a consumer asks. The other
3615        // use-limitation arms must not absorb it.
3616        let table = itempropdef_with_labels(&[(44, "UseLimitationClass")]);
3617        let overrides = override_with_2da("itempropdef", &table);
3618        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3619        let mut cache = TwoDaCache::new(&resolver);
3620
3621        let uti = Uti {
3622            properties: vec![property(44, 0)],
3623            ..Uti::default()
3624        };
3625        let view = uti.snapshot(&mut cache);
3626
3627        let DecodedProperty::Unknown { property_label, .. } = &view.properties()[0] else {
3628            panic!("expected Unknown variant for `UseLimitationClass`");
3629        };
3630        assert_eq!(property_label.as_deref(), Some("UseLimitationClass"));
3631    }
3632
3633    #[test]
3634    fn damage_racial_group_label_routes_to_typed_variant() {
3635        let table = itempropdef_with_labels(&[(13, "DamageRacialGroup")]);
3636        let overrides = override_with_2da("itempropdef", &table);
3637        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3638        let mut cache = TwoDaCache::new(&resolver);
3639
3640        let mut shaped = property(13, 3);
3641        shaped.cost_table = 4;
3642        shaped.cost_value = 2;
3643        let uti = Uti {
3644            properties: vec![shaped],
3645            ..Uti::default()
3646        };
3647        let view = uti.snapshot(&mut cache);
3648
3649        let DecodedProperty::DamageRacialGroup {
3650            property_id,
3651            subtype_id,
3652            cost_table,
3653            cost_value,
3654        } = &view.properties()[0]
3655        else {
3656            panic!("expected DamageRacialGroup variant for `DamageRacialGroup` label");
3657        };
3658        assert_eq!(*property_id, 13);
3659        assert_eq!(*subtype_id, 3);
3660        assert_eq!(*cost_table, 4);
3661        assert_eq!(*cost_value, 2);
3662    }
3663
3664    #[test]
3665    fn damage_alignment_group_label_routes_to_typed_variant() {
3666        let table = itempropdef_with_labels(&[(12, "DamageAlignmentGroup")]);
3667        let overrides = override_with_2da("itempropdef", &table);
3668        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3669        let mut cache = TwoDaCache::new(&resolver);
3670
3671        let uti = Uti {
3672            properties: vec![property(12, 4)],
3673            ..Uti::default()
3674        };
3675        let view = uti.snapshot(&mut cache);
3676
3677        let DecodedProperty::DamageAlignmentGroup {
3678            property_id,
3679            subtype_id,
3680            ..
3681        } = &view.properties()[0]
3682        else {
3683            panic!("expected DamageAlignmentGroup variant for `DamageAlignmentGroup` label");
3684        };
3685        assert_eq!(*property_id, 12);
3686        assert_eq!(*subtype_id, 4);
3687    }
3688
3689    #[test]
3690    fn enhancement_racial_group_label_routes_to_typed_variant() {
3691        let table = itempropdef_with_labels(&[(7, "EnhancementRacialGroup")]);
3692        let overrides = override_with_2da("itempropdef", &table);
3693        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3694        let mut cache = TwoDaCache::new(&resolver);
3695
3696        let uti = Uti {
3697            properties: vec![property(7, 3)],
3698            ..Uti::default()
3699        };
3700        let view = uti.snapshot(&mut cache);
3701
3702        let DecodedProperty::EnhancementRacialGroup {
3703            property_id,
3704            subtype_id,
3705            ..
3706        } = &view.properties()[0]
3707        else {
3708            panic!("expected EnhancementRacialGroup variant for `EnhancementRacialGroup` label");
3709        };
3710        assert_eq!(*property_id, 7);
3711        assert_eq!(*subtype_id, 3);
3712    }
3713
3714    #[test]
3715    fn conditional_bonus_family_subtype_labels_resolve_via_their_2das() {
3716        // DamageRacialGroup and EnhancementRacialGroup share
3717        // `racialtypes.2da`; DamageAlignmentGroup uses
3718        // `iprp_aligngrp.2da`. Verify each resolves through the
3719        // correct table.
3720        let propdef = itempropdef_with_subtypes(&[
3721            (7, "EnhancementRacialGroup", "racialtypes"),
3722            (12, "DamageAlignmentGroup", "iprp_aligngrp"),
3723            (13, "DamageRacialGroup", "racialtypes"),
3724        ]);
3725        let racial = subtype_2da(&[(0, "Human"), (3, "Wookiee")]);
3726        let aligngrp = subtype_2da(&[(0, "Lawful_Good"), (4, "Chaotic_Evil")]);
3727
3728        let mut overrides = OverrideSource::new();
3729        add_2da_entry(&mut overrides, "itempropdef", &propdef);
3730        add_2da_entry(&mut overrides, "racialtypes", &racial);
3731        add_2da_entry(&mut overrides, "iprp_aligngrp", &aligngrp);
3732        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3733        let mut cache = TwoDaCache::new(&resolver);
3734
3735        let damage_racial = DecodedProperty::DamageRacialGroup {
3736            property_id: 13,
3737            subtype_id: 3,
3738            cost_table: 0,
3739            cost_value: 0,
3740        };
3741        assert_eq!(
3742            damage_racial.subtype_label(&mut cache).as_deref(),
3743            Some("Wookiee")
3744        );
3745
3746        let damage_alignment = DecodedProperty::DamageAlignmentGroup {
3747            property_id: 12,
3748            subtype_id: 4,
3749            cost_table: 0,
3750            cost_value: 0,
3751        };
3752        assert_eq!(
3753            damage_alignment.subtype_label(&mut cache).as_deref(),
3754            Some("Chaotic_Evil")
3755        );
3756
3757        let enhancement_racial = DecodedProperty::EnhancementRacialGroup {
3758            property_id: 7,
3759            subtype_id: 3,
3760            cost_table: 0,
3761            cost_value: 0,
3762        };
3763        assert_eq!(
3764            enhancement_racial.subtype_label(&mut cache).as_deref(),
3765            Some("Wookiee")
3766        );
3767    }
3768
3769    #[test]
3770    fn use_limitation_family_subtype_labels_resolve_via_their_2das() {
3771        // All three use-limitation variants back onto different
3772        // subtype tables. Verify the helper resolves each through
3773        // the correct 2DA.
3774        let propdef = itempropdef_with_subtypes(&[
3775            (43, "UseLimitationAlignmentGroup", "iprp_aligngrp"),
3776            (45, "UseLimitationRacial", "racialtypes"),
3777            (57, "Use_Limitation_Feat", "feat"),
3778        ]);
3779        let aligngrp = subtype_2da(&[(0, "Lawful_Good"), (4, "Chaotic_Evil")]);
3780        let racial = subtype_2da(&[(0, "Human"), (3, "Wookiee")]);
3781        let feats = subtype_2da(&[(0, "Toughness"), (42, "Force_Sensitive")]);
3782
3783        let mut overrides = OverrideSource::new();
3784        add_2da_entry(&mut overrides, "itempropdef", &propdef);
3785        add_2da_entry(&mut overrides, "iprp_aligngrp", &aligngrp);
3786        add_2da_entry(&mut overrides, "racialtypes", &racial);
3787        add_2da_entry(&mut overrides, "feat", &feats);
3788        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3789        let mut cache = TwoDaCache::new(&resolver);
3790
3791        let alignment = DecodedProperty::UseLimitationAlignmentGroup {
3792            property_id: 43,
3793            subtype_id: 4,
3794            cost_table: 0,
3795            cost_value: 0,
3796        };
3797        assert_eq!(
3798            alignment.subtype_label(&mut cache).as_deref(),
3799            Some("Chaotic_Evil")
3800        );
3801
3802        let racial = DecodedProperty::UseLimitationRacial {
3803            property_id: 45,
3804            subtype_id: 3,
3805            cost_table: 0,
3806            cost_value: 0,
3807        };
3808        assert_eq!(racial.subtype_label(&mut cache).as_deref(), Some("Wookiee"));
3809
3810        let feat = DecodedProperty::UseLimitationFeat {
3811            property_id: 57,
3812            subtype_id: 42,
3813            cost_table: 0,
3814            cost_value: 0,
3815        };
3816        assert_eq!(
3817            feat.subtype_label(&mut cache).as_deref(),
3818            Some("Force_Sensitive")
3819        );
3820    }
3821
3822    #[test]
3823    fn low_volume_catchall_variants_route_to_typed_variants() {
3824        // Seven low-volume vanilla rows get their own typed variants
3825        // in this batch: AttackPenalty (4 items), DamagePenalty (1),
3826        // ImprovedMagicResist (4), DamageNone (3), Regeneration (6),
3827        // Regeneration_Force_Points (4), and Disguise (2). This test
3828        // pins the routing for all seven at once.
3829        let table = itempropdef_with_labels(&[
3830            (8, "AttackPenalty"),
3831            (15, "DamagePenalty"),
3832            (25, "ImprovedMagicResist"),
3833            (31, "DamageNone"),
3834            (35, "Regeneration"),
3835            (54, "Regeneration_Force_Points"),
3836            (59, "Disguise"),
3837        ]);
3838        let overrides = override_with_2da("itempropdef", &table);
3839        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3840        let mut cache = TwoDaCache::new(&resolver);
3841
3842        let uti = Uti {
3843            properties: vec![
3844                property(8, 0),
3845                property(15, 0),
3846                property(25, 0),
3847                property(31, 0),
3848                property(35, 0),
3849                property(54, 0),
3850                property(59, 7),
3851            ],
3852            ..Uti::default()
3853        };
3854        let view = uti.snapshot(&mut cache);
3855
3856        assert!(matches!(
3857            view.properties()[0],
3858            DecodedProperty::AttackPenalty { .. }
3859        ));
3860        assert!(matches!(
3861            view.properties()[1],
3862            DecodedProperty::DamagePenalty { .. }
3863        ));
3864        assert!(matches!(
3865            view.properties()[2],
3866            DecodedProperty::MagicResistBonus { .. }
3867        ));
3868        assert!(matches!(
3869            view.properties()[3],
3870            DecodedProperty::DamageNone { .. }
3871        ));
3872        assert!(matches!(
3873            view.properties()[4],
3874            DecodedProperty::Regeneration { .. }
3875        ));
3876        assert!(matches!(
3877            view.properties()[5],
3878            DecodedProperty::RegenerationForcePoints { .. }
3879        ));
3880        let DecodedProperty::Disguise { subtype_id, .. } = &view.properties()[6] else {
3881            panic!("expected Disguise variant for `Disguise` label");
3882        };
3883        assert_eq!(*subtype_id, 7);
3884    }
3885
3886    #[test]
3887    fn low_volume_subtypeless_variants_short_circuit_subtype_label() {
3888        // Six of the seven low-volume variants have no SubTypeResRef
3889        // in vanilla. The helper must short-circuit to None for each.
3890        let propdef = itempropdef_with_subtypes(&[
3891            (8, "AttackPenalty", ""),
3892            (15, "DamagePenalty", ""),
3893            (25, "ImprovedMagicResist", ""),
3894            (31, "DamageNone", ""),
3895            (35, "Regeneration", ""),
3896            (54, "Regeneration_Force_Points", ""),
3897        ]);
3898        let overrides = override_with_2da("itempropdef", &propdef);
3899        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3900        let mut cache = TwoDaCache::new(&resolver);
3901
3902        for prop in [
3903            DecodedProperty::AttackPenalty {
3904                property_id: 8,
3905                subtype_id: 0,
3906                cost_table: 0,
3907                cost_value: 0,
3908            },
3909            DecodedProperty::DamagePenalty {
3910                property_id: 15,
3911                subtype_id: 0,
3912                cost_table: 0,
3913                cost_value: 0,
3914            },
3915            DecodedProperty::MagicResistBonus {
3916                property_id: 25,
3917                subtype_id: 0,
3918                cost_table: 0,
3919                cost_value: 0,
3920            },
3921            DecodedProperty::DamageNone {
3922                property_id: 31,
3923                subtype_id: 0,
3924                cost_table: 0,
3925                cost_value: 0,
3926            },
3927            DecodedProperty::Regeneration {
3928                property_id: 35,
3929                subtype_id: 0,
3930                cost_table: 0,
3931                cost_value: 0,
3932            },
3933            DecodedProperty::RegenerationForcePoints {
3934                property_id: 54,
3935                subtype_id: 0,
3936                cost_table: 0,
3937                cost_value: 0,
3938            },
3939        ] {
3940            assert!(
3941                prop.subtype_label(&mut cache).is_none(),
3942                "expected None subtype_label for subtypeless variant, got {prop:?}"
3943            );
3944        }
3945    }
3946
3947    #[test]
3948    fn disguise_subtype_label_resolves_via_appearance_2da() {
3949        let propdef = itempropdef_with_subtypes(&[(59, "Disguise", "appearance")]);
3950        let appearance = subtype_2da(&[(0, "Human"), (7, "Tusken_Raider")]);
3951        let mut overrides = OverrideSource::new();
3952        add_2da_entry(&mut overrides, "itempropdef", &propdef);
3953        add_2da_entry(&mut overrides, "appearance", &appearance);
3954        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3955        let mut cache = TwoDaCache::new(&resolver);
3956
3957        let prop = DecodedProperty::Disguise {
3958            property_id: 59,
3959            subtype_id: 7,
3960            cost_table: 0,
3961            cost_value: 0,
3962        };
3963        assert_eq!(
3964            prop.subtype_label(&mut cache).as_deref(),
3965            Some("Tusken_Raider")
3966        );
3967    }
3968
3969    #[test]
3970    fn newly_visible_alignment_and_special_variants_route_to_typed() {
3971        // After the GFF reader's cycle-detection bound was widened
3972        // to allow flat one-entry property lists, four more rows
3973        // surfaced in the corpus survey and got typed variants:
3974        // EnhancementAlignmentGroup (row 6, 5 items),
3975        // AttackBonusAlignmentGroup (row 39, 1 item), Light (row 29,
3976        // 3 items, with param fields), and TrueSeeing (row 47,
3977        // 1 item). Pins routing for all four.
3978        let table = itempropdef_with_labels(&[
3979            (6, "EnhancementAlignmentGroup"),
3980            (29, "Light"),
3981            (39, "AttackBonusAlignmentGroup"),
3982            (47, "True_Seeing"),
3983        ]);
3984        let overrides = override_with_2da("itempropdef", &table);
3985        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3986        let mut cache = TwoDaCache::new(&resolver);
3987
3988        let mut light_prop = property(29, 0);
3989        light_prop.param1 = 9;
3990        light_prop.param1_value = 4;
3991        let uti = Uti {
3992            properties: vec![property(6, 2), light_prop, property(39, 3), property(47, 0)],
3993            ..Uti::default()
3994        };
3995        let view = uti.snapshot(&mut cache);
3996
3997        let DecodedProperty::EnhancementAlignmentGroup {
3998            subtype_id: ealignment_subtype,
3999            ..
4000        } = &view.properties()[0]
4001        else {
4002            panic!("expected EnhancementAlignmentGroup for row 6");
4003        };
4004        assert_eq!(*ealignment_subtype, 2);
4005
4006        let DecodedProperty::Light {
4007            param1,
4008            param1_value,
4009            ..
4010        } = &view.properties()[1]
4011        else {
4012            panic!("expected Light for row 29");
4013        };
4014        assert_eq!(*param1, 9);
4015        assert_eq!(*param1_value, 4);
4016
4017        let DecodedProperty::AttackBonusAlignmentGroup {
4018            subtype_id: ab_alignment_subtype,
4019            ..
4020        } = &view.properties()[2]
4021        else {
4022            panic!("expected AttackBonusAlignmentGroup for row 39");
4023        };
4024        assert_eq!(*ab_alignment_subtype, 3);
4025
4026        assert!(matches!(
4027            view.properties()[3],
4028            DecodedProperty::TrueSeeing { .. }
4029        ));
4030    }
4031
4032    #[test]
4033    fn alignment_group_variants_subtype_labels_resolve_via_iprp_aligngrp() {
4034        // EnhancementAlignmentGroup (row 6) and
4035        // AttackBonusAlignmentGroup (row 39) both back onto
4036        // iprp_aligngrp.2da, the same table as the existing
4037        // UseLimitationAlignmentGroup and DamageAlignmentGroup
4038        // variants. Verify both new variants resolve through it.
4039        let propdef = itempropdef_with_subtypes(&[
4040            (6, "EnhancementAlignmentGroup", "iprp_aligngrp"),
4041            (39, "AttackBonusAlignmentGroup", "iprp_aligngrp"),
4042        ]);
4043        let aligngrp = subtype_2da(&[(0, "Lawful_Good"), (2, "Neutral"), (4, "Chaotic_Evil")]);
4044
4045        let mut overrides = OverrideSource::new();
4046        add_2da_entry(&mut overrides, "itempropdef", &propdef);
4047        add_2da_entry(&mut overrides, "iprp_aligngrp", &aligngrp);
4048        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4049        let mut cache = TwoDaCache::new(&resolver);
4050
4051        let enhancement = DecodedProperty::EnhancementAlignmentGroup {
4052            property_id: 6,
4053            subtype_id: 4,
4054            cost_table: 0,
4055            cost_value: 0,
4056        };
4057        assert_eq!(
4058            enhancement.subtype_label(&mut cache).as_deref(),
4059            Some("Chaotic_Evil")
4060        );
4061
4062        let attack = DecodedProperty::AttackBonusAlignmentGroup {
4063            property_id: 39,
4064            subtype_id: 0,
4065            cost_table: 0,
4066            cost_value: 0,
4067        };
4068        assert_eq!(
4069            attack.subtype_label(&mut cache).as_deref(),
4070            Some("Lawful_Good")
4071        );
4072    }
4073
4074    #[test]
4075    fn light_and_true_seeing_short_circuit_subtype_label() {
4076        // Light (row 29) and TrueSeeing (row 47) carry no
4077        // SubTypeResRef in vanilla; the helper must return None.
4078        let propdef = itempropdef_with_subtypes(&[(29, "Light", ""), (47, "True_Seeing", "")]);
4079        let overrides = override_with_2da("itempropdef", &propdef);
4080        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4081        let mut cache = TwoDaCache::new(&resolver);
4082
4083        let light = DecodedProperty::Light {
4084            property_id: 29,
4085            subtype_id: 0,
4086            cost_table: 0,
4087            cost_value: 0,
4088            param1: 9,
4089            param1_value: 4,
4090        };
4091        assert!(light.subtype_label(&mut cache).is_none());
4092
4093        let true_seeing = DecodedProperty::TrueSeeing {
4094            property_id: 47,
4095            subtype_id: 0,
4096            cost_table: 0,
4097            cost_value: 0,
4098        };
4099        assert!(true_seeing.subtype_label(&mut cache).is_none());
4100    }
4101
4102    #[test]
4103    fn deferred_zero_use_rows_stay_in_unknown_by_design() {
4104        // The decoder intentionally leaves the vanilla itempropdef
4105        // rows with no .uti corpus references in Unknown. This test
4106        // pins a representative subset across the families listed in
4107        // the deferral comment at the `_ => Unknown` arm in
4108        // `decode_property`. If a future batch types any of these,
4109        // remove the corresponding entry here and update the
4110        // deferral comment.
4111        let table = itempropdef_with_labels(&[
4112            (2, "ArmorAlignmentGroup"),
4113            (16, "DamageReduced"),
4114            (19, "DecreaseAbilityScore"),
4115            (22, "DamageMelee"),
4116            (40, "AttackBonusRacialGroup"),
4117            (44, "UseLimitationClass"),
4118            (48, "OnMonsterHit"),
4119            (56, "Blaster_Bolt_Defect_Decrease"),
4120        ]);
4121        let overrides = override_with_2da("itempropdef", &table);
4122        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4123        let mut cache = TwoDaCache::new(&resolver);
4124
4125        let uti = Uti {
4126            properties: vec![
4127                property(2, 0),
4128                property(16, 0),
4129                property(19, 0),
4130                property(22, 0),
4131                property(40, 0),
4132                property(44, 0),
4133                property(48, 0),
4134                property(56, 0),
4135            ],
4136            ..Uti::default()
4137        };
4138        let view = uti.snapshot(&mut cache);
4139
4140        for prop in view.properties() {
4141            assert!(
4142                matches!(prop, DecodedProperty::Unknown { .. }),
4143                "expected Unknown for deferred row, got {prop:?}"
4144            );
4145        }
4146    }
4147
4148    #[test]
4149    fn properties_accessor_preserves_source_order() {
4150        let uti = Uti {
4151            properties: vec![property(7, 0), property(0, 0), property(45, 0)],
4152            ..Uti::default()
4153        };
4154        let resolver = Resolver::new();
4155        let mut cache = TwoDaCache::new(&resolver);
4156
4157        let view = uti.snapshot(&mut cache);
4158        let ids: Vec<u16> = view
4159            .properties()
4160            .iter()
4161            .map(|prop| match prop {
4162                DecodedProperty::AbilityBonus { property_id, .. }
4163                | DecodedProperty::SaveBonus { property_id, .. }
4164                | DecodedProperty::SaveBonusSpecific { property_id, .. }
4165                | DecodedProperty::SavePenalty { property_id, .. }
4166                | DecodedProperty::SavePenaltySpecific { property_id, .. }
4167                | DecodedProperty::DamageBonus { property_id, .. }
4168                | DecodedProperty::DamageImmunity { property_id, .. }
4169                | DecodedProperty::DamageResistance { property_id, .. }
4170                | DecodedProperty::AcBonus { property_id, .. }
4171                | DecodedProperty::EnhancementBonus { property_id, .. }
4172                | DecodedProperty::OnHit { property_id, .. }
4173                | DecodedProperty::CastSpell { property_id, .. }
4174                | DecodedProperty::Trap { property_id, .. }
4175                | DecodedProperty::ThievesTools { property_id, .. }
4176                | DecodedProperty::ComputerSpike { property_id, .. }
4177                | DecodedProperty::UseLimitationFeat { property_id, .. }
4178                | DecodedProperty::UseLimitationRacial { property_id, .. }
4179                | DecodedProperty::UseLimitationAlignmentGroup { property_id, .. }
4180                | DecodedProperty::DamageRacialGroup { property_id, .. }
4181                | DecodedProperty::DamageAlignmentGroup { property_id, .. }
4182                | DecodedProperty::EnhancementRacialGroup { property_id, .. }
4183                | DecodedProperty::EnhancementAlignmentGroup { property_id, .. }
4184                | DecodedProperty::AttackBonusAlignmentGroup { property_id, .. }
4185                | DecodedProperty::TrueSeeing { property_id, .. }
4186                | DecodedProperty::Light { property_id, .. }
4187                | DecodedProperty::AttackBonus { property_id, .. }
4188                | DecodedProperty::Keen { property_id, .. }
4189                | DecodedProperty::MassiveCriticals { property_id, .. }
4190                | DecodedProperty::BlasterBoltDeflectIncrease { property_id, .. }
4191                | DecodedProperty::MonsterDamage { property_id, .. }
4192                | DecodedProperty::BonusFeats { property_id, .. }
4193                | DecodedProperty::Immunity { property_id, .. }
4194                | DecodedProperty::Skill { property_id, .. }
4195                | DecodedProperty::AttackPenalty { property_id, .. }
4196                | DecodedProperty::DamagePenalty { property_id, .. }
4197                | DecodedProperty::MagicResistBonus { property_id, .. }
4198                | DecodedProperty::DamageNone { property_id, .. }
4199                | DecodedProperty::Regeneration { property_id, .. }
4200                | DecodedProperty::RegenerationForcePoints { property_id, .. }
4201                | DecodedProperty::Disguise { property_id, .. }
4202                | DecodedProperty::Unknown { property_id, .. } => *property_id,
4203            })
4204            .collect();
4205        assert_eq!(ids, vec![7, 0, 45]);
4206    }
4207
4208    #[test]
4209    fn is_armor_delegates_to_source_uti_for_armor_base_item() {
4210        // Base item 35 is in the armor block per `is_armor_base_item`.
4211        let uti = Uti {
4212            base_item: 35,
4213            ..Uti::default()
4214        };
4215        let resolver = Resolver::new();
4216        let mut cache = TwoDaCache::new(&resolver);
4217
4218        let view = uti.snapshot(&mut cache);
4219        assert!(view.is_armor());
4220        // Sanity: matches the source's own pure check.
4221        assert_eq!(view.is_armor(), uti.is_armor());
4222    }
4223
4224    #[test]
4225    fn is_armor_returns_false_for_non_armor_base_item() {
4226        // Base item 0 (typically a melee weapon slot) is not armor.
4227        let uti = Uti {
4228            base_item: 0,
4229            ..Uti::default()
4230        };
4231        let resolver = Resolver::new();
4232        let mut cache = TwoDaCache::new(&resolver);
4233
4234        let view = uti.snapshot(&mut cache);
4235        assert!(!view.is_armor());
4236    }
4237
4238    /// Builds a `baseitems.2da` fixture with the columns the combat /
4239    /// equip queries read. Each `(row_index, weaponwield, stacking,
4240    /// equipableslots, modeltype)` tuple populates one row; intermediate
4241    /// rows get blank cells.
4242    fn baseitems_with_rows(rows: &[(usize, &str, &str, &str, &str)]) -> TwoDa {
4243        let max_row = rows.iter().map(|(idx, ..)| *idx).max().unwrap_or(0);
4244        let mut table_rows = Vec::with_capacity(max_row + 1);
4245        for row_index in 0..=max_row {
4246            let cells = rows
4247                .iter()
4248                .find(|(idx, ..)| *idx == row_index)
4249                .map(|(_, ww, st, eq, mt)| {
4250                    vec![
4251                        (*ww).to_string(),
4252                        (*st).to_string(),
4253                        (*eq).to_string(),
4254                        (*mt).to_string(),
4255                    ]
4256                })
4257                .unwrap_or_else(|| vec![String::new(); 4]);
4258            table_rows.push(TwoDaRow {
4259                label: row_index.to_string(),
4260                cells,
4261            });
4262        }
4263        TwoDa {
4264            headers: vec![
4265                "weaponwield".to_string(),
4266                "stacking".to_string(),
4267                "equipableslots".to_string(),
4268                "modeltype".to_string(),
4269            ],
4270            rows: table_rows,
4271        }
4272    }
4273
4274    #[test]
4275    fn combat_equip_queries_default_to_safe_values_without_baseitems() {
4276        // No `baseitems.2da` in any source: queries return their
4277        // documented "we don't know" defaults rather than panicking.
4278        let uti = Uti {
4279            base_item: 2,
4280            ..Uti::default()
4281        };
4282        let resolver = Resolver::new();
4283        let mut cache = TwoDaCache::new(&resolver);
4284
4285        let view = uti.snapshot(&mut cache);
4286        assert!(!view.is_weapon());
4287        assert!(!view.is_consumable());
4288        assert!(view.equip_slot_mask().is_none());
4289        assert!(view.model_type().is_none());
4290    }
4291
4292    #[test]
4293    fn combat_equip_queries_default_when_base_item_row_is_absent() {
4294        // `baseitems.2da` is loaded but does not contain row 99.
4295        // Queries should behave as if the table were missing.
4296        let table = baseitems_with_rows(&[(2, "2", "1", "0x00030", "0")]);
4297        let overrides = override_with_2da("baseitems", &table);
4298        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4299        let mut cache = TwoDaCache::new(&resolver);
4300
4301        let uti = Uti {
4302            base_item: 99,
4303            ..Uti::default()
4304        };
4305        let view = uti.snapshot(&mut cache);
4306        assert!(!view.is_weapon());
4307        assert!(!view.is_consumable());
4308        assert!(view.equip_slot_mask().is_none());
4309        assert!(view.model_type().is_none());
4310    }
4311
4312    #[test]
4313    fn is_weapon_reads_weaponwield_column() {
4314        // Row 2 in vanilla baseitems is Long_Sword with weaponwield=2.
4315        // Row 35 is an armor row with weaponwield blank (-> 0).
4316        let table =
4317            baseitems_with_rows(&[(2, "2", "1", "0x00030", "0"), (35, "", "1", "0x00018", "0")]);
4318        let overrides = override_with_2da("baseitems", &table);
4319        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4320        let mut cache = TwoDaCache::new(&resolver);
4321
4322        let weapon = Uti {
4323            base_item: 2,
4324            ..Uti::default()
4325        };
4326        assert!(weapon.snapshot(&mut cache).is_weapon());
4327
4328        let armor = Uti {
4329            base_item: 35,
4330            ..Uti::default()
4331        };
4332        assert!(!armor.snapshot(&mut cache).is_weapon());
4333    }
4334
4335    #[test]
4336    fn is_consumable_reads_stacking_column() {
4337        // Vanilla consumables like stim packs / grenades / med kits
4338        // have stacking > 1 (typically 99). Equipment has stacking
4339        // exactly 1. The threshold is `> 1`.
4340        let table = baseitems_with_rows(&[
4341            (60, "0", "99", "0x40000", "0"),
4342            (35, "0", "1", "0x00018", "0"),
4343        ]);
4344        let overrides = override_with_2da("baseitems", &table);
4345        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4346        let mut cache = TwoDaCache::new(&resolver);
4347
4348        let stim = Uti {
4349            base_item: 60,
4350            ..Uti::default()
4351        };
4352        assert!(stim.snapshot(&mut cache).is_consumable());
4353
4354        let armor = Uti {
4355            base_item: 35,
4356            ..Uti::default()
4357        };
4358        assert!(!armor.snapshot(&mut cache).is_consumable());
4359    }
4360
4361    #[test]
4362    fn equip_slot_mask_parses_hex_string_with_or_without_prefix() {
4363        // The vanilla cell stores hex with `0x` prefix
4364        // (e.g. `0x00030`). Tolerate uppercase prefix and a bare
4365        // hex string too, in case mod tables omit the prefix.
4366        let table = baseitems_with_rows(&[
4367            (2, "2", "1", "0x00030", "0"),
4368            (3, "2", "1", "0X00030", "0"),
4369            (4, "2", "1", "00030", "0"),
4370        ]);
4371        let overrides = override_with_2da("baseitems", &table);
4372        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4373        let mut cache = TwoDaCache::new(&resolver);
4374
4375        for base in [2, 3, 4] {
4376            let uti = Uti {
4377                base_item: base,
4378                ..Uti::default()
4379            };
4380            assert_eq!(uti.snapshot(&mut cache).equip_slot_mask(), Some(0x0030));
4381        }
4382    }
4383
4384    #[test]
4385    fn model_type_reads_numeric_column() {
4386        let table = baseitems_with_rows(&[
4387            (2, "2", "1", "0x00030", "0"),
4388            (38, "0", "1", "0x00018", "1"),
4389            (75, "0", "1", "0x00400", "2"),
4390        ]);
4391        let overrides = override_with_2da("baseitems", &table);
4392        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4393        let mut cache = TwoDaCache::new(&resolver);
4394
4395        for (base, expected) in [(2, 0_u8), (38, 1), (75, 2)] {
4396            let uti = Uti {
4397                base_item: base,
4398                ..Uti::default()
4399            };
4400            assert_eq!(uti.snapshot(&mut cache).model_type(), Some(expected));
4401        }
4402    }
4403
4404    #[test]
4405    fn has_property_kind_matches_each_family() {
4406        // Build a Uti carrying one property from each family the
4407        // filter enum covers, then confirm each filter matches.
4408        let table = itempropdef_with_labels(&[
4409            (0, "Ability"),
4410            (10, "CastSpell"),
4411            (11, "Damage"),
4412            (26, "ImprovedSavingThrows"),
4413            (38, "AttackBonus"),
4414            (5, "Enhancement"),
4415            (57, "Use_Limitation_Feat"),
4416        ]);
4417        let overrides = override_with_2da("itempropdef", &table);
4418        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4419        let mut cache = TwoDaCache::new(&resolver);
4420
4421        let uti = Uti {
4422            properties: vec![
4423                property(0, 0),
4424                property(10, 0),
4425                property(11, 0),
4426                property(26, 0),
4427                property(38, 0),
4428                property(5, 0),
4429                property(57, 0),
4430            ],
4431            ..Uti::default()
4432        };
4433        let view = uti.snapshot(&mut cache);
4434
4435        assert!(view.has_property_kind(PropertyKindFilter::Ability));
4436        assert!(view.has_property_kind(PropertyKindFilter::Active));
4437        assert!(view.has_property_kind(PropertyKindFilter::Damage));
4438        assert!(view.has_property_kind(PropertyKindFilter::Save));
4439        assert!(view.has_property_kind(PropertyKindFilter::Attack));
4440        assert!(view.has_property_kind(PropertyKindFilter::Enhancement));
4441        assert!(view.has_property_kind(PropertyKindFilter::UseLimitation));
4442    }
4443
4444    #[test]
4445    fn has_property_kind_returns_false_for_unrepresented_families() {
4446        // Item with only an AbilityBonus should match Ability but
4447        // none of the other filters.
4448        let table = itempropdef_with_labels(&[(0, "Ability")]);
4449        let overrides = override_with_2da("itempropdef", &table);
4450        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4451        let mut cache = TwoDaCache::new(&resolver);
4452
4453        let uti = Uti {
4454            properties: vec![property(0, 0)],
4455            ..Uti::default()
4456        };
4457        let view = uti.snapshot(&mut cache);
4458
4459        assert!(view.has_property_kind(PropertyKindFilter::Ability));
4460        for filter in [
4461            PropertyKindFilter::Damage,
4462            PropertyKindFilter::Save,
4463            PropertyKindFilter::Attack,
4464            PropertyKindFilter::Enhancement,
4465            PropertyKindFilter::UseLimitation,
4466            PropertyKindFilter::Active,
4467        ] {
4468            assert!(
4469                !view.has_property_kind(filter),
4470                "unexpected match for {filter:?}"
4471            );
4472        }
4473    }
4474
4475    #[test]
4476    fn has_property_kind_damage_covers_full_damage_family() {
4477        // The Damage filter is the broadest: it should match
4478        // DamageBonus, DamageImmunity, DamageResistance,
4479        // DamageRacialGroup, DamageAlignmentGroup, and DamagePenalty.
4480        // (DamageNone is intentionally excluded from the filter set.)
4481        let table = itempropdef_with_labels(&[
4482            (11, "Damage"),
4483            (12, "DamageAlignmentGroup"),
4484            (13, "DamageRacialGroup"),
4485            (14, "DamageImmunity"),
4486            (15, "DamagePenalty"),
4487            (17, "DamageResist"),
4488        ]);
4489        let overrides = override_with_2da("itempropdef", &table);
4490        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4491        let mut cache = TwoDaCache::new(&resolver);
4492
4493        for row in [11_u16, 12, 13, 14, 15, 17] {
4494            let uti = Uti {
4495                properties: vec![property(row, 0)],
4496                ..Uti::default()
4497            };
4498            let view = uti.snapshot(&mut cache);
4499            assert!(
4500                view.has_property_kind(PropertyKindFilter::Damage),
4501                "expected Damage filter to match row {row}"
4502            );
4503        }
4504    }
4505
4506    #[test]
4507    fn has_property_kind_does_not_match_unknown_variants() {
4508        // A property whose label has no typed dispatch arm becomes
4509        // Unknown; no filter family includes Unknown.
4510        let table = itempropdef_with_labels(&[(200, "FakeMod_GrantsCookies")]);
4511        let overrides = override_with_2da("itempropdef", &table);
4512        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4513        let mut cache = TwoDaCache::new(&resolver);
4514
4515        let uti = Uti {
4516            properties: vec![property(200, 0)],
4517            ..Uti::default()
4518        };
4519        let view = uti.snapshot(&mut cache);
4520
4521        for filter in [
4522            PropertyKindFilter::Damage,
4523            PropertyKindFilter::Ability,
4524            PropertyKindFilter::Save,
4525            PropertyKindFilter::Attack,
4526            PropertyKindFilter::Enhancement,
4527            PropertyKindFilter::UseLimitation,
4528            PropertyKindFilter::Active,
4529        ] {
4530            assert!(
4531                !view.has_property_kind(filter),
4532                "Unknown variant should not match {filter:?}"
4533            );
4534        }
4535    }
4536
4537    #[test]
4538    fn has_property_kind_returns_false_for_empty_property_list() {
4539        let uti = Uti::default();
4540        let resolver = Resolver::new();
4541        let mut cache = TwoDaCache::new(&resolver);
4542        let view = uti.snapshot(&mut cache);
4543
4544        for filter in [
4545            PropertyKindFilter::Damage,
4546            PropertyKindFilter::Ability,
4547            PropertyKindFilter::Save,
4548            PropertyKindFilter::Attack,
4549            PropertyKindFilter::Enhancement,
4550            PropertyKindFilter::UseLimitation,
4551            PropertyKindFilter::Active,
4552        ] {
4553            assert!(!view.has_property_kind(filter));
4554        }
4555    }
4556
4557    #[test]
4558    fn combat_equip_queries_return_field_defaults_for_unparseable_cells() {
4559        // A row exists but cells are unparseable (non-numeric). Each
4560        // query falls back to its documented safe default rather than
4561        // crashing or surfacing garbage.
4562        let table = baseitems_with_rows(&[(2, "junk", "stuff", "not-hex", "wat")]);
4563        let overrides = override_with_2da("baseitems", &table);
4564        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4565        let mut cache = TwoDaCache::new(&resolver);
4566
4567        let uti = Uti {
4568            base_item: 2,
4569            ..Uti::default()
4570        };
4571        let view = uti.snapshot(&mut cache);
4572        assert!(!view.is_weapon());
4573        assert!(!view.is_consumable());
4574        assert!(view.equip_slot_mask().is_none());
4575        assert!(view.model_type().is_none());
4576    }
4577
4578    /// Builds an `itempropdef.2da` fixture with both the `label` and
4579    /// `SubTypeResRef` columns populated per row. Empty `subtype_resref`
4580    /// matches a property that has no subtype dimension (engine
4581    /// short-circuits the dispatch).
4582    fn itempropdef_with_subtypes(rows: &[(usize, &str, &str)]) -> TwoDa {
4583        let max_row = rows.iter().map(|(idx, _, _)| *idx).max().unwrap_or(0);
4584        let mut table_rows = Vec::with_capacity(max_row + 1);
4585        for row_index in 0..=max_row {
4586            let (label, subtype_resref) = rows
4587                .iter()
4588                .find(|(idx, _, _)| *idx == row_index)
4589                .map(|(_, label, sr)| ((*label).to_string(), (*sr).to_string()))
4590                .unwrap_or_default();
4591            table_rows.push(TwoDaRow {
4592                label: row_index.to_string(),
4593                cells: vec![label, subtype_resref],
4594            });
4595        }
4596        TwoDa {
4597            headers: vec!["label".to_string(), "SubTypeResRef".to_string()],
4598            rows: table_rows,
4599        }
4600    }
4601
4602    /// Builds a per-property subtype 2DA (e.g. `iprp_damagecost.2da`)
4603    /// with just the `label` column populated.
4604    fn subtype_2da(labels: &[(usize, &str)]) -> TwoDa {
4605        // The actual game tables carry a Name (StrRef) column too, but
4606        // the helper only consumes `label`, so we keep the fixture
4607        // minimal.
4608        itempropdef_with_labels(labels)
4609    }
4610
4611    fn add_2da_entry(overrides: &mut OverrideSource, name: &str, table: &TwoDa) {
4612        let bytes = write_twoda_to_vec(table).expect("write 2da fixture");
4613        overrides
4614            .add_entry(
4615                name,
4616                ResourceTypeCode::from(ResourceType::TwoDa),
4617                bytes,
4618                "test",
4619            )
4620            .expect("add override entry");
4621    }
4622
4623    #[test]
4624    fn subtype_label_resolves_full_chain_for_known_property() {
4625        // PropertyName 7 -> "Damage" -> iprp_damagecost.2da
4626        // Subtype 5 -> "Acid"
4627        let propdef = itempropdef_with_subtypes(&[
4628            (0, "Ability", "iprp_abilities"),
4629            (7, "Damage", "iprp_damagecost"),
4630        ]);
4631        let damagecost = subtype_2da(&[(0, "Bludgeoning"), (5, "Acid")]);
4632
4633        let mut overrides = OverrideSource::new();
4634        add_2da_entry(&mut overrides, "itempropdef", &propdef);
4635        add_2da_entry(&mut overrides, "iprp_damagecost", &damagecost);
4636        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4637        let mut cache = TwoDaCache::new(&resolver);
4638
4639        let prop = DecodedProperty::Unknown {
4640            property_id: 7,
4641            property_label: Some("Damage".to_string()),
4642            subtype: 5,
4643            cost_table: 0,
4644            cost_value: 0,
4645            param1: 0,
4646            param1_value: 0,
4647        };
4648        assert_eq!(prop.subtype_label(&mut cache).as_deref(), Some("Acid"));
4649    }
4650
4651    #[test]
4652    fn subtype_label_returns_none_for_property_with_empty_subtype_resref() {
4653        // Property 7 exists but has no subtype dimension (empty
4654        // SubTypeResRef cell). Engine short-circuits; helper does too.
4655        let propdef = itempropdef_with_subtypes(&[(7, "Light", "")]);
4656        let mut overrides = OverrideSource::new();
4657        add_2da_entry(&mut overrides, "itempropdef", &propdef);
4658        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4659        let mut cache = TwoDaCache::new(&resolver);
4660
4661        let prop = DecodedProperty::Unknown {
4662            property_id: 7,
4663            property_label: Some("Light".to_string()),
4664            subtype: 0,
4665            cost_table: 0,
4666            cost_value: 0,
4667            param1: 0,
4668            param1_value: 0,
4669        };
4670        assert!(prop.subtype_label(&mut cache).is_none());
4671    }
4672
4673    #[test]
4674    fn subtype_label_returns_none_when_subtype_table_resref_is_missing() {
4675        // Property 99 isn't in itempropdef at all (row absent). No
4676        // dispatch chain to walk.
4677        let propdef = itempropdef_with_subtypes(&[(0, "Ability", "iprp_abilities")]);
4678        let mut overrides = OverrideSource::new();
4679        add_2da_entry(&mut overrides, "itempropdef", &propdef);
4680        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4681        let mut cache = TwoDaCache::new(&resolver);
4682
4683        let prop = DecodedProperty::Unknown {
4684            property_id: 99,
4685            property_label: None,
4686            subtype: 0,
4687            cost_table: 0,
4688            cost_value: 0,
4689            param1: 0,
4690            param1_value: 0,
4691        };
4692        assert!(prop.subtype_label(&mut cache).is_none());
4693    }
4694
4695    #[test]
4696    fn subtype_label_returns_none_when_subtype_table_cannot_be_loaded() {
4697        // itempropdef points at iprp_damagecost but no source provides
4698        // that 2DA. Helper must not panic.
4699        let propdef = itempropdef_with_subtypes(&[(7, "Damage", "iprp_damagecost")]);
4700        let mut overrides = OverrideSource::new();
4701        add_2da_entry(&mut overrides, "itempropdef", &propdef);
4702        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4703        let mut cache = TwoDaCache::new(&resolver);
4704
4705        let prop = DecodedProperty::Unknown {
4706            property_id: 7,
4707            property_label: Some("Damage".to_string()),
4708            subtype: 0,
4709            cost_table: 0,
4710            cost_value: 0,
4711            param1: 0,
4712            param1_value: 0,
4713        };
4714        assert!(prop.subtype_label(&mut cache).is_none());
4715    }
4716
4717    #[test]
4718    fn subtype_label_returns_none_when_subtype_row_is_out_of_bounds() {
4719        // Subtype 99 is past the loaded subtype table's row count.
4720        let propdef = itempropdef_with_subtypes(&[(7, "Damage", "iprp_damagecost")]);
4721        let damagecost = subtype_2da(&[(0, "Bludgeoning"), (1, "Slashing")]);
4722        let mut overrides = OverrideSource::new();
4723        add_2da_entry(&mut overrides, "itempropdef", &propdef);
4724        add_2da_entry(&mut overrides, "iprp_damagecost", &damagecost);
4725        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4726        let mut cache = TwoDaCache::new(&resolver);
4727
4728        let prop = DecodedProperty::Unknown {
4729            property_id: 7,
4730            property_label: Some("Damage".to_string()),
4731            subtype: 99,
4732            cost_table: 0,
4733            cost_value: 0,
4734            param1: 0,
4735            param1_value: 0,
4736        };
4737        assert!(prop.subtype_label(&mut cache).is_none());
4738    }
4739
4740    #[test]
4741    fn subtype_label_returns_none_when_itempropdef_is_missing() {
4742        // No 2DAs at all. Helper must not panic.
4743        let resolver = Resolver::new();
4744        let mut cache = TwoDaCache::new(&resolver);
4745
4746        let prop = DecodedProperty::Unknown {
4747            property_id: 7,
4748            property_label: None,
4749            subtype: 5,
4750            cost_table: 0,
4751            cost_value: 0,
4752            param1: 0,
4753            param1_value: 0,
4754        };
4755        assert!(prop.subtype_label(&mut cache).is_none());
4756    }
4757
4758    #[test]
4759    fn subtype_label_resolves_mod_extended_subtype_row() {
4760        // Vanilla iprp_damagecost typically stops short of row 200; a
4761        // mod adds row 200 carrying a custom label. The helper must
4762        // surface that label without downgrading to None.
4763        let propdef = itempropdef_with_subtypes(&[(7, "Damage", "iprp_damagecost")]);
4764        let damagecost = subtype_2da(&[(200, "FakeMod_PsionicBurn")]);
4765        let mut overrides = OverrideSource::new();
4766        add_2da_entry(&mut overrides, "itempropdef", &propdef);
4767        add_2da_entry(&mut overrides, "iprp_damagecost", &damagecost);
4768        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4769        let mut cache = TwoDaCache::new(&resolver);
4770
4771        let prop = DecodedProperty::Unknown {
4772            property_id: 7,
4773            property_label: Some("Damage".to_string()),
4774            subtype: 200,
4775            cost_table: 0,
4776            cost_value: 0,
4777            param1: 0,
4778            param1_value: 0,
4779        };
4780        assert_eq!(
4781            prop.subtype_label(&mut cache).as_deref(),
4782            Some("FakeMod_PsionicBurn")
4783        );
4784    }
4785
4786    // -- Projection / snapshot two-stage path --
4787
4788    #[test]
4789    fn project_with_itempropdef_dispatches_typed_variants() {
4790        // Property name 0 (`Ability`) routes to AbilityBonus when the
4791        // projection has the itempropdef table to dispatch from.
4792        let uti = Uti {
4793            properties: vec![property(0, 2)],
4794            ..Uti::default()
4795        };
4796        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
4797
4798        let projection = uti.project(Some(&propdef));
4799        assert!(matches!(
4800            projection.properties()[0],
4801            DecodedProperty::AbilityBonus { subtype_id: 2, .. }
4802        ));
4803    }
4804
4805    #[test]
4806    fn project_without_itempropdef_falls_back_to_unknown_no_label() {
4807        // Passing None for itempropdef must not panic and must
4808        // gracefully degrade every property to Unknown with no label.
4809        // Equivalent to the engine's tolerance of a missing
4810        // itempropdef.2da at load time.
4811        let uti = Uti {
4812            properties: vec![property(0, 2), property(11, 4)],
4813            ..Uti::default()
4814        };
4815
4816        let projection = uti.project(None);
4817        let labels: Vec<Option<&str>> = projection
4818            .properties()
4819            .iter()
4820            .map(|prop| match prop {
4821                DecodedProperty::Unknown { property_label, .. } => property_label.as_deref(),
4822                _ => panic!("expected every property to land in Unknown without itempropdef"),
4823            })
4824            .collect();
4825        assert_eq!(labels, vec![None, None]);
4826    }
4827
4828    #[test]
4829    fn projection_snapshot_loads_baseitems_for_snapshot_queries() {
4830        // Build a projection with no itempropdef (irrelevant for the
4831        // baseitems-backed query under test), then snapshot it through
4832        // a cache that exposes a weapon row at the UTI's base_item id.
4833        let uti = Uti {
4834            base_item: 0,
4835            ..Uti::default()
4836        };
4837        let baseitems = baseitems_with_rows(&[(0, "5", "0", "0x0010", "3")]);
4838        let overrides = override_with_2da("baseitems", &baseitems);
4839        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4840        let mut cache = TwoDaCache::new(&resolver);
4841
4842        let projection = uti.project(None);
4843        let snapshot = projection.snapshot(&mut cache);
4844
4845        assert!(snapshot.is_weapon());
4846        assert_eq!(snapshot.equip_slot_mask(), Some(0x0010));
4847        assert_eq!(snapshot.model_type(), Some(3));
4848    }
4849
4850    #[test]
4851    fn one_projection_feeds_independent_snapshots_per_scope() {
4852        // Mod conflict scenario: same UTI bytes, two scopes whose
4853        // baseitems.2da rows disagree on whether base_item 0 is a
4854        // weapon. The projection's typed dispatch is shared (no
4855        // re-projection); each snapshot reports the resolution under
4856        // its own context.
4857        let uti = Uti {
4858            base_item: 0,
4859            ..Uti::default()
4860        };
4861
4862        let vanilla_baseitems = baseitems_with_rows(&[(0, "5", "0", "0x0010", "3")]);
4863        let vanilla_overrides = override_with_2da("baseitems", &vanilla_baseitems);
4864        let vanilla_resolver =
4865            Resolver::new().with_source(ResolverSourceRef::Override(&vanilla_overrides));
4866        let mut vanilla_cache = TwoDaCache::new(&vanilla_resolver);
4867
4868        // Mod rebalances the row so the same base_item id is no
4869        // longer wielded as a weapon (weaponwield = 0).
4870        let mod_baseitems = baseitems_with_rows(&[(0, "0", "10", "0x0000", "0")]);
4871        let mod_overrides = override_with_2da("baseitems", &mod_baseitems);
4872        let mod_resolver = Resolver::new().with_source(ResolverSourceRef::Override(&mod_overrides));
4873        let mut mod_cache = TwoDaCache::new(&mod_resolver);
4874
4875        let projection = uti.project(None);
4876        let vanilla = projection.snapshot(&mut vanilla_cache);
4877        let modded = projection.snapshot(&mut mod_cache);
4878
4879        assert!(vanilla.is_weapon());
4880        assert!(!modded.is_weapon());
4881        assert!(modded.is_consumable(), "mod row marks the item stackable");
4882        assert!(!vanilla.is_consumable());
4883    }
4884
4885    #[test]
4886    fn snapshot_sugar_matches_project_then_snapshot() {
4887        // `Uti::snapshot(&mut cache)` is defined as sugar for
4888        // `project(propdef_from_cache).snapshot(cache)`. Both paths
4889        // must produce identical resolutions on every query they
4890        // expose.
4891        let uti = Uti {
4892            base_item: 0,
4893            properties: vec![property(0, 2)],
4894            ..Uti::default()
4895        };
4896        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
4897        let baseitems = baseitems_with_rows(&[(0, "5", "0", "0x0010", "3")]);
4898        let mut overrides = OverrideSource::new();
4899        add_2da_entry(&mut overrides, "itempropdef", &propdef);
4900        add_2da_entry(&mut overrides, "baseitems", &baseitems);
4901        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
4902
4903        let mut sugar_cache = TwoDaCache::new(&resolver);
4904        let sugar = uti.snapshot(&mut sugar_cache);
4905
4906        let mut explicit_cache = TwoDaCache::new(&resolver);
4907        let projection = {
4908            let propdef_ref = explicit_cache.twoda(tables::ITEMPROPDEF).ok();
4909            uti.project(propdef_ref)
4910        };
4911        let explicit = projection.snapshot(&mut explicit_cache);
4912
4913        assert_eq!(sugar.properties(), explicit.properties());
4914        assert_eq!(sugar.is_weapon(), explicit.is_weapon());
4915        assert_eq!(sugar.is_consumable(), explicit.is_consumable());
4916        assert_eq!(sugar.equip_slot_mask(), explicit.equip_slot_mask());
4917        assert_eq!(sugar.model_type(), explicit.model_type());
4918    }
4919
4920    // -- Property-bundle iterators (magnitude resolution) --
4921
4922    /// Builds an `iprp_costtable.2da` fixture with a `Name` column
4923    /// holding the per-cost 2DA resref for each declared row.
4924    /// Intermediate rows get an empty `Name`.
4925    fn iprp_costtable_with_entries(rows: &[(usize, &str)]) -> TwoDa {
4926        let max_row = rows.iter().map(|(idx, _)| *idx).max().unwrap_or(0);
4927        let mut table_rows = Vec::with_capacity(max_row + 1);
4928        for row_index in 0..=max_row {
4929            let name = rows
4930                .iter()
4931                .find(|(idx, _)| *idx == row_index)
4932                .map(|(_, name)| (*name).to_string())
4933                .unwrap_or_default();
4934            table_rows.push(TwoDaRow {
4935                label: row_index.to_string(),
4936                cells: vec![name],
4937            });
4938        }
4939        TwoDa {
4940            headers: vec!["Name".to_string()],
4941            rows: table_rows,
4942        }
4943    }
4944
4945    /// Builds a generic per-cost 2DA (`iprp_bonuscost`,
4946    /// `iprp_immuncost`, etc.) with a `Value` column holding the
4947    /// magnitude for each declared row. Intermediate rows get an
4948    /// empty cell.
4949    fn cost_value_2da(rows: &[(usize, i32)]) -> TwoDa {
4950        let max_row = rows.iter().map(|(idx, _)| *idx).max().unwrap_or(0);
4951        let mut table_rows = Vec::with_capacity(max_row + 1);
4952        for row_index in 0..=max_row {
4953            let value = rows
4954                .iter()
4955                .find(|(idx, _)| *idx == row_index)
4956                .map(|(_, value)| value.to_string())
4957                .unwrap_or_default();
4958            table_rows.push(TwoDaRow {
4959                label: row_index.to_string(),
4960                cells: vec![value],
4961            });
4962        }
4963        TwoDa {
4964            headers: vec!["Value".to_string()],
4965            rows: table_rows,
4966        }
4967    }
4968
4969    /// Builds a UTI carrying one property with explicit cost-table
4970    /// addressing. The decoder cares only about `property_name`,
4971    /// `subtype`, `cost_table`, and `cost_value` for the kinds the
4972    /// iterators yield; other fields take their default sentinels.
4973    fn property_with_cost(
4974        property_name: u16,
4975        subtype: u16,
4976        cost_table: u8,
4977        cost_value: u16,
4978    ) -> UtiProperty {
4979        UtiProperty {
4980            cost_table,
4981            cost_value,
4982            param1: 0xFF,
4983            param1_value: 0,
4984            property_name,
4985            subtype,
4986            chance_appear: 100,
4987            useable: None,
4988            uses_per_day: None,
4989            upgrade_type: None,
4990        }
4991    }
4992
4993    #[test]
4994    fn damage_bonuses_yields_cost_value_as_magnitude() {
4995        // ApplyDamageBonus is a bypass handler: CostValue is the
4996        // damage amount directly. No per-cost 2DA needs to be in
4997        // the resolver, only itempropdef for typed dispatch.
4998        let uti = Uti {
4999            properties: vec![
5000                property_with_cost(11, 3, 4, 7),
5001                property_with_cost(11, 5, 4, 12),
5002            ],
5003            ..Uti::default()
5004        };
5005        let propdef = itempropdef_with_labels(&[(11, "Damage")]);
5006        let overrides = override_with_2da("itempropdef", &propdef);
5007        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
5008        let mut cache = TwoDaCache::new(&resolver);
5009
5010        let snapshot = uti.snapshot(&mut cache);
5011        let yielded: Vec<(u16, i32)> = snapshot.damage_bonuses().collect();
5012        assert_eq!(yielded, vec![(3, 7), (5, 12)]);
5013    }
5014
5015    #[test]
5016    fn ability_bonuses_resolve_magnitude_from_iprp_bonuscost_value() {
5017        // ApplyAbilityBonus reads iprp_bonuscost row at CostValue,
5018        // column Value, with the cost-table index hardcoded by the
5019        // handler. The property's cost_table field is irrelevant
5020        // and should not influence resolution.
5021        let uti = Uti {
5022            properties: vec![
5023                property_with_cost(0, 2, 99, 4), // STR, cost_value 4 -> 2
5024                property_with_cost(0, 4, 99, 7), // WIS, cost_value 7 -> 5
5025            ],
5026            ..Uti::default()
5027        };
5028        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
5029        let bonuscost = cost_value_2da(&[(4, 2), (7, 5)]);
5030        let mut overrides = OverrideSource::new();
5031        add_2da_entry(&mut overrides, "itempropdef", &propdef);
5032        add_2da_entry(&mut overrides, "iprp_bonuscost", &bonuscost);
5033        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
5034        let mut cache = TwoDaCache::new(&resolver);
5035
5036        let snapshot = uti.snapshot(&mut cache);
5037        let yielded: Vec<(u16, i32)> = snapshot.ability_bonuses().collect();
5038        assert_eq!(yielded, vec![(2, 2), (4, 5)]);
5039    }
5040
5041    #[test]
5042    fn ability_bonuses_yield_nothing_when_iprp_bonuscost_missing() {
5043        // Resolver has itempropdef so the AbilityBonus variant
5044        // dispatches, but iprp_bonuscost is absent. The iterator
5045        // silently skips properties whose magnitude could not be
5046        // resolved rather than panicking or yielding a sentinel.
5047        let uti = Uti {
5048            properties: vec![property_with_cost(0, 2, 1, 4)],
5049            ..Uti::default()
5050        };
5051        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
5052        let overrides = override_with_2da("itempropdef", &propdef);
5053        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
5054        let mut cache = TwoDaCache::new(&resolver);
5055
5056        let snapshot = uti.snapshot(&mut cache);
5057        assert_eq!(snapshot.ability_bonuses().count(), 0);
5058    }
5059
5060    #[test]
5061    fn damage_immunities_walk_dynamic_cost_table_dispatch() {
5062        // ApplyDamageImmunity reads the cost-table index from each
5063        // property's cost_table field. The vanilla path uses index
5064        // 5 -> iprp_immuncost. Verify the dispatch follows the
5065        // iprp_costtable.Name resref and looks up the magnitude.
5066        let uti = Uti {
5067            properties: vec![property_with_cost(14, 8, 5, 3)],
5068            ..Uti::default()
5069        };
5070        let propdef = itempropdef_with_labels(&[(14, "DamageImmunity")]);
5071        // Index 5 -> "iprp_immuncost", matching vanilla's layout.
5072        let costtable = iprp_costtable_with_entries(&[(5, "IPRP_IMMUNCOST")]);
5073        let immuncost = cost_value_2da(&[(3, 50)]);
5074        let mut overrides = OverrideSource::new();
5075        add_2da_entry(&mut overrides, "itempropdef", &propdef);
5076        add_2da_entry(&mut overrides, "iprp_costtable", &costtable);
5077        add_2da_entry(&mut overrides, "iprp_immuncost", &immuncost);
5078        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
5079        let mut cache = TwoDaCache::new(&resolver);
5080
5081        let snapshot = uti.snapshot(&mut cache);
5082        let yielded: Vec<(u16, i32)> = snapshot.damage_immunities().collect();
5083        assert_eq!(yielded, vec![(8, 50)]);
5084    }
5085
5086    #[test]
5087    fn damage_immunities_follow_mod_extended_cost_table_index() {
5088        // Mod-extended cost-table scenario: iprp_costtable carries a
5089        // row past vanilla's range pointing at a new cost 2DA. The
5090        // iterator must follow the dispatch via the property's own
5091        // cost_table field rather than hardcoding the vanilla index.
5092        let uti = Uti {
5093            properties: vec![property_with_cost(14, 8, 26, 1)],
5094            ..Uti::default()
5095        };
5096        let propdef = itempropdef_with_labels(&[(14, "DamageImmunity")]);
5097        let costtable = iprp_costtable_with_entries(&[(26, "mod_immuncost")]);
5098        let modded_cost = cost_value_2da(&[(1, 75)]);
5099        let mut overrides = OverrideSource::new();
5100        add_2da_entry(&mut overrides, "itempropdef", &propdef);
5101        add_2da_entry(&mut overrides, "iprp_costtable", &costtable);
5102        add_2da_entry(&mut overrides, "mod_immuncost", &modded_cost);
5103        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
5104        let mut cache = TwoDaCache::new(&resolver);
5105
5106        let snapshot = uti.snapshot(&mut cache);
5107        let yielded: Vec<(u16, i32)> = snapshot.damage_immunities().collect();
5108        assert_eq!(yielded, vec![(8, 75)]);
5109    }
5110
5111    #[test]
5112    fn iterators_only_yield_matching_kind() {
5113        // An item carries one of each magnitude-resolvable kind.
5114        // Each iterator must yield only its own kind.
5115        let uti = Uti {
5116            properties: vec![
5117                property_with_cost(0, 2, 1, 4),  // AbilityBonus
5118                property_with_cost(11, 3, 4, 7), // DamageBonus
5119                property_with_cost(14, 8, 5, 3), // DamageImmunity
5120            ],
5121            ..Uti::default()
5122        };
5123        let propdef =
5124            itempropdef_with_labels(&[(0, "Ability"), (11, "Damage"), (14, "DamageImmunity")]);
5125        let bonuscost = cost_value_2da(&[(4, 2)]);
5126        let costtable = iprp_costtable_with_entries(&[(5, "iprp_immuncost")]);
5127        let immuncost = cost_value_2da(&[(3, 50)]);
5128        let mut overrides = OverrideSource::new();
5129        add_2da_entry(&mut overrides, "itempropdef", &propdef);
5130        add_2da_entry(&mut overrides, "iprp_bonuscost", &bonuscost);
5131        add_2da_entry(&mut overrides, "iprp_costtable", &costtable);
5132        add_2da_entry(&mut overrides, "iprp_immuncost", &immuncost);
5133        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
5134        let mut cache = TwoDaCache::new(&resolver);
5135
5136        let snapshot = uti.snapshot(&mut cache);
5137        assert_eq!(snapshot.ability_bonuses().collect::<Vec<_>>(), vec![(2, 2)]);
5138        assert_eq!(snapshot.damage_bonuses().collect::<Vec<_>>(), vec![(3, 7)]);
5139        assert_eq!(
5140            snapshot.damage_immunities().collect::<Vec<_>>(),
5141            vec![(8, 50)]
5142        );
5143    }
5144
5145    #[test]
5146    fn one_projection_yields_diverging_magnitudes_per_scope() {
5147        // Mod conflict scenario for magnitude resolution: same UTI
5148        // bytes, two scopes whose iprp_bonuscost rebalances the
5149        // ability bonus magnitude. The shared projection survives;
5150        // each per-scope snapshot reports its own resolution.
5151        let uti = Uti {
5152            properties: vec![property_with_cost(0, 2, 1, 4)],
5153            ..Uti::default()
5154        };
5155        let propdef = itempropdef_with_labels(&[(0, "Ability")]);
5156
5157        let vanilla_bonus = cost_value_2da(&[(4, 2)]);
5158        let mut vanilla_overrides = OverrideSource::new();
5159        add_2da_entry(&mut vanilla_overrides, "itempropdef", &propdef);
5160        add_2da_entry(&mut vanilla_overrides, "iprp_bonuscost", &vanilla_bonus);
5161        let vanilla_resolver =
5162            Resolver::new().with_source(ResolverSourceRef::Override(&vanilla_overrides));
5163
5164        let mod_bonus = cost_value_2da(&[(4, 6)]); // Mod rebalances row 4 from +2 to +6.
5165        let mut mod_overrides = OverrideSource::new();
5166        add_2da_entry(&mut mod_overrides, "itempropdef", &propdef);
5167        add_2da_entry(&mut mod_overrides, "iprp_bonuscost", &mod_bonus);
5168        let mod_resolver = Resolver::new().with_source(ResolverSourceRef::Override(&mod_overrides));
5169
5170        let mut vanilla_cache = TwoDaCache::new(&vanilla_resolver);
5171        let mut mod_cache = TwoDaCache::new(&mod_resolver);
5172
5173        let projection = {
5174            let propdef_ref = vanilla_cache.twoda(tables::ITEMPROPDEF).ok();
5175            uti.project(propdef_ref)
5176        };
5177        let vanilla = projection.snapshot(&mut vanilla_cache);
5178        let modded = projection.snapshot(&mut mod_cache);
5179
5180        assert_eq!(vanilla.ability_bonuses().collect::<Vec<_>>(), vec![(2, 2)]);
5181        assert_eq!(modded.ability_bonuses().collect::<Vec<_>>(), vec![(2, 6)]);
5182    }
5183}